chore: initial release
This commit is contained in:
397
lib/features/chat/services/conversation_search_service.dart
Normal file
397
lib/features/chat/services/conversation_search_service.dart
Normal file
@@ -0,0 +1,397 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/models/conversation.dart';
|
||||
import '../../../core/models/chat_message.dart';
|
||||
|
||||
/// Advanced conversation search service with multiple search strategies
|
||||
class ConversationSearchService {
|
||||
static const int maxResults = 50;
|
||||
static const int contextLines = 2; // Lines before/after match for context
|
||||
|
||||
/// Search through conversations with various criteria
|
||||
Future<ConversationSearchResults> searchConversations({
|
||||
required List<Conversation> conversations,
|
||||
required String query,
|
||||
ConversationSearchOptions options = const ConversationSearchOptions(),
|
||||
}) async {
|
||||
if (query.trim().isEmpty) {
|
||||
return ConversationSearchResults.empty();
|
||||
}
|
||||
|
||||
final normalizedQuery = query.toLowerCase().trim();
|
||||
final results = <ConversationSearchMatch>[];
|
||||
|
||||
// Search through each conversation
|
||||
for (final conversation in conversations) {
|
||||
final matches = await _searchInConversation(
|
||||
conversation: conversation,
|
||||
query: normalizedQuery,
|
||||
options: options,
|
||||
);
|
||||
results.addAll(matches);
|
||||
}
|
||||
|
||||
// Sort results by relevance and date
|
||||
results.sort((a, b) {
|
||||
// First by relevance score (higher is better)
|
||||
final relevanceCompare = b.relevanceScore.compareTo(a.relevanceScore);
|
||||
if (relevanceCompare != 0) return relevanceCompare;
|
||||
|
||||
// Then by date (newer first)
|
||||
return b.timestamp.compareTo(a.timestamp);
|
||||
});
|
||||
|
||||
// Limit results
|
||||
final limitedResults = results.take(maxResults).toList();
|
||||
|
||||
return ConversationSearchResults(
|
||||
query: query,
|
||||
results: limitedResults,
|
||||
totalMatches: results.length,
|
||||
searchDuration: DateTime.now().difference(DateTime.now()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Search within a single conversation
|
||||
Future<List<ConversationSearchMatch>> _searchInConversation({
|
||||
required Conversation conversation,
|
||||
required String query,
|
||||
required ConversationSearchOptions options,
|
||||
}) async {
|
||||
final matches = <ConversationSearchMatch>[];
|
||||
|
||||
// Search in conversation title
|
||||
if (options.searchTitles && _containsQuery(conversation.title, query)) {
|
||||
matches.add(
|
||||
ConversationSearchMatch(
|
||||
conversationId: conversation.id,
|
||||
conversationTitle: conversation.title,
|
||||
matchType: SearchMatchType.title,
|
||||
snippet: conversation.title,
|
||||
highlightedSnippet: _highlightQuery(conversation.title, query),
|
||||
relevanceScore: _calculateTitleRelevance(conversation.title, query),
|
||||
timestamp: conversation.updatedAt,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Search in messages
|
||||
if (options.searchMessages) {
|
||||
final messageMatches = await _searchInMessages(
|
||||
conversation: conversation,
|
||||
query: query,
|
||||
options: options,
|
||||
);
|
||||
matches.addAll(messageMatches);
|
||||
}
|
||||
|
||||
// Search in tags
|
||||
if (options.searchTags) {
|
||||
for (final tag in conversation.tags) {
|
||||
if (_containsQuery(tag, query)) {
|
||||
matches.add(
|
||||
ConversationSearchMatch(
|
||||
conversationId: conversation.id,
|
||||
conversationTitle: conversation.title,
|
||||
matchType: SearchMatchType.tag,
|
||||
snippet: tag,
|
||||
highlightedSnippet: _highlightQuery(tag, query),
|
||||
relevanceScore: _calculateTagRelevance(tag, query),
|
||||
timestamp: conversation.updatedAt,
|
||||
additionalInfo: {'tag': tag},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/// Search within messages of a conversation
|
||||
Future<List<ConversationSearchMatch>> _searchInMessages({
|
||||
required Conversation conversation,
|
||||
required String query,
|
||||
required ConversationSearchOptions options,
|
||||
}) async {
|
||||
final matches = <ConversationSearchMatch>[];
|
||||
|
||||
for (int i = 0; i < conversation.messages.length; i++) {
|
||||
final message = conversation.messages[i];
|
||||
|
||||
// Skip system messages if not enabled
|
||||
if (!options.includeSystemMessages && message.role == 'system') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter by role if specified
|
||||
if (options.roleFilter != null && message.role != options.roleFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if message contains query
|
||||
if (_containsQuery(message.content, query)) {
|
||||
final snippet = _extractSnippet(message.content, query);
|
||||
final contextMessages = _getContextMessages(conversation.messages, i);
|
||||
|
||||
matches.add(
|
||||
ConversationSearchMatch(
|
||||
conversationId: conversation.id,
|
||||
conversationTitle: conversation.title,
|
||||
messageId: message.id,
|
||||
matchType: SearchMatchType.message,
|
||||
snippet: snippet,
|
||||
highlightedSnippet: _highlightQuery(snippet, query),
|
||||
relevanceScore: _calculateMessageRelevance(message.content, query),
|
||||
timestamp: message.timestamp,
|
||||
messageRole: message.role,
|
||||
messageIndex: i,
|
||||
contextMessages: contextMessages,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/// Extract relevant snippet around the query match
|
||||
String _extractSnippet(String content, String query) {
|
||||
const maxSnippetLength = 200;
|
||||
final queryIndex = content.toLowerCase().indexOf(query);
|
||||
|
||||
if (queryIndex == -1) {
|
||||
return content.substring(0, maxSnippetLength.clamp(0, content.length));
|
||||
}
|
||||
|
||||
// Calculate snippet bounds
|
||||
final start = (queryIndex - 50).clamp(0, content.length);
|
||||
final end = (queryIndex + query.length + 50).clamp(0, content.length);
|
||||
|
||||
String snippet = content.substring(start, end);
|
||||
|
||||
// Add ellipsis if needed
|
||||
if (start > 0) snippet = '...$snippet';
|
||||
if (end < content.length) snippet = '$snippet...';
|
||||
|
||||
return snippet;
|
||||
}
|
||||
|
||||
/// Get context messages around a matched message
|
||||
List<ChatMessage> _getContextMessages(List<ChatMessage> messages, int index) {
|
||||
final start = (index - contextLines).clamp(0, messages.length);
|
||||
final end = (index + contextLines + 1).clamp(0, messages.length);
|
||||
return messages.sublist(start, end);
|
||||
}
|
||||
|
||||
/// Highlight query matches in text
|
||||
String _highlightQuery(String text, String query) {
|
||||
if (query.isEmpty) return text;
|
||||
|
||||
final regex = RegExp(RegExp.escape(query), caseSensitive: false);
|
||||
return text.replaceAllMapped(regex, (match) {
|
||||
return '<mark>${match.group(0)}</mark>';
|
||||
});
|
||||
}
|
||||
|
||||
/// Check if text contains the query
|
||||
bool _containsQuery(String text, String query) {
|
||||
return text.toLowerCase().contains(query);
|
||||
}
|
||||
|
||||
/// Calculate relevance score for title matches
|
||||
double _calculateTitleRelevance(String title, String query) {
|
||||
final titleLower = title.toLowerCase();
|
||||
final queryLower = query.toLowerCase();
|
||||
|
||||
// Exact match gets highest score
|
||||
if (titleLower == queryLower) return 100.0;
|
||||
|
||||
// Title starts with query gets high score
|
||||
if (titleLower.startsWith(queryLower)) return 90.0;
|
||||
|
||||
// Title contains query as whole word gets medium score
|
||||
if (RegExp(
|
||||
r'\b' + RegExp.escape(queryLower) + r'\b',
|
||||
).hasMatch(titleLower)) {
|
||||
return 70.0;
|
||||
}
|
||||
|
||||
// Partial match gets lower score
|
||||
return 50.0;
|
||||
}
|
||||
|
||||
/// Calculate relevance score for message matches
|
||||
double _calculateMessageRelevance(String content, String query) {
|
||||
final contentLower = content.toLowerCase();
|
||||
final queryLower = query.toLowerCase();
|
||||
|
||||
// Count occurrences
|
||||
final occurrences = queryLower.allMatches(contentLower).length;
|
||||
|
||||
// Base score for containing the query
|
||||
double score = 30.0;
|
||||
|
||||
// Bonus for multiple occurrences
|
||||
score += (occurrences - 1) * 10.0;
|
||||
|
||||
// Bonus for whole word matches
|
||||
if (RegExp(
|
||||
r'\b' + RegExp.escape(queryLower) + r'\b',
|
||||
).hasMatch(contentLower)) {
|
||||
score += 20.0;
|
||||
}
|
||||
|
||||
// Penalty for very long messages (relevance dilution)
|
||||
if (content.length > 1000) {
|
||||
score *= 0.8;
|
||||
}
|
||||
|
||||
return score.clamp(0.0, 100.0);
|
||||
}
|
||||
|
||||
/// Calculate relevance score for tag matches
|
||||
double _calculateTagRelevance(String tag, String query) {
|
||||
final tagLower = tag.toLowerCase();
|
||||
final queryLower = query.toLowerCase();
|
||||
|
||||
// Exact match gets highest score
|
||||
if (tagLower == queryLower) return 80.0;
|
||||
|
||||
// Tag starts with query gets high score
|
||||
if (tagLower.startsWith(queryLower)) return 70.0;
|
||||
|
||||
// Partial match gets medium score
|
||||
return 50.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Search options for conversation search
|
||||
@immutable
|
||||
class ConversationSearchOptions {
|
||||
final bool searchTitles;
|
||||
final bool searchMessages;
|
||||
final bool searchTags;
|
||||
final bool includeSystemMessages;
|
||||
final String? roleFilter; // 'user', 'assistant', 'system'
|
||||
final DateTime? dateFrom;
|
||||
final DateTime? dateTo;
|
||||
final bool caseSensitive;
|
||||
|
||||
const ConversationSearchOptions({
|
||||
this.searchTitles = true,
|
||||
this.searchMessages = true,
|
||||
this.searchTags = true,
|
||||
this.includeSystemMessages = false,
|
||||
this.roleFilter,
|
||||
this.dateFrom,
|
||||
this.dateTo,
|
||||
this.caseSensitive = false,
|
||||
});
|
||||
|
||||
ConversationSearchOptions copyWith({
|
||||
bool? searchTitles,
|
||||
bool? searchMessages,
|
||||
bool? searchTags,
|
||||
bool? includeSystemMessages,
|
||||
String? roleFilter,
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
bool? caseSensitive,
|
||||
}) {
|
||||
return ConversationSearchOptions(
|
||||
searchTitles: searchTitles ?? this.searchTitles,
|
||||
searchMessages: searchMessages ?? this.searchMessages,
|
||||
searchTags: searchTags ?? this.searchTags,
|
||||
includeSystemMessages:
|
||||
includeSystemMessages ?? this.includeSystemMessages,
|
||||
roleFilter: roleFilter ?? this.roleFilter,
|
||||
dateFrom: dateFrom ?? this.dateFrom,
|
||||
dateTo: dateTo ?? this.dateTo,
|
||||
caseSensitive: caseSensitive ?? this.caseSensitive,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Search results container
|
||||
@immutable
|
||||
class ConversationSearchResults {
|
||||
final String query;
|
||||
final List<ConversationSearchMatch> results;
|
||||
final int totalMatches;
|
||||
final Duration searchDuration;
|
||||
|
||||
const ConversationSearchResults({
|
||||
required this.query,
|
||||
required this.results,
|
||||
required this.totalMatches,
|
||||
required this.searchDuration,
|
||||
});
|
||||
|
||||
factory ConversationSearchResults.empty() {
|
||||
return ConversationSearchResults(
|
||||
query: '',
|
||||
results: const [],
|
||||
totalMatches: 0,
|
||||
searchDuration: Duration.zero,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isEmpty => results.isEmpty;
|
||||
bool get isNotEmpty => results.isNotEmpty;
|
||||
int get length => results.length;
|
||||
}
|
||||
|
||||
/// Individual search match
|
||||
@immutable
|
||||
class ConversationSearchMatch {
|
||||
final String conversationId;
|
||||
final String conversationTitle;
|
||||
final String? messageId;
|
||||
final SearchMatchType matchType;
|
||||
final String snippet;
|
||||
final String highlightedSnippet;
|
||||
final double relevanceScore;
|
||||
final DateTime timestamp;
|
||||
final String? messageRole;
|
||||
final int? messageIndex;
|
||||
final List<ChatMessage>? contextMessages;
|
||||
final Map<String, dynamic>? additionalInfo;
|
||||
|
||||
const ConversationSearchMatch({
|
||||
required this.conversationId,
|
||||
required this.conversationTitle,
|
||||
this.messageId,
|
||||
required this.matchType,
|
||||
required this.snippet,
|
||||
required this.highlightedSnippet,
|
||||
required this.relevanceScore,
|
||||
required this.timestamp,
|
||||
this.messageRole,
|
||||
this.messageIndex,
|
||||
this.contextMessages,
|
||||
this.additionalInfo,
|
||||
});
|
||||
}
|
||||
|
||||
/// Types of search matches
|
||||
enum SearchMatchType { title, message, tag }
|
||||
|
||||
/// Provider for conversation search service
|
||||
final conversationSearchServiceProvider = Provider<ConversationSearchService>((
|
||||
ref,
|
||||
) {
|
||||
return ConversationSearchService();
|
||||
});
|
||||
|
||||
/// Provider for search results
|
||||
final conversationSearchResultsProvider =
|
||||
StateProvider<ConversationSearchResults?>((ref) {
|
||||
return null;
|
||||
});
|
||||
|
||||
/// Provider for search options
|
||||
final searchOptionsProvider = StateProvider<ConversationSearchOptions>((ref) {
|
||||
return const ConversationSearchOptions();
|
||||
});
|
||||
433
lib/features/chat/services/file_attachment_service.dart
Normal file
433
lib/features/chat/services/file_attachment_service.dart
Normal file
@@ -0,0 +1,433 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import '../../../core/services/api_service.dart';
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
|
||||
class FileAttachmentService {
|
||||
final ApiService _apiService;
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
|
||||
FileAttachmentService(this._apiService);
|
||||
|
||||
// Pick files from device
|
||||
Future<List<File>> pickFiles({
|
||||
bool allowMultiple = true,
|
||||
List<String>? allowedExtensions,
|
||||
}) async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: allowMultiple,
|
||||
type: allowedExtensions != null ? FileType.custom : FileType.any,
|
||||
allowedExtensions: allowedExtensions,
|
||||
);
|
||||
|
||||
if (result == null || result.files.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.files
|
||||
.where((file) => file.path != null)
|
||||
.map((file) => File(file.path!))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
throw Exception('Failed to pick files: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Pick image from gallery
|
||||
Future<File?> pickImage() async {
|
||||
try {
|
||||
final XFile? image = await _imagePicker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (image == null) return null;
|
||||
return File(image.path);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to pick image: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Take photo from camera
|
||||
Future<File?> takePhoto() async {
|
||||
try {
|
||||
final XFile? photo = await _imagePicker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (photo == null) return null;
|
||||
return File(photo.path);
|
||||
} catch (e) {
|
||||
throw Exception('Failed to take photo: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Compress image similar to OpenWebUI's implementation
|
||||
Future<String> compressImage(
|
||||
String imageDataUrl,
|
||||
int? maxWidth,
|
||||
int? maxHeight,
|
||||
) async {
|
||||
try {
|
||||
// Decode base64 data
|
||||
final data = imageDataUrl.split(',')[1];
|
||||
final bytes = base64Decode(data);
|
||||
|
||||
// Decode image
|
||||
final codec = await ui.instantiateImageCodec(bytes);
|
||||
final frame = await codec.getNextFrame();
|
||||
final image = frame.image;
|
||||
|
||||
int width = image.width;
|
||||
int height = image.height;
|
||||
|
||||
// Calculate new dimensions maintaining aspect ratio
|
||||
if (maxWidth != null && maxHeight != null) {
|
||||
if (width <= maxWidth && height <= maxHeight) {
|
||||
return imageDataUrl; // No compression needed
|
||||
}
|
||||
|
||||
if (width / height > maxWidth / maxHeight) {
|
||||
height = ((maxWidth * height) / width).round();
|
||||
width = maxWidth;
|
||||
} else {
|
||||
width = ((maxHeight * width) / height).round();
|
||||
height = maxHeight;
|
||||
}
|
||||
} else if (maxWidth != null) {
|
||||
if (width <= maxWidth) {
|
||||
return imageDataUrl; // No compression needed
|
||||
}
|
||||
height = ((maxWidth * height) / width).round();
|
||||
width = maxWidth;
|
||||
} else if (maxHeight != null) {
|
||||
if (height <= maxHeight) {
|
||||
return imageDataUrl; // No compression needed
|
||||
}
|
||||
width = ((maxHeight * width) / height).round();
|
||||
height = maxHeight;
|
||||
}
|
||||
|
||||
// Create compressed image
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
|
||||
canvas.drawImageRect(
|
||||
image,
|
||||
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
|
||||
Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()),
|
||||
Paint(),
|
||||
);
|
||||
|
||||
final picture = recorder.endRecording();
|
||||
final compressedImage = await picture.toImage(width, height);
|
||||
final byteData = await compressedImage.toByteData(
|
||||
format: ui.ImageByteFormat.png,
|
||||
);
|
||||
final compressedBytes = byteData!.buffer.asUint8List();
|
||||
|
||||
// Convert back to data URL
|
||||
final compressedBase64 = base64Encode(compressedBytes);
|
||||
return 'data:image/png;base64,$compressedBase64';
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Image compression failed: $e');
|
||||
return imageDataUrl; // Return original if compression fails
|
||||
}
|
||||
}
|
||||
|
||||
// Convert image file to base64 data URL with compression
|
||||
Future<String?> convertImageToDataUrl(
|
||||
File imageFile, {
|
||||
bool enableCompression = false,
|
||||
int? maxWidth,
|
||||
int? maxHeight,
|
||||
}) async {
|
||||
try {
|
||||
debugPrint('DEBUG: Converting image to data URL: ${imageFile.path}');
|
||||
|
||||
// Read the file as bytes
|
||||
final bytes = await imageFile.readAsBytes();
|
||||
|
||||
// Determine MIME type based on file extension
|
||||
final ext = path.extension(imageFile.path).toLowerCase();
|
||||
String mimeType = 'image/png'; // default
|
||||
|
||||
if (ext == '.jpg' || ext == '.jpeg') {
|
||||
mimeType = 'image/jpeg';
|
||||
} else if (ext == '.gif') {
|
||||
mimeType = 'image/gif';
|
||||
} else if (ext == '.webp') {
|
||||
mimeType = 'image/webp';
|
||||
}
|
||||
|
||||
// Convert to base64
|
||||
final base64String = base64Encode(bytes);
|
||||
String dataUrl = 'data:$mimeType;base64,$base64String';
|
||||
|
||||
// Apply compression if enabled
|
||||
if (enableCompression && (maxWidth != null || maxHeight != null)) {
|
||||
dataUrl = await compressImage(dataUrl, maxWidth, maxHeight);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'DEBUG: Image converted to data URL with MIME type: $mimeType',
|
||||
);
|
||||
return dataUrl;
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Failed to convert image to data URL: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Upload file with progress tracking
|
||||
Stream<FileUploadState> uploadFile(File file) async* {
|
||||
debugPrint('DEBUG: Starting file upload for: ${file.path}');
|
||||
try {
|
||||
final fileName = path.basename(file.path);
|
||||
final fileSize = await file.length();
|
||||
|
||||
debugPrint(
|
||||
'DEBUG: File details - Name: $fileName, Size: $fileSize bytes',
|
||||
);
|
||||
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
progress: 0.0,
|
||||
status: FileUploadStatus.uploading,
|
||||
);
|
||||
|
||||
// Check if this is an image file
|
||||
final ext = path.extension(fileName).toLowerCase();
|
||||
final isImage = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
].contains(ext.substring(1));
|
||||
|
||||
if (isImage) {
|
||||
debugPrint(
|
||||
'DEBUG: Image file detected, converting to data URL instead of uploading',
|
||||
);
|
||||
|
||||
// For images, convert to data URL instead of uploading
|
||||
final dataUrl = await convertImageToDataUrl(file);
|
||||
if (dataUrl != null) {
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
progress: 1.0,
|
||||
status: FileUploadStatus.completed,
|
||||
fileId: dataUrl, // Use data URL as fileId for images
|
||||
isImage: true,
|
||||
);
|
||||
} else {
|
||||
throw Exception('Failed to convert image to data URL');
|
||||
}
|
||||
} else {
|
||||
debugPrint('DEBUG: Non-image file, uploading to server...');
|
||||
// Upload file using the API service
|
||||
final fileId = await _apiService.uploadFile(file.path, fileName);
|
||||
debugPrint('DEBUG: File uploaded successfully with ID: $fileId');
|
||||
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
progress: 1.0,
|
||||
status: FileUploadStatus.completed,
|
||||
fileId: fileId,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: File upload failed: $e');
|
||||
final fileName = path.basename(file.path);
|
||||
final fileSize = await file.length();
|
||||
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
progress: 0.0,
|
||||
status: FileUploadStatus.failed,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload multiple files
|
||||
Stream<List<FileUploadState>> uploadMultipleFiles(List<File> files) async* {
|
||||
final states = <String, FileUploadState>{};
|
||||
|
||||
for (final file in files) {
|
||||
final uploadStream = uploadFile(file);
|
||||
await for (final state in uploadStream) {
|
||||
states[file.path] = state;
|
||||
yield states.values.toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format file size for display
|
||||
String formatFileSize(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
}
|
||||
|
||||
// Get file icon based on extension
|
||||
String getFileIcon(String fileName) {
|
||||
final ext = path.extension(fileName).toLowerCase();
|
||||
|
||||
// Documents
|
||||
if (['.pdf', '.doc', '.docx'].contains(ext)) return '📄';
|
||||
if (['.xls', '.xlsx'].contains(ext)) return '📊';
|
||||
if (['.ppt', '.pptx'].contains(ext)) return '📊';
|
||||
|
||||
// Images
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext)) return '🖼️';
|
||||
|
||||
// Code
|
||||
if (['.js', '.ts', '.py', '.dart', '.java', '.cpp'].contains(ext)) {
|
||||
return '💻';
|
||||
}
|
||||
if (['.html', '.css', '.json', '.xml'].contains(ext)) return '🌐';
|
||||
|
||||
// Archives
|
||||
if (['.zip', '.rar', '.7z', '.tar', '.gz'].contains(ext)) return '📦';
|
||||
|
||||
// Media
|
||||
if (['.mp3', '.wav', '.flac', '.m4a'].contains(ext)) return '🎵';
|
||||
if (['.mp4', '.avi', '.mov', '.mkv'].contains(ext)) return '🎬';
|
||||
|
||||
return '📎';
|
||||
}
|
||||
}
|
||||
|
||||
// File upload state
|
||||
class FileUploadState {
|
||||
final File file;
|
||||
final String fileName;
|
||||
final int fileSize;
|
||||
final double progress;
|
||||
final FileUploadStatus status;
|
||||
final String? fileId;
|
||||
final String? error;
|
||||
final bool? isImage; // Added for image files
|
||||
|
||||
FileUploadState({
|
||||
required this.file,
|
||||
required this.fileName,
|
||||
required this.fileSize,
|
||||
required this.progress,
|
||||
required this.status,
|
||||
this.fileId,
|
||||
this.error,
|
||||
this.isImage, // Added for image files
|
||||
});
|
||||
|
||||
String get formattedSize {
|
||||
if (fileSize < 1024) return '$fileSize B';
|
||||
if (fileSize < 1024 * 1024) {
|
||||
return '${(fileSize / 1024).toStringAsFixed(1)} KB';
|
||||
}
|
||||
if (fileSize < 1024 * 1024 * 1024) {
|
||||
return '${(fileSize / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(fileSize / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
}
|
||||
|
||||
String get fileIcon {
|
||||
final ext = path.extension(fileName).toLowerCase();
|
||||
|
||||
// Documents
|
||||
if (['.pdf', '.doc', '.docx'].contains(ext)) return '📄';
|
||||
if (['.xls', '.xlsx'].contains(ext)) return '📊';
|
||||
if (['.ppt', '.pptx'].contains(ext)) return '📊';
|
||||
|
||||
// Images
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext)) return '🖼️';
|
||||
|
||||
// Code
|
||||
if (['.js', '.ts', '.py', '.dart', '.java', '.cpp'].contains(ext)) {
|
||||
return '💻';
|
||||
}
|
||||
if (['.html', '.css', '.json', '.xml'].contains(ext)) return '🌐';
|
||||
|
||||
// Archives
|
||||
if (['.zip', '.rar', '.7z', '.tar', '.gz'].contains(ext)) return '📦';
|
||||
|
||||
// Media
|
||||
if (['.mp3', '.wav', '.flac', '.m4a'].contains(ext)) return '🎵';
|
||||
if (['.mp4', '.avi', '.mov', '.mkv'].contains(ext)) return '🎬';
|
||||
|
||||
return '📎';
|
||||
}
|
||||
}
|
||||
|
||||
enum FileUploadStatus { pending, uploading, completed, failed }
|
||||
|
||||
// Providers
|
||||
final fileAttachmentServiceProvider = Provider<FileAttachmentService?>((ref) {
|
||||
final apiService = ref.watch(apiServiceProvider);
|
||||
if (apiService == null) return null;
|
||||
return FileAttachmentService(apiService);
|
||||
});
|
||||
|
||||
// State notifier for managing attached files
|
||||
class AttachedFilesNotifier extends StateNotifier<List<FileUploadState>> {
|
||||
AttachedFilesNotifier() : super([]);
|
||||
|
||||
void addFiles(List<File> files) {
|
||||
final newStates = files
|
||||
.map(
|
||||
(file) => FileUploadState(
|
||||
file: file,
|
||||
fileName: path.basename(file.path),
|
||||
fileSize: file.lengthSync(),
|
||||
progress: 0.0,
|
||||
status: FileUploadStatus.pending,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
state = [...state, ...newStates];
|
||||
}
|
||||
|
||||
void updateFileState(String filePath, FileUploadState newState) {
|
||||
state = [
|
||||
for (final fileState in state)
|
||||
if (fileState.file.path == filePath) newState else fileState,
|
||||
];
|
||||
}
|
||||
|
||||
void removeFile(String filePath) {
|
||||
state = state
|
||||
.where((fileState) => fileState.file.path != filePath)
|
||||
.toList();
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
state = [];
|
||||
}
|
||||
}
|
||||
|
||||
final attachedFilesProvider =
|
||||
StateNotifierProvider<AttachedFilesNotifier, List<FileUploadState>>((ref) {
|
||||
return AttachedFilesNotifier();
|
||||
});
|
||||
538
lib/features/chat/services/message_batch_service.dart
Normal file
538
lib/features/chat/services/message_batch_service.dart
Normal file
@@ -0,0 +1,538 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/models/chat_message.dart';
|
||||
import '../../../core/models/conversation.dart';
|
||||
|
||||
/// Service for managing batch operations on messages
|
||||
class MessageBatchService {
|
||||
/// Export messages to various formats
|
||||
Future<BatchOperationResult> exportMessages({
|
||||
required List<ChatMessage> messages,
|
||||
required ExportFormat format,
|
||||
ExportOptions? options,
|
||||
}) async {
|
||||
try {
|
||||
final exportOptions = options ?? const ExportOptions();
|
||||
String content;
|
||||
|
||||
switch (format) {
|
||||
case ExportFormat.text:
|
||||
content = _exportToText(messages, exportOptions);
|
||||
break;
|
||||
case ExportFormat.markdown:
|
||||
content = _exportToMarkdown(messages, exportOptions);
|
||||
break;
|
||||
case ExportFormat.json:
|
||||
content = _exportToJson(messages, exportOptions);
|
||||
break;
|
||||
case ExportFormat.csv:
|
||||
content = _exportToCsv(messages, exportOptions);
|
||||
break;
|
||||
}
|
||||
|
||||
return BatchOperationResult.success(
|
||||
operation: BatchOperation.export,
|
||||
data: {'content': content, 'format': format.name},
|
||||
affectedCount: messages.length,
|
||||
);
|
||||
} catch (e) {
|
||||
return BatchOperationResult.error(
|
||||
operation: BatchOperation.export,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete multiple messages
|
||||
Future<BatchOperationResult> deleteMessages({
|
||||
required List<String> messageIds,
|
||||
required Conversation conversation,
|
||||
}) async {
|
||||
try {
|
||||
final updatedMessages = conversation.messages
|
||||
.where((message) => !messageIds.contains(message.id))
|
||||
.toList();
|
||||
|
||||
final updatedConversation = conversation.copyWith(
|
||||
messages: updatedMessages,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
return BatchOperationResult.success(
|
||||
operation: BatchOperation.delete,
|
||||
data: {'conversation': updatedConversation},
|
||||
affectedCount: messageIds.length,
|
||||
);
|
||||
} catch (e) {
|
||||
return BatchOperationResult.error(
|
||||
operation: BatchOperation.delete,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy messages to clipboard or another conversation
|
||||
Future<BatchOperationResult> copyMessages({
|
||||
required List<ChatMessage> messages,
|
||||
String? targetConversationId,
|
||||
CopyFormat? format,
|
||||
}) async {
|
||||
try {
|
||||
final copyFormat = format ?? CopyFormat.markdown;
|
||||
String content;
|
||||
|
||||
switch (copyFormat) {
|
||||
case CopyFormat.plain:
|
||||
content = messages.map((m) => m.content).join('\n\n');
|
||||
break;
|
||||
case CopyFormat.markdown:
|
||||
content = _exportToMarkdown(messages, const ExportOptions());
|
||||
break;
|
||||
case CopyFormat.json:
|
||||
content = _exportToJson(messages, const ExportOptions());
|
||||
break;
|
||||
}
|
||||
|
||||
return BatchOperationResult.success(
|
||||
operation: BatchOperation.copy,
|
||||
data: {
|
||||
'content': content,
|
||||
'format': copyFormat.name,
|
||||
'targetConversation': targetConversationId,
|
||||
},
|
||||
affectedCount: messages.length,
|
||||
);
|
||||
} catch (e) {
|
||||
return BatchOperationResult.error(
|
||||
operation: BatchOperation.copy,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Move messages to another conversation
|
||||
Future<BatchOperationResult> moveMessages({
|
||||
required List<String> messageIds,
|
||||
required Conversation sourceConversation,
|
||||
required Conversation targetConversation,
|
||||
}) async {
|
||||
try {
|
||||
final messagesToMove = sourceConversation.messages
|
||||
.where((message) => messageIds.contains(message.id))
|
||||
.toList();
|
||||
|
||||
final updatedSourceMessages = sourceConversation.messages
|
||||
.where((message) => !messageIds.contains(message.id))
|
||||
.toList();
|
||||
|
||||
final updatedTargetMessages = [
|
||||
...targetConversation.messages,
|
||||
...messagesToMove,
|
||||
];
|
||||
|
||||
final updatedSourceConversation = sourceConversation.copyWith(
|
||||
messages: updatedSourceMessages,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
final updatedTargetConversation = targetConversation.copyWith(
|
||||
messages: updatedTargetMessages,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
return BatchOperationResult.success(
|
||||
operation: BatchOperation.move,
|
||||
data: {
|
||||
'sourceConversation': updatedSourceConversation,
|
||||
'targetConversation': updatedTargetConversation,
|
||||
},
|
||||
affectedCount: messageIds.length,
|
||||
);
|
||||
} catch (e) {
|
||||
return BatchOperationResult.error(
|
||||
operation: BatchOperation.move,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Archive multiple messages
|
||||
Future<BatchOperationResult> archiveMessages({
|
||||
required List<String> messageIds,
|
||||
required Conversation conversation,
|
||||
}) async {
|
||||
try {
|
||||
final updatedMessages = conversation.messages.map((message) {
|
||||
if (messageIds.contains(message.id)) {
|
||||
return message.copyWith(
|
||||
metadata: {
|
||||
...?message.metadata,
|
||||
'archived': true,
|
||||
'archivedAt': DateTime.now().toIso8601String(),
|
||||
},
|
||||
);
|
||||
}
|
||||
return message;
|
||||
}).toList();
|
||||
|
||||
final updatedConversation = conversation.copyWith(
|
||||
messages: updatedMessages,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
return BatchOperationResult.success(
|
||||
operation: BatchOperation.archive,
|
||||
data: {'conversation': updatedConversation},
|
||||
affectedCount: messageIds.length,
|
||||
);
|
||||
} catch (e) {
|
||||
return BatchOperationResult.error(
|
||||
operation: BatchOperation.archive,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add tags to multiple messages
|
||||
Future<BatchOperationResult> tagMessages({
|
||||
required List<String> messageIds,
|
||||
required List<String> tags,
|
||||
required Conversation conversation,
|
||||
}) async {
|
||||
try {
|
||||
final updatedMessages = conversation.messages.map((message) {
|
||||
if (messageIds.contains(message.id)) {
|
||||
final existingTags =
|
||||
(message.metadata?['tags'] as List<String>?) ?? <String>[];
|
||||
final newTags = <String>{...existingTags, ...tags}.toList();
|
||||
|
||||
return message.copyWith(
|
||||
metadata: {...?message.metadata, 'tags': newTags},
|
||||
);
|
||||
}
|
||||
return message;
|
||||
}).toList();
|
||||
|
||||
final updatedConversation = conversation.copyWith(
|
||||
messages: updatedMessages,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
return BatchOperationResult.success(
|
||||
operation: BatchOperation.tag,
|
||||
data: {'conversation': updatedConversation},
|
||||
affectedCount: messageIds.length,
|
||||
);
|
||||
} catch (e) {
|
||||
return BatchOperationResult.error(
|
||||
operation: BatchOperation.tag,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter messages by criteria
|
||||
List<ChatMessage> filterMessages({
|
||||
required List<ChatMessage> messages,
|
||||
MessageFilter? filter,
|
||||
}) {
|
||||
if (filter == null) return messages;
|
||||
|
||||
return messages.where((message) {
|
||||
// Role filter
|
||||
if (filter.roles.isNotEmpty && !filter.roles.contains(message.role)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if (filter.dateFrom != null &&
|
||||
message.timestamp.isBefore(filter.dateFrom!)) {
|
||||
return false;
|
||||
}
|
||||
if (filter.dateTo != null && message.timestamp.isAfter(filter.dateTo!)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Content filter
|
||||
if (filter.contentFilter != null &&
|
||||
!message.content.toLowerCase().contains(
|
||||
filter.contentFilter!.toLowerCase(),
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tag filter
|
||||
if (filter.tags.isNotEmpty) {
|
||||
final messageTags = (message.metadata?['tags'] as List<String>?) ?? [];
|
||||
if (!filter.tags.any((tag) => messageTags.contains(tag))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Has attachments filter
|
||||
if (filter.hasAttachments != null) {
|
||||
final hasAttachments = message.attachmentIds?.isNotEmpty ?? false;
|
||||
if (filter.hasAttachments! != hasAttachments) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Export format implementations
|
||||
String _exportToText(List<ChatMessage> messages, ExportOptions options) {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
if (options.includeMetadata) {
|
||||
buffer.writeln('Exported on: ${DateTime.now().toIso8601String()}');
|
||||
buffer.writeln('Messages: ${messages.length}');
|
||||
buffer.writeln('${'=' * 50}\n');
|
||||
}
|
||||
|
||||
for (final message in messages) {
|
||||
if (options.includeTimestamps) {
|
||||
buffer.writeln('[${message.timestamp.toIso8601String()}]');
|
||||
}
|
||||
|
||||
buffer.writeln('${_formatRole(message.role)}: ${message.content}');
|
||||
|
||||
if (options.includeMetadata && message.metadata?.isNotEmpty == true) {
|
||||
buffer.writeln('Metadata: ${message.metadata}');
|
||||
}
|
||||
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _exportToMarkdown(List<ChatMessage> messages, ExportOptions options) {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
if (options.includeMetadata) {
|
||||
buffer.writeln('# Conversation Export\n');
|
||||
buffer.writeln('- **Exported on:** ${DateTime.now().toIso8601String()}');
|
||||
buffer.writeln('- **Messages:** ${messages.length}\n');
|
||||
buffer.writeln('---\n');
|
||||
}
|
||||
|
||||
for (final message in messages) {
|
||||
buffer.writeln('## ${_formatRole(message.role)}');
|
||||
|
||||
if (options.includeTimestamps) {
|
||||
buffer.writeln('*${message.timestamp.toIso8601String()}*\n');
|
||||
}
|
||||
|
||||
buffer.writeln(message.content);
|
||||
buffer.writeln();
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _exportToJson(List<ChatMessage> messages, ExportOptions options) {
|
||||
final data = {
|
||||
if (options.includeMetadata) ...{
|
||||
'exportedAt': DateTime.now().toIso8601String(),
|
||||
'messageCount': messages.length,
|
||||
},
|
||||
'messages': messages
|
||||
.map(
|
||||
(message) => {
|
||||
'id': message.id,
|
||||
'role': message.role,
|
||||
'content': message.content,
|
||||
if (options.includeTimestamps)
|
||||
'timestamp': message.timestamp.toIso8601String(),
|
||||
if (message.model != null) 'model': message.model,
|
||||
if (message.attachmentIds?.isNotEmpty == true)
|
||||
'attachmentIds': message.attachmentIds,
|
||||
if (options.includeMetadata &&
|
||||
message.metadata?.isNotEmpty == true)
|
||||
'metadata': message.metadata,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
};
|
||||
|
||||
return JsonEncoder.withIndent(' ').convert(data);
|
||||
}
|
||||
|
||||
String _exportToCsv(List<ChatMessage> messages, ExportOptions options) {
|
||||
final buffer = StringBuffer();
|
||||
|
||||
// Header
|
||||
final headers = ['Role', 'Content'];
|
||||
if (options.includeTimestamps) headers.insert(1, 'Timestamp');
|
||||
if (options.includeMetadata) headers.add('Metadata');
|
||||
|
||||
buffer.writeln(headers.map(_escapeCsv).join(','));
|
||||
|
||||
// Data rows
|
||||
for (final message in messages) {
|
||||
final row = <String>[
|
||||
message.role,
|
||||
message.content.replaceAll('\n', '\\n'),
|
||||
];
|
||||
|
||||
if (options.includeTimestamps) {
|
||||
row.insert(1, message.timestamp.toIso8601String());
|
||||
}
|
||||
|
||||
if (options.includeMetadata) {
|
||||
row.add(message.metadata?.toString() ?? '');
|
||||
}
|
||||
|
||||
buffer.writeln(row.map(_escapeCsv).join(','));
|
||||
}
|
||||
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
String _formatRole(String role) {
|
||||
switch (role.toLowerCase()) {
|
||||
case 'user':
|
||||
return 'User';
|
||||
case 'assistant':
|
||||
return 'Assistant';
|
||||
case 'system':
|
||||
return 'System';
|
||||
default:
|
||||
return role;
|
||||
}
|
||||
}
|
||||
|
||||
String _escapeCsv(String value) {
|
||||
if (value.contains(',') || value.contains('"') || value.contains('\n')) {
|
||||
return '"${value.replaceAll('"', '""')}"';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/// Export formats supported by the batch service
|
||||
enum ExportFormat { text, markdown, json, csv }
|
||||
|
||||
/// Copy formats for clipboard operations
|
||||
enum CopyFormat { plain, markdown, json }
|
||||
|
||||
/// Batch operations that can be performed
|
||||
enum BatchOperation { export, delete, copy, move, archive, tag }
|
||||
|
||||
/// Options for export operations
|
||||
@immutable
|
||||
class ExportOptions {
|
||||
final bool includeTimestamps;
|
||||
final bool includeMetadata;
|
||||
final bool includeAttachments;
|
||||
|
||||
const ExportOptions({
|
||||
this.includeTimestamps = true,
|
||||
this.includeMetadata = false,
|
||||
this.includeAttachments = true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Filter criteria for messages
|
||||
@immutable
|
||||
class MessageFilter {
|
||||
final List<String> roles;
|
||||
final DateTime? dateFrom;
|
||||
final DateTime? dateTo;
|
||||
final String? contentFilter;
|
||||
final List<String> tags;
|
||||
final bool? hasAttachments;
|
||||
|
||||
const MessageFilter({
|
||||
this.roles = const [],
|
||||
this.dateFrom,
|
||||
this.dateTo,
|
||||
this.contentFilter,
|
||||
this.tags = const [],
|
||||
this.hasAttachments,
|
||||
});
|
||||
|
||||
MessageFilter copyWith({
|
||||
List<String>? roles,
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
String? contentFilter,
|
||||
List<String>? tags,
|
||||
bool? hasAttachments,
|
||||
}) {
|
||||
return MessageFilter(
|
||||
roles: roles ?? this.roles,
|
||||
dateFrom: dateFrom ?? this.dateFrom,
|
||||
dateTo: dateTo ?? this.dateTo,
|
||||
contentFilter: contentFilter ?? this.contentFilter,
|
||||
tags: tags ?? this.tags,
|
||||
hasAttachments: hasAttachments ?? this.hasAttachments,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a batch operation
|
||||
@immutable
|
||||
class BatchOperationResult {
|
||||
final BatchOperation operation;
|
||||
final bool success;
|
||||
final String? error;
|
||||
final Map<String, dynamic>? data;
|
||||
final int affectedCount;
|
||||
|
||||
const BatchOperationResult({
|
||||
required this.operation,
|
||||
required this.success,
|
||||
this.error,
|
||||
this.data,
|
||||
this.affectedCount = 0,
|
||||
});
|
||||
|
||||
factory BatchOperationResult.success({
|
||||
required BatchOperation operation,
|
||||
Map<String, dynamic>? data,
|
||||
int affectedCount = 0,
|
||||
}) {
|
||||
return BatchOperationResult(
|
||||
operation: operation,
|
||||
success: true,
|
||||
data: data,
|
||||
affectedCount: affectedCount,
|
||||
);
|
||||
}
|
||||
|
||||
factory BatchOperationResult.error({
|
||||
required BatchOperation operation,
|
||||
required String error,
|
||||
}) {
|
||||
return BatchOperationResult(
|
||||
operation: operation,
|
||||
success: false,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for message batch service
|
||||
final messageBatchServiceProvider = Provider<MessageBatchService>((ref) {
|
||||
return MessageBatchService();
|
||||
});
|
||||
|
||||
/// Provider for selected messages (for batch operations)
|
||||
final selectedMessagesProvider = StateProvider<Set<String>>((ref) {
|
||||
return <String>{};
|
||||
});
|
||||
|
||||
/// Provider for batch operation mode
|
||||
final batchModeProvider = StateProvider<bool>((ref) {
|
||||
return false;
|
||||
});
|
||||
|
||||
/// Provider for message filter
|
||||
final messageFilterProvider = StateProvider<MessageFilter?>((ref) {
|
||||
return null;
|
||||
});
|
||||
220
lib/features/chat/services/voice_input_service.dart
Normal file
220
lib/features/chat/services/voice_input_service.dart
Normal file
@@ -0,0 +1,220 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:record/record.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class VoiceInputService {
|
||||
final AudioRecorder _recorder = AudioRecorder();
|
||||
bool _isInitialized = false;
|
||||
bool _isListening = false;
|
||||
StreamController<String>? _textStreamController;
|
||||
String _currentText = '';
|
||||
// Public stream for UI waveform visualization (emits partial text length as proxy)
|
||||
StreamController<int>? _intensityController;
|
||||
Stream<int> get intensityStream =>
|
||||
_intensityController?.stream ?? const Stream<int>.empty();
|
||||
Timer? _autoStopTimer;
|
||||
StreamSubscription<Amplitude>? _ampSub;
|
||||
|
||||
bool get isSupportedPlatform => Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
Future<bool> initialize() async {
|
||||
if (_isInitialized) return true;
|
||||
if (!isSupportedPlatform) return false;
|
||||
// Log platform for diagnostics
|
||||
// ignore: avoid_print
|
||||
print(
|
||||
'DEBUG: VoiceInputService initialize on platform: '
|
||||
'${Platform.isAndroid
|
||||
? 'Android'
|
||||
: Platform.isIOS
|
||||
? 'iOS'
|
||||
: 'Other'}',
|
||||
);
|
||||
_isInitialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> checkPermissions() async {
|
||||
try {
|
||||
return await _recorder.hasPermission();
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool get isListening => _isListening;
|
||||
bool get isAvailable => _isInitialized;
|
||||
|
||||
Stream<String> startListening() {
|
||||
// Ensure initialized; we allow initialize to pass even if native STT unavailable
|
||||
if (!_isInitialized) {
|
||||
throw Exception('Voice input not initialized');
|
||||
}
|
||||
|
||||
if (_isListening) {
|
||||
stopListening();
|
||||
}
|
||||
|
||||
_textStreamController = StreamController<String>.broadcast();
|
||||
_currentText = '';
|
||||
_isListening = true;
|
||||
|
||||
_intensityController = StreamController<int>.broadcast();
|
||||
|
||||
// Start recording raw audio; UI or auto-timer will stop and trigger transcription via API
|
||||
// ignore: avoid_print
|
||||
print('DEBUG: VoiceInputService startListening');
|
||||
_startRecordingProxyIntensity();
|
||||
|
||||
// Auto-stop after 30 seconds similar to native STT behavior
|
||||
_autoStopTimer?.cancel();
|
||||
_autoStopTimer = Timer(const Duration(seconds: 30), () {
|
||||
if (_isListening) {
|
||||
_stopListening();
|
||||
}
|
||||
});
|
||||
|
||||
return _textStreamController!.stream;
|
||||
}
|
||||
|
||||
Future<void> stopListening() async {
|
||||
await _stopListening();
|
||||
}
|
||||
|
||||
Future<void> _stopListening() async {
|
||||
if (!_isListening) return;
|
||||
|
||||
_isListening = false;
|
||||
// Also stop recorder if active
|
||||
await _stopRecording();
|
||||
// ignore: avoid_print
|
||||
print('DEBUG: VoiceInputService stopped listening');
|
||||
|
||||
_autoStopTimer?.cancel();
|
||||
_autoStopTimer = null;
|
||||
_ampSub?.cancel();
|
||||
_ampSub = null;
|
||||
|
||||
if (_currentText.isNotEmpty) {
|
||||
_textStreamController?.add(_currentText);
|
||||
}
|
||||
|
||||
_textStreamController?.close();
|
||||
_textStreamController = null;
|
||||
_intensityController?.close();
|
||||
_intensityController = null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
stopListening();
|
||||
_stopRecording(force: true);
|
||||
}
|
||||
|
||||
// --- Recording and intensity proxy for server transcription path ---
|
||||
Future<void> _startRecordingProxyIntensity() async {
|
||||
try {
|
||||
final hasMic = await _recorder.hasPermission();
|
||||
if (!hasMic) {
|
||||
_textStreamController?.addError('Microphone permission not granted');
|
||||
_stopListening();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start recording in a portable format (WAV/PCM) for best compatibility with server
|
||||
final tmpDir = await getTemporaryDirectory();
|
||||
final filePath = p.join(
|
||||
tmpDir.path,
|
||||
'conduit_voice_${DateTime.now().millisecondsSinceEpoch}.wav',
|
||||
);
|
||||
await _recorder.start(
|
||||
const RecordConfig(
|
||||
encoder: AudioEncoder.wav,
|
||||
numChannels: 1,
|
||||
sampleRate: 16000,
|
||||
bitRate: 128000,
|
||||
),
|
||||
path: filePath,
|
||||
);
|
||||
// ignore: avoid_print
|
||||
print('DEBUG: VoiceInputService recording started at: ' + filePath);
|
||||
|
||||
// Drive intensity from amplitude stream and detect silence
|
||||
// Consider amplitude less than threshold as silence; stop after ~3s of continuous silence
|
||||
const silenceThresholdDb = -45.0; // dBFS threshold
|
||||
const silenceWindow = Duration(seconds: 3);
|
||||
DateTime lastNonSilent = DateTime.now();
|
||||
|
||||
_ampSub = _recorder
|
||||
.onAmplitudeChanged(const Duration(milliseconds: 125))
|
||||
.listen((amp) {
|
||||
if (!_isListening) return;
|
||||
// Normalize peak power (dBFS) into 0-10 bar scale
|
||||
final db = amp.current;
|
||||
// Map dB [-60..0] -> [0..10]
|
||||
final clamped = db.clamp(-60.0, 0.0);
|
||||
final norm = ((clamped + 60.0) / 60.0) * 10.0;
|
||||
_intensityController?.add(norm.round().clamp(0, 10));
|
||||
|
||||
if (db > silenceThresholdDb) {
|
||||
lastNonSilent = DateTime.now();
|
||||
} else {
|
||||
if (DateTime.now().difference(lastNonSilent) >= silenceWindow) {
|
||||
_stopListening();
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// ignore: avoid_print
|
||||
print('DEBUG: VoiceInputService recording failed: $e');
|
||||
_textStreamController?.addError('Audio recording failed: $e');
|
||||
_stopListening();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopRecording({bool force = false}) async {
|
||||
try {
|
||||
if (!await _recorder.isRecording() && !force) return;
|
||||
final path = await _recorder.stop();
|
||||
if (path == null) {
|
||||
_textStreamController?.addError('Recording failed: no file path');
|
||||
return;
|
||||
}
|
||||
// ignore: avoid_print
|
||||
print('DEBUG: VoiceInputService recording saved: ' + path);
|
||||
// Hand off recorded file path to listeners as a special token; UI layer will upload for transcription
|
||||
_textStreamController?.add('[[AUDIO_FILE_PATH]]:$path');
|
||||
} catch (e) {
|
||||
_textStreamController?.addError('Stop recording error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Native locales not used in server transcription mode
|
||||
}
|
||||
|
||||
final voiceInputServiceProvider = Provider<VoiceInputService>((ref) {
|
||||
return VoiceInputService();
|
||||
});
|
||||
|
||||
final voiceInputAvailableProvider = FutureProvider<bool>((ref) async {
|
||||
final service = ref.watch(voiceInputServiceProvider);
|
||||
if (!service.isSupportedPlatform) return false;
|
||||
final initialized = await service.initialize();
|
||||
if (!initialized) return false;
|
||||
final hasPermission = await service.checkPermissions();
|
||||
if (!hasPermission) return false;
|
||||
return service.isAvailable;
|
||||
});
|
||||
|
||||
final voiceInputStreamProvider = StreamProvider<String>((ref) {
|
||||
// Voice input stream would be initialized when needed
|
||||
return const Stream.empty();
|
||||
});
|
||||
|
||||
/// Stream of crude voice intensity for waveform visuals
|
||||
final voiceIntensityStreamProvider = StreamProvider<int>((ref) {
|
||||
// Connected at runtime by the UI after calling startListening
|
||||
return const Stream.empty();
|
||||
});
|
||||
Reference in New Issue
Block a user