chore: initial release

This commit is contained in:
cogwheel0
2025-08-10 01:20:45 +05:30
commit 758615813f
218 changed files with 67743 additions and 0 deletions

View 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();
});

View 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();
});

View 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;
});

View 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();
});