Merge pull request #182 from cogwheel0/feat-chat-context-attachment-support

feat(chat): Add context attachment and knowledge base support
This commit is contained in:
cogwheel
2025-11-26 23:03:35 +05:30
committed by GitHub
11 changed files with 1052 additions and 65 deletions

View File

@@ -1483,6 +1483,52 @@ class ApiService {
return []; return [];
} }
Future<Map<String, dynamic>?> 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<String, dynamic>) {
return response.data as Map<String, dynamic>;
}
return null;
} catch (e) {
_traceApi('Process webpage failed: $e');
return null;
}
}
Future<Map<String, dynamic>?> 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<String, dynamic>) {
return response.data as Map<String, dynamic>;
}
return null;
} catch (e) {
_traceApi('Process YouTube failed: $e');
return null;
}
}
// Web Search // Web Search
Future<Map<String, dynamic>> performWebSearch(List<String> queries) async { Future<Map<String, dynamic>> performWebSearch(List<String> queries) async {
_traceApi('Performing web search for queries: $queries'); _traceApi('Performing web search for queries: $queries');

View File

@@ -13,6 +13,8 @@ import '../providers/app_providers.dart';
import '../utils/debug_logger.dart'; import '../utils/debug_logger.dart';
import 'navigation_service.dart'; import 'navigation_service.dart';
import '../../features/chat/providers/chat_providers.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/auth/providers/unified_auth_providers.dart';
import '../../features/chat/views/voice_call_page.dart'; import '../../features/chat/views/voice_call_page.dart';
import '../../features/chat/services/file_attachment_service.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 _sendTextIntentId = 'app.cogwheel.conduit.send_text';
const _sendUrlIntentId = 'app.cogwheel.conduit.send_url'; const _sendUrlIntentId = 'app.cogwheel.conduit.send_url';
const _sendImageIntentId = 'app.cogwheel.conduit.send_image'; const _sendImageIntentId = 'app.cogwheel.conduit.send_image';
const _attachKnowledgeIntentId = 'app.cogwheel.conduit.attach_knowledge';
/// Registers and handles iOS App Intents for Siri/Shortcuts. /// Registers and handles iOS App Intents for Siri/Shortcuts.
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)
@@ -39,6 +42,7 @@ class AppIntentCoordinator extends _$AppIntentCoordinator {
unawaited(_registerSendTextIntent()); unawaited(_registerSendTextIntent());
unawaited(_registerSendUrlIntent()); unawaited(_registerSendUrlIntent());
unawaited(_registerSendImageIntent()); unawaited(_registerSendImageIntent());
unawaited(_registerAttachKnowledgeIntent());
} }
Future<void> _registerAskIntent() async { Future<void> _registerAskIntent() async {
@@ -216,6 +220,41 @@ class AppIntentCoordinator extends _$AppIntentCoordinator {
} }
} }
Future<void> _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<AppIntentResult> _handleAskIntent( Future<AppIntentResult> _handleAskIntent(
Map<String, dynamic> parameters, Map<String, dynamic> parameters,
) async { ) async {
@@ -301,17 +340,78 @@ class AppIntentCoordinator extends _$AppIntentCoordinator {
return AppIntentResult.failed(error: 'No URL provided.'); return AppIntentResult.failed(error: 'No URL provided.');
} }
final prompt = 'Please summarize or analyze:\n$url';
try { 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<String, dynamic>();
final fileData =
(file?['data'] as Map?)?.cast<String, dynamic>();
content = fileData?['content']?.toString() ?? '';
final meta = (file?['meta'] as Map?)?.cast<String, dynamic>();
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( await _prepareChatWithOptions(
prompt: prompt, prompt: prompt,
focusComposer: true, focusComposer: true,
resetChat: true, resetChat: 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( return AppIntentResult.successful(
value: 'Opening Conduit for this link', value: isYoutube
? 'YouTube video attached in Conduit'
: 'Webpage attached in Conduit',
needsToContinueInApp: true, needsToContinueInApp: true,
); );
} else {
return AppIntentResult.successful(
value: 'Opening Conduit with URL (content could not be fetched)',
needsToContinueInApp: true,
);
}
} catch (error, stackTrace) { } catch (error, stackTrace) {
DebugLogger.error( DebugLogger.error(
'app-intents-url', 'app-intents-url',
@@ -354,6 +454,69 @@ class AppIntentCoordinator extends _$AppIntentCoordinator {
} }
} }
Future<AppIntentResult> _handleAttachKnowledgeIntent(
Map<String, dynamic> 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<void> _prepareChat({String? prompt}) async { Future<void> _prepareChat({String? prompt}) async {
await _prepareChatWithOptions( await _prepareChatWithOptions(
prompt: prompt, prompt: prompt,
@@ -424,6 +587,9 @@ class AppIntentCoordinator extends _$AppIntentCoordinator {
throw StateError('Navigation is not available.'); throw StateError('Navigation is not available.');
} }
// Dismiss keyboard before navigating
FocusManager.instance.primaryFocus?.unfocus();
await navigator.push( await navigator.push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => const VoiceCallPage(startNewConversation: true), builder: (_) => const VoiceCallPage(startNewConversation: true),

View File

@@ -156,6 +156,9 @@ class AndroidAssistantHandler {
return; return;
} }
// Dismiss keyboard before navigating
FocusScope.of(context).unfocus();
// Navigate to voice call page with new conversation flag // Navigate to voice call page with new conversation flag
await Navigator.of(context).push( await Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(

View File

@@ -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 }

View File

@@ -18,6 +18,8 @@ import '../../../core/services/worker_manager.dart';
import '../../../core/utils/debug_logger.dart'; import '../../../core/utils/debug_logger.dart';
import '../../../core/utils/markdown_stream_formatter.dart'; import '../../../core/utils/markdown_stream_formatter.dart';
import '../../../core/utils/tool_calls_parser.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 '../../../shared/services/tasks/task_queue.dart';
import '../../tools/providers/tools_providers.dart'; import '../../tools/providers/tools_providers.dart';
import '../services/reviewer_mode_service.dart'; import '../services/reviewer_mode_service.dart';
@@ -848,6 +850,9 @@ void startNewChat(dynamic ref) {
// Clear messages // Clear messages
ref.read(chatMessagesProvider.notifier).clearMessages(); ref.read(chatMessagesProvider.notifier).clearMessages();
// Clear context attachments (web pages, YouTube, knowledge base docs)
ref.read(contextAttachmentsProvider.notifier).clear();
} }
// Available tools provider // Available tools provider
@@ -944,47 +949,6 @@ bool validateFileCount(int currentCount, int newFilesCount, int? maxCount) {
return (currentCount + newFilesCount) <= maxCount; return (currentCount + newFilesCount) <= maxCount;
} }
// Helper function to build files array from attachment IDs
Future<List<Map<String, dynamic>>?> _buildFilesArrayFromAttachments(
dynamic api,
List<String> attachmentIds,
) async {
final filesArray = <Map<String, dynamic>>[];
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 // Helper function to get file content as base64
Future<String?> _getFileAsBase64(dynamic api, String fileId) async { Future<String?> _getFileAsBase64(dynamic api, String fileId) async {
// Check if this is already a data URL (for images) // Check if this is already a data URL (for images)
@@ -1102,6 +1066,60 @@ Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
return messageMap; return messageMap;
} }
List<Map<String, dynamic>> _contextAttachmentsToFiles(
List<ChatContextAttachment> 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 = <String, dynamic>{
'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 // Regenerate message function that doesn't duplicate user message
Future<void> regenerateMessage( Future<void> regenerateMessage(
dynamic ref, dynamic ref,
@@ -1605,13 +1623,15 @@ Future<void> _sendMessageInternal(
var activeConversation = ref.read(activeConversationProvider); var activeConversation = ref.read(activeConversationProvider);
// Create user message first // Create user message first
List<Map<String, dynamic>>? userFiles; // Note: We only store context attachments (web/youtube/knowledge) in msg.files.
if (attachments != null && // Uploaded files are tracked via attachmentIds and will be rebuilt by
attachments.isNotEmpty && // _buildMessagePayloadWithAttachments when constructing the API payload.
!reviewerMode && // This prevents uploaded files from being duplicated in the final message.
api != null) { final contextAttachments = ref.read(contextAttachmentsProvider);
userFiles = await _buildFilesArrayFromAttachments(api, attachments); final contextFiles = _contextAttachmentsToFiles(contextAttachments);
} final List<Map<String, dynamic>>? userFiles = contextFiles.isNotEmpty
? contextFiles
: null;
final userMessage = ChatMessage( final userMessage = ChatMessage(
id: const Uuid().v4(), id: const Uuid().v4(),
@@ -1768,10 +1788,23 @@ Future<void> _sendMessageInternal(
cleanedText: cleaned, cleanedText: cleaned,
attachmentIds: ids, attachmentIds: ids,
); );
if (msg.files != null && msg.files!.isNotEmpty) {
messageMap['files'] = [
...?messageMap['files'] as List<dynamic>?,
...msg.files!,
];
}
conversationMessages.add(messageMap); conversationMessages.add(messageMap);
} else { } else {
// Regular text-only message // Regular text-only message
conversationMessages.add({'role': msg.role, 'content': cleaned}); final Map<String, dynamic> 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<void> _sendMessageInternal(
activeStream.socketSubscriptions, activeStream.socketSubscriptions,
onDispose: activeStream.disposeWatchdog, 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; return;
} catch (e) { } catch (e) {
// Handle error - remove the assistant message placeholder // Handle error - remove the assistant message placeholder

View File

@@ -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<List<ChatContextAttachment>> {
@override
List<ChatContextAttachment> 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, List<ChatContextAttachment>>(
ContextAttachmentsNotifier.new,
);

View File

@@ -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<KnowledgeBase>? getCachedBases() {
final (hit: hit, value: bases) =
_cache.lookup<List<KnowledgeBase>>(_basesKey);
if (hit) {
DebugLogger.log('cache-hit', scope: 'knowledge/bases');
}
return hit ? bases : null;
}
/// Caches knowledge bases.
void cacheBases(List<KnowledgeBase> bases) {
_cache.write<List<KnowledgeBase>>(_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<KnowledgeBaseItem>? getCachedItems(String baseId) {
final (hit: hit, value: items) =
_cache.lookup<List<KnowledgeBaseItem>>(_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<KnowledgeBaseItem> items) {
_cache.write<List<KnowledgeBaseItem>>(_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<String, dynamic> stats() => _cache.stats();
}
/// State for the knowledge cache provider.
class KnowledgeCacheState {
const KnowledgeCacheState({
this.bases = const <KnowledgeBase>[],
this.items = const <String, List<KnowledgeBaseItem>>{},
this.isLoading = false,
});
final List<KnowledgeBase> bases;
final Map<String, List<KnowledgeBaseItem>> items;
final bool isLoading;
KnowledgeCacheState copyWith({
List<KnowledgeBase>? bases,
Map<String, List<KnowledgeBaseItem>>? 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<KnowledgeCacheState> {
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<void> 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<void> 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<String, List<KnowledgeBaseItem>>.from(state.items);
next[baseId] = cached;
state = state.copyWith(items: next);
return;
}
if (_api == null) return;
final next = Map<String, List<KnowledgeBaseItem>>.from(state.items);
try {
final items = await _api!.getKnowledgeBaseItems(baseId);
_cacheManager.cacheItems(baseId, items);
next[baseId] = items;
} catch (_) {
next[baseId] = const <KnowledgeBaseItem>[];
}
state = state.copyWith(items: next);
}
/// Clears both in-memory state and persistent cache.
void clearCache() {
_cacheManager.clear();
state = const KnowledgeCacheState();
}
}
final knowledgeCacheProvider =
NotifierProvider<KnowledgeCacheNotifier, KnowledgeCacheState>(
KnowledgeCacheNotifier.new,
);

View File

@@ -23,6 +23,7 @@ import '../widgets/user_message_bubble.dart';
import '../widgets/assistant_message_widget.dart' as assistant; import '../widgets/assistant_message_widget.dart' as assistant;
import '../widgets/streaming_title_text.dart'; import '../widgets/streaming_title_text.dart';
import '../widgets/file_attachment_widget.dart'; import '../widgets/file_attachment_widget.dart';
import '../widgets/context_attachment_widget.dart';
import '../services/voice_input_service.dart'; import '../services/voice_input_service.dart';
import '../services/file_attachment_service.dart'; import '../services/file_attachment_service.dart';
import 'voice_call_page.dart'; import 'voice_call_page.dart';
@@ -30,6 +31,7 @@ import '../../../shared/services/tasks/task_queue.dart';
import '../../tools/providers/tools_providers.dart'; import '../../tools/providers/tools_providers.dart';
import '../../../core/models/chat_message.dart'; import '../../../core/models/chat_message.dart';
import '../../../core/models/model.dart'; import '../../../core/models/model.dart';
import '../providers/context_attachments_provider.dart';
import '../../../shared/widgets/loading_states.dart'; import '../../../shared/widgets/loading_states.dart';
import 'chat_page_helpers.dart'; import 'chat_page_helpers.dart';
import '../../../shared/widgets/themed_dialogs.dart'; import '../../../shared/widgets/themed_dialogs.dart';
@@ -123,6 +125,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
ref.read(chatMessagesProvider.notifier).clearMessages(); ref.read(chatMessagesProvider.notifier).clearMessages();
ref.read(activeConversationProvider.notifier).clear(); ref.read(activeConversationProvider.notifier).clear();
// Clear context attachments (web pages, YouTube, knowledge base docs)
ref.read(contextAttachmentsProvider.notifier).clear();
// Scroll to top // Scroll to top
if (_scrollController.hasClients) { if (_scrollController.hasClients) {
_scrollController.jumpTo(0); _scrollController.jumpTo(0);
@@ -597,6 +602,163 @@ class _ChatPageState extends ConsumerState<ChatPage> {
} }
} }
/// 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<void> _promptAttachWebpage() async {
final api = ref.read(apiServiceProvider);
if (api == null) return;
final l10n = AppLocalizations.of(context)!;
String url = '';
bool submitting = false;
await showDialog<void>(
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<String, dynamic>();
final fileData = (file?['data'] as Map?)
?.cast<String, dynamic>();
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<String, dynamic>();
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() { void _handleNewChat() {
// Start a new chat using the existing function // Start a new chat using the existing function
startNewChat(); startNewChat();
@@ -610,6 +772,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
} }
void _handleVoiceCall() { void _handleVoiceCall() {
// Dismiss keyboard before navigating
FocusScope.of(context).unfocus();
// Navigate to voice call page // Navigate to voice call page
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
@@ -1842,6 +2007,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// File attachments // File attachments
const FileAttachmentWidget(), const FileAttachmentWidget(),
const ContextAttachmentWidget(),
// Modern Input (root matches input background including safe area) // Modern Input (root matches input background including safe area)
RepaintBoundary( RepaintBoundary(
@@ -1862,6 +2028,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
onImageAttachment: _handleImageAttachment, onImageAttachment: _handleImageAttachment,
onCameraCapture: () => onCameraCapture: () =>
_handleImageAttachment(fromCamera: true), _handleImageAttachment(fromCamera: true),
onWebAttachment: _promptAttachWebpage,
), ),
), ),
), ),

View File

@@ -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;
}
}
}

View File

@@ -12,6 +12,8 @@ import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'dart:math' as math; import 'dart:math' as math;
import '../providers/chat_providers.dart'; import '../providers/chat_providers.dart';
import '../providers/context_attachments_provider.dart';
import '../providers/knowledge_cache_provider.dart';
import '../../tools/providers/tools_providers.dart'; import '../../tools/providers/tools_providers.dart';
import '../../prompts/providers/prompts_providers.dart'; import '../../prompts/providers/prompts_providers.dart';
import '../../../core/models/tool.dart'; import '../../../core/models/tool.dart';
@@ -19,6 +21,7 @@ import '../../../core/models/prompt.dart';
import '../../../core/providers/app_providers.dart'; import '../../../core/providers/app_providers.dart';
import '../../../core/services/settings_service.dart'; import '../../../core/services/settings_service.dart';
import '../../chat/services/voice_input_service.dart'; import '../../chat/services/voice_input_service.dart';
import '../../../core/models/knowledge_base.dart';
import '../../../shared/utils/platform_utils.dart'; import '../../../shared/utils/platform_utils.dart';
import 'package:conduit/l10n/app_localizations.dart'; import 'package:conduit/l10n/app_localizations.dart';
@@ -64,6 +67,7 @@ class ModernChatInput extends ConsumerStatefulWidget {
final Function()? onFileAttachment; final Function()? onFileAttachment;
final Function()? onImageAttachment; final Function()? onImageAttachment;
final Function()? onCameraCapture; final Function()? onCameraCapture;
final Function()? onWebAttachment;
const ModernChatInput({ const ModernChatInput({
super.key, super.key,
@@ -74,6 +78,7 @@ class ModernChatInput extends ConsumerStatefulWidget {
this.onFileAttachment, this.onFileAttachment,
this.onImageAttachment, this.onImageAttachment,
this.onCameraCapture, this.onCameraCapture,
this.onWebAttachment,
}); });
@override @override
@@ -291,9 +296,11 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
if (!wasShowing && shouldShow) { if (!wasShowing && shouldShow) {
// Trigger prompt fetch lazily when overlay first appears // Trigger prompt fetch lazily when overlay first appears
if (_currentPromptCommand.startsWith('/')) {
ref.read(promptsListProvider.future); ref.read(promptsListProvider.future);
} }
} }
}
_PromptCommandMatch? _resolvePromptCommand( _PromptCommandMatch? _resolvePromptCommand(
String text, String text,
@@ -317,7 +324,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
} }
final String candidate = text.substring(start, cursor); final String candidate = text.substring(start, cursor);
if (candidate.isEmpty || !candidate.startsWith('/')) { if (candidate.isEmpty ||
!(candidate.startsWith('/') || candidate.startsWith('#'))) {
return null; return null;
} }
@@ -326,13 +334,18 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
List<Prompt> _filterPrompts(List<Prompt> prompts) { List<Prompt> _filterPrompts(List<Prompt> prompts) {
if (prompts.isEmpty) return const <Prompt>[]; if (prompts.isEmpty) return const <Prompt>[];
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 <Prompt>[];
final List<Prompt> filtered = final List<Prompt> filtered =
prompts prompts
.where( .where(
(prompt) => (prompt) =>
prompt.command.toLowerCase().contains(query.trim()) && prompt.command.toLowerCase().contains(searchQuery) &&
prompt.content.isNotEmpty, prompt.content.isNotEmpty,
) )
.toList() .toList()
@@ -348,6 +361,11 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
} }
void _movePromptSelection(int delta) { void _movePromptSelection(int delta) {
if (_currentPromptCommand.startsWith('#')) {
// Only a single option in knowledge overlay; nothing to move.
return;
}
final AsyncValue<List<Prompt>> promptsAsync = ref.read(promptsListProvider); final AsyncValue<List<Prompt>> promptsAsync = ref.read(promptsListProvider);
final List<Prompt>? prompts = promptsAsync.value; final List<Prompt>? prompts = promptsAsync.value;
if (prompts == null || prompts.isEmpty) return; if (prompts == null || prompts.isEmpty) return;
@@ -369,6 +387,11 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
} }
void _confirmPromptSelection() { void _confirmPromptSelection() {
if (_currentPromptCommand.startsWith('#')) {
_openKnowledgePicker();
return;
}
final AsyncValue<List<Prompt>> promptsAsync = ref.read(promptsListProvider); final AsyncValue<List<Prompt>> promptsAsync = ref.read(promptsListProvider);
final List<Prompt>? prompts = promptsAsync.value; final List<Prompt>? prompts = promptsAsync.value;
if (prompts == null || prompts.isEmpty) return; if (prompts == null || prompts.isEmpty) return;
@@ -421,6 +444,147 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
}); });
} }
Future<void> _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 <KnowledgeBaseItem>[]
: const <KnowledgeBaseItem>[];
final loading = cacheState.isLoading ||
(selectedBaseId != null &&
!itemsMap.containsKey(selectedBaseId));
Future<void> 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) { Widget _buildPromptOverlay(BuildContext context) {
final Brightness brightness = Theme.of(context).brightness; final Brightness brightness = Theme.of(context).brightness;
final overlayColor = context.conduitTheme.cardBackground; final overlayColor = context.conduitTheme.cardBackground;
@@ -428,6 +592,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
alpha: brightness == Brightness.dark ? 0.6 : 0.4, alpha: brightness == Brightness.dark ? 0.6 : 0.4,
); );
if (_currentPromptCommand.startsWith('#')) {
return _buildKnowledgeOverlay(context, overlayColor, borderColor);
}
final AsyncValue<List<Prompt>> promptsAsync = ref.watch( final AsyncValue<List<Prompt>> promptsAsync = ref.watch(
promptsListProvider, promptsListProvider,
); );
@@ -593,6 +761,38 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
); );
} }
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
ref.listen<String?>(prefilledInputTextProvider, (previous, next) { ref.listen<String?>(prefilledInputTextProvider, (previous, next) {
@@ -1710,6 +1910,16 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
widget.onCameraCapture!.call(); widget.onCameraCapture!.call();
}, },
), ),
_buildOverflowAction(
icon: Icons.public,
label: 'Attach webpage',
onTap: widget.onWebAttachment == null
? null
: () {
HapticFeedback.lightImpact();
widget.onWebAttachment!.call();
},
),
]; ];
final featureTiles = <Widget>[]; final featureTiles = <Widget>[];

View File

@@ -7,6 +7,7 @@ import '../../../core/providers/app_providers.dart';
import '../../../core/services/attachment_upload_queue.dart'; import '../../../core/services/attachment_upload_queue.dart';
import '../../../core/utils/debug_logger.dart'; import '../../../core/utils/debug_logger.dart';
import '../../../features/chat/providers/chat_providers.dart' as chat; 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 '../../../features/chat/services/file_attachment_service.dart';
import 'outbound_task.dart'; import 'outbound_task.dart';
@@ -55,13 +56,21 @@ class TaskWorker {
} }
} catch (_) {} } catch (_) {}
// Delegate to existing unified send implementation // 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( await chat.sendMessageFromService(
_ref, _ref,
task.text, task.text,
task.attachments.isEmpty ? null : task.attachments, task.attachments.isEmpty ? null : task.attachments,
task.toolIds.isEmpty ? null : task.toolIds, task.toolIds.isEmpty ? null : task.toolIds,
); );
} finally {
try {
_ref.read(contextAttachmentsProvider.notifier).clear();
} catch (_) {}
}
} }
Future<void> _performUploadMedia(UploadMediaTask task) async { Future<void> _performUploadMedia(UploadMediaTask task) async {