fix: image attachment flashing and not persisting

This commit is contained in:
cogwheel0
2025-08-16 21:17:01 +05:30
parent 9be04ef2b9
commit d57ddf67c5
4 changed files with 310 additions and 338 deletions

View File

@@ -636,12 +636,27 @@ class ApiService {
role = 'user'; role = 'user';
} }
// Parse attachments from 'files' field
List<String>? attachmentIds;
if (msgData['files'] != null) {
final filesList = msgData['files'] as List;
attachmentIds = filesList
.where((file) => file is Map && file['file_id'] != null)
.map((file) => file['file_id'] as String)
.toList();
if (attachmentIds.isEmpty) {
attachmentIds = null;
}
}
return ChatMessage( return ChatMessage(
id: msgData['id']?.toString() ?? uuid.v4(), id: msgData['id']?.toString() ?? uuid.v4(),
role: role, role: role,
content: contentString, content: contentString,
timestamp: _parseTimestamp(msgData['timestamp']), timestamp: _parseTimestamp(msgData['timestamp']),
model: msgData['model'] as String?, model: msgData['model'] as String?,
attachmentIds: attachmentIds,
); );
} }
@@ -760,6 +775,8 @@ class ApiService {
if (msg.role == 'assistant') 'modelIdx': 0, if (msg.role == 'assistant') 'modelIdx': 0,
if (msg.role == 'assistant') 'done': true, if (msg.role == 'assistant') 'done': true,
if (msg.role == 'user' && model != null) 'models': [model], if (msg.role == 'user' && model != null) 'models': [model],
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty)
'files': msg.attachmentIds!.map((id) => {'file_id': id}).toList(),
}; };
// Update parent's childrenIds // Update parent's childrenIds
@@ -780,6 +797,8 @@ class ApiService {
if (msg.role == 'assistant') 'modelIdx': 0, if (msg.role == 'assistant') 'modelIdx': 0,
if (msg.role == 'assistant') 'done': true, if (msg.role == 'assistant') 'done': true,
if (msg.role == 'user' && model != null) 'models': [model], if (msg.role == 'user' && model != null) 'models': [model],
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty)
'files': msg.attachmentIds!.map((id) => {'file_id': id}).toList(),
}); });
previousId = messageId; previousId = messageId;

View File

@@ -49,6 +49,7 @@ class _DocumentationMessageWidgetState
String _renderedContent = ''; String _renderedContent = '';
Timer? _throttleTimer; Timer? _throttleTimer;
String? _pendingContent; String? _pendingContent;
Widget? _cachedAvatar;
@override @override
void initState() { void initState() {
@@ -67,6 +68,13 @@ class _DocumentationMessageWidgetState
_updateReasoningContent(); _updateReasoningContent();
} }
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Build cached avatar when theme context is available
_buildCachedAvatar();
}
@override @override
void didUpdateWidget(DocumentationMessageWidget oldWidget) { void didUpdateWidget(DocumentationMessageWidget oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
@@ -77,6 +85,11 @@ class _DocumentationMessageWidgetState
_scheduleRenderUpdate(widget.message.content ?? ''); _scheduleRenderUpdate(widget.message.content ?? '');
_updateReasoningContent(); _updateReasoningContent();
} }
// Rebuild cached avatar if model name changes
if (oldWidget.modelName != widget.modelName) {
_buildCachedAvatar();
}
} }
void _updateReasoningContent() { void _updateReasoningContent() {
@@ -124,6 +137,41 @@ class _DocumentationMessageWidgetState
return content; return content;
} }
void _buildCachedAvatar() {
_cachedAvatar = Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary,
borderRadius: BorderRadius.circular(
AppBorderRadius.small,
),
),
child: Icon(
Icons.auto_awesome,
color: context.conduitTheme.buttonPrimaryText,
size: 12,
),
),
const SizedBox(width: Spacing.xs),
Text(
widget.modelName ?? 'Assistant',
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontSize: AppTypography.bodySmall,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
),
),
],
),
);
}
@override @override
void dispose() { void dispose() {
_fadeController.dispose(); _fadeController.dispose();
@@ -158,29 +206,29 @@ class _DocumentationMessageWidgetState
return Container( return Container(
width: double.infinity, width: double.infinity,
margin: const EdgeInsets.only(bottom: 16, left: 50, right: 12), margin: const EdgeInsets.only(bottom: 16, left: 50, right: 12),
child: Row( child: Column(
mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Flexible( Row(
child: GestureDetector( mainAxisAlignment: MainAxisAlignment.end,
onLongPress: () => _toggleActions(), children: [
child: Container( Flexible(
padding: const EdgeInsets.symmetric( child: GestureDetector(
horizontal: 16, onLongPress: () => _toggleActions(),
vertical: 12, child: Container(
), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration( horizontal: 16,
color: context.conduitTheme.chatBubbleUser, vertical: 12,
borderRadius: BorderRadius.circular(AppBorderRadius.lg), ),
border: Border.all( decoration: BoxDecoration(
color: context.conduitTheme.chatBubbleUserBorder, color: context.conduitTheme.chatBubbleUser,
width: BorderWidth.regular, borderRadius: BorderRadius.circular(AppBorderRadius.lg),
), border: Border.all(
), color: context.conduitTheme.chatBubbleUserBorder,
child: Column( width: BorderWidth.regular,
crossAxisAlignment: CrossAxisAlignment.start, ),
children: [ ),
Text( child: Text(
widget.message.content, widget.message.content,
style: TextStyle( style: TextStyle(
color: context.conduitTheme.chatBubbleUserText, color: context.conduitTheme.chatBubbleUserText,
@@ -189,17 +237,17 @@ class _DocumentationMessageWidgetState
letterSpacing: 0.1, letterSpacing: 0.1,
), ),
), ),
),
// Action buttons for user messages
if (_showActions) ...[
const SizedBox(height: 12),
_buildUserActionButtons(),
],
],
), ),
), ),
), ],
), ),
// Action buttons below the message bubble
if (_showActions) ...[
const SizedBox(height: Spacing.sm),
_buildUserActionButtons(),
],
], ],
), ),
) )
@@ -220,39 +268,8 @@ class _DocumentationMessageWidgetState
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Simplified AI Name and Avatar // Cached AI Name and Avatar to prevent flashing
Padding( _cachedAvatar ?? const SizedBox.shrink(),
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary,
borderRadius: BorderRadius.circular(
AppBorderRadius.small,
),
),
child: Icon(
Icons.auto_awesome,
color: context.conduitTheme.buttonPrimaryText,
size: 12,
),
),
const SizedBox(width: Spacing.xs),
Text(
widget.modelName ?? 'Assistant',
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontSize: AppTypography.bodySmall,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,
),
),
],
),
),
// Reasoning Section (if present) // Reasoning Section (if present)
if (_reasoningContent != null) ...[ if (_reasoningContent != null) ...[
@@ -364,16 +381,16 @@ class _DocumentationMessageWidgetState
_reasoningContent?.mainContent ?? _reasoningContent?.mainContent ??
widget.message.content, widget.message.content,
), ),
// Action buttons - inline and minimal
if (_showActions) ...[
const SizedBox(height: Spacing.md),
_buildActionButtons(),
],
], ],
), ),
), ),
), ),
// Action buttons below the message content
if (_showActions) ...[
const SizedBox(height: Spacing.sm),
_buildActionButtons(),
],
], ],
), ),
) )

View File

@@ -46,6 +46,10 @@ class _ModernMessageBubbleState extends ConsumerState<ModernMessageBubble>
// Cache for image base64 data to prevent repeated API calls // Cache for image base64 data to prevent repeated API calls
final Map<String, String?> _imageCache = {}; final Map<String, String?> _imageCache = {};
// Cache for rendered image widgets to prevent rebuilding during streaming
final Map<String, Widget> _imageWidgetCache = {};
String? _lastAttachmentIds;
@override @override
void initState() { void initState() {
@@ -60,6 +64,207 @@ class _ModernMessageBubbleState extends ConsumerState<ModernMessageBubble>
); );
} }
Widget _buildAttachmentImages() {
if (widget.message.attachmentIds == null ||
widget.message.attachmentIds!.isEmpty) {
return const SizedBox.shrink();
}
final currentAttachmentIds = widget.message.attachmentIds!.join('_');
// Clear cache if attachment IDs changed
if (_lastAttachmentIds != currentAttachmentIds) {
_imageWidgetCache.clear();
_lastAttachmentIds = currentAttachmentIds;
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.message.attachmentIds!.map<Widget>((attachmentId) {
// Return cached widget if available
if (_imageWidgetCache.containsKey(attachmentId)) {
return _imageWidgetCache[attachmentId]!;
}
// Build widget and cache it
final imageWidget = _buildSingleImageWidget(attachmentId);
_imageWidgetCache[attachmentId] = imageWidget;
return imageWidget;
}).toList(),
);
}
Widget _buildSingleImageWidget(String attachmentId) {
return Consumer(
builder: (context, ref, child) {
final api = ref.read(apiServiceProvider);
if (api == null) return const SizedBox.shrink();
return FutureBuilder<String?>(
key: ValueKey('img_$attachmentId'),
future: _getCachedImageBase64(api, attachmentId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
height: 150,
width: 200,
margin: const EdgeInsets.only(bottom: Spacing.xs),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
),
child: Center(
child: CircularProgressIndicator(
color: context.conduitTheme.buttonPrimary,
strokeWidth: 2,
),
),
);
}
if (snapshot.hasError ||
!snapshot.hasData ||
snapshot.data == null) {
return Container(
height: 100,
width: 150,
margin: const EdgeInsets.only(bottom: Spacing.xs),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
border: Border.all(
color: context.conduitTheme.textSecondary.withValues(
alpha: 0.3,
),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.broken_image_outlined,
color: context.conduitTheme.textSecondary,
size: 32,
),
const SizedBox(height: Spacing.xs),
Text(
'Image unavailable',
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontSize: AppTypography.bodySmall,
),
),
],
),
);
}
final base64Data = snapshot.data!;
try {
// Handle data URLs (data:image/...;base64,...)
String actualBase64;
if (base64Data.startsWith('data:')) {
// Extract base64 part from data URL
final commaIndex = base64Data.indexOf(',');
if (commaIndex != -1) {
actualBase64 = base64Data.substring(commaIndex + 1);
} else {
throw Exception('Invalid data URL format');
}
} else {
// Direct base64 string
actualBase64 = base64Data;
}
final imageBytes = base64.decode(actualBase64);
return Container(
margin: const EdgeInsets.only(bottom: Spacing.xs),
constraints: const BoxConstraints(
maxWidth: 300,
maxHeight: 300,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
child: Image.memory(
imageBytes,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
height: 100,
width: 150,
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground
.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(
AppBorderRadius.sm,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: context.conduitTheme.error,
size: 32,
),
const SizedBox(height: Spacing.xs),
Text(
'Failed to load image',
style: TextStyle(
color: context.conduitTheme.error,
fontSize: AppTypography.bodySmall,
),
),
],
),
);
},
),
),
);
} catch (e) {
return Container(
height: 100,
width: 150,
margin: const EdgeInsets.only(bottom: Spacing.xs),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: context.conduitTheme.error,
size: 32,
),
const SizedBox(height: Spacing.xs),
Text(
'Invalid image format',
style: TextStyle(
color: context.conduitTheme.error,
fontSize: AppTypography.bodySmall,
),
),
],
),
);
}
},
);
},
);
}
@override @override
void dispose() { void dispose() {
_fadeController.dispose(); _fadeController.dispose();
@@ -279,183 +484,7 @@ class _ModernMessageBubbleState extends ConsumerState<ModernMessageBubble>
); );
} }
Widget _buildAttachmentImages() {
if (widget.message.attachmentIds == null ||
widget.message.attachmentIds!.isEmpty) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widget.message.attachmentIds!.map<Widget>((attachmentId) {
return Consumer(
builder: (context, ref, child) {
final api = ref.watch(apiServiceProvider);
if (api == null) return const SizedBox.shrink();
return FutureBuilder<String?>(
future: _getCachedImageBase64(api, attachmentId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
height: 150,
width: 200,
margin: const EdgeInsets.only(bottom: Spacing.xs),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
),
child: Center(
child: CircularProgressIndicator(
color: context.conduitTheme.buttonPrimary,
strokeWidth: 2,
),
),
);
}
if (snapshot.hasError ||
!snapshot.hasData ||
snapshot.data == null) {
return Container(
height: 100,
width: 150,
margin: const EdgeInsets.only(bottom: Spacing.xs),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
border: Border.all(
color: context.conduitTheme.textSecondary.withValues(
alpha: 0.3,
),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.broken_image_outlined,
color: context.conduitTheme.textSecondary,
size: 32,
),
const SizedBox(height: Spacing.xs),
Text(
'Image unavailable',
style: TextStyle(
color: context.conduitTheme.textSecondary,
fontSize: AppTypography.bodySmall,
),
),
],
),
);
}
final base64Data = snapshot.data!;
try {
// Handle data URLs (data:image/...;base64,...)
String actualBase64;
if (base64Data.startsWith('data:')) {
// Extract base64 part from data URL
final commaIndex = base64Data.indexOf(',');
if (commaIndex != -1) {
actualBase64 = base64Data.substring(commaIndex + 1);
} else {
throw Exception('Invalid data URL format');
}
} else {
// Direct base64 string
actualBase64 = base64Data;
}
final imageBytes = base64.decode(actualBase64);
return Container(
margin: const EdgeInsets.only(bottom: Spacing.xs),
constraints: const BoxConstraints(
maxWidth: 300,
maxHeight: 300,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
child: Image.memory(
imageBytes,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
height: 100,
width: 150,
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground
.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(
AppBorderRadius.sm,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: context.conduitTheme.error,
size: 32,
),
const SizedBox(height: Spacing.xs),
Text(
'Failed to load image',
style: TextStyle(
color: context.conduitTheme.error,
fontSize: AppTypography.bodySmall,
),
),
],
),
);
},
),
),
);
} catch (e) {
return Container(
height: 100,
width: 150,
margin: const EdgeInsets.only(bottom: Spacing.xs),
decoration: BoxDecoration(
color: context.conduitTheme.surfaceBackground.withValues(
alpha: 0.3,
),
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: context.conduitTheme.error,
size: 32,
),
const SizedBox(height: Spacing.xs),
Text(
'Invalid image format',
style: TextStyle(
color: context.conduitTheme.error,
fontSize: AppTypography.bodySmall,
),
),
],
),
);
}
},
);
},
);
}).toList(),
);
}
Future<String?> _getCachedImageBase64(dynamic api, String fileId) async { Future<String?> _getCachedImageBase64(dynamic api, String fileId) async {
// Check cache first to prevent repeated API calls // Check cache first to prevent repeated API calls

View File

@@ -1,93 +0,0 @@
# Testing Background Streaming Resilience
## Quick Test Steps
1. **Start a Chat Stream**
- Open the app and start a new conversation
- Send a message that will generate a long response
- Verify streaming starts normally
2. **Test Background Resilience**
- While response is streaming, switch to another app (press home button)
- Wait 10-15 seconds
- Return to the app
- Verify: Stream continues or resumes without duplicate content
3. **Test Network Interruption**
- Start streaming a response
- Turn on airplane mode for 5 seconds
- Turn off airplane mode
- Verify: Stream recovers and continues
4. **Test App Lifecycle**
- Start streaming
- Background the app multiple times rapidly
- Verify: No memory leaks, single active stream
## Implementation Summary
### Core Changes Made:
1. **BackgroundStreamingHandler** (`lib/core/services/background_streaming_handler.dart`)
- Manages stream state across app lifecycle changes
- Handles iOS background tasks and Android foreground services
- Tracks stream metadata for recovery
2. **Enhanced PersistentStreamingService** (`lib/core/services/persistent_streaming_service.dart`)
- Integrates with BackgroundStreamingHandler
- Monitors connectivity and app lifecycle
- Implements exponential backoff retry logic
- Tracks stream progress for resume capability
3. **Robust SSE Parser** (`lib/core/services/sse_parser.dart`)
- Heartbeat monitoring with configurable timeout
- Tolerates partial Unicode and network hiccups
- Emits reconnection requests on timeout
- Handles incomplete data gracefully
4. **Enhanced API Service** (`lib/core/services/api_service.dart`)
- Updated `_streamSSE` method to use persistent service
- Better error handling and recovery
- Longer timeouts for streaming connections
- Progress tracking for resume capability
5. **iOS Integration** (`ios/Runner/BackgroundStreamingHandler.swift`)
- Proper Flutter plugin registration
- Background task management (~30 seconds)
- Stream state persistence in UserDefaults
6. **Android Integration** (`android/.../BackgroundStreamingHandler.kt`)
- Foreground service for extended background processing
- Wake lock management for reliable networking
- SharedPreferences for stream state persistence
- Notification handling for user awareness
### Key Features:
- **Automatic Recovery**: Streams auto-resume when app returns to foreground
- **Connectivity Awareness**: Pauses on network loss, resumes on reconnection
- **Background Execution**:
- iOS: ~30 seconds of background streaming via background tasks
- Android: Foreground service with wake lock for extended background processing
- **Heartbeat Monitoring**: Detects dead connections and triggers recovery
- **Progress Tracking**: Tracks chunk sequence and content for resumption
- **Exponential Backoff**: Smart retry logic with jitter to avoid thundering herd
- **Cross-Platform**: Works on both iOS and Android with platform-specific optimizations
### Testing Scenarios Covered:
✅ App backgrounding during stream
✅ Network connectivity loss/restore
✅ Rapid background/foreground cycles
✅ Long-running streams (>5 min)
✅ Server-side disconnections
✅ Auth token expiration during stream
✅ Multiple concurrent streams
## Next Steps
1. Test with real OpenWebUI server
2. Verify memory usage during long streams
3. Test with poor network conditions
4. Add telemetry for recovery success rates
5. Consider adding user notification for background recovery