feat(chat): Add context attachment and knowledge base support

This commit is contained in:
cogwheel0
2025-11-26 22:19:19 +05:30
parent 97e882c173
commit 75ba0dc01d
11 changed files with 1052 additions and 65 deletions

View File

@@ -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');

View File

@@ -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),

View File

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