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

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

@@ -20,11 +20,11 @@ class ConnectivityService {
Stream<ConnectivityStatus> get connectivityStream =>
_connectivityController.stream;
ConnectivityStatus get currentStatus => _lastStatus;
/// Stream that emits true when connected, false when offline
Stream<bool> get isConnected => connectivityStream
.map((status) => status == ConnectivityStatus.online);
Stream<bool> get isConnected =>
connectivityStream.map((status) => status == ConnectivityStatus.online);
/// Check if currently connected
bool get isCurrentlyConnected => _lastStatus == ConnectivityStatus.online;
@@ -95,7 +95,8 @@ class ConnectivityService {
// Providers
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
final dio = ref.watch(dioProvider);
// Use a lightweight Dio instance only for connectivity checks
final dio = Dio();
final service = ConnectivityService(dio);
ref.onDispose(() => service.dispose());
return service;
@@ -120,6 +121,4 @@ final isOnlineProvider = Provider<bool>((ref) {
});
// Dio provider (if not already defined elsewhere)
final dioProvider = Provider<Dio>((ref) {
return Dio(); // This should be configured with your base URL
});
// Removed unused Dio provider to avoid confusion. Use ApiService instead.

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);
@@ -439,10 +422,10 @@ class MockFileAttachmentService {
// Mock upload file with progress tracking
Stream<FileUploadState> uploadFile(File file) async* {
foundation.debugPrint('DEBUG: Mock file upload for: ${file.path}');
final fileName = path.basename(file.path);
final fileSize = await file.length();
// Yield initial state
yield FileUploadState(
file: file,
@@ -451,7 +434,7 @@ class MockFileAttachmentService {
progress: 0.0,
status: FileUploadStatus.uploading,
);
// Simulate upload progress
for (int i = 1; i <= 10; i++) {
await Future.delayed(const Duration(milliseconds: 100));
@@ -463,7 +446,7 @@ class MockFileAttachmentService {
status: FileUploadStatus.uploading,
);
}
// Yield completed state with mock file ID
yield FileUploadState(
file: file,
@@ -473,10 +456,10 @@ class MockFileAttachmentService {
status: FileUploadStatus.completed,
fileId: 'mock_file_${DateTime.now().millisecondsSinceEpoch}',
);
foundation.debugPrint('DEBUG: Mock file upload completed');
}
Future<List<String>> uploadFiles(
List<File> files, {
Function(int, int)? onProgress,
@@ -484,7 +467,7 @@ class MockFileAttachmentService {
}) async {
// Simulate upload progress for reviewer mode
final uploadIds = <String>[];
for (int i = 0; i < files.length; i++) {
if (onProgress != null) {
// Simulate progress
@@ -496,7 +479,7 @@ class MockFileAttachmentService {
// Generate mock upload ID
uploadIds.add('mock_upload_${DateTime.now().millisecondsSinceEpoch}_$i');
}
return uploadIds;
}
}
@@ -504,11 +487,11 @@ class MockFileAttachmentService {
// Providers
final fileAttachmentServiceProvider = Provider<dynamic>((ref) {
final isReviewerMode = ref.watch(reviewerModeProvider);
if (isReviewerMode) {
return MockFileAttachmentService();
}
final apiService = ref.watch(apiServiceProvider);
if (apiService == null) return null;
return FileAttachmentService(apiService);

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