diff --git a/docs/flutter_vs_openwebui_comparison.md b/docs/flutter_vs_openwebui_comparison.md new file mode 100644 index 0000000..431ac43 --- /dev/null +++ b/docs/flutter_vs_openwebui_comparison.md @@ -0,0 +1,190 @@ +# Conduit (Flutter) vs Open-WebUI Web Client: Architecture Comparison, Issues, and Improvements + +## Executive Summary + +- Conduit aligns closely with Open-WebUI’s backend contract (auth, chat, tasks, sockets, files, i18n). +- Largest gaps: inconsistent background task flow vs SSE, partial parity for socket event mirroring, file/image handling differences, settings sync, and error UX. +- This document lists concrete issues with targeted improvements that match AGENTS.md guardrails (small, surgical edits, no new deps unless justified). + +## Scope and Criteria + +- Compared major areas: networking, auth/token, chat send/streaming, sockets, files, settings, i18n, and error handling. +- Cross-referenced key files in `lib/` and `openwebui-src/` to identify parity gaps and opportunities. + +--- + +## Networking & API Layer + +- Flutter: `lib/core/services/api_service.dart` uses `Dio` with `ApiAuthInterceptor` and `ApiErrorInterceptor`, plus debug wrappers. + - Base URL: `ServerConfig.url`. + - Validates statuses `<400`. Adds custom headers safely. +- Web: `openwebui-src/src/lib/apis/**` uses `fetch`, explicit `Authorization` header, and returns JSON/SSE readers. + +Issues +- ApiErrorInterceptor file is referenced but not present in the workspace snapshot (potentially moved/renamed). Risk of inconsistent error shaping. +- `lib/core/services/connectivity_service.dart` defines a simple `dioProvider` that is not wired to project’s configured `Dio`. Possible confusion or unused provider. + +Improvements +- Ensure a single `Dio` source of truth. If `dioProvider` is unused, remove it to avoid drift. +- Verify the actual error interceptor path exists and standardizes error payloads (ensure 401, validation errors, and transport errors surface with actionable messages). If missing, add an error transformer consistent with web client’s `handleOpenAIError` patterns. + +--- + +## Authentication & Tokens + +- Flutter: `AuthStateManager` persists token via `OptimizedStorageService` and `FlutterSecureStorage`. Adds `Authorization: Bearer` in `ApiAuthInterceptor` only for required or optional endpoints. +- Web: On login, sets `localStorage.token` and uses token in `fetch` headers; `socket` `user-join` is also emitted. + +Issues +- Flutter’s auth interceptor strictly blocks required endpoints without token (401 synthesis). Web sometimes tries endpoint and shows backend error. Flutter approach is OK, but ensure optional endpoints are fully listed to match backend behavior. + +Improvements +- Keep the strict check but review endpoint lists in `ApiAuthInterceptor` to match Open-WebUI’s optional/required matrix (e.g., config and public assets). +- Add a token-expiry check cadence similar to web layout’s interval if not already running, or rely on backend 401 → logout flow. + +--- + +## Chat Send, Streaming, Tasks, and Sockets + +- Web: Primarily streams via SSE (`chatCompletion`) and relays to socket channels; also supports background task flow with `task_id` and event mirroring (`chat-events`, `channel-events`). +- Flutter: `ApiService.sendMessage` supports SSE-like stream but currently forces background tools flow (`useBackgroundTasks = true`), attaches `session_id`/`id`, and polls chat until tool sections are done. Socket integration exists via `SocketService` and `streaming_helper.dart` to listen to lines or JSON payloads. + +Issues +- SSE vs Task Mode: Flutter hard-forces background flow, which disables direct SSE streaming and can add latency. Web uses SSE for pure completions and task mode when tools/search/image-gen are active. +- Dynamic Channel Handling: `streaming_helper.dart` has line handlers and `chatHandler`, but suppression flags and switching between SSE and socket content may not mirror all event types from web (`chat:message:delta`, `chat:message:files`, `chat:message:error`, `source/citation`, confirmation, etc.). +- Stop/Cancel: Web manages `taskIds` on active chat to stop. Flutter exposes `stopTask` but does not surface a complete UI flow in providers/widgets here. + +Improvements +- Restore dual-path streaming: + - Use pure SSE (no `session_id`/`id`) when no tools/web-search/image-gen are used to reduce latency and match web UX. + - Use background task + dynamic channel session only when features require it. +- Expand event handling parity in `streaming_helper.dart` to map Open-WebUI event types to mobile UI updates (files, citations, errors, followups). +- Wire cancel/stop control in UI: keep the task API and expose current `taskIds` per chat to stop ongoing responses. + +--- + +## Files and Attachments + +- Web: Uses `/api/v1/files/` with streaming status for processing; images can be inlined in content; shows progress. +- Flutter: `FileAttachmentService` converts images to data-URL instead of uploading; non-images upload via `ApiService.uploadFile` (multipart). `AttachmentUploadQueue` exists for background/offline retries. + +Issues +- Image Handling Divergence: Converting images to data URLs increases payload size and memory; web often uploads and references by id/URL, enabling server-side processing (RAG, OCR, etc.). +- Processing Status: Web listens to file processing SSE to surface parse status; Flutter does not reflect live processing feedback after upload. + +Improvements +- Prefer consistent behavior: upload images too (like web) and store file `id`, letting server handle compression/processing. +- Optionally implement file processing progress by consuming `/files/{id}/status` SSE (if available) and reflect it in `FileAttachmentWidget`. + +--- + +## Settings and Preferences + +- Web: `settings` store persists UI prefs, updates to backend via `updateUserSettings` under `/users/user/settings/update` with `{ ui: settings }`. +- Flutter: `SettingsService` persists locally via `SharedPreferences`; `userSettingsProvider` fetches server settings via `api.getUserSettings()` returning `UserSettings` model. + +Issues +- Potential divergence between local-only settings and backend user settings (`ui` namespace). Some settings (e.g., haptics, stream_response default, chat direction) exist on web but not mirrored in Flutter’s `UserSettings`. + +Improvements +- Align `UserSettings` fields with backend `ui` structure where applicable; when user is authenticated, prefer server-backed settings for cross-device consistency and fall back to local when offline. +- When settings change, POST updates to backend similar to web (`/users/user/settings/update`). Add a small merge strategy: local-only keys remain local, cross-device keys go to server. + +--- + +## Internationalization (i18n) + +- Web: `i18next` with lazy-loaded JSON; language auto-detect and backend default locale; sets `lang` attribute. +- Flutter: `gen-l10n` ARB files with `AppLocalizations`, docs in `docs/localization.md` and CI step. + +Issues +- Parity of strings: ensure Flutter ARB keys cover web parity for shared concepts (errors, chat UI hints, settings labels). Some strings appear hardcoded in Flutter widgets (e.g., "Attachments"). + +Improvements +- Move visible strings to ARB and reuse placeholders/ICU. Mirror important UI preferences and messages so translation workflows match. + +--- + +## Error Handling and UX + +- Web: Centralized toast errors (e.g., `handleOpenAIError` path, Error.svelte rendering message/detail with fallbacks). +- Flutter: `ApiErrorInterceptor` (referenced), `ErrorBoundary`, and some ad-hoc `debugPrint`s. + +Issues +- Missing or moved `ApiErrorInterceptor` risks inconsistent UX. Error messages from task flow/polling are not always promoted to UI components. + +Improvements +- Ensure API errors map to user-friendly messages, with context (network vs auth vs provider error). Surface task/polling errors in the chat UI just like streaming failures. + +--- + +## Connectivity and Offline + +- Flutter: `connectivity_service.dart` provides online/offline providers; `AttachmentUploadQueue` retries. +- Web: Browser online/offline events; no background file queue. + +Improvements +- Good mobile-first enhancement: keep AttachmentUploadQueue. Consider surfacing an inline banner or per-file retry affordances. + +--- + +## Sockets + +- Web: Socket.IO setup in `+layout.svelte`, emits `user-join`, registers `chat-events` and `channel-events`, forwards streamed lines to channel. +- Flutter: `SocketService` mirrors handshake headers, reconnect flow, and event subscription methods. + +Issues +- Ensure socket path `/ws/socket.io` and headers match server expectations across reverse proxies; feature flags in mobile to prefer WS-only if environment requires it. + +Improvements +- Expose transport mode in settings (already present, `socket_transport_mode`), and surface diagnostics screen to test connectivity. + +--- + +## Security & Privacy + +- Flutter: Secure storage for token, avoids logging secrets, custom headers filtered. Web: localStorage for token. + +Improvements +- Maintain secure storage for mobile; avoid verbose logging of payloads. Redact tokens in debug logs. + +--- + +## Concrete Action Items (Minimal, Targeted) + +1) Streaming mode parity +- In `ApiService.sendMessage`, choose SSE when no tools/web_search/image_generation; use background task session only when required. +- Keep `streaming_helper.dart` suppression flags but extend to support more event types from web. + +2) Files +- Upload images via `/api/v1/files/` instead of converting to data URLs. Store `id` and let server serve compressed/derived forms. +- Optionally consume processing status SSE for richer feedback. + +3) Settings sync +- Map `UserSettings` to backend `ui` fields; on change, POST to `/users/user/settings/update` similar to web. + +4) Error handling +- Ensure `ApiErrorInterceptor` exists and standardizes Dio exceptions. Add chat-level error surfacing in providers/widgets for background task flow. + +5) Cleanup and consistency +- Remove or wire the unused `dioProvider` from `connectivity_service.dart`. +- Move literal strings like "Attachments" into ARB files. + +6) Diagnostics +- Add a hidden debug screen to exercise socket connectivity and API endpoints (a wrapper exists in `api_service.debugApiEndpoints`). + +--- + +## Deferred / Optional (Non-breaking) + +- Stop/Cancel UX parity: expose active `taskIds` and provide a Stop button for ongoing responses. +- Model/tool server parity: ensure `tool_servers` payload and function-calling hints are preserved (already partially implemented). +- Performance: Consider `StreamTransformer` backpressure tuning in `streaming_helper.dart` for low-end devices. + +--- + +## Justification and Guardrails + +- Minimal changes; no new dependencies required. +- Aligns with Open-WebUI semantics for better feature parity and user expectations. +- Improves robustness and UX while preserving current architecture and patterns (Riverpod, Dio, interceptors, providers). diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index afbd080..32b3264 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -145,8 +145,16 @@ class BackgroundStreamingHandler: NSObject { ) -> Bool { GeneratedPluginRegistrant.register(with: self) - // Setup background streaming handler manually - if let controller = window?.rootViewController as? FlutterViewController { + // Setup background streaming handler with scene-safe rootViewController access + var controller: FlutterViewController? + if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let root = scene.windows.first?.rootViewController as? FlutterViewController { + controller = root + } else if let legacy = window?.rootViewController as? FlutterViewController { + controller = legacy + } + + if let controller { let channel = FlutterMethodChannel( name: "conduit/background_streaming", binaryMessenger: controller.binaryMessenger diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 476f292..d19fd95 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -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 updateUserSettings(Map 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 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 + ? (response.data as List) + : (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> getFileInfo(String fileId) async { @@ -2633,7 +2660,8 @@ class ApiService { final streamController = StreamController(); // 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> 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 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 ( diff --git a/lib/core/services/connectivity_service.dart b/lib/core/services/connectivity_service.dart index 4b1eeff..4a54c04 100644 --- a/lib/core/services/connectivity_service.dart +++ b/lib/core/services/connectivity_service.dart @@ -20,11 +20,11 @@ class ConnectivityService { Stream get connectivityStream => _connectivityController.stream; ConnectivityStatus get currentStatus => _lastStatus; - + /// Stream that emits true when connected, false when offline - Stream get isConnected => connectivityStream - .map((status) => status == ConnectivityStatus.online); - + Stream 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((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((ref) { }); // Dio provider (if not already defined elsewhere) -final dioProvider = Provider((ref) { - return Dio(); // This should be configured with your base URL -}); +// Removed unused Dio provider to avoid confusion. Use ApiService instead. diff --git a/lib/core/services/share_receiver_service.dart b/lib/core/services/share_receiver_service.dart index e9467e3..99841b5 100644 --- a/lib/core/services/share_receiver_service.dart +++ b/lib/core/services/share_receiver_service.dart @@ -56,7 +56,10 @@ final shareReceiverInitializerProvider = Provider((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 _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 (_) {} } } diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index fc72982..3cb161a 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -163,7 +163,8 @@ class ChatMessagesNotifier extends StateNotifier> { orElse: () => null, ); if (textItem != null) { - content = (textItem as Map)['text']?.toString() ?? ''; + content = + (textItem as Map)['text']?.toString() ?? ''; } } } @@ -765,11 +766,12 @@ Future 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 messageAttachments = (isLastUser && (attachments != null && attachments.isNotEmpty)) - ? List.from(attachments) - : (msg.attachmentIds ?? const []); + ? List.from(attachments) + : (msg.attachmentIds ?? const []); if (messageAttachments.isNotEmpty) { final messageMap = await _buildMessagePayloadWithAttachments( @@ -946,6 +948,11 @@ Future 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 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 _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 _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( diff --git a/lib/features/chat/services/file_attachment_service.dart b/lib/features/chat/services/file_attachment_service.dart index d87be2d..a08a18c 100644 --- a/lib/features/chat/services/file_attachment_service.dart +++ b/lib/features/chat/services/file_attachment_service.dart @@ -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 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> uploadFiles( List files, { Function(int, int)? onProgress, @@ -484,7 +467,7 @@ class MockFileAttachmentService { }) async { // Simulate upload progress for reviewer mode final uploadIds = []; - + 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((ref) { final isReviewerMode = ref.watch(reviewerModeProvider); - + if (isReviewerMode) { return MockFileAttachmentService(); } - + final apiService = ref.watch(apiServiceProvider); if (apiService == null) return null; return FileAttachmentService(apiService); diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index 283cee9..68663a8 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -59,10 +59,7 @@ class _ChatPageState extends ConsumerState { 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 { // 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 { // 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 { 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 { 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 { 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 { // 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 { 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 { }, 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( - 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( + 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 ], ), ), diff --git a/lib/features/chat/widgets/file_attachment_widget.dart b/lib/features/chat/widgets/file_attachment_widget.dart index 999e2d8..54e069d 100644 --- a/lib/features/chat/widgets/file_attachment_widget.dart +++ b/lib/features/chat/widgets/file_attachment_widget.dart @@ -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, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 93f16fa..59d10ed 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5e5f12c..0761fbb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -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: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 935e714..448b666 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -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'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 8042c03..33390de 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 284a4d5..70fa841 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -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'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 8f17fcd..94c07b9 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -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'; diff --git a/lib/shared/services/tasks/task_worker.dart b/lib/shared/services/tasks/task_worker.dart index 866dd82..8a1b3d2 100644 --- a/lib/shared/services/tasks/task_worker.dart +++ b/lib/shared/services/tasks/task_worker.dart @@ -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 _performExecuteToolCall(ExecuteToolCallTask task) async { @@ -203,12 +207,7 @@ class TaskWorker { ? [resolvedToolId] : null; - await chat.sendMessageFromService( - _ref, - instruction, - null, - toolIds, - ); + await chat.sendMessageFromService(_ref, instruction, null, toolIds); } Future _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 _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(); + late final StreamSubscription> 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