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:
@@ -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');
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
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/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
|
||||||
|
|||||||
@@ -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/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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
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: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>[];
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user