diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index aa4320e..fe02353 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -1483,6 +1483,52 @@ class ApiService { return []; } + Future?> processWebpage({ + required String url, + String? collectionName, + }) async { + _traceApi('Processing webpage: $url'); + try { + final response = await _dio.post( + '/api/v1/retrieval/process/web', + data: { + 'url': url, + if (collectionName != null) 'collection_name': collectionName, + }, + ); + if (response.data is Map) { + return response.data as Map; + } + return null; + } catch (e) { + _traceApi('Process webpage failed: $e'); + return null; + } + } + + Future?> processYoutube({ + required String url, + String? collectionName, + }) async { + _traceApi('Processing YouTube URL: $url'); + try { + final response = await _dio.post( + '/api/v1/retrieval/process/youtube', + data: { + 'url': url, + if (collectionName != null) 'collection_name': collectionName, + }, + ); + if (response.data is Map) { + return response.data as Map; + } + return null; + } catch (e) { + _traceApi('Process YouTube failed: $e'); + return null; + } + } + // Web Search Future> performWebSearch(List queries) async { _traceApi('Performing web search for queries: $queries'); diff --git a/lib/core/services/app_intents_service.dart b/lib/core/services/app_intents_service.dart index ebfbaa8..8273e95 100644 --- a/lib/core/services/app_intents_service.dart +++ b/lib/core/services/app_intents_service.dart @@ -13,6 +13,8 @@ import '../providers/app_providers.dart'; import '../utils/debug_logger.dart'; import 'navigation_service.dart'; import '../../features/chat/providers/chat_providers.dart'; +import '../../features/chat/providers/context_attachments_provider.dart'; +import '../../features/chat/providers/knowledge_cache_provider.dart'; import '../../features/auth/providers/unified_auth_providers.dart'; import '../../features/chat/views/voice_call_page.dart'; import '../../features/chat/services/file_attachment_service.dart'; @@ -25,6 +27,7 @@ const _voiceCallIntentId = 'app.cogwheel.conduit.start_voice_call'; const _sendTextIntentId = 'app.cogwheel.conduit.send_text'; const _sendUrlIntentId = 'app.cogwheel.conduit.send_url'; const _sendImageIntentId = 'app.cogwheel.conduit.send_image'; +const _attachKnowledgeIntentId = 'app.cogwheel.conduit.attach_knowledge'; /// Registers and handles iOS App Intents for Siri/Shortcuts. @Riverpod(keepAlive: true) @@ -39,6 +42,7 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { unawaited(_registerSendTextIntent()); unawaited(_registerSendUrlIntent()); unawaited(_registerSendImageIntent()); + unawaited(_registerAttachKnowledgeIntent()); } Future _registerAskIntent() async { @@ -216,6 +220,41 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { } } + Future _registerAttachKnowledgeIntent() async { + final client = FlutterAppIntentsClient.instance; + final intent = AppIntentBuilder() + .identifier(_attachKnowledgeIntentId) + .title('Attach Knowledge') + .description('Attach a document from your knowledge base to the chat.') + .parameter( + const AppIntentParameter( + name: 'documentName', + title: 'Document Name', + description: 'Name of the knowledge base document to attach.', + type: AppIntentParameterType.string, + isOptional: false, + ), + ) + .build(); + + try { + await client.registerIntent(intent, _handleAttachKnowledgeIntent); + await FlutterAppIntentsService.donateIntentWithMetadata( + _attachKnowledgeIntentId, + const {}, + relevanceScore: 0.7, + context: {'feature': 'knowledge', 'source': 'app_intent'}, + ); + } catch (error, stackTrace) { + DebugLogger.error( + 'app-intents-register-knowledge', + scope: 'siri', + error: error, + stackTrace: stackTrace, + ); + } + } + Future _handleAskIntent( Map parameters, ) async { @@ -301,17 +340,78 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { return AppIntentResult.failed(error: 'No URL provided.'); } - final prompt = 'Please summarize or analyze:\n$url'; try { + // Determine if this is a YouTube URL + final isYoutube = url.startsWith('https://www.youtube.com') || + url.startsWith('https://youtu.be') || + url.startsWith('https://youtube.com') || + url.startsWith('https://m.youtube.com'); + + // Try to fetch the URL content first + String? content; + String? name; + String? collectionName; + final api = ref.read(apiServiceProvider); + if (api != null) { + final result = isYoutube + ? await api.processYoutube(url: url) + : await api.processWebpage(url: url); + + final file = + (result?['file'] as Map?)?.cast(); + final fileData = + (file?['data'] as Map?)?.cast(); + content = fileData?['content']?.toString() ?? ''; + final meta = (file?['meta'] as Map?)?.cast(); + name = meta?['name']?.toString() ?? Uri.parse(url).host; + collectionName = result?['collection_name']?.toString(); + } + + final prompt = isYoutube + ? 'Please summarize or analyze this video:' + : 'Please summarize or analyze this page:'; + + // Reset chat first, then add attachments (startNewChat clears attachments) await _prepareChatWithOptions( prompt: prompt, focusComposer: true, resetChat: true, ); - return AppIntentResult.successful( - value: 'Opening Conduit for this link', - needsToContinueInApp: true, - ); + + // Add attachments after reset so they aren't cleared + final bool contentAttached = content != null && content.isNotEmpty; + if (contentAttached) { + final notifier = ref.read(contextAttachmentsProvider.notifier); + if (isYoutube) { + notifier.addYoutube( + displayName: name ?? Uri.parse(url).host, + content: content, + url: url, + collectionName: collectionName, + ); + } else { + notifier.addWeb( + displayName: name ?? Uri.parse(url).host, + content: content, + url: url, + collectionName: collectionName, + ); + } + } + + if (contentAttached) { + return AppIntentResult.successful( + value: isYoutube + ? 'YouTube video attached in Conduit' + : 'Webpage attached in Conduit', + needsToContinueInApp: true, + ); + } else { + return AppIntentResult.successful( + value: 'Opening Conduit with URL (content could not be fetched)', + needsToContinueInApp: true, + ); + } } catch (error, stackTrace) { DebugLogger.error( 'app-intents-url', @@ -354,6 +454,69 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { } } + Future _handleAttachKnowledgeIntent( + Map parameters, + ) async { + final documentName = (parameters['documentName'] as String?)?.trim(); + if (documentName == null || documentName.isEmpty) { + return AppIntentResult.failed(error: 'No document name provided.'); + } + + try { + // Ensure knowledge bases are loaded + final cacheNotifier = ref.read(knowledgeCacheProvider.notifier); + await cacheNotifier.ensureBases(); + + final cacheState = ref.read(knowledgeCacheProvider); + final bases = cacheState.bases; + + // Search through all knowledge bases for matching items + for (final base in bases) { + await cacheNotifier.fetchItemsForBase(base.id); + final updatedState = ref.read(knowledgeCacheProvider); + final items = updatedState.items[base.id] ?? const []; + + for (final item in items) { + final itemTitle = item.title ?? item.metadata['name']?.toString() ?? ''; + if (itemTitle.toLowerCase().contains(documentName.toLowerCase())) { + // Reset chat first, then add attachment (startNewChat clears attachments) + await _prepareChatWithOptions( + focusComposer: true, + resetChat: true, + ); + + // Add attachment after reset so it isn't cleared + ref.read(contextAttachmentsProvider.notifier).addKnowledge( + displayName: itemTitle, + fileId: item.id, + collectionName: base.name, + url: item.metadata['source']?.toString(), + ); + + return AppIntentResult.successful( + value: 'Attached "$itemTitle" from ${base.name}', + needsToContinueInApp: true, + ); + } + } + } + + return AppIntentResult.failed( + error: 'No document found matching "$documentName".', + ); + } catch (error, stackTrace) { + DebugLogger.error( + 'app-intents-knowledge', + scope: 'siri', + error: error, + stackTrace: stackTrace, + ); + return AppIntentResult.failed( + error: 'Unable to attach knowledge: $error', + ); + } + } + Future _prepareChat({String? prompt}) async { await _prepareChatWithOptions( prompt: prompt, @@ -424,6 +587,9 @@ class AppIntentCoordinator extends _$AppIntentCoordinator { throw StateError('Navigation is not available.'); } + // Dismiss keyboard before navigating + FocusManager.instance.primaryFocus?.unfocus(); + await navigator.push( MaterialPageRoute( builder: (_) => const VoiceCallPage(startNewConversation: true), diff --git a/lib/core/utils/android_assistant_handler.dart b/lib/core/utils/android_assistant_handler.dart index e86ac9a..e889fd9 100644 --- a/lib/core/utils/android_assistant_handler.dart +++ b/lib/core/utils/android_assistant_handler.dart @@ -156,6 +156,9 @@ class AndroidAssistantHandler { return; } + // Dismiss keyboard before navigating + FocusScope.of(context).unfocus(); + // Navigate to voice call page with new conversation flag await Navigator.of(context).push( MaterialPageRoute( diff --git a/lib/features/chat/models/chat_context_attachment.dart b/lib/features/chat/models/chat_context_attachment.dart new file mode 100644 index 0000000..a6b8f33 --- /dev/null +++ b/lib/features/chat/models/chat_context_attachment.dart @@ -0,0 +1,27 @@ +import 'package:flutter/foundation.dart'; + +/// Represents a non-file attachment that enriches a chat message, +/// such as a web page, YouTube video transcript, or an existing +/// knowledge base document reference. +@immutable +class ChatContextAttachment { + const ChatContextAttachment({ + required this.id, + required this.type, + required this.displayName, + this.url, + this.content, + this.collectionName, + this.fileId, + }); + + final String id; + final ChatContextAttachmentType type; + final String displayName; + final String? url; + final String? content; + final String? collectionName; + final String? fileId; +} + +enum ChatContextAttachmentType { web, youtube, knowledge } diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index c9f8307..44b40bc 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -18,6 +18,8 @@ import '../../../core/services/worker_manager.dart'; import '../../../core/utils/debug_logger.dart'; import '../../../core/utils/markdown_stream_formatter.dart'; import '../../../core/utils/tool_calls_parser.dart'; +import '../models/chat_context_attachment.dart'; +import '../providers/context_attachments_provider.dart'; import '../../../shared/services/tasks/task_queue.dart'; import '../../tools/providers/tools_providers.dart'; import '../services/reviewer_mode_service.dart'; @@ -848,6 +850,9 @@ void startNewChat(dynamic ref) { // Clear messages ref.read(chatMessagesProvider.notifier).clearMessages(); + + // Clear context attachments (web pages, YouTube, knowledge base docs) + ref.read(contextAttachmentsProvider.notifier).clear(); } // Available tools provider @@ -944,47 +949,6 @@ bool validateFileCount(int currentCount, int newFilesCount, int? maxCount) { return (currentCount + newFilesCount) <= maxCount; } -// Helper function to build files array from attachment IDs -Future>?> _buildFilesArrayFromAttachments( - dynamic api, - List attachmentIds, -) async { - final filesArray = >[]; - - for (final attachmentId in attachmentIds) { - try { - final fileInfo = await api.getFileInfo(attachmentId); - final fileName = fileInfo['filename'] ?? fileInfo['name'] ?? 'Unknown'; - final fileSize = fileInfo['size']; - - // Check if it's an image - final ext = fileName.toLowerCase().split('.').last; - final isImage = ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext); - - // Add all files to the files array for WebUI display - // Note: This is for storage/display, not for API message sending - filesArray.add({ - 'type': isImage ? 'image' : 'file', - 'id': attachmentId, // Required for RAG system to lookup file content - 'url': '/api/v1/files/$attachmentId/content', - 'name': fileName, - if (fileSize != null) 'size': fileSize, - }); - } catch (_) { - // If we can't get file info, assume it's a non-image file - // Images should be handled in the content array anyway - filesArray.add({ - 'type': 'file', - 'id': attachmentId, // Required for RAG system to lookup file content - 'url': '/api/v1/files/$attachmentId/content', - 'name': 'Unknown', - }); - } - } - - return filesArray.isNotEmpty ? filesArray : null; -} - // Helper function to get file content as base64 Future _getFileAsBase64(dynamic api, String fileId) async { // Check if this is already a data URL (for images) @@ -1102,6 +1066,60 @@ Future> _buildMessagePayloadWithAttachments({ return messageMap; } +List> _contextAttachmentsToFiles( + List attachments, +) { + return attachments.map((attachment) { + switch (attachment.type) { + case ChatContextAttachmentType.web: + // Web pages use type 'text' with file data nested under 'file' key + return { + 'type': 'text', + 'name': attachment.url ?? attachment.displayName, + if (attachment.url != null) 'url': attachment.url, + if (attachment.collectionName != null) + 'collection_name': attachment.collectionName, + 'file': { + 'data': {'content': attachment.content ?? ''}, + 'meta': { + 'name': attachment.displayName, + if (attachment.url != null) 'source': attachment.url, + }, + }, + }; + case ChatContextAttachmentType.youtube: + // YouTube uses type 'text' with context 'full' for full transcript + return { + 'type': 'text', + 'name': attachment.url ?? attachment.displayName, + if (attachment.url != null) 'url': attachment.url, + 'context': 'full', + if (attachment.collectionName != null) + 'collection_name': attachment.collectionName, + 'file': { + 'data': {'content': attachment.content ?? ''}, + 'meta': { + 'name': attachment.displayName, + if (attachment.url != null) 'source': attachment.url, + }, + }, + }; + case ChatContextAttachmentType.knowledge: + // Knowledge base files use type 'file' with id for lookup + final map = { + 'type': 'file', + 'id': attachment.fileId ?? attachment.id, + 'name': attachment.displayName, + 'knowledge': true, + if (attachment.collectionName != null) + 'collection_name': attachment.collectionName, + if (attachment.url != null) 'source': attachment.url, + }; + return map; + } + }).toList(); +} + // Regenerate message function that doesn't duplicate user message Future regenerateMessage( dynamic ref, @@ -1605,13 +1623,15 @@ Future _sendMessageInternal( var activeConversation = ref.read(activeConversationProvider); // Create user message first - List>? userFiles; - if (attachments != null && - attachments.isNotEmpty && - !reviewerMode && - api != null) { - userFiles = await _buildFilesArrayFromAttachments(api, attachments); - } + // Note: We only store context attachments (web/youtube/knowledge) in msg.files. + // Uploaded files are tracked via attachmentIds and will be rebuilt by + // _buildMessagePayloadWithAttachments when constructing the API payload. + // This prevents uploaded files from being duplicated in the final message. + final contextAttachments = ref.read(contextAttachmentsProvider); + final contextFiles = _contextAttachmentsToFiles(contextAttachments); + final List>? userFiles = contextFiles.isNotEmpty + ? contextFiles + : null; final userMessage = ChatMessage( id: const Uuid().v4(), @@ -1768,10 +1788,23 @@ Future _sendMessageInternal( cleanedText: cleaned, attachmentIds: ids, ); + if (msg.files != null && msg.files!.isNotEmpty) { + messageMap['files'] = [ + ...?messageMap['files'] as List?, + ...msg.files!, + ]; + } conversationMessages.add(messageMap); } else { // Regular text-only message - conversationMessages.add({'role': msg.role, 'content': cleaned}); + final Map messageMap = { + 'role': msg.role, + 'content': cleaned, + }; + if (msg.files != null && msg.files!.isNotEmpty) { + messageMap['files'] = msg.files; + } + conversationMessages.add(messageMap); } } } @@ -2110,6 +2143,13 @@ Future _sendMessageInternal( activeStream.socketSubscriptions, onDispose: activeStream.disposeWatchdog, ); + + // Clear context attachments after successfully initiating the message send. + // This prevents stale attachments from being included in subsequent messages. + try { + ref.read(contextAttachmentsProvider.notifier).clear(); + } catch (_) {} + return; } catch (e) { // Handle error - remove the assistant message placeholder diff --git a/lib/features/chat/providers/context_attachments_provider.dart b/lib/features/chat/providers/context_attachments_provider.dart new file mode 100644 index 0000000..ba27d10 --- /dev/null +++ b/lib/features/chat/providers/context_attachments_provider.dart @@ -0,0 +1,83 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/chat_context_attachment.dart'; + +class ContextAttachmentsNotifier extends Notifier> { + @override + List build() => const []; + + void add(ChatContextAttachment attachment) { + state = [...state, attachment]; + } + + void addWeb({ + required String displayName, + required String content, + required String url, + String? collectionName, + }) { + final id = const Uuid().v4(); + add( + ChatContextAttachment( + id: id, + type: ChatContextAttachmentType.web, + displayName: displayName, + url: url, + content: content, + collectionName: collectionName, + ), + ); + } + + void addYoutube({ + required String displayName, + required String content, + required String url, + String? collectionName, + }) { + final id = const Uuid().v4(); + add( + ChatContextAttachment( + id: id, + type: ChatContextAttachmentType.youtube, + displayName: displayName, + url: url, + content: content, + collectionName: collectionName, + ), + ); + } + + void addKnowledge({ + required String displayName, + required String fileId, + String? collectionName, + String? url, + }) { + final id = const Uuid().v4(); + add( + ChatContextAttachment( + id: id, + type: ChatContextAttachmentType.knowledge, + displayName: displayName, + fileId: fileId, + url: url, + collectionName: collectionName, + ), + ); + } + + void remove(String id) { + state = state.where((item) => item.id != id).toList(); + } + + void clear() { + state = const []; + } +} + +final contextAttachmentsProvider = + NotifierProvider>( + ContextAttachmentsNotifier.new, + ); diff --git a/lib/features/chat/providers/knowledge_cache_provider.dart b/lib/features/chat/providers/knowledge_cache_provider.dart new file mode 100644 index 0000000..5f39f04 --- /dev/null +++ b/lib/features/chat/providers/knowledge_cache_provider.dart @@ -0,0 +1,179 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/models/knowledge_base.dart'; +import '../../../core/services/api_service.dart'; +import '../../../core/services/cache_manager.dart'; +import '../../../core/providers/app_providers.dart'; +import '../../../core/utils/debug_logger.dart'; + +/// Cache keys for knowledge base data. +const String _basesKey = 'knowledge_bases'; +String _itemsKey(String baseId) => 'knowledge_items:$baseId'; + +/// TTL for knowledge cache entries. +const Duration _knowledgeCacheTtl = Duration(minutes: 10); + +/// Centralized cache manager for knowledge base data. +/// +/// Uses the shared [CacheManager] pattern for TTL and LRU eviction. +class KnowledgeCacheManager { + static final KnowledgeCacheManager _instance = + KnowledgeCacheManager._internal(); + factory KnowledgeCacheManager() => _instance; + KnowledgeCacheManager._internal(); + + final CacheManager _cache = CacheManager( + defaultTtl: _knowledgeCacheTtl, + maxEntries: 64, + ); + + /// Returns cached knowledge bases, or null if not cached. + List? getCachedBases() { + final (hit: hit, value: bases) = + _cache.lookup>(_basesKey); + if (hit) { + DebugLogger.log('cache-hit', scope: 'knowledge/bases'); + } + return hit ? bases : null; + } + + /// Caches knowledge bases. + void cacheBases(List bases) { + _cache.write>(_basesKey, bases); + DebugLogger.log( + 'cache-write', + scope: 'knowledge/bases', + data: {'count': bases.length}, + ); + } + + /// Returns cached items for a knowledge base, or null if not cached. + List? getCachedItems(String baseId) { + final (hit: hit, value: items) = + _cache.lookup>(_itemsKey(baseId)); + if (hit) { + DebugLogger.log('cache-hit', scope: 'knowledge/items', data: {'baseId': baseId}); + } + return hit ? items : null; + } + + /// Caches items for a knowledge base. + void cacheItems(String baseId, List items) { + _cache.write>(_itemsKey(baseId), items); + DebugLogger.log( + 'cache-write', + scope: 'knowledge/items', + data: {'baseId': baseId, 'count': items.length}, + ); + } + + /// Clears all knowledge cache entries. + void clear() { + _cache.invalidateMatching((key) => key.startsWith('knowledge')); + DebugLogger.log('cache-clear', scope: 'knowledge'); + } + + /// Returns cache statistics for debugging. + Map stats() => _cache.stats(); +} + +/// State for the knowledge cache provider. +class KnowledgeCacheState { + const KnowledgeCacheState({ + this.bases = const [], + this.items = const >{}, + this.isLoading = false, + }); + + final List bases; + final Map> items; + final bool isLoading; + + KnowledgeCacheState copyWith({ + List? bases, + Map>? items, + bool? isLoading, + }) { + return KnowledgeCacheState( + bases: bases ?? this.bases, + items: items ?? this.items, + isLoading: isLoading ?? this.isLoading, + ); + } +} + +/// Notifier that wraps [KnowledgeCacheManager] with Riverpod reactivity. +class KnowledgeCacheNotifier extends Notifier { + final _cacheManager = KnowledgeCacheManager(); + + @override + KnowledgeCacheState build() { + // Initialize from cache if available + final cachedBases = _cacheManager.getCachedBases(); + if (cachedBases != null && cachedBases.isNotEmpty) { + return KnowledgeCacheState(bases: cachedBases); + } + return const KnowledgeCacheState(); + } + + ApiService? get _api => ref.read(apiServiceProvider); + + Future ensureBases() async { + // Check if already loaded in state + if (state.bases.isNotEmpty) return; + + // Check cache + final cached = _cacheManager.getCachedBases(); + if (cached != null && cached.isNotEmpty) { + state = state.copyWith(bases: cached); + return; + } + + if (_api == null) return; + state = state.copyWith(isLoading: true); + try { + final bases = await _api!.getKnowledgeBases(); + _cacheManager.cacheBases(bases); + state = state.copyWith(bases: bases, isLoading: false); + } catch (_) { + state = state.copyWith(isLoading: false); + } + } + + Future fetchItemsForBase(String baseId) async { + // Check if already in state + if (state.items.containsKey(baseId)) return; + + // Check cache + final cached = _cacheManager.getCachedItems(baseId); + if (cached != null) { + final next = Map>.from(state.items); + next[baseId] = cached; + state = state.copyWith(items: next); + return; + } + + if (_api == null) return; + + final next = Map>.from(state.items); + try { + final items = await _api!.getKnowledgeBaseItems(baseId); + _cacheManager.cacheItems(baseId, items); + next[baseId] = items; + } catch (_) { + next[baseId] = const []; + } + state = state.copyWith(items: next); + } + + /// Clears both in-memory state and persistent cache. + void clearCache() { + _cacheManager.clear(); + state = const KnowledgeCacheState(); + } +} + +final knowledgeCacheProvider = + NotifierProvider( + KnowledgeCacheNotifier.new, +); diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 1f2ce43..9bfcbf4 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -23,6 +23,7 @@ import '../widgets/user_message_bubble.dart'; import '../widgets/assistant_message_widget.dart' as assistant; import '../widgets/streaming_title_text.dart'; import '../widgets/file_attachment_widget.dart'; +import '../widgets/context_attachment_widget.dart'; import '../services/voice_input_service.dart'; import '../services/file_attachment_service.dart'; import 'voice_call_page.dart'; @@ -30,6 +31,7 @@ import '../../../shared/services/tasks/task_queue.dart'; import '../../tools/providers/tools_providers.dart'; import '../../../core/models/chat_message.dart'; import '../../../core/models/model.dart'; +import '../providers/context_attachments_provider.dart'; import '../../../shared/widgets/loading_states.dart'; import 'chat_page_helpers.dart'; import '../../../shared/widgets/themed_dialogs.dart'; @@ -123,6 +125,9 @@ class _ChatPageState extends ConsumerState { ref.read(chatMessagesProvider.notifier).clearMessages(); ref.read(activeConversationProvider.notifier).clear(); + // Clear context attachments (web pages, YouTube, knowledge base docs) + ref.read(contextAttachmentsProvider.notifier).clear(); + // Scroll to top if (_scrollController.hasClients) { _scrollController.jumpTo(0); @@ -597,6 +602,163 @@ class _ChatPageState extends ConsumerState { } } + /// Checks if a URL is a YouTube URL. + bool _isYoutubeUrl(String url) { + return url.startsWith('https://www.youtube.com') || + url.startsWith('https://youtu.be') || + url.startsWith('https://youtube.com') || + url.startsWith('https://m.youtube.com'); + } + + Future _promptAttachWebpage() async { + final api = ref.read(apiServiceProvider); + if (api == null) return; + final l10n = AppLocalizations.of(context)!; + String url = ''; + bool submitting = false; + await showDialog( + context: context, + builder: (dialogContext) { + String? errorText; + return StatefulBuilder( + builder: (innerContext, setState) { + void setError(String? msg) { + setState(() { + errorText = msg; + }); + } + + return AlertDialog( + title: const Text('Attach webpage'), + content: SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Paste a URL to ingest its content into the chat.', + style: Theme.of(innerContext).textTheme.bodySmall, + ), + const SizedBox(height: 12), + TextField( + decoration: InputDecoration( + labelText: 'Webpage URL', + hintText: 'https://example.com/article', + border: const OutlineInputBorder(), + errorText: errorText, + ), + onChanged: (value) { + url = value; + if (errorText != null) setError(null); + }, + autofocus: true, + keyboardType: TextInputType.url, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: submitting + ? null + : () { + Navigator.of(dialogContext).pop(); + }, + child: Text(l10n.cancel), + ), + ElevatedButton( + onPressed: submitting + ? null + : () async { + final parsed = Uri.tryParse(url.trim()); + if (parsed == null || + !(parsed.isScheme('http') || + parsed.isScheme('https'))) { + setError('Enter a valid http(s) URL.'); + return; + } + setState(() { + submitting = true; + errorText = null; + }); + try { + final trimmedUrl = url.trim(); + final isYoutube = _isYoutubeUrl(trimmedUrl); + + // Use appropriate API based on URL type + final result = isYoutube + ? await api.processYoutube(url: trimmedUrl) + : await api.processWebpage(url: trimmedUrl); + + final file = (result?['file'] as Map?) + ?.cast(); + final fileData = (file?['data'] as Map?) + ?.cast(); + final content = + fileData?['content']?.toString() ?? ''; + if (content.isEmpty) { + setError( + isYoutube + ? 'Could not fetch YouTube transcript.' + : 'The page had no readable content.', + ); + return; + } + final meta = (file?['meta'] as Map?) + ?.cast(); + final name = + meta?['name']?.toString() ?? parsed.host; + final collectionName = + result?['collection_name']?.toString(); + + // Add as appropriate type + final notifier = + ref.read(contextAttachmentsProvider.notifier); + if (isYoutube) { + notifier.addYoutube( + displayName: name, + content: content, + url: trimmedUrl, + collectionName: collectionName, + ); + } else { + notifier.addWeb( + displayName: name, + content: content, + url: trimmedUrl, + collectionName: collectionName, + ); + } + + if (!mounted || !dialogContext.mounted) { + return; + } + Navigator.of(dialogContext).pop(); + } catch (_) { + setError('Failed to attach content.'); + } finally { + if (mounted) { + setState(() => submitting = false); + } + } + }, + child: submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Attach'), + ), + ], + ); + }, + ); + }, + ); + } + void _handleNewChat() { // Start a new chat using the existing function startNewChat(); @@ -610,6 +772,9 @@ class _ChatPageState extends ConsumerState { } void _handleVoiceCall() { + // Dismiss keyboard before navigating + FocusScope.of(context).unfocus(); + // Navigate to voice call page Navigator.of(context).push( MaterialPageRoute( @@ -1842,6 +2007,7 @@ class _ChatPageState extends ConsumerState { // File attachments const FileAttachmentWidget(), + const ContextAttachmentWidget(), // Modern Input (root matches input background including safe area) RepaintBoundary( @@ -1862,6 +2028,7 @@ class _ChatPageState extends ConsumerState { onImageAttachment: _handleImageAttachment, onCameraCapture: () => _handleImageAttachment(fromCamera: true), + onWebAttachment: _promptAttachWebpage, ), ), ), diff --git a/lib/features/chat/widgets/context_attachment_widget.dart b/lib/features/chat/widgets/context_attachment_widget.dart new file mode 100644 index 0000000..8cd31bd --- /dev/null +++ b/lib/features/chat/widgets/context_attachment_widget.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:conduit/l10n/app_localizations.dart'; + +import '../models/chat_context_attachment.dart'; +import '../providers/context_attachments_provider.dart'; + +class ContextAttachmentWidget extends ConsumerWidget { + const ContextAttachmentWidget({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final attachments = ref.watch(contextAttachmentsProvider); + if (attachments.isEmpty) return const SizedBox.shrink(); + + final l10n = AppLocalizations.of(context)!; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.attachments, style: Theme.of(context).textTheme.bodySmall), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: attachments + .map( + (attachment) => InputChip( + label: Text( + attachment.displayName, + overflow: TextOverflow.ellipsis, + ), + avatar: Icon(_iconForType(attachment.type), size: 18), + onDeleted: () => ref + .read(contextAttachmentsProvider.notifier) + .remove(attachment.id), + ), + ) + .toList(), + ), + ], + ), + ); + } + + IconData _iconForType(ChatContextAttachmentType type) { + switch (type) { + case ChatContextAttachmentType.web: + return Icons.public; + case ChatContextAttachmentType.youtube: + return Icons.play_circle_outline; + case ChatContextAttachmentType.knowledge: + return Icons.folder_outlined; + } + } +} diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index d3daf19..f20b0fb 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -12,6 +12,8 @@ import 'dart:async'; import 'dart:ui'; import 'dart:math' as math; import '../providers/chat_providers.dart'; +import '../providers/context_attachments_provider.dart'; +import '../providers/knowledge_cache_provider.dart'; import '../../tools/providers/tools_providers.dart'; import '../../prompts/providers/prompts_providers.dart'; import '../../../core/models/tool.dart'; @@ -19,6 +21,7 @@ import '../../../core/models/prompt.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/services/settings_service.dart'; import '../../chat/services/voice_input_service.dart'; +import '../../../core/models/knowledge_base.dart'; import '../../../shared/utils/platform_utils.dart'; import 'package:conduit/l10n/app_localizations.dart'; @@ -64,6 +67,7 @@ class ModernChatInput extends ConsumerStatefulWidget { final Function()? onFileAttachment; final Function()? onImageAttachment; final Function()? onCameraCapture; + final Function()? onWebAttachment; const ModernChatInput({ super.key, @@ -74,6 +78,7 @@ class ModernChatInput extends ConsumerStatefulWidget { this.onFileAttachment, this.onImageAttachment, this.onCameraCapture, + this.onWebAttachment, }); @override @@ -291,7 +296,9 @@ class _ModernChatInputState extends ConsumerState if (!wasShowing && shouldShow) { // Trigger prompt fetch lazily when overlay first appears - ref.read(promptsListProvider.future); + if (_currentPromptCommand.startsWith('/')) { + ref.read(promptsListProvider.future); + } } } @@ -317,7 +324,8 @@ class _ModernChatInputState extends ConsumerState } final String candidate = text.substring(start, cursor); - if (candidate.isEmpty || !candidate.startsWith('/')) { + if (candidate.isEmpty || + !(candidate.startsWith('/') || candidate.startsWith('#'))) { return null; } @@ -326,13 +334,18 @@ class _ModernChatInputState extends ConsumerState List _filterPrompts(List prompts) { if (prompts.isEmpty) return const []; - final String query = _currentPromptCommand.toLowerCase(); + final String query = _currentPromptCommand.toLowerCase().trim(); + // Strip leading '/' prefix so we can match prompt commands (e.g., "help") + final String searchQuery = query.startsWith('/') ? query.substring(1) : query; + + // Prevent matching all prompts when user types only '/' + if (searchQuery.isEmpty) return const []; final List filtered = prompts .where( (prompt) => - prompt.command.toLowerCase().contains(query.trim()) && + prompt.command.toLowerCase().contains(searchQuery) && prompt.content.isNotEmpty, ) .toList() @@ -348,6 +361,11 @@ class _ModernChatInputState extends ConsumerState } void _movePromptSelection(int delta) { + if (_currentPromptCommand.startsWith('#')) { + // Only a single option in knowledge overlay; nothing to move. + return; + } + final AsyncValue> promptsAsync = ref.read(promptsListProvider); final List? prompts = promptsAsync.value; if (prompts == null || prompts.isEmpty) return; @@ -369,6 +387,11 @@ class _ModernChatInputState extends ConsumerState } void _confirmPromptSelection() { + if (_currentPromptCommand.startsWith('#')) { + _openKnowledgePicker(); + return; + } + final AsyncValue> promptsAsync = ref.read(promptsListProvider); final List? prompts = promptsAsync.value; if (prompts == null || prompts.isEmpty) return; @@ -421,6 +444,147 @@ class _ModernChatInputState extends ConsumerState }); } + Future _openKnowledgePicker() async { + _hidePromptOverlay(); + + // Ensure bases are loaded in the centralized cache + final cacheNotifier = ref.read(knowledgeCacheProvider.notifier); + await cacheNotifier.ensureBases(); + if (!mounted) return; + + // Track selected base ID outside the builder so it persists across rebuilds + String? selectedBaseId; + + await showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (modalContext) { + return ModalSheetSafeArea( + // Use StatefulBuilder to manage selectedBaseId locally so that + // selecting a knowledge base triggers a proper rebuild. + child: StatefulBuilder( + builder: (statefulContext, setModalState) { + return Consumer( + builder: (innerContext, innerRef, _) { + final cacheState = innerRef.watch(knowledgeCacheProvider); + final bases = cacheState.bases; + final itemsMap = cacheState.items; + final items = selectedBaseId != null + ? itemsMap[selectedBaseId] ?? const [] + : const []; + final loading = cacheState.isLoading || + (selectedBaseId != null && + !itemsMap.containsKey(selectedBaseId)); + + Future loadItems(KnowledgeBase base) async { + setModalState(() { + selectedBaseId = base.id; + }); + await innerRef + .read(knowledgeCacheProvider.notifier) + .fetchItemsForBase(base.id); + } + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: innerContext.conduitTheme.surfaceBackground, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.modal), + ), + boxShadow: ConduitShadows.modal(innerContext), + ), + child: SizedBox( + height: MediaQuery.of(innerContext).size.height * 0.6, + child: Row( + children: [ + Expanded( + flex: 1, + child: ListView.builder( + itemCount: bases.length, + itemBuilder: (context, index) { + final base = bases[index]; + final isSelected = selectedBaseId == base.id; + return ListTile( + dense: true, + selected: isSelected, + title: Text(base.name), + onTap: () => loadItems(base), + ); + }, + ), + ), + const VerticalDivider(width: 1), + Expanded( + flex: 2, + child: loading + ? const Center( + child: CircularProgressIndicator(), + ) + : ListView.builder( + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + final KnowledgeBase? selectedBase = + bases.isEmpty + ? null + : bases.firstWhere( + (b) => b.id == selectedBaseId, + orElse: () => bases.first, + ); + return ListTile( + title: Text( + item.title ?? + item.metadata['name']?.toString() ?? + 'Document', + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + item.metadata['source']?.toString() ?? + item.content, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () { + innerRef + .read( + contextAttachmentsProvider + .notifier, + ) + .addKnowledge( + displayName: item.title ?? + item.metadata['name'] + ?.toString() ?? + 'Document', + fileId: item.id, + collectionName: + selectedBase?.name ?? + 'Unknown', + url: item.metadata['source'] + ?.toString(), + ); + if (modalContext.mounted) { + Navigator.of(modalContext).pop(); + } + }, + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ); + }, + ); + } + Widget _buildPromptOverlay(BuildContext context) { final Brightness brightness = Theme.of(context).brightness; final overlayColor = context.conduitTheme.cardBackground; @@ -428,6 +592,10 @@ class _ModernChatInputState extends ConsumerState alpha: brightness == Brightness.dark ? 0.6 : 0.4, ); + if (_currentPromptCommand.startsWith('#')) { + return _buildKnowledgeOverlay(context, overlayColor, borderColor); + } + final AsyncValue> promptsAsync = ref.watch( promptsListProvider, ); @@ -593,6 +761,38 @@ class _ModernChatInputState extends ConsumerState ); } + Widget _buildKnowledgeOverlay( + BuildContext context, + Color overlayColor, + Color borderColor, + ) { + return Container( + decoration: BoxDecoration( + color: overlayColor, + borderRadius: BorderRadius.circular(AppBorderRadius.card), + border: Border.all(color: borderColor, width: BorderWidth.thin), + boxShadow: [ + BoxShadow( + color: context.conduitTheme.cardShadow.withValues( + alpha: Theme.of(context).brightness == Brightness.dark + ? 0.28 + : 0.16, + ), + blurRadius: 22, + offset: const Offset(0, 8), + spreadRadius: -4, + ), + ], + ), + child: ListTile( + title: const Text('Browse knowledge base'), + subtitle: const Text('Press Enter to pick a document'), + leading: const Icon(Icons.folder_outlined), + onTap: _openKnowledgePicker, + ), + ); + } + @override Widget build(BuildContext context) { ref.listen(prefilledInputTextProvider, (previous, next) { @@ -1710,6 +1910,16 @@ class _ModernChatInputState extends ConsumerState widget.onCameraCapture!.call(); }, ), + _buildOverflowAction( + icon: Icons.public, + label: 'Attach webpage', + onTap: widget.onWebAttachment == null + ? null + : () { + HapticFeedback.lightImpact(); + widget.onWebAttachment!.call(); + }, + ), ]; final featureTiles = []; diff --git a/lib/shared/services/tasks/task_worker.dart b/lib/shared/services/tasks/task_worker.dart index 6f10e55..573f590 100644 --- a/lib/shared/services/tasks/task_worker.dart +++ b/lib/shared/services/tasks/task_worker.dart @@ -7,6 +7,7 @@ import '../../../core/providers/app_providers.dart'; import '../../../core/services/attachment_upload_queue.dart'; import '../../../core/utils/debug_logger.dart'; import '../../../features/chat/providers/chat_providers.dart' as chat; +import '../../../features/chat/providers/context_attachments_provider.dart'; import '../../../features/chat/services/file_attachment_service.dart'; import 'outbound_task.dart'; @@ -55,13 +56,21 @@ class TaskWorker { } } catch (_) {} - // Delegate to existing unified send implementation - await chat.sendMessageFromService( - _ref, - task.text, - task.attachments.isEmpty ? null : task.attachments, - task.toolIds.isEmpty ? null : task.toolIds, - ); + // Delegate to existing unified send implementation. + // Always clear context attachments after send, even on failure, + // to prevent stale attachments from leaking into subsequent messages. + try { + await chat.sendMessageFromService( + _ref, + task.text, + task.attachments.isEmpty ? null : task.attachments, + task.toolIds.isEmpty ? null : task.toolIds, + ); + } finally { + try { + _ref.read(contextAttachmentsProvider.notifier).clear(); + } catch (_) {} + } } Future _performUploadMedia(UploadMediaTask task) async {