/// Stateless HTTP wrapper around the FastAPI backend (docs/05 endpoints). /// Returns raw domain models; caching/offline lives in the repository layer. library; import 'dart:async'; import 'dart:convert'; import 'package:http/http.dart' as http; import '../../domain/models/artifact.dart'; import 'local_store.dart'; import '../../domain/models/card.dart'; import '../../domain/models/collection.dart'; import '../../domain/models/concept.dart'; import '../../domain/models/enums.dart'; import '../../domain/models/feed.dart'; import '../../domain/models/graph.dart'; import 'lan_discovery.dart'; import '../../domain/models/pipeline_event.dart'; class ApiException implements Exception { ApiException(this.statusCode, this.message); final int statusCode; final String message; // raw body — for logs only, never for UI /// What users see. Raw bodies (which may include server details or /// tracebacks) never leave the data layer. String get friendlyMessage => switch (statusCode) { 401 || 403 => 'Session expired — please sign in again.', 404 => "That card isn't there anymore.", 429 => "You've hit today's limit. It resets at midnight UTC.", >= 500 => 'Something went wrong on our side. Try again in a moment.', _ => "That didn't work. Try again.", }; @override String toString() => 'ApiException($statusCode): $message'; } /// UI-safe message for any thrown object. The only thing view-models should /// ever store in a user-visible error field. String friendlyError(Object e) => switch (e) { ApiException api => api.friendlyMessage, _ => "Can't reach Cachy. Check your connection.", }; class CreateCardResult { const CreateCardResult({ required this.cardId, required this.state, required this.cached, this.quotaDegraded = false, }); final String cardId; final CardState state; final bool cached; /// Past today's AI budget — server will produce a paragraph fallback card; /// the on-device model (if installed) can upgrade it afterwards. final bool quotaDegraded; } /// One card cited by a library-chat answer. class LibrarySource { const LibrarySource({required this.cardId, required this.oneLiner}); final String cardId; final String oneLiner; } /// A library-chat turn result: the reply plus the cards it was grounded on. class LibraryChatResult { const LibraryChatResult({required this.reply, required this.sources}); final String reply; final List sources; } /// One step down the rabbit hole (docs/14): the tapped [topic], its /// [explanation], and the fresh [threads] that branch onward from it. class RabbitHoleStep { const RabbitHoleStep({ required this.topic, required this.explanation, required this.threads, }); final String topic; final String explanation; final List threads; } /// Today's metered usage for the signed-in owner (profile quota meter). class QuotaStatus { const QuotaStatus({ required this.cardsUsed, required this.cardsLimit, required this.chatUsed, required this.chatLimit, }); final int cardsUsed; final int cardsLimit; final int chatUsed; final int chatLimit; } class ApiClient { ApiClient({ String? baseUrl, http.Client? client, LocalStore? store, this.tokenProvider, }) : baseUrl = (baseUrl ?? _defaultBaseUrl).replaceAll(RegExp(r'/+$'), ''), _client = client ?? http.Client(), _store = store { // Warm the token so the first thumbnails (rendered from cache before any // API call) can carry the bearer header for the /media proxy. if (tokenProvider != null) unawaited(_authHeader()); } /// Supplies the Firebase ID token (uid = backend owner_id). `forceRefresh` /// mints a fresh token after a 401. Null when signed out. final Future Function({bool forceRefresh})? tokenProvider; /// Default backend for end users: the hosted Hugging Face Space, so a plain /// `flutter build apk` connects with zero config. Override at build time with /// `--dart-define=CACHY_API_BASE=https://host` (empty = same-origin, for the /// web deploy). static const String _hostedDefault = 'https://vatxzz-cachy.hf.space'; static const String _defaultBaseUrl = String.fromEnvironment( 'CACHY_API_BASE', defaultValue: _hostedDefault, ); /// The build-time default backend (hosted Space, or a `--dart-define`), used /// by Developer settings to reset a manual override. static String get defaultBaseUrl => _defaultBaseUrl; /// Resolve the backend URL at launch. A manual override set in Developer /// settings (persisted) wins so it sticks across launches; otherwise use the /// build-time default (the hosted Space, or a `--dart-define` for local dev). static Future resolveBaseUrl({LocalStore? store}) async { final stored = store?.apiBaseUrl; if (stored != null && stored.isNotEmpty) return stored; return _defaultBaseUrl; } String baseUrl; final http.Client _client; final LocalStore? _store; void setBaseUrl(String url) { baseUrl = url.replaceAll(RegExp(r'/+$'), ''); if (_store != null) unawaited(_store.setApiBaseUrl(baseUrl)); } Future discoverAndUpdateBaseUrl() async { final discovered = await discoverBackend(); if (discovered != null) setBaseUrl(discovered); return discovered; } /// Most recent bearer token seen by [_authHeader]. Lets [mediaHeaders] attach /// auth synchronously (cached_network_image needs sync headers). String? _lastToken; Future> _authHeader({bool forceRefresh = false}) async { final token = await tokenProvider?.call(forceRefresh: forceRefresh); if (token == null || token.isEmpty) return const {}; _lastToken = token; return {'authorization': 'Bearer $token'}; } /// Auth headers for a resolved media URL. The owner-checked `/media/` proxy /// needs the bearer token; external artifact images get none. Uses the most /// recent token (refreshed on every API call) — fine because tokens last ~1h /// and images are disk-cached, so at worst one thumbnail retries after the /// next API call refreshes the token. Map mediaHeaders(String resolvedUrl) { if (_lastToken == null) return const {}; final path = Uri.tryParse(resolvedUrl)?.path ?? ''; final isProxy = resolvedUrl.startsWith(baseUrl) && path.startsWith('/media/'); return isProxy ? {'authorization': 'Bearer $_lastToken'} : const {}; } /// All verbs funnel through here: auth header + one refresh-retry on 401. Future _send( Future Function(Map headers) go, { Map extra = const {}, }) async { var resp = await go({...extra, ...await _authHeader()}); if (resp.statusCode == 401 && tokenProvider != null) { resp = await go({...extra, ...await _authHeader(forceRefresh: true)}); } return resp; } Uri _uri(String path, [Map? query]) => Uri.parse('$baseUrl$path') .replace(queryParameters: query?.map((k, v) => MapEntry(k, '$v'))); /// Resolve a media reference returned by the backend. Absolute URLs pass /// through; bare paths are joined onto the base host (forward-compatible with /// a static media mount or an absolute R2 URL). String resolveMedia(String ref) { if (ref.startsWith('http://') || ref.startsWith('https://')) return ref; final path = ref.startsWith('/') ? ref : '/$ref'; return '$baseUrl$path'; } // ------------------------------------------------------------------------- // // Account — quota meter + legacy library claim // ------------------------------------------------------------------------- // Future quota() async { final resp = await _send((h) => _client.get(_uri('/me/quota'), headers: h)); final json = _decodeMap(resp); int pick(String kind, String field) => ((json[kind] as Map?)?[field] as num?)?.toInt() ?? 0; return QuotaStatus( cardsUsed: pick('cards', 'used'), cardsLimit: pick('cards', 'limit'), chatUsed: pick('chat', 'used'), chatLimit: pick('chat', 'limit'), ); } /// Adopt the legacy name-keyed library under the current uid (first-claim- /// wins). Returns the number of rows claimed; throws [ApiException] (409) /// when that name was already taken by another uid. Future claimLegacyLibrary(String name) async { final resp = await _send( (h) => _client.post(_uri('/auth/claim'), headers: h, body: jsonEncode({'name': name})), extra: const {'content-type': 'application/json'}, ); return (_decodeMap(resp)['claimed'] as num?)?.toInt() ?? 0; } // ------------------------------------------------------------------------- // // Cards // ------------------------------------------------------------------------- // Future createCard(String url) async { final resp = await _send( (h) => _client.post(_uri('/cards'), headers: h, body: jsonEncode({'url': url})), extra: const {'content-type': 'application/json'}, ); final json = _decodeMap(resp); return CreateCardResult( cardId: (json['card_id'] as String?) ?? '', state: CardState.fromWire(json['state'] as String?), cached: (json['cached'] as bool?) ?? false, quotaDegraded: (json['quota_degraded'] as bool?) ?? false, ); } /// Stored extraction bundle of a quota-degraded card, or null when the /// server has none (not degraded / already upgraded / not the owner). Future?> getBundle(String cardId) async { final resp = await _send((h) => _client.get(_uri('/cards/$cardId/bundle'), headers: h)); if (resp.statusCode == 404) return null; final json = _decodeMap(resp); return { 'bundle': (json['bundle'] as String?) ?? '', 'transcript': (json['transcript'] as String?) ?? '', 'caption': (json['caption'] as String?) ?? '', }; } /// Upload a device-generated structured card. The server re-validates and /// throws [ApiException] (422) when the payload doesn't survive validation. Future uploadStructure(String cardId, Map payload) async { final resp = await _send( (h) => _client.post(_uri('/cards/$cardId/structure'), headers: h, body: jsonEncode(payload)), extra: const {'content-type': 'application/json'}, ); return Card.fromJson(_decodeMap(resp)); } Future getCard(String cardId) async { final resp = await _send((h) => _client.get(_uri('/cards/$cardId'), headers: h)); return Card.fromJson(_decodeMap(resp)); } Future> listCards({ CardState? state, String? contentType, String? collectionId, int limit = 50, int offset = 0, }) async { final resp = await _send((h) => _client.get( _uri('/cards', { 'state': ?state?.wire, 'content_type': ?contentType, 'collection_id': ?collectionId, 'limit': limit, 'offset': offset, }), headers: h, )); return _decodeList(resp).map(Card.fromJson).toList(); } /// Persist user-mutable block state (e.g. checked items). Sends raw block JSON. Future patchCardBlocks( String cardId, List> blocks, ) async { final resp = await _send( (h) => _client.patch(_uri('/cards/$cardId'), headers: h, body: jsonEncode({'blocks': blocks})), extra: const {'content-type': 'application/json'}, ); return Card.fromJson(_decodeMap(resp)); } /// Persist the card's action list (docs/13): follow toggle + per-item done. Future patchCardActionItems( String cardId, Map actionItems, ) async { final resp = await _send( (h) => _client.patch(_uri('/cards/$cardId'), headers: h, body: jsonEncode({'action_items': actionItems})), extra: const {'content-type': 'application/json'}, ); return Card.fromJson(_decodeMap(resp)); } Future deleteCard(String cardId) async { final resp = await _send((h) => _client.delete(_uri('/cards/$cardId'), headers: h)); if (resp.statusCode >= 400) { throw ApiException(resp.statusCode, resp.body); } } Future importCards(List> cards) async { if (cards.isEmpty) return; final resp = await _send( (h) => _client.post(_uri('/cards/import'), headers: h, body: jsonEncode({'cards': cards})), extra: const {'content-type': 'application/json'}, ); if (resp.statusCode >= 400) throw ApiException(resp.statusCode, resp.body); } Future> search(String query, {int limit = 30}) async { final resp = await _send( (h) => _client.get(_uri('/search', {'q': query, 'limit': limit}), headers: h)); return _decodeList(resp).map(Card.fromJson).toList(); } /// Grounded Q&A over one card (docs/13). Send the full history each turn as /// [{'role','content'}]; returns the assistant's reply text. The conversation /// is persisted server-side per owner (docs/14). Future chat(String cardId, List> messages) async { final resp = await _send( (h) => _client.post(_uri('/cards/$cardId/chat'), headers: h, body: jsonEncode({'messages': messages})), extra: const {'content-type': 'application/json'}, ); return (_decodeMap(resp)['reply'] as String?) ?? ''; } /// Restore this owner's saved chat for a card (docs/14) as /// [{'role','content'}] maps, oldest → newest. Empty when none saved. Future>> chatHistory(String cardId) async { final resp = await _send((h) => _client.get(_uri('/cards/$cardId/chat'), headers: h)); return _decodeMessages(_decodeMap(resp)['messages']); } /// Explore one thread of the rabbit hole (docs/14). Unlike [chat], this is not /// confined to the card: it returns an [explanation] drawn from general /// knowledge plus fresh follow-on [threads]. Send the [trail] of threads /// already explored plus the [root] topic; the exploration is persisted /// server-side per owner, keyed by root. Future exploreRabbitHole( String cardId, String topic, List trail, String root, ) async { final resp = await _send( (h) => _client.post(_uri('/cards/$cardId/rabbithole'), headers: h, body: jsonEncode({'topic': topic, 'trail': trail, 'root': root})), extra: const {'content-type': 'application/json'}, ); final json = _decodeMap(resp); return RabbitHoleStep( topic: topic, explanation: (json['explanation'] as String?) ?? '', threads: ((json['threads'] as List?) ?? const []) .map((e) => e.toString()) .toList(), ); } /// Restore this owner's saved rabbit-hole trail for a card + [root] topic /// (docs/14), oldest → deepest. Empty when none saved. Future> rabbitHoleHistory(String cardId, String root) async { final resp = await _send((h) => _client.get(_uri('/cards/$cardId/rabbithole', {'root': root}), headers: h)); return ((_decodeMap(resp)['steps'] as List?) ?? const []) .whereType>() .map((s) => RabbitHoleStep( topic: (s['topic'] as String?) ?? '', explanation: (s['explanation'] as String?) ?? '', threads: ((s['threads'] as List?) ?? const []) .map((e) => e.toString()) .toList(), )) .toList(); } // ------------------------------------------------------------------------- // // Collections // ------------------------------------------------------------------------- // Future> listCollections() async { final resp = await _send((h) => _client.get(_uri('/collections'), headers: h)); return _decodeList(resp).map(CollectionEntry.fromJson).toList(); } Future renameCollection(String id, String name) async { final resp = await _send( (h) => _client.patch(_uri('/collections/$id'), headers: h, body: jsonEncode({'name': name})), extra: const {'content-type': 'application/json'}, ); return CollectionEntry.fromJson(_decodeMap(resp)); } Future createCollection(String name) async { final resp = await _send( (h) => _client.post(_uri('/collections'), headers: h, body: jsonEncode({'name': name})), extra: const {'content-type': 'application/json'}, ); return CollectionEntry.fromJson(_decodeMap(resp)); } Future moveCardToCollection(String cardId, String? collectionId) async { final resp = await _send( (h) => _client.post(_uri('/collections/cards/$cardId/move'), headers: h, body: jsonEncode({'collection_id': collectionId})), extra: const {'content-type': 'application/json'}, ); if (resp.statusCode >= 400) throw ApiException(resp.statusCode, resp.body); } // ------------------------------------------------------------------------- // // Catalog — referenced artifacts aggregated across cards (docs/12) // ------------------------------------------------------------------------- // Future> listCatalog({ ArtifactType? type, int limit = 200, int offset = 0, }) async { final resp = await _send((h) => _client.get( _uri('/catalog', { 'type': ?type?.wire, 'limit': limit, 'offset': offset, }), headers: h, )); return _decodeList(resp).map(CatalogEntry.fromJson).toList(); } Future deleteCatalogEntry(String artifactId) async { final resp = await _send((h) => _client.delete(_uri('/catalog/$artifactId'), headers: h)); if (resp.statusCode >= 400) { throw ApiException(resp.statusCode, resp.body); } } /// Save a referenced artifact into the catalog tab (long-press to save). Future saveCatalogEntry(String artifactId) async { final resp = await _send((h) => _client.post(_uri('/catalog/$artifactId/save'), headers: h)); return CatalogEntry.fromJson(_decodeMap(resp)); } /// Generate + persist the on-demand LLM detail for an artifact (Fetch info). Future fetchCatalogInfo(String artifactId) async { final resp = await _send((h) => _client.post(_uri('/catalog/$artifactId/fetch-info'), headers: h)); return CatalogEntry.fromJson(_decodeMap(resp)); } /// Artifacts a single card references (docs/12) — the reader "References" strip. Future> cardArtifacts(String cardId, {int limit = 50}) async { final resp = await _send((h) => _client.get(_uri('/catalog', {'card_id': cardId, 'limit': limit}), headers: h)); return _decodeList(resp).map(CatalogEntry.fromJson).toList(); } // ------------------------------------------------------------------------- // // Concepts — evergreen ideas aggregated across cards // ------------------------------------------------------------------------- // Future> listConcepts({String? cardId, int limit = 200}) async { final resp = await _send((h) => _client.get( _uri('/concepts', { 'card_id': ?cardId, 'limit': limit, }), headers: h, )); return _decodeList(resp).map(ConceptEntry.fromJson).toList(); } Future getConcept(String conceptId) async { final resp = await _send((h) => _client.get(_uri('/concepts/$conceptId'), headers: h)); return ConceptDetail.fromJson(_decodeMap(resp)); } Future defineConcept(String conceptId) async { final resp = await _send((h) => _client.post(_uri('/concepts/$conceptId/define'), headers: h)); return ConceptEntry.fromJson(_decodeMap(resp)); } Future deleteConcept(String conceptId) async { final resp = await _send((h) => _client.delete(_uri('/concepts/$conceptId'), headers: h)); if (resp.statusCode >= 400) throw ApiException(resp.statusCode, resp.body); } // ------------------------------------------------------------------------- // // Knowledge graph — cards as nodes, similarity as edges // ------------------------------------------------------------------------- // Future graph({double threshold = 0.55, int topK = 4}) async { final resp = await _send((h) => _client.get(_uri('/graph', {'threshold': threshold, 'top_k': topK}), headers: h)); return GraphData.fromJson(_decodeMap(resp)); } // ------------------------------------------------------------------------- // // Library chat — cross-card grounded Q&A (docs/09) // ------------------------------------------------------------------------- // /// Cross-card Q&A. Stateless like single-card chat: replay the full history. /// Returns the reply plus the cards it was grounded on. Future libraryChat( List> messages) async { final resp = await _send( (h) => _client.post(_uri('/library/chat'), headers: h, body: jsonEncode({'messages': messages})), extra: const {'content-type': 'application/json'}, ); final json = _decodeMap(resp); final sources = ((json['sources'] as List?) ?? const []) .whereType>() .map((s) => LibrarySource( cardId: (s['card_id'] as String?) ?? '', oneLiner: (s['one_liner'] as String?) ?? '', )) .toList(); return LibraryChatResult( reply: (json['reply'] as String?) ?? '', sources: sources, ); } /// Restore this owner's saved library chat (docs/14) as [{'role','content'}] /// maps, oldest → newest. Empty when none saved. Future>> libraryChatHistory() async { final resp = await _send((h) => _client.get(_uri('/library/chat'), headers: h)); return _decodeMessages(_decodeMap(resp)['messages']); } // ------------------------------------------------------------------------- // // Knowledge Feed + Connections (serendipity) // ------------------------------------------------------------------------- // /// The reel-style knowledge feed: a shuffled stream of moments built from the /// owner's cards. Owner-scoped. Future> feed({int limit = 40}) async { final resp = await _send((h) => _client.get(_uri('/feed', {'limit': limit}), headers: h)); return ((_decodeMap(resp)['items'] as List?) ?? const []) .whereType>() .map(FeedItem.fromJson) .toList(); } /// Surprising links between the owner's cards. [refresh] spends a little more /// LLM budget to surface fresh connections. Future> connections({int limit = 12, bool refresh = false}) async { final resp = await _send((h) => _client.get( _uri('/connections', {'limit': limit, if (refresh) 'refresh': true}), headers: h, )); return ((_decodeMap(resp)['connections'] as List?) ?? const []) .whereType>() .map(Connection.fromJson) .toList(); } // ------------------------------------------------------------------------- // // SSE pipeline stream — GET /cards/{id}/stream // ------------------------------------------------------------------------- // /// Subscribe to the transparent pipeline. Yields a [PipelineEvent] per stage /// and completes when the card reaches a terminal state. Caller cancels the /// subscription to disconnect early. Stream streamCard(String cardId) async* { final request = http.Request('GET', _uri('/cards/$cardId/stream')) ..headers['accept'] = 'text/event-stream' ..headers.addAll(await _authHeader()); final response = await _client.send(request); if (response.statusCode >= 400) { throw ApiException(response.statusCode, 'stream failed'); } final lines = response.stream .transform(utf8.decoder) .transform(const LineSplitter()); final dataBuffer = StringBuffer(); await for (final line in lines) { if (line.isEmpty) { // Blank line terminates an SSE event; flush the accumulated data. if (dataBuffer.isNotEmpty) { final event = _parseEvent(dataBuffer.toString()); dataBuffer.clear(); if (event != null) { yield event; if (event.isTerminal) return; } } continue; } if (line.startsWith(':')) continue; // comment / keep-alive frame if (line.startsWith('data:')) { dataBuffer.write(line.substring(5).trimLeft()); } } } PipelineEvent? _parseEvent(String data) { try { final decoded = jsonDecode(data); if (decoded is Map) return PipelineEvent.fromJson(decoded); } catch (_) { // Ignore malformed frames; the stream stays alive. } return null; } // ------------------------------------------------------------------------- // // Decoding helpers // ------------------------------------------------------------------------- // Map _decodeMap(http.Response resp) { if (resp.statusCode >= 400) throw ApiException(resp.statusCode, resp.body); final decoded = jsonDecode(utf8.decode(resp.bodyBytes)); if (decoded is Map) return decoded; throw ApiException(resp.statusCode, 'expected object, got ${decoded.runtimeType}'); } /// Normalise a decoded `messages` array into role/content string maps. List> _decodeMessages(Object? raw) { return ((raw as List?) ?? const []) .whereType>() .map((m) => { 'role': (m['role'] as String?) ?? '', 'content': (m['content'] as String?) ?? '', }) .where((m) => m['content']!.isNotEmpty) .toList(); } List> _decodeList(http.Response resp) { if (resp.statusCode >= 400) throw ApiException(resp.statusCode, resp.body); final decoded = jsonDecode(utf8.decode(resp.bodyBytes)); if (decoded is List) return decoded.whereType>().toList(); throw ApiException(resp.statusCode, 'expected list, got ${decoded.runtimeType}'); } void close() => _client.close(); }