refactor: text streaming
This commit is contained in:
@@ -565,8 +565,7 @@ class ApiService {
|
||||
}
|
||||
|
||||
// Fallback to chat.messages (list format) if history is missing or empty
|
||||
if (((messagesList?.isEmpty ?? true)) &&
|
||||
chatObject['messages'] != null) {
|
||||
if (((messagesList?.isEmpty ?? true)) && chatObject['messages'] != null) {
|
||||
messagesList = chatObject['messages'] as List;
|
||||
debugPrint(
|
||||
'DEBUG: Found ${messagesList.length} messages in chat.messages (fallback)',
|
||||
@@ -1229,7 +1228,8 @@ class ApiService {
|
||||
|
||||
Future<void> updateUserSettings(Map<String, dynamic> settings) async {
|
||||
debugPrint('DEBUG: Updating user settings');
|
||||
await _dio.post('/api/v1/users/user/settings', data: settings);
|
||||
// Align with web client update route
|
||||
await _dio.post('/api/v1/users/user/settings/update', data: settings);
|
||||
}
|
||||
|
||||
// Suggestions
|
||||
@@ -1384,8 +1384,35 @@ class ApiService {
|
||||
// Files
|
||||
Future<String> getFileContent(String fileId) async {
|
||||
debugPrint('DEBUG: Fetching file content: $fileId');
|
||||
final response = await _dio.get('/api/v1/files/$fileId/content');
|
||||
return response.data as String;
|
||||
// The Open-WebUI endpoint returns the raw file bytes with appropriate
|
||||
// Content-Type headers, not JSON. We must read bytes and base64-encode
|
||||
// them for consistent handling across platforms/widgets.
|
||||
final response = await _dio.get(
|
||||
'/api/v1/files/$fileId/content',
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
|
||||
// Try to determine the mime type from response headers; fallback to text/plain
|
||||
final contentType =
|
||||
response.headers.value(HttpHeaders.contentTypeHeader) ?? '';
|
||||
String mimeType = 'text/plain';
|
||||
if (contentType.isNotEmpty) {
|
||||
// Strip charset if present
|
||||
mimeType = contentType.split(';').first.trim();
|
||||
}
|
||||
|
||||
final bytes = response.data is List<int>
|
||||
? (response.data as List<int>)
|
||||
: (response.data as Uint8List).toList();
|
||||
|
||||
final base64Data = base64Encode(bytes);
|
||||
|
||||
// For images, return a data URL so UI can render directly; otherwise return raw base64
|
||||
if (mimeType.startsWith('image/')) {
|
||||
return 'data:$mimeType;base64,$base64Data';
|
||||
}
|
||||
|
||||
return base64Data;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getFileInfo(String fileId) async {
|
||||
@@ -2633,7 +2660,8 @@ class ApiService {
|
||||
final streamController = StreamController<String>();
|
||||
|
||||
// Generate unique IDs
|
||||
final messageId = (responseMessageId != null && responseMessageId.isNotEmpty)
|
||||
final messageId =
|
||||
(responseMessageId != null && responseMessageId.isNotEmpty)
|
||||
? responseMessageId
|
||||
: const Uuid().v4();
|
||||
final sessionId =
|
||||
@@ -2770,20 +2798,23 @@ class ApiService {
|
||||
debugPrint(
|
||||
'DEBUG: Has background_tasks (pre-bg): ${data.containsKey('background_tasks')}',
|
||||
);
|
||||
debugPrint('DEBUG: Has session_id (pre-bg): ${data.containsKey('session_id')}');
|
||||
debugPrint('DEBUG: background_tasks value (pre-bg): ${data['background_tasks']}');
|
||||
debugPrint(
|
||||
'DEBUG: Has session_id (pre-bg): ${data.containsKey('session_id')}',
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: background_tasks value (pre-bg): ${data['background_tasks']}',
|
||||
);
|
||||
debugPrint('DEBUG: session_id value (pre-bg): ${data['session_id']}');
|
||||
debugPrint('DEBUG: id value (pre-bg): ${data['id']}');
|
||||
|
||||
// Decide whether to use background task flow.
|
||||
// Only enable background task mode when we actually need socket/dynamic-channel
|
||||
// behavior (e.g., provider-native tools or explicit background tasks with a session).
|
||||
// Always use task-based background flow for unified pipeline.
|
||||
// When a dynamic channel (session_id) is not provided, this method falls
|
||||
// back to polling and streams deltas to the UI.
|
||||
// Always use background task flow (matches web client) to ensure
|
||||
// server maintains correct history with pre-seeded assistant id.
|
||||
final bool useBackgroundTasks = true;
|
||||
// Use background task mode when tools, web_search, image_generation are enabled,
|
||||
// or when an explicit dynamic socket session binding is requested.
|
||||
final bool useBackgroundTasks =
|
||||
(toolIds != null && toolIds.isNotEmpty) ||
|
||||
enableWebSearch ||
|
||||
enableImageGeneration ||
|
||||
(sessionIdOverride != null && sessionIdOverride.isNotEmpty);
|
||||
|
||||
// Use background flow only when required; otherwise prefer SSE even with chat_id.
|
||||
// SSE must not include session_id/id to avoid server falling back to task mode.
|
||||
@@ -2804,8 +2835,12 @@ class ApiService {
|
||||
debugPrint('DEBUG: Background flow payload keys: ${data.keys.toList()}');
|
||||
debugPrint('DEBUG: Using session_id: $sessionId');
|
||||
debugPrint('DEBUG: Using message id: $messageId');
|
||||
debugPrint('DEBUG: Has tool_ids: ${data.containsKey('tool_ids')} -> ${data['tool_ids']}');
|
||||
debugPrint('DEBUG: Has background_tasks: ${data.containsKey('background_tasks')}');
|
||||
debugPrint(
|
||||
'DEBUG: Has tool_ids: ${data.containsKey('tool_ids')} -> ${data['tool_ids']}',
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Has background_tasks: ${data.containsKey('background_tasks')}',
|
||||
);
|
||||
|
||||
debugPrint('DEBUG: Initiating background tools flow (task-based)');
|
||||
debugPrint('DEBUG: Posting to /api/chat/completions (no SSE)');
|
||||
@@ -2838,6 +2873,113 @@ class ApiService {
|
||||
if (!streamController.isClosed) streamController.close();
|
||||
}
|
||||
}();
|
||||
} else {
|
||||
// SSE streaming path for low-latency pure completions (no background tasks)
|
||||
() async {
|
||||
try {
|
||||
// Request SSE stream; avoid session_id/id which break streaming
|
||||
final resp = await _dio.post(
|
||||
'/api/chat/completions',
|
||||
data: data,
|
||||
options: Options(
|
||||
responseType: ResponseType.stream,
|
||||
headers: {'Accept': 'text/event-stream'},
|
||||
),
|
||||
);
|
||||
|
||||
// Parse SSE lines and forward deltas to the controller
|
||||
final body = resp.data;
|
||||
// Dio returns ResponseBody for stream responseType
|
||||
final stream = (body is ResponseBody) ? body.stream : null;
|
||||
if (stream == null) {
|
||||
// Fallback: if server responded JSON, emit once
|
||||
try {
|
||||
final dataStr = resp.data?.toString() ?? '';
|
||||
if (dataStr.isNotEmpty && !streamController.isClosed) {
|
||||
streamController.add(dataStr);
|
||||
}
|
||||
} catch (_) {}
|
||||
if (!streamController.isClosed) streamController.close();
|
||||
return;
|
||||
}
|
||||
|
||||
String buffer = '';
|
||||
late final StreamSubscription<List<int>> sub;
|
||||
sub = stream.listen(
|
||||
(chunk) {
|
||||
try {
|
||||
buffer += utf8.decode(chunk, allowMalformed: true);
|
||||
// Process complete lines; keep remainder in buffer
|
||||
final parts = buffer.split('\n');
|
||||
buffer = parts.isNotEmpty ? parts.removeLast() : '';
|
||||
for (final raw in parts) {
|
||||
final line = raw.trim();
|
||||
if (line.isEmpty) continue;
|
||||
if (line == 'data: [DONE]') {
|
||||
try {
|
||||
if (!streamController.isClosed) streamController.close();
|
||||
} catch (_) {}
|
||||
sub.cancel();
|
||||
return;
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
final payloadStr = line.substring(5).trim();
|
||||
if (payloadStr.isEmpty) continue;
|
||||
try {
|
||||
final Map<String, dynamic> j = jsonDecode(payloadStr);
|
||||
final choices = j['choices'];
|
||||
if (choices is List && choices.isNotEmpty) {
|
||||
final choice = choices.first;
|
||||
final delta = choice is Map ? choice['delta'] : null;
|
||||
if (delta is Map) {
|
||||
final content = delta['content']?.toString() ?? '';
|
||||
if (content.isNotEmpty &&
|
||||
!streamController.isClosed) {
|
||||
streamController.add(content);
|
||||
}
|
||||
} else {
|
||||
final message = (choice is Map)
|
||||
? (choice['message']?['content']?.toString() ??
|
||||
'')
|
||||
: '';
|
||||
if (message.isNotEmpty &&
|
||||
!streamController.isClosed) {
|
||||
streamController.add(message);
|
||||
}
|
||||
}
|
||||
} else if (j['content'] is String) {
|
||||
final content = j['content'] as String;
|
||||
if (content.isNotEmpty && !streamController.isClosed) {
|
||||
streamController.add(content);
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Non-JSON payload; forward as-is
|
||||
if (!streamController.isClosed) {
|
||||
streamController.add(payloadStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
onDone: () {
|
||||
try {
|
||||
if (!streamController.isClosed) streamController.close();
|
||||
} catch (_) {}
|
||||
},
|
||||
onError: (_) {
|
||||
try {
|
||||
if (!streamController.isClosed) streamController.close();
|
||||
} catch (_) {}
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: SSE streaming failed: $e');
|
||||
if (!streamController.isClosed) streamController.close();
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,11 +20,11 @@ class ConnectivityService {
|
||||
Stream<ConnectivityStatus> get connectivityStream =>
|
||||
_connectivityController.stream;
|
||||
ConnectivityStatus get currentStatus => _lastStatus;
|
||||
|
||||
|
||||
/// Stream that emits true when connected, false when offline
|
||||
Stream<bool> get isConnected => connectivityStream
|
||||
.map((status) => status == ConnectivityStatus.online);
|
||||
|
||||
Stream<bool> get isConnected =>
|
||||
connectivityStream.map((status) => status == ConnectivityStatus.online);
|
||||
|
||||
/// Check if currently connected
|
||||
bool get isCurrentlyConnected => _lastStatus == ConnectivityStatus.online;
|
||||
|
||||
@@ -95,7 +95,8 @@ class ConnectivityService {
|
||||
|
||||
// Providers
|
||||
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
|
||||
final dio = ref.watch(dioProvider);
|
||||
// Use a lightweight Dio instance only for connectivity checks
|
||||
final dio = Dio();
|
||||
final service = ConnectivityService(dio);
|
||||
ref.onDispose(() => service.dispose());
|
||||
return service;
|
||||
@@ -120,6 +121,4 @@ final isOnlineProvider = Provider<bool>((ref) {
|
||||
});
|
||||
|
||||
// Dio provider (if not already defined elsewhere)
|
||||
final dioProvider = Provider<Dio>((ref) {
|
||||
return Dio(); // This should be configured with your base URL
|
||||
});
|
||||
// Removed unused Dio provider to avoid confusion. Use ApiService instead.
|
||||
|
||||
@@ -56,7 +56,10 @@ final shareReceiverInitializerProvider = Provider<void>((ref) {
|
||||
);
|
||||
ref.listen(selectedModelProvider, (prev, next) => maybeProcessPending());
|
||||
// Also poll once shortly after navigation settles to ensure ChatPage is ready
|
||||
Future.delayed(const Duration(milliseconds: 150), () => maybeProcessPending());
|
||||
Future.delayed(
|
||||
const Duration(milliseconds: 150),
|
||||
() => maybeProcessPending(),
|
||||
);
|
||||
|
||||
// Hook into share_handler
|
||||
final handler = sh.ShareHandler.instance;
|
||||
@@ -158,22 +161,14 @@ Future<void> _processPayload(Ref ref, SharedPayload payload) async {
|
||||
final activeConv = ref.read(activeConversationProvider);
|
||||
for (final file in files) {
|
||||
try {
|
||||
final ext = path.extension(file.path).toLowerCase();
|
||||
final isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext);
|
||||
if (isImage) {
|
||||
await ref.read(taskQueueProvider.notifier).enqueueImageToDataUrl(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: file.path,
|
||||
fileName: path.basename(file.path),
|
||||
);
|
||||
} else {
|
||||
await ref.read(taskQueueProvider.notifier).enqueueUploadMedia(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: file.path,
|
||||
fileName: path.basename(file.path),
|
||||
fileSize: await file.length(),
|
||||
);
|
||||
}
|
||||
await ref
|
||||
.read(taskQueueProvider.notifier)
|
||||
.enqueueUploadMedia(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: file.path,
|
||||
fileName: path.basename(file.path),
|
||||
fileSize: await file.length(),
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +163,8 @@ class ChatMessagesNotifier extends StateNotifier<List<ChatMessage>> {
|
||||
orElse: () => null,
|
||||
);
|
||||
if (textItem != null) {
|
||||
content = (textItem as Map)['text']?.toString() ?? '';
|
||||
content =
|
||||
(textItem as Map)['text']?.toString() ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -765,11 +766,12 @@ Future<void> regenerateMessage(
|
||||
final cleaned = ToolCallsParser.sanitizeForApi(msg.content);
|
||||
|
||||
// Prefer provided attachments for the last user message; otherwise use message attachments
|
||||
final bool isLastUser = (i == messages.length - 1) && msg.role == 'user';
|
||||
final bool isLastUser =
|
||||
(i == messages.length - 1) && msg.role == 'user';
|
||||
final List<String> messageAttachments =
|
||||
(isLastUser && (attachments != null && attachments.isNotEmpty))
|
||||
? List<String>.from(attachments)
|
||||
: (msg.attachmentIds ?? const <String>[]);
|
||||
? List<String>.from(attachments)
|
||||
: (msg.attachmentIds ?? const <String>[]);
|
||||
|
||||
if (messageAttachments.isNotEmpty) {
|
||||
final messageMap = await _buildMessagePayloadWithAttachments(
|
||||
@@ -946,6 +948,11 @@ Future<void> regenerateMessage(
|
||||
final bool isBackgroundWebSearchPre = webSearchEnabled;
|
||||
|
||||
// Dispatch using unified send pipeline (background tools flow)
|
||||
final bool _isBackgroundFlowPre =
|
||||
isBackgroundToolsFlowPre ||
|
||||
isBackgroundWebSearchPre ||
|
||||
imageGenerationEnabled;
|
||||
final bool _passSocketSession = wantSessionBinding && _isBackgroundFlowPre;
|
||||
final response = api!.sendMessage(
|
||||
messages: conversationMessages,
|
||||
model: selectedModel.id,
|
||||
@@ -954,7 +961,7 @@ Future<void> regenerateMessage(
|
||||
enableWebSearch: webSearchEnabled,
|
||||
enableImageGeneration: imageGenerationEnabled,
|
||||
modelItem: modelItem,
|
||||
sessionIdOverride: wantSessionBinding ? socketSessionId : null,
|
||||
sessionIdOverride: _passSocketSession ? socketSessionId : null,
|
||||
toolServers: toolServers,
|
||||
backgroundTasks: bgTasks,
|
||||
responseMessageId: assistantMessageId,
|
||||
@@ -1935,7 +1942,9 @@ Future<void> _sendMessageInternal(
|
||||
content = payload['message'];
|
||||
}
|
||||
if (content.isNotEmpty) {
|
||||
ref.read(chatMessagesProvider.notifier).replaceLastMessageContent('⚠️ ' + content);
|
||||
ref
|
||||
.read(chatMessagesProvider.notifier)
|
||||
.replaceLastMessageContent('⚠️ ' + content);
|
||||
}
|
||||
} catch (_) {}
|
||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||
@@ -1984,7 +1993,8 @@ Future<void> _sendMessageInternal(
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (_) {}
|
||||
} else if ((type == 'files' || type == 'chat:message:files') && payload != null) {
|
||||
} else if ((type == 'files' || type == 'chat:message:files') &&
|
||||
payload != null) {
|
||||
// Handle files event from socket (image generation results)
|
||||
try {
|
||||
DebugLogger.stream(
|
||||
|
||||
@@ -152,7 +152,9 @@ class FileAttachmentService {
|
||||
int? maxHeight,
|
||||
}) async {
|
||||
try {
|
||||
foundation.debugPrint('DEBUG: Converting image to data URL: ${imageFile.path}');
|
||||
foundation.debugPrint(
|
||||
'DEBUG: Converting image to data URL: ${imageFile.path}',
|
||||
);
|
||||
|
||||
// Read the file as bytes
|
||||
final bytes = await imageFile.readAsBytes();
|
||||
@@ -217,41 +219,22 @@ class FileAttachmentService {
|
||||
'webp',
|
||||
].contains(ext.substring(1));
|
||||
|
||||
if (isImage) {
|
||||
foundation.debugPrint(
|
||||
'DEBUG: Image file detected, converting to data URL instead of uploading',
|
||||
);
|
||||
// Upload ALL files (including images) to server for consistency with web client
|
||||
foundation.debugPrint('DEBUG: Uploading file to server...');
|
||||
final fileId = await _apiService.uploadFile(file.path, fileName);
|
||||
foundation.debugPrint(
|
||||
'DEBUG: File uploaded successfully with ID: $fileId',
|
||||
);
|
||||
|
||||
// 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 {
|
||||
foundation.debugPrint('DEBUG: Non-image file, uploading to server...');
|
||||
// Upload file using the API service
|
||||
final fileId = await _apiService.uploadFile(file.path, fileName);
|
||||
foundation.debugPrint('DEBUG: File uploaded successfully with ID: $fileId');
|
||||
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
progress: 1.0,
|
||||
status: FileUploadStatus.completed,
|
||||
fileId: fileId,
|
||||
);
|
||||
}
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
progress: 1.0,
|
||||
status: FileUploadStatus.completed,
|
||||
fileId: fileId,
|
||||
isImage: isImage,
|
||||
);
|
||||
} catch (e) {
|
||||
foundation.debugPrint('DEBUG: File upload failed: $e');
|
||||
final fileName = path.basename(file.path);
|
||||
@@ -439,10 +422,10 @@ class MockFileAttachmentService {
|
||||
// Mock upload file with progress tracking
|
||||
Stream<FileUploadState> uploadFile(File file) async* {
|
||||
foundation.debugPrint('DEBUG: Mock file upload for: ${file.path}');
|
||||
|
||||
|
||||
final fileName = path.basename(file.path);
|
||||
final fileSize = await file.length();
|
||||
|
||||
|
||||
// Yield initial state
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
@@ -451,7 +434,7 @@ class MockFileAttachmentService {
|
||||
progress: 0.0,
|
||||
status: FileUploadStatus.uploading,
|
||||
);
|
||||
|
||||
|
||||
// Simulate upload progress
|
||||
for (int i = 1; i <= 10; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
@@ -463,7 +446,7 @@ class MockFileAttachmentService {
|
||||
status: FileUploadStatus.uploading,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Yield completed state with mock file ID
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
@@ -473,10 +456,10 @@ class MockFileAttachmentService {
|
||||
status: FileUploadStatus.completed,
|
||||
fileId: 'mock_file_${DateTime.now().millisecondsSinceEpoch}',
|
||||
);
|
||||
|
||||
|
||||
foundation.debugPrint('DEBUG: Mock file upload completed');
|
||||
}
|
||||
|
||||
|
||||
Future<List<String>> uploadFiles(
|
||||
List<File> files, {
|
||||
Function(int, int)? onProgress,
|
||||
@@ -484,7 +467,7 @@ class MockFileAttachmentService {
|
||||
}) async {
|
||||
// Simulate upload progress for reviewer mode
|
||||
final uploadIds = <String>[];
|
||||
|
||||
|
||||
for (int i = 0; i < files.length; i++) {
|
||||
if (onProgress != null) {
|
||||
// Simulate progress
|
||||
@@ -496,7 +479,7 @@ class MockFileAttachmentService {
|
||||
// Generate mock upload ID
|
||||
uploadIds.add('mock_upload_${DateTime.now().millisecondsSinceEpoch}_$i');
|
||||
}
|
||||
|
||||
|
||||
return uploadIds;
|
||||
}
|
||||
}
|
||||
@@ -504,11 +487,11 @@ class MockFileAttachmentService {
|
||||
// Providers
|
||||
final fileAttachmentServiceProvider = Provider<dynamic>((ref) {
|
||||
final isReviewerMode = ref.watch(reviewerModeProvider);
|
||||
|
||||
|
||||
if (isReviewerMode) {
|
||||
return MockFileAttachmentService();
|
||||
}
|
||||
|
||||
|
||||
final apiService = ref.watch(apiServiceProvider);
|
||||
if (apiService == null) return null;
|
||||
return FileAttachmentService(apiService);
|
||||
|
||||
@@ -59,10 +59,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
bool _lastKeyboardVisible = false; // track keyboard visibility transitions
|
||||
bool _didStartupFocus = false; // one-time auto-focus on startup
|
||||
|
||||
String _formatModelDisplayName(
|
||||
String name, {
|
||||
required bool omitProvider,
|
||||
}) {
|
||||
String _formatModelDisplayName(String name, {required bool omitProvider}) {
|
||||
var display = name.trim();
|
||||
if (omitProvider) {
|
||||
// Prefer the segment after the last '/'
|
||||
@@ -295,8 +292,11 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
// Get attached files and collect uploaded file IDs (including data URLs for images)
|
||||
final attachedFiles = ref.read(attachedFilesProvider);
|
||||
final uploadedFileIds = attachedFiles
|
||||
.where((file) =>
|
||||
file.status == FileUploadStatus.completed && file.fileId != null)
|
||||
.where(
|
||||
(file) =>
|
||||
file.status == FileUploadStatus.completed &&
|
||||
file.fileId != null,
|
||||
)
|
||||
.map((file) => file.fileId!)
|
||||
.toList();
|
||||
|
||||
@@ -305,7 +305,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
|
||||
// Enqueue task-based send to unify flow across text, images, and tools
|
||||
final activeConv = ref.read(activeConversationProvider);
|
||||
await ref.read(taskQueueProvider.notifier).enqueueSendText(
|
||||
await ref
|
||||
.read(taskQueueProvider.notifier)
|
||||
.enqueueSendText(
|
||||
conversationId: activeConv?.id,
|
||||
text: text,
|
||||
attachments: uploadedFileIds.isNotEmpty ? uploadedFileIds : null,
|
||||
@@ -373,22 +375,14 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
final activeConv = ref.read(activeConversationProvider);
|
||||
for (final file in files) {
|
||||
try {
|
||||
final ext = path.extension(file.path).toLowerCase();
|
||||
final isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].contains(ext);
|
||||
if (isImage) {
|
||||
await ref.read(taskQueueProvider.notifier).enqueueImageToDataUrl(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: file.path,
|
||||
fileName: path.basename(file.path),
|
||||
);
|
||||
} else {
|
||||
await ref.read(taskQueueProvider.notifier).enqueueUploadMedia(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: file.path,
|
||||
fileName: path.basename(file.path),
|
||||
fileSize: await file.length(),
|
||||
);
|
||||
}
|
||||
await ref
|
||||
.read(taskQueueProvider.notifier)
|
||||
.enqueueUploadMedia(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: file.path,
|
||||
fileName: path.basename(file.path),
|
||||
fileSize: await file.length(),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
debugPrint('Enqueue upload failed: $e');
|
||||
@@ -453,10 +447,13 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
debugPrint('DEBUG: Enqueueing image upload...');
|
||||
final activeConv = ref.read(activeConversationProvider);
|
||||
try {
|
||||
await ref.read(taskQueueProvider.notifier).enqueueImageToDataUrl(
|
||||
await ref
|
||||
.read(taskQueueProvider.notifier)
|
||||
.enqueueUploadMedia(
|
||||
conversationId: activeConv?.id,
|
||||
filePath: image.path,
|
||||
fileName: path.basename(image.path),
|
||||
fileSize: imageSize,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Enqueue image upload failed: $e');
|
||||
@@ -709,8 +706,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
String? displayModelName;
|
||||
final rawModel = message.model;
|
||||
if (rawModel != null && rawModel.isNotEmpty) {
|
||||
final omitProvider =
|
||||
ref.watch(appSettingsProvider).omitProviderInModelName;
|
||||
final omitProvider = ref
|
||||
.watch(appSettingsProvider)
|
||||
.omitProviderInModelName;
|
||||
final modelsAsync = ref.watch(modelsProvider);
|
||||
if (modelsAsync.hasValue) {
|
||||
final models = modelsAsync.value!;
|
||||
@@ -931,7 +929,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
// Keyboard visibility
|
||||
final keyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
|
||||
// Whether the messages list can actually scroll (avoids showing button when not needed)
|
||||
final canScroll = _scrollController.hasClients &&
|
||||
final canScroll =
|
||||
_scrollController.hasClients &&
|
||||
_scrollController.position.maxScrollExtent > 0;
|
||||
|
||||
// On keyboard open, if already near bottom, auto-scroll to bottom to keep input visible
|
||||
@@ -1128,10 +1127,11 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
label,
|
||||
style: AppTypography.headlineSmallStyle
|
||||
.copyWith(
|
||||
color:
|
||||
context.conduitTheme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
color: context
|
||||
.conduitTheme
|
||||
.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
semanticsLabel: label,
|
||||
);
|
||||
@@ -1366,158 +1366,170 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
// Messages Area with pull-to-refresh
|
||||
Expanded(
|
||||
child: ConduitRefreshIndicator(
|
||||
onRefresh: () async {
|
||||
// Reload active conversation messages from server
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final active = ref.read(activeConversationProvider);
|
||||
if (api != null && active != null) {
|
||||
try {
|
||||
final full = await api.getConversation(active.id);
|
||||
ref
|
||||
.read(activeConversationProvider.notifier)
|
||||
.state = full;
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Failed to refresh conversation: $e');
|
||||
Column(
|
||||
children: [
|
||||
// Messages Area with pull-to-refresh
|
||||
Expanded(
|
||||
child: ConduitRefreshIndicator(
|
||||
onRefresh: () async {
|
||||
// Reload active conversation messages from server
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final active = ref.read(activeConversationProvider);
|
||||
if (api != null && active != null) {
|
||||
try {
|
||||
final full = await api.getConversation(active.id);
|
||||
ref
|
||||
.read(activeConversationProvider.notifier)
|
||||
.state =
|
||||
full;
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'DEBUG: Failed to refresh conversation: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also refresh the conversations list to reconcile missed events
|
||||
// and keep timestamps/order in sync with the server.
|
||||
try {
|
||||
ref.invalidate(conversationsProvider);
|
||||
// Best-effort await to stabilize UI; ignore errors.
|
||||
await ref.read(conversationsProvider.future);
|
||||
} catch (_) {}
|
||||
|
||||
// Add small delay for better UX feedback
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
},
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
// Also refresh the conversations list to reconcile missed events
|
||||
// and keep timestamps/order in sync with the server.
|
||||
try {
|
||||
SystemChannels.textInput.invokeMethod('TextInput.hide');
|
||||
ref.invalidate(conversationsProvider);
|
||||
// Best-effort await to stabilize UI; ignore errors.
|
||||
await ref.read(conversationsProvider.future);
|
||||
} catch (_) {}
|
||||
|
||||
// Add small delay for better UX feedback
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 300),
|
||||
);
|
||||
},
|
||||
child: RepaintBoundary(
|
||||
child: _buildMessagesList(theme),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
try {
|
||||
SystemChannels.textInput.invokeMethod(
|
||||
'TextInput.hide',
|
||||
);
|
||||
} catch (_) {}
|
||||
},
|
||||
child: RepaintBoundary(
|
||||
child: _buildMessagesList(theme),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// File attachments
|
||||
const FileAttachmentWidget(),
|
||||
// File attachments
|
||||
const FileAttachmentWidget(),
|
||||
|
||||
// Offline indicator
|
||||
const ChatOfflineOverlay(),
|
||||
// Offline indicator
|
||||
const ChatOfflineOverlay(),
|
||||
|
||||
// Modern Input (root matches input background including safe area)
|
||||
RepaintBoundary(
|
||||
child: MeasureSize(
|
||||
onChange: (size) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_inputHeight = size.height;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: ModernChatInput(
|
||||
enabled:
|
||||
selectedModel != null &&
|
||||
(isOnline || ref.watch(reviewerModeProvider)),
|
||||
onSendMessage: (text) =>
|
||||
_handleMessageSend(text, selectedModel),
|
||||
onVoiceInput: null,
|
||||
onFileAttachment: _handleFileAttachment,
|
||||
onImageAttachment: _handleImageAttachment,
|
||||
onCameraCapture: () =>
|
||||
_handleImageAttachment(fromCamera: true),
|
||||
// Modern Input (root matches input background including safe area)
|
||||
RepaintBoundary(
|
||||
child: MeasureSize(
|
||||
onChange: (size) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_inputHeight = size.height;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: ModernChatInput(
|
||||
enabled:
|
||||
selectedModel != null &&
|
||||
(isOnline || ref.watch(reviewerModeProvider)),
|
||||
onSendMessage: (text) =>
|
||||
_handleMessageSend(text, selectedModel),
|
||||
onVoiceInput: null,
|
||||
onFileAttachment: _handleFileAttachment,
|
||||
onImageAttachment: _handleImageAttachment,
|
||||
onCameraCapture: () =>
|
||||
_handleImageAttachment(fromCamera: true),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Floating Scroll to Bottom Button with smooth appear/disappear
|
||||
Positioned(
|
||||
bottom: ((_inputHeight > 0) ? _inputHeight : (Spacing.xxl + Spacing.xxxl)) + Spacing.sm,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: AnimatedSwitcher(
|
||||
duration: AnimationDuration.microInteraction,
|
||||
switchInCurve: AnimationCurves.microInteraction,
|
||||
switchOutCurve: AnimationCurves.microInteraction,
|
||||
transitionBuilder: (child, animation) {
|
||||
final slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, 0.15),
|
||||
end: Offset.zero,
|
||||
).animate(animation);
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: slideAnimation,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: (_showScrollToBottom &&
|
||||
!keyboardVisible &&
|
||||
canScroll &&
|
||||
ref.watch(chatMessagesProvider).isNotEmpty)
|
||||
? Center(
|
||||
key: const ValueKey('scroll_to_bottom_visible'),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.floatingButton,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context
|
||||
.conduitTheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.75),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.cardBorder
|
||||
.withValues(alpha: 0.3),
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.floatingButton,
|
||||
),
|
||||
boxShadow: ConduitShadows.button,
|
||||
// Floating Scroll to Bottom Button with smooth appear/disappear
|
||||
Positioned(
|
||||
bottom:
|
||||
((_inputHeight > 0)
|
||||
? _inputHeight
|
||||
: (Spacing.xxl + Spacing.xxxl)) +
|
||||
Spacing.sm,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: AnimatedSwitcher(
|
||||
duration: AnimationDuration.microInteraction,
|
||||
switchInCurve: AnimationCurves.microInteraction,
|
||||
switchOutCurve: AnimationCurves.microInteraction,
|
||||
transitionBuilder: (child, animation) {
|
||||
final slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, 0.15),
|
||||
end: Offset.zero,
|
||||
).animate(animation);
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: slideAnimation,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child:
|
||||
(_showScrollToBottom &&
|
||||
!keyboardVisible &&
|
||||
canScroll &&
|
||||
ref.watch(chatMessagesProvider).isNotEmpty)
|
||||
? Center(
|
||||
key: const ValueKey('scroll_to_bottom_visible'),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.floatingButton,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: TouchTarget.button,
|
||||
height: TouchTarget.button,
|
||||
child: IconButton(
|
||||
onPressed: _scrollToBottom,
|
||||
splashRadius: 24,
|
||||
icon: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.arrow_down
|
||||
: Icons.keyboard_arrow_down,
|
||||
size: IconSize.lg,
|
||||
color: context.conduitTheme.iconPrimary
|
||||
.withValues(alpha: 0.9),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context
|
||||
.conduitTheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.75),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.cardBorder
|
||||
.withValues(alpha: 0.3),
|
||||
width: BorderWidth.regular,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.floatingButton,
|
||||
),
|
||||
boxShadow: ConduitShadows.button,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: TouchTarget.button,
|
||||
height: TouchTarget.button,
|
||||
child: IconButton(
|
||||
onPressed: _scrollToBottom,
|
||||
splashRadius: 24,
|
||||
icon: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.arrow_down
|
||||
: Icons.keyboard_arrow_down,
|
||||
size: IconSize.lg,
|
||||
color: context.conduitTheme.iconPrimary
|
||||
.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(
|
||||
key: ValueKey('scroll_to_bottom_hidden'),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(
|
||||
key: ValueKey('scroll_to_bottom_hidden'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Edge overlay removed; rely on native interactive drawer drag
|
||||
// Edge overlay removed; rely on native interactive drawer drag
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:conduit/l10n/app_localizations.dart';
|
||||
import '../services/file_attachment_service.dart';
|
||||
import '../../../shared/widgets/loading_states.dart';
|
||||
|
||||
@@ -24,7 +25,7 @@ class FileAttachmentWidget extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Attachments',
|
||||
AppLocalizations.of(context)!.attachments,
|
||||
style: TextStyle(
|
||||
color: context.conduitTheme.textSecondary.withValues(alpha: 0.7),
|
||||
fontSize: AppTypography.labelMedium,
|
||||
|
||||
@@ -84,6 +84,8 @@
|
||||
"@uploadDocsPrompt": {"description": "Prompt encouraging users to upload documents."},
|
||||
"uploadFirstFile": "Upload your first file",
|
||||
"@uploadFirstFile": {"description": "CTA to add the first file."},
|
||||
"attachments": "Attachments",
|
||||
"@attachments": {"description": "Header above list of attached files in compose area."},
|
||||
"knowledgeBaseEmpty": "Knowledge base is empty",
|
||||
"@knowledgeBaseEmpty": {"description": "Empty state title for the knowledge base section."},
|
||||
"createCollectionsPrompt": "Create collections of related documents for easy reference",
|
||||
|
||||
@@ -306,6 +306,12 @@ abstract class AppLocalizations {
|
||||
/// **'Upload your first file'**
|
||||
String get uploadFirstFile;
|
||||
|
||||
/// Header above list of attached files in compose area.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Attachments'**
|
||||
String get attachments;
|
||||
|
||||
/// Empty state title for the knowledge base section.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -124,6 +124,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
@override
|
||||
String get uploadFirstFile => 'Erste Datei hochladen';
|
||||
|
||||
@override
|
||||
String get attachments => 'Attachments';
|
||||
|
||||
@override
|
||||
String get knowledgeBaseEmpty => 'Wissensdatenbank ist leer';
|
||||
|
||||
|
||||
@@ -123,6 +123,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get uploadFirstFile => 'Upload your first file';
|
||||
|
||||
@override
|
||||
String get attachments => 'Attachments';
|
||||
|
||||
@override
|
||||
String get knowledgeBaseEmpty => 'Knowledge base is empty';
|
||||
|
||||
|
||||
@@ -123,6 +123,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
@override
|
||||
String get uploadFirstFile => 'Importer votre premier fichier';
|
||||
|
||||
@override
|
||||
String get attachments => 'Attachments';
|
||||
|
||||
@override
|
||||
String get knowledgeBaseEmpty => 'La base de connaissances est vide';
|
||||
|
||||
|
||||
@@ -122,6 +122,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
||||
@override
|
||||
String get uploadFirstFile => 'Carica il tuo primo file';
|
||||
|
||||
@override
|
||||
String get attachments => 'Attachments';
|
||||
|
||||
@override
|
||||
String get knowledgeBaseEmpty => 'La base di conoscenza è vuota';
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import '../../../core/services/attachment_upload_queue.dart';
|
||||
import '../../../core/utils/debug_logger.dart';
|
||||
import '../../../features/chat/providers/chat_providers.dart' as chat;
|
||||
import '../../../features/chat/services/file_attachment_service.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'outbound_task.dart';
|
||||
|
||||
class TaskWorker {
|
||||
@@ -73,9 +72,7 @@ class TaskWorker {
|
||||
try {
|
||||
final api = _ref.read(apiServiceProvider);
|
||||
if (api != null) {
|
||||
await uploader.initialize(
|
||||
onUpload: (p, n) => api.uploadFile(p, n),
|
||||
);
|
||||
await uploader.initialize(onUpload: (p, n) => api.uploadFile(p, n));
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
@@ -116,7 +113,9 @@ class TaskWorker {
|
||||
file: File(task.filePath),
|
||||
fileName: task.fileName,
|
||||
fileSize: task.fileSize ?? existing.fileSize,
|
||||
progress: status == FileUploadStatus.completed ? 1.0 : existing.progress,
|
||||
progress: status == FileUploadStatus.completed
|
||||
? 1.0
|
||||
: existing.progress,
|
||||
status: status,
|
||||
fileId: entry.fileId ?? existing.fileId,
|
||||
error: entry.lastError,
|
||||
@@ -140,11 +139,16 @@ class TaskWorker {
|
||||
|
||||
// Fire a process tick
|
||||
unawaited(uploader.processQueue());
|
||||
await completer.future.timeout(const Duration(minutes: 2), onTimeout: () {
|
||||
try { sub.cancel(); } catch (_) {}
|
||||
DebugLogger.warning('UploadMediaTask timed out: ${task.fileName}');
|
||||
return;
|
||||
});
|
||||
await completer.future.timeout(
|
||||
const Duration(minutes: 2),
|
||||
onTimeout: () {
|
||||
try {
|
||||
sub.cancel();
|
||||
} catch (_) {}
|
||||
DebugLogger.warning('UploadMediaTask timed out: ${task.fileName}');
|
||||
return;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _performExecuteToolCall(ExecuteToolCallTask task) async {
|
||||
@@ -203,12 +207,7 @@ class TaskWorker {
|
||||
? <String>[resolvedToolId]
|
||||
: null;
|
||||
|
||||
await chat.sendMessageFromService(
|
||||
_ref,
|
||||
instruction,
|
||||
null,
|
||||
toolIds,
|
||||
);
|
||||
await chat.sendMessageFromService(_ref, instruction, null, toolIds);
|
||||
}
|
||||
|
||||
Future<void> _performGenerateImage(GenerateImageTask task) async {
|
||||
@@ -235,97 +234,109 @@ class TaskWorker {
|
||||
final prev = _ref.read(chat.imageGenerationEnabledProvider);
|
||||
try {
|
||||
_ref.read(chat.imageGenerationEnabledProvider.notifier).state = true;
|
||||
await chat.sendMessageFromService(
|
||||
_ref,
|
||||
task.prompt,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
await chat.sendMessageFromService(_ref, task.prompt, null, null);
|
||||
} finally {
|
||||
_ref.read(chat.imageGenerationEnabledProvider.notifier).state = prev;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _performImageToDataUrl(ImageToDataUrlTask task) async {
|
||||
// Upload images to server instead of converting to data URLs
|
||||
final uploader = AttachmentUploadQueue();
|
||||
try {
|
||||
// Update UI to uploading state first
|
||||
try {
|
||||
final current = _ref.read(attachedFilesProvider);
|
||||
final idx = current.indexWhere((f) => f.file.path == task.filePath);
|
||||
if (idx != -1) {
|
||||
final existing = current[idx];
|
||||
final uploading = FileUploadState(
|
||||
file: existing.file,
|
||||
fileName: task.fileName,
|
||||
fileSize: existing.fileSize,
|
||||
progress: 0.5,
|
||||
status: FileUploadStatus.uploading,
|
||||
fileId: existing.fileId,
|
||||
);
|
||||
_ref.read(attachedFilesProvider.notifier).updateFileState(
|
||||
task.filePath,
|
||||
uploading,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Read file and convert to data URL
|
||||
final file = File(task.filePath);
|
||||
final bytes = await file.readAsBytes();
|
||||
final b64 = base64Encode(bytes);
|
||||
final ext = path.extension(task.fileName).toLowerCase();
|
||||
String mime = 'image/png';
|
||||
if (ext == '.jpg' || ext == '.jpeg') {
|
||||
mime = 'image/jpeg';
|
||||
} else if (ext == '.gif') {
|
||||
mime = 'image/gif';
|
||||
} else if (ext == '.webp') {
|
||||
mime = 'image/webp';
|
||||
final api = _ref.read(apiServiceProvider);
|
||||
if (api != null) {
|
||||
await uploader.initialize(onUpload: (p, n) => api.uploadFile(p, n));
|
||||
}
|
||||
final dataUrl = 'data:$mime;base64,$b64';
|
||||
} catch (_) {}
|
||||
|
||||
// Mark as completed with data URL as fileId
|
||||
try {
|
||||
final current = _ref.read(attachedFilesProvider);
|
||||
final idx = current.indexWhere((f) => f.file.path == task.filePath);
|
||||
if (idx != -1) {
|
||||
final existing = current[idx];
|
||||
final uploading = FileUploadState(
|
||||
file: existing.file,
|
||||
fileName: task.fileName,
|
||||
fileSize: existing.fileSize,
|
||||
progress: 0.0,
|
||||
status: FileUploadStatus.uploading,
|
||||
fileId: existing.fileId,
|
||||
);
|
||||
_ref
|
||||
.read(attachedFilesProvider.notifier)
|
||||
.updateFileState(task.filePath, uploading);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final id = await uploader.enqueue(
|
||||
filePath: task.filePath,
|
||||
fileName: task.fileName,
|
||||
fileSize: File(task.filePath).lengthSync(),
|
||||
);
|
||||
|
||||
final completer = Completer<void>();
|
||||
late final StreamSubscription<List<QueuedAttachment>> sub;
|
||||
sub = uploader.queueStream.listen((items) {
|
||||
QueuedAttachment? entry;
|
||||
try {
|
||||
entry = items.firstWhere((e) => e.id == id);
|
||||
} catch (_) {
|
||||
entry = null;
|
||||
}
|
||||
if (entry == null) return;
|
||||
try {
|
||||
final current = _ref.read(attachedFilesProvider);
|
||||
final idx = current.indexWhere((f) => f.file.path == task.filePath);
|
||||
if (idx != -1) {
|
||||
final existing = current[idx];
|
||||
final done = FileUploadState(
|
||||
file: existing.file,
|
||||
final status = switch (entry.status) {
|
||||
QueuedAttachmentStatus.pending => FileUploadStatus.uploading,
|
||||
QueuedAttachmentStatus.uploading => FileUploadStatus.uploading,
|
||||
QueuedAttachmentStatus.completed => FileUploadStatus.completed,
|
||||
QueuedAttachmentStatus.failed => FileUploadStatus.failed,
|
||||
QueuedAttachmentStatus.cancelled => FileUploadStatus.failed,
|
||||
};
|
||||
final newState = FileUploadState(
|
||||
file: File(task.filePath),
|
||||
fileName: task.fileName,
|
||||
fileSize: existing.fileSize,
|
||||
progress: 1.0,
|
||||
status: FileUploadStatus.completed,
|
||||
fileId: dataUrl,
|
||||
progress: status == FileUploadStatus.completed
|
||||
? 1.0
|
||||
: existing.progress,
|
||||
status: status,
|
||||
fileId: entry.fileId ?? existing.fileId,
|
||||
isImage: true,
|
||||
error: entry.lastError,
|
||||
);
|
||||
_ref.read(attachedFilesProvider.notifier).updateFileState(
|
||||
task.filePath,
|
||||
done,
|
||||
);
|
||||
_ref
|
||||
.read(attachedFilesProvider.notifier)
|
||||
.updateFileState(task.filePath, newState);
|
||||
}
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
try {
|
||||
final current = _ref.read(attachedFilesProvider);
|
||||
final idx = current.indexWhere((f) => f.file.path == task.filePath);
|
||||
if (idx != -1) {
|
||||
final existing = current[idx];
|
||||
final failed = FileUploadState(
|
||||
file: existing.file,
|
||||
fileName: task.fileName,
|
||||
fileSize: existing.fileSize,
|
||||
progress: 0.0,
|
||||
status: FileUploadStatus.failed,
|
||||
fileId: existing.fileId,
|
||||
error: e.toString(),
|
||||
);
|
||||
_ref.read(attachedFilesProvider.notifier).updateFileState(
|
||||
task.filePath,
|
||||
failed,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
switch (entry.status) {
|
||||
case QueuedAttachmentStatus.completed:
|
||||
case QueuedAttachmentStatus.failed:
|
||||
case QueuedAttachmentStatus.cancelled:
|
||||
sub.cancel();
|
||||
completer.complete();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
unawaited(uploader.processQueue());
|
||||
await completer.future.timeout(
|
||||
const Duration(minutes: 2),
|
||||
onTimeout: () {
|
||||
try {
|
||||
sub.cancel();
|
||||
} catch (_) {}
|
||||
DebugLogger.warning('Image upload timed out: ${task.fileName}');
|
||||
return;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// _performSaveConversation removed
|
||||
|
||||
Reference in New Issue
Block a user