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

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