| |
| |
| 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; |
|
|
| |
| |
| 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'; |
| } |
|
|
| |
| |
| 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; |
|
|
| |
| |
| final bool quotaDegraded; |
| } |
|
|
| |
| class LibrarySource { |
| const LibrarySource({required this.cardId, required this.oneLiner}); |
| final String cardId; |
| final String oneLiner; |
| } |
|
|
| |
| class LibraryChatResult { |
| const LibraryChatResult({required this.reply, required this.sources}); |
| final String reply; |
| final List<LibrarySource> sources; |
| } |
|
|
| |
| |
| class RabbitHoleStep { |
| const RabbitHoleStep({ |
| required this.topic, |
| required this.explanation, |
| required this.threads, |
| }); |
| final String topic; |
| final String explanation; |
| final List<String> threads; |
| } |
|
|
| |
| 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 { |
| |
| |
| if (tokenProvider != null) unawaited(_authHeader()); |
| } |
|
|
| |
| |
| final Future<String?> Function({bool forceRefresh})? tokenProvider; |
|
|
| |
| |
| |
| |
| static const String _hostedDefault = 'https://vatxzz-cachy.hf.space'; |
| static const String _defaultBaseUrl = String.fromEnvironment( |
| 'CACHY_API_BASE', |
| defaultValue: _hostedDefault, |
| ); |
|
|
| |
| |
| static String get defaultBaseUrl => _defaultBaseUrl; |
|
|
| |
| |
| |
| static Future<String> 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<String?> discoverAndUpdateBaseUrl() async { |
| final discovered = await discoverBackend(); |
| if (discovered != null) setBaseUrl(discovered); |
| return discovered; |
| } |
|
|
| |
| |
| String? _lastToken; |
|
|
| Future<Map<String, String>> _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'}; |
| } |
|
|
| |
| |
| |
| |
| |
| Map<String, String> 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 {}; |
| } |
|
|
| |
| Future<http.Response> _send( |
| Future<http.Response> Function(Map<String, String> headers) go, { |
| Map<String, String> 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<String, dynamic>? query]) => Uri.parse('$baseUrl$path') |
| .replace(queryParameters: query?.map((k, v) => MapEntry(k, '$v'))); |
|
|
| |
| |
| |
| String resolveMedia(String ref) { |
| if (ref.startsWith('http://') || ref.startsWith('https://')) return ref; |
| final path = ref.startsWith('/') ? ref : '/$ref'; |
| return '$baseUrl$path'; |
| } |
|
|
| |
| |
| |
|
|
| Future<QuotaStatus> 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<String, dynamic>?)?[field] as num?)?.toInt() ?? 0; |
| return QuotaStatus( |
| cardsUsed: pick('cards', 'used'), |
| cardsLimit: pick('cards', 'limit'), |
| chatUsed: pick('chat', 'used'), |
| chatLimit: pick('chat', 'limit'), |
| ); |
| } |
|
|
| |
| |
| |
| Future<int> 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; |
| } |
|
|
| |
| |
| |
|
|
| Future<CreateCardResult> 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, |
| ); |
| } |
|
|
| |
| |
| Future<Map<String, String>?> 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?) ?? '', |
| }; |
| } |
|
|
| |
| |
| Future<Card> uploadStructure(String cardId, Map<String, dynamic> 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<Card> getCard(String cardId) async { |
| final resp = await _send((h) => _client.get(_uri('/cards/$cardId'), headers: h)); |
| return Card.fromJson(_decodeMap(resp)); |
| } |
|
|
| Future<List<Card>> 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(); |
| } |
|
|
| |
| Future<Card> patchCardBlocks( |
| String cardId, |
| List<Map<String, dynamic>> 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)); |
| } |
|
|
| |
| Future<Card> patchCardActionItems( |
| String cardId, |
| Map<String, dynamic> 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<void> 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<void> importCards(List<Map<String, dynamic>> 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<List<Card>> 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(); |
| } |
|
|
| |
| |
| |
| Future<String> chat(String cardId, List<Map<String, String>> 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?) ?? ''; |
| } |
|
|
| |
| |
| Future<List<Map<String, String>>> chatHistory(String cardId) async { |
| final resp = await _send((h) => _client.get(_uri('/cards/$cardId/chat'), headers: h)); |
| return _decodeMessages(_decodeMap(resp)['messages']); |
| } |
|
|
| |
| |
| |
| |
| |
| Future<RabbitHoleStep> exploreRabbitHole( |
| String cardId, |
| String topic, |
| List<String> 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(), |
| ); |
| } |
|
|
| |
| |
| Future<List<RabbitHoleStep>> 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<String, dynamic>>() |
| .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(); |
| } |
|
|
| |
| |
| |
|
|
| Future<List<CollectionEntry>> listCollections() async { |
| final resp = await _send((h) => _client.get(_uri('/collections'), headers: h)); |
| return _decodeList(resp).map(CollectionEntry.fromJson).toList(); |
| } |
|
|
| Future<CollectionEntry> 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<CollectionEntry> 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<void> 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); |
| } |
|
|
| |
| |
| |
|
|
| Future<List<CatalogEntry>> 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<void> 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); |
| } |
| } |
|
|
| |
| Future<CatalogEntry> saveCatalogEntry(String artifactId) async { |
| final resp = await _send((h) => _client.post(_uri('/catalog/$artifactId/save'), headers: h)); |
| return CatalogEntry.fromJson(_decodeMap(resp)); |
| } |
|
|
| |
| Future<CatalogEntry> fetchCatalogInfo(String artifactId) async { |
| final resp = |
| await _send((h) => _client.post(_uri('/catalog/$artifactId/fetch-info'), headers: h)); |
| return CatalogEntry.fromJson(_decodeMap(resp)); |
| } |
|
|
| |
| Future<List<CatalogEntry>> 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(); |
| } |
|
|
| |
| |
| |
|
|
| Future<List<ConceptEntry>> 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<ConceptDetail> getConcept(String conceptId) async { |
| final resp = await _send((h) => _client.get(_uri('/concepts/$conceptId'), headers: h)); |
| return ConceptDetail.fromJson(_decodeMap(resp)); |
| } |
|
|
| Future<ConceptEntry> defineConcept(String conceptId) async { |
| final resp = await _send((h) => _client.post(_uri('/concepts/$conceptId/define'), headers: h)); |
| return ConceptEntry.fromJson(_decodeMap(resp)); |
| } |
|
|
| Future<void> 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); |
| } |
|
|
| |
| |
| |
|
|
| Future<GraphData> 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)); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| Future<LibraryChatResult> libraryChat( |
| List<Map<String, String>> 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<String, dynamic>>() |
| .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, |
| ); |
| } |
|
|
| |
| |
| Future<List<Map<String, String>>> libraryChatHistory() async { |
| final resp = await _send((h) => _client.get(_uri('/library/chat'), headers: h)); |
| return _decodeMessages(_decodeMap(resp)['messages']); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| Future<List<FeedItem>> 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<String, dynamic>>() |
| .map(FeedItem.fromJson) |
| .toList(); |
| } |
|
|
| |
| |
| Future<List<Connection>> 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<String, dynamic>>() |
| .map(Connection.fromJson) |
| .toList(); |
| } |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| |
| Stream<PipelineEvent> 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) { |
| |
| if (dataBuffer.isNotEmpty) { |
| final event = _parseEvent(dataBuffer.toString()); |
| dataBuffer.clear(); |
| if (event != null) { |
| yield event; |
| if (event.isTerminal) return; |
| } |
| } |
| continue; |
| } |
| if (line.startsWith(':')) continue; |
| if (line.startsWith('data:')) { |
| dataBuffer.write(line.substring(5).trimLeft()); |
| } |
| } |
| } |
|
|
| PipelineEvent? _parseEvent(String data) { |
| try { |
| final decoded = jsonDecode(data); |
| if (decoded is Map<String, dynamic>) return PipelineEvent.fromJson(decoded); |
| } catch (_) { |
| |
| } |
| return null; |
| } |
|
|
| |
| |
| |
|
|
| Map<String, dynamic> _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<String, dynamic>) return decoded; |
| throw ApiException(resp.statusCode, 'expected object, got ${decoded.runtimeType}'); |
| } |
|
|
| |
| List<Map<String, String>> _decodeMessages(Object? raw) { |
| return ((raw as List?) ?? const []) |
| .whereType<Map<String, dynamic>>() |
| .map((m) => { |
| 'role': (m['role'] as String?) ?? '', |
| 'content': (m['content'] as String?) ?? '', |
| }) |
| .where((m) => m['content']!.isNotEmpty) |
| .toList(); |
| } |
|
|
| List<Map<String, dynamic>> _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<Map<String, dynamic>>().toList(); |
| throw ApiException(resp.statusCode, 'expected list, got ${decoded.runtimeType}'); |
| } |
|
|
| void close() => _client.close(); |
| } |
|
|