fix: image gen during streaming

This commit is contained in:
cogwheel0
2025-09-05 21:05:58 +05:30
parent 542a61deee
commit 2580bf961e
3 changed files with 114 additions and 35 deletions

View File

@@ -1047,24 +1047,87 @@ Future<void> _sendMessageInternal(
final msgs = ref.read(chatMessagesProvider);
if (msgs.isEmpty || msgs.last.role != 'assistant') return;
final content = msgs.last.content;
if (content.isEmpty || !content.contains('<details')) return;
final parsed = ToolCallsParser.parse(content);
if (parsed == null) return;
if (content.isEmpty) return;
final collected = <Map<String, dynamic>>[];
for (final entry in parsed.toolCalls) {
if (entry.files != null && entry.files!.isNotEmpty) {
collected.addAll(_extractFilesFromResult(entry.files));
}
if (entry.result != null) {
collected.addAll(_extractFilesFromResult(entry.result));
// First, try the complete parsing approach
if (content.contains('<details')) {
final parsed = ToolCallsParser.parse(content);
if (parsed != null) {
for (final entry in parsed.toolCalls) {
if (entry.files != null && entry.files!.isNotEmpty) {
collected.addAll(_extractFilesFromResult(entry.files));
}
if (entry.result != null) {
collected.addAll(_extractFilesFromResult(entry.result));
}
}
}
}
// Streaming-friendly: Extract images from partial content
// Look for common image generation patterns even in incomplete blocks
if (collected.isEmpty) {
// Extract base64 images directly from content
final base64Pattern = RegExp(r'data:image/[^;\s]+;base64,[A-Za-z0-9+/]+=*');
final base64Matches = base64Pattern.allMatches(content);
for (final match in base64Matches) {
final url = match.group(0);
if (url != null && url.isNotEmpty) {
collected.add({'type': 'image', 'url': url});
}
}
// Extract URLs from partial tool call content
final urlPattern = RegExp(r'https?://[^\s<>"]+\.(jpg|jpeg|png|gif|webp)', caseSensitive: false);
final urlMatches = urlPattern.allMatches(content);
for (final match in urlMatches) {
final url = match.group(0);
if (url != null && url.isNotEmpty) {
collected.add({'type': 'image', 'url': url});
}
}
// Look for JSON-like structures in streaming content
final jsonPattern = RegExp(r'\{[^}]*"url"[^}]*:[^}]*"(data:image/[^"]+|https?://[^"]+\.(jpg|jpeg|png|gif|webp))"[^}]*\}', caseSensitive: false);
final jsonMatches = jsonPattern.allMatches(content);
for (final match in jsonMatches) {
final url = RegExp(r'"url"[^:]*:[^"]*"([^"]+)"').firstMatch(match.group(0) ?? '')?.group(1);
if (url != null && url.isNotEmpty) {
collected.add({'type': 'image', 'url': url});
}
}
// Look for image generation results in partial results/files attributes
final partialResultsPattern = RegExp(r'(result|files)="([^"]*(?:data:image/[^"]*|https?://[^"]*\.(jpg|jpeg|png|gif|webp))[^"]*)"', caseSensitive: false);
final partialMatches = partialResultsPattern.allMatches(content);
for (final match in partialMatches) {
final attrValue = match.group(2);
if (attrValue != null) {
// Try to parse as JSON array or single value
try {
final decoded = json.decode(attrValue);
collected.addAll(_extractFilesFromResult(decoded));
} catch (_) {
// If not JSON, check if it's a direct URL
if (attrValue.startsWith('data:image/') ||
RegExp(r'https?://[^\s]+\.(jpg|jpeg|png|gif|webp)$', caseSensitive: false).hasMatch(attrValue)) {
collected.add({'type': 'image', 'url': attrValue});
}
}
}
}
}
if (collected.isEmpty) return;
final existing = msgs.last.files ?? <Map<String, dynamic>>[];
final seen = <String>{
for (final f in existing)
if (f['url'] is String) (f['url'] as String) else '',
}..removeWhere((e) => e.isEmpty);
final merged = <Map<String, dynamic>>[...existing];
for (final f in collected) {
final url = f['url'] as String?;
@@ -1073,6 +1136,7 @@ Future<void> _sendMessageInternal(
seen.add(url);
}
}
if (merged.length != existing.length) {
ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction(
(m) => m.copyWith(files: merged),
@@ -1537,6 +1601,40 @@ Future<void> _sendMessageInternal(
}
} catch (_) {}
} catch (_) {}
} else if (type == 'files' && payload != null) {
// Handle files event from socket (image generation results)
try {
DebugLogger.stream('Socket files event received: ${payload.toString()}');
final files = _extractFilesFromResult(payload);
if (files.isNotEmpty) {
final msgs = ref.read(chatMessagesProvider);
if (msgs.isNotEmpty && msgs.last.role == 'assistant') {
final existing = msgs.last.files ?? <Map<String, dynamic>>[];
final seen = <String>{
for (final f in existing)
if (f['url'] is String) (f['url'] as String) else '',
}..removeWhere((e) => e.isEmpty);
final merged = <Map<String, dynamic>>[...existing];
for (final f in files) {
final url = f['url'] as String?;
if (url != null && url.isNotEmpty && !seen.contains(url)) {
merged.add({'type': 'image', 'url': url});
seen.add(url);
}
}
if (merged.length != existing.length) {
DebugLogger.stream('Socket files: Adding ${merged.length - existing.length} new images');
final updatedMessage = ref.read(chatMessagesProvider).last.copyWith(files: merged);
DebugLogger.stream('Socket files: Updated message files count: ${updatedMessage.files?.length}');
ref.read(chatMessagesProvider.notifier).updateLastMessageWithFunction(
(ChatMessage m) => m.copyWith(files: merged),
);
}
}
}
} catch (e) {
DebugLogger.stream('Socket files event error: $e');
}
}
} catch (_) {}
}

View File

@@ -570,7 +570,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
const SizedBox(height: Spacing.md),
],
// Display generated images from files property
// Display generated images from files property - OUTSIDE AnimatedSwitcher to prevent fade issues
if (widget.message.files != null &&
widget.message.files!.isNotEmpty) ...[
_buildGeneratedImages(),
@@ -785,8 +785,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
maxWidth: 500,
maxHeight: 400,
),
disableAnimation: widget
.isStreaming, // Disable animation during streaming
disableAnimation: false, // Keep animations enabled to prevent black display
);
},
),
@@ -809,8 +808,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
maxWidth: imageCount == 2 ? 245 : 160,
maxHeight: imageCount == 2 ? 245 : 160,
),
disableAnimation:
widget.isStreaming, // Disable animation during streaming
disableAnimation: false, // Keep animations enabled to prevent black display
);
}).toList(),
),

View File

@@ -247,22 +247,8 @@ class _EnhancedImageAttachmentState
Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin
// Use a single container with AnimatedSwitcher for smooth transitions
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
switchInCurve: Curves.easeInOut,
switchOutCurve: Curves.easeInOut,
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.center,
children: <Widget>[
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
child: _buildContent(),
);
// Directly return content without AnimatedSwitcher to prevent black flash during streaming
return _buildContent();
}
Widget _buildContent() {
@@ -286,11 +272,8 @@ class _EnhancedImageAttachmentState
imageWidget = _buildBase64Image();
}
// Apply fade animation only when first showing content
if (!widget.disableAnimation && _hasShownContent) {
return FadeTransition(opacity: _fadeAnimation, child: imageWidget);
}
// Always show the image without fade transitions during streaming to prevent black display
// The AutomaticKeepAliveClientMixin and global caching should preserve the image state
return imageWidget;
}