refactor: text streaming

This commit is contained in:
cogwheel0
2025-09-13 10:16:58 +05:30
parent d903e795d9
commit 7e6009d2cc
16 changed files with 719 additions and 348 deletions

View File

@@ -0,0 +1,190 @@
# Conduit (Flutter) vs Open-WebUI Web Client: Architecture Comparison, Issues, and Improvements
## Executive Summary
- Conduit aligns closely with Open-WebUIs 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 projects 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 clients `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
- Flutters 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-WebUIs optional/required matrix (e.g., config and public assets).
- Add a token-expiry check cadence similar to web layouts 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 Flutters `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).

View File

@@ -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

View File

@@ -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 (

View File

@@ -22,8 +22,8 @@ class ConnectivityService {
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.

View File

@@ -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 (_) {}
}
}

View File

@@ -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(

View File

@@ -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);

View File

@@ -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
],
),
),

View File

@@ -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,

View File

@@ -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",

View File

@@ -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:

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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