feat(chat): Add context attachment and knowledge base support
This commit is contained in:
@@ -1483,6 +1483,52 @@ class ApiService {
|
||||
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
|
||||
Future<Map<String, dynamic>> performWebSearch(List<String> queries) async {
|
||||
_traceApi('Performing web search for queries: $queries');
|
||||
|
||||
@@ -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<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(
|
||||
Map<String, dynamic> 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<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(
|
||||
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<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 {
|
||||
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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
27
lib/features/chat/models/chat_context_attachment.dart
Normal file
27
lib/features/chat/models/chat_context_attachment.dart
Normal 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 }
|
||||
@@ -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<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
|
||||
Future<String?> _getFileAsBase64(dynamic api, String fileId) async {
|
||||
// Check if this is already a data URL (for images)
|
||||
@@ -1102,6 +1066,60 @@ Future<Map<String, dynamic>> _buildMessagePayloadWithAttachments({
|
||||
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
|
||||
Future<void> regenerateMessage(
|
||||
dynamic ref,
|
||||
@@ -1605,13 +1623,15 @@ Future<void> _sendMessageInternal(
|
||||
var activeConversation = ref.read(activeConversationProvider);
|
||||
|
||||
// Create user message first
|
||||
List<Map<String, dynamic>>? 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<Map<String, dynamic>>? userFiles = contextFiles.isNotEmpty
|
||||
? contextFiles
|
||||
: null;
|
||||
|
||||
final userMessage = ChatMessage(
|
||||
id: const Uuid().v4(),
|
||||
@@ -1768,10 +1788,23 @@ Future<void> _sendMessageInternal(
|
||||
cleanedText: cleaned,
|
||||
attachmentIds: ids,
|
||||
);
|
||||
if (msg.files != null && msg.files!.isNotEmpty) {
|
||||
messageMap['files'] = [
|
||||
...?messageMap['files'] as List<dynamic>?,
|
||||
...msg.files!,
|
||||
];
|
||||
}
|
||||
conversationMessages.add(messageMap);
|
||||
} else {
|
||||
// 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,
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
179
lib/features/chat/providers/knowledge_cache_provider.dart
Normal file
179
lib/features/chat/providers/knowledge_cache_provider.dart
Normal 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,
|
||||
);
|
||||
@@ -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<ChatPage> {
|
||||
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<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() {
|
||||
// Start a new chat using the existing function
|
||||
startNewChat();
|
||||
@@ -610,6 +772,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
}
|
||||
|
||||
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<ChatPage> {
|
||||
|
||||
// File attachments
|
||||
const FileAttachmentWidget(),
|
||||
const ContextAttachmentWidget(),
|
||||
|
||||
// Modern Input (root matches input background including safe area)
|
||||
RepaintBoundary(
|
||||
@@ -1862,6 +2028,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
onImageAttachment: _handleImageAttachment,
|
||||
onCameraCapture: () =>
|
||||
_handleImageAttachment(fromCamera: true),
|
||||
onWebAttachment: _promptAttachWebpage,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
57
lib/features/chat/widgets/context_attachment_widget.dart
Normal file
57
lib/features/chat/widgets/context_attachment_widget.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ModernChatInput>
|
||||
|
||||
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<ModernChatInput>
|
||||
}
|
||||
|
||||
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<ModernChatInput>
|
||||
|
||||
List<Prompt> _filterPrompts(List<Prompt> prompts) {
|
||||
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 =
|
||||
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<ModernChatInput>
|
||||
}
|
||||
|
||||
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 List<Prompt>? prompts = promptsAsync.value;
|
||||
if (prompts == null || prompts.isEmpty) return;
|
||||
@@ -369,6 +387,11 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
}
|
||||
|
||||
void _confirmPromptSelection() {
|
||||
if (_currentPromptCommand.startsWith('#')) {
|
||||
_openKnowledgePicker();
|
||||
return;
|
||||
}
|
||||
|
||||
final AsyncValue<List<Prompt>> promptsAsync = ref.read(promptsListProvider);
|
||||
final List<Prompt>? prompts = promptsAsync.value;
|
||||
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) {
|
||||
final Brightness brightness = Theme.of(context).brightness;
|
||||
final overlayColor = context.conduitTheme.cardBackground;
|
||||
@@ -428,6 +592,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
alpha: brightness == Brightness.dark ? 0.6 : 0.4,
|
||||
);
|
||||
|
||||
if (_currentPromptCommand.startsWith('#')) {
|
||||
return _buildKnowledgeOverlay(context, overlayColor, borderColor);
|
||||
}
|
||||
|
||||
final AsyncValue<List<Prompt>> promptsAsync = ref.watch(
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen<String?>(prefilledInputTextProvider, (previous, next) {
|
||||
@@ -1710,6 +1910,16 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
widget.onCameraCapture!.call();
|
||||
},
|
||||
),
|
||||
_buildOverflowAction(
|
||||
icon: Icons.public,
|
||||
label: 'Attach webpage',
|
||||
onTap: widget.onWebAttachment == null
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.lightImpact();
|
||||
widget.onWebAttachment!.call();
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
final featureTiles = <Widget>[];
|
||||
|
||||
@@ -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<void> _performUploadMedia(UploadMediaTask task) async {
|
||||
|
||||
Reference in New Issue
Block a user