feat(chat): Add worker manager to streaming helper for image processing

This commit is contained in:
cogwheel0
2025-11-01 00:57:40 +05:30
parent 0b8d5b5d31
commit ada6d40e5e
6 changed files with 387 additions and 104 deletions

View File

@@ -17,6 +17,7 @@ import '../utils/debug_logger.dart';
import '../utils/openwebui_source_parser.dart'; import '../utils/openwebui_source_parser.dart';
import 'streaming_response_controller.dart'; import 'streaming_response_controller.dart';
import 'api_service.dart'; import 'api_service.dart';
import 'worker_manager.dart';
// Keep local verbosity toggle for socket logs // Keep local verbosity toggle for socket logs
const bool kSocketVerboseLogging = false; const bool kSocketVerboseLogging = false;
@@ -43,6 +44,74 @@ final _imageFilePattern = RegExp(
caseSensitive: false, caseSensitive: false,
); );
List<Map<String, dynamic>> _collectImageReferencesWorker(String content) {
final collected = <Map<String, dynamic>>[];
if (content.isEmpty) {
return collected;
}
if (content.contains('<details') && 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));
}
}
}
}
if (collected.isNotEmpty) {
return collected;
}
final base64Matches = _base64ImagePattern.allMatches(content);
for (final match in base64Matches) {
final url = match.group(0);
if (url != null && url.isNotEmpty) {
collected.add({'type': 'image', 'url': url});
}
}
final urlMatches = _urlImagePattern.allMatches(content);
for (final match in urlMatches) {
final url = match.group(0);
if (url != null && url.isNotEmpty) {
collected.add({'type': 'image', 'url': url});
}
}
final jsonMatches = _jsonImagePattern.allMatches(content);
for (final match in jsonMatches) {
final url = _jsonUrlExtractPattern
.firstMatch(match.group(0) ?? '')
?.group(1);
if (url != null && url.isNotEmpty) {
collected.add({'type': 'image', 'url': url});
}
}
final partialMatches = _partialResultsPattern.allMatches(content);
for (final match in partialMatches) {
final attrValue = match.group(2);
if (attrValue == null) continue;
try {
final decoded = json.decode(attrValue);
collected.addAll(_extractFilesFromResult(decoded));
} catch (_) {
if (attrValue.startsWith('data:image/') ||
_imageFilePattern.hasMatch(attrValue)) {
collected.add({'type': 'image', 'url': attrValue});
}
}
}
return collected;
}
class ActiveSocketStream { class ActiveSocketStream {
ActiveSocketStream({ ActiveSocketStream({
required this.controller, required this.controller,
@@ -70,6 +139,7 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
required String? activeConversationId, required String? activeConversationId,
required ApiService api, required ApiService api,
required SocketService? socketService, required SocketService? socketService,
required WorkerManager workerManager,
RegisterConversationDeltaListener? registerDeltaListener, RegisterConversationDeltaListener? registerDeltaListener,
// Message update callbacks // Message update callbacks
required void Function(String) appendToLastMessage, required void Function(String) appendToLastMessage,
@@ -228,71 +298,24 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
final content = msgs.last.content; final content = msgs.last.content;
if (content.isEmpty) return; if (content.isEmpty) return;
final collected = <Map<String, dynamic>>[]; final targetMessageId = msgs.last.id;
unawaited(
// Quick check: only parse tool calls if complete details blocks exist workerManager
if (content.contains('<details') && content.contains('</details>')) { .schedule<String, List<Map<String, dynamic>>>(
final parsed = ToolCallsParser.parse(content); _collectImageReferencesWorker,
if (parsed != null) { content,
for (final entry in parsed.toolCalls) { debugLabel: 'stream_collect_images',
if (entry.files != null && entry.files!.isNotEmpty) { )
collected.addAll(_extractFilesFromResult(entry.files)); .then((collected) {
}
if (entry.result != null) {
collected.addAll(_extractFilesFromResult(entry.result));
}
}
}
}
if (collected.isEmpty) {
// Use pre-compiled patterns for better performance
final base64Matches = _base64ImagePattern.allMatches(content);
for (final match in base64Matches) {
final url = match.group(0);
if (url != null && url.isNotEmpty) {
collected.add({'type': 'image', 'url': url});
}
}
final urlMatches = _urlImagePattern.allMatches(content);
for (final match in urlMatches) {
final url = match.group(0);
if (url != null && url.isNotEmpty) {
collected.add({'type': 'image', 'url': url});
}
}
final jsonMatches = _jsonImagePattern.allMatches(content);
for (final match in jsonMatches) {
final url = _jsonUrlExtractPattern
.firstMatch(match.group(0) ?? '')
?.group(1);
if (url != null && url.isNotEmpty) {
collected.add({'type': 'image', 'url': url});
}
}
final partialMatches = _partialResultsPattern.allMatches(content);
for (final match in partialMatches) {
final attrValue = match.group(2);
if (attrValue != null) {
try {
final decoded = json.decode(attrValue);
collected.addAll(_extractFilesFromResult(decoded));
} catch (_) {
if (attrValue.startsWith('data:image/') ||
_imageFilePattern.hasMatch(attrValue)) {
collected.add({'type': 'image', 'url': attrValue});
}
}
}
}
}
if (collected.isEmpty) return; if (collected.isEmpty) return;
final currentMessages = getMessages();
if (currentMessages.isEmpty) return;
final last = currentMessages.last;
if (last.id != targetMessageId || last.role != 'assistant') {
return;
}
final existing = msgs.last.files ?? <Map<String, dynamic>>[]; final existing = last.files ?? <Map<String, dynamic>>[];
final seen = <String>{ final seen = <String>{
for (final f in existing) for (final f in existing)
if (f['url'] is String) (f['url'] as String) else '', if (f['url'] is String) (f['url'] as String) else '',
@@ -310,6 +333,9 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
if (merged.length != existing.length) { if (merged.length != existing.length) {
updateLastMessageWith((m) => m.copyWith(files: merged)); updateLastMessageWith((m) => m.copyWith(files: merged));
} }
})
.catchError((_) {}),
);
} catch (_) {} } catch (_) {}
} }

View File

@@ -0,0 +1,200 @@
import 'dart:async';
import 'dart:collection';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../utils/debug_logger.dart';
part 'worker_manager.g.dart';
/// Signature of a task that can be executed by [WorkerManager].
typedef WorkerTask<Q, R> = ComputeCallback<Q, R>;
/// Coordinates CPU intensive work off the UI isolate with lightweight pooling.
///
/// The manager throttles concurrent isolate usage to avoid overwhelming the
/// platform while still enabling parallel work. On web the callback executes
/// synchronously because secondary isolates are not supported.
class WorkerManager {
WorkerManager({int maxConcurrentTasks = _defaultMaxConcurrentTasks})
: _maxConcurrentTasks = math.max(1, maxConcurrentTasks) {
DebugLogger.log(
'initialized',
scope: 'worker',
data: {'max': _maxConcurrentTasks},
);
}
static const int _defaultMaxConcurrentTasks = 2;
final int _maxConcurrentTasks;
final Queue<_EnqueuedJob> _pendingJobs = Queue<_EnqueuedJob>();
bool _disposed = false;
int _activeJobs = 0;
int _jobCounter = 0;
/// Schedule [callback] with [message] to run on a worker isolate.
///
/// The [callback] must be a top-level or static function, mirroring the
/// constraints of `compute`. Errors from the task are propagated to the
/// returned [Future].
Future<R> schedule<Q, R>(
WorkerTask<Q, R> callback,
Q message, {
String? debugLabel,
}) {
if (_disposed) {
return Future.error(StateError('WorkerManager has been disposed'));
}
final jobId = ++_jobCounter;
final completer = Completer<R>();
final job = _EnqueuedJob(
id: jobId,
debugLabel: debugLabel,
run: () {
if (kIsWeb) {
return Future<R>.sync(() => callback(message));
}
return compute(callback, message);
},
onComplete: (value) {
if (!completer.isCompleted) {
completer.complete(value as R);
}
},
onError: (error, stackTrace) {
if (!completer.isCompleted) {
completer.completeError(error, stackTrace);
}
},
);
_pendingJobs.add(job);
DebugLogger.log(
'queued',
scope: 'worker',
data: {
'id': jobId,
if (debugLabel != null) 'label': debugLabel,
'pending': _pendingJobs.length,
'active': _activeJobs,
},
);
_processQueue();
return completer.future;
}
/// Dispose the manager and reject all pending work.
void dispose() {
if (_disposed) {
return;
}
_disposed = true;
while (_pendingJobs.isNotEmpty) {
final job = _pendingJobs.removeFirst();
job.cancel(
StateError('WorkerManager disposed before job ${job.id} started'),
);
}
DebugLogger.log('disposed', scope: 'worker', data: {'active': _activeJobs});
}
void _processQueue() {
if (_disposed) {
return;
}
while (_activeJobs < _maxConcurrentTasks && _pendingJobs.isNotEmpty) {
final job = _pendingJobs.removeFirst();
_startJob(job);
}
}
void _startJob(_EnqueuedJob job) {
_activeJobs++;
DebugLogger.log(
'started',
scope: 'worker',
data: {
'id': job.id,
if (job.debugLabel != null) 'label': job.debugLabel,
'active': _activeJobs,
},
);
unawaited(_runJob(job));
}
Future<void> _runJob(_EnqueuedJob job) async {
try {
final result = await job.run();
job.onComplete(result);
DebugLogger.log(
'completed',
scope: 'worker',
data: {
'id': job.id,
if (job.debugLabel != null) 'label': job.debugLabel,
'pending': _pendingJobs.length,
},
);
} catch (error, stackTrace) {
job.onError(error, stackTrace);
DebugLogger.error(
'failed',
scope: 'worker',
error: error,
stackTrace: stackTrace,
data: {
'id': job.id,
if (job.debugLabel != null) 'label': job.debugLabel,
},
);
} finally {
_activeJobs = math.max(0, _activeJobs - 1);
_processQueue();
}
}
}
/// Keep a single [WorkerManager] alive across the app.
@Riverpod(keepAlive: true)
// ignore: functional_ref
WorkerManager workerManager(Ref ref) {
final concurrency = kIsWeb ? 1 : WorkerManager._defaultMaxConcurrentTasks;
final manager = WorkerManager(maxConcurrentTasks: concurrency);
ref.onDispose(manager.dispose);
return manager;
}
class _EnqueuedJob {
_EnqueuedJob({
required this.id,
required this.run,
required this.onComplete,
required this.onError,
this.debugLabel,
});
final int id;
final FutureOr<dynamic> Function() run;
final void Function(dynamic value) onComplete;
final void Function(Object error, StackTrace stackTrace) onError;
final String? debugLabel;
final DateTime queuedAt = DateTime.now();
void cancel(Object error) {
onError(error, StackTrace.current);
}
}

View File

@@ -14,6 +14,7 @@ import '../../../core/providers/app_providers.dart';
import '../../../core/services/conversation_delta_listener.dart'; import '../../../core/services/conversation_delta_listener.dart';
import '../../../core/services/streaming_helper.dart'; import '../../../core/services/streaming_helper.dart';
import '../../../core/services/streaming_response_controller.dart'; import '../../../core/services/streaming_response_controller.dart';
import '../../../core/services/worker_manager.dart';
import '../../../core/utils/debug_logger.dart'; import '../../../core/utils/debug_logger.dart';
import '../../../core/utils/markdown_stream_formatter.dart'; import '../../../core/utils/markdown_stream_formatter.dart';
import '../../../core/utils/tool_calls_parser.dart'; import '../../../core/utils/tool_calls_parser.dart';
@@ -1449,6 +1450,7 @@ Future<void> regenerateMessage(
activeConversationId: activeConversation.id, activeConversationId: activeConversation.id,
api: api!, api: api!,
socketService: socketService, socketService: socketService,
workerManager: ref.read(workerManagerProvider),
registerDeltaListener: registerDeltaListener, registerDeltaListener: registerDeltaListener,
appendToLastMessage: (c) => appendToLastMessage: (c) =>
ref.read(chatMessagesProvider.notifier).appendToLastMessage(c), ref.read(chatMessagesProvider.notifier).appendToLastMessage(c),
@@ -1997,6 +1999,7 @@ Future<void> _sendMessageInternal(
activeConversationId: activeConversation?.id, activeConversationId: activeConversation?.id,
api: api!, api: api!,
socketService: socketService, socketService: socketService,
workerManager: ref.read(workerManagerProvider),
registerDeltaListener: registerDeltaListener, registerDeltaListener: registerDeltaListener,
appendToLastMessage: (c) => appendToLastMessage: (c) =>
ref.read(chatMessagesProvider.notifier).appendToLastMessage(c), ref.read(chatMessagesProvider.notifier).appendToLastMessage(c),

View File

@@ -24,6 +24,7 @@ import '../providers/chat_providers.dart' show sendMessageWithContainer;
import '../../../core/utils/debug_logger.dart'; import '../../../core/utils/debug_logger.dart';
import 'sources/openwebui_sources.dart'; import 'sources/openwebui_sources.dart';
import '../providers/assistant_response_builder_provider.dart'; import '../providers/assistant_response_builder_provider.dart';
import '../../../core/services/worker_manager.dart';
// Pre-compiled regex patterns for image processing (performance optimization) // Pre-compiled regex patterns for image processing (performance optimization)
final _base64ImagePattern = RegExp(r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*'); final _base64ImagePattern = RegExp(r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*');
@@ -104,7 +105,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
); );
// Parse reasoning and tool-calls sections // Parse reasoning and tool-calls sections
_reparseSections(); unawaited(_reparseSections());
_updateTypingIndicatorGate(); _updateTypingIndicatorGate();
} }
@@ -121,7 +122,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
// Re-parse sections when message content changes // Re-parse sections when message content changes
if (oldWidget.message.content != widget.message.content) { if (oldWidget.message.content != widget.message.content) {
_reparseSections(); unawaited(_reparseSections());
_updateTypingIndicatorGate(); _updateTypingIndicatorGate();
} }
@@ -141,7 +142,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
} }
} }
void _reparseSections() { Future<void> _reparseSections() async {
final raw0 = _activeVersionIndex >= 0 final raw0 = _activeVersionIndex >= 0
? (widget.message.versions[_activeVersionIndex].content as String?) ?? ? (widget.message.versions[_activeVersionIndex].content as String?) ??
'' ''
@@ -162,11 +163,13 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
final out = <MessageSegment>[]; final out = <MessageSegment>[];
final textBuf = StringBuffer(); final textBuf = StringBuffer();
final textSegments = <String>[];
if (rSegs == null || rSegs.isEmpty) { if (rSegs == null || rSegs.isEmpty) {
final tSegs = ToolCallsParser.segments(raw); final tSegs = ToolCallsParser.segments(raw);
if (tSegs == null || tSegs.isEmpty) { if (tSegs == null || tSegs.isEmpty) {
out.add(MessageSegment.text(raw)); out.add(MessageSegment.text(raw));
textBuf.write(raw); textBuf.write(raw);
textSegments.add(raw);
} else { } else {
for (final s in tSegs) { for (final s in tSegs) {
if (s.isToolCall && s.entry != null) { if (s.isToolCall && s.entry != null) {
@@ -174,6 +177,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
} else if ((s.text ?? '').isNotEmpty) { } else if ((s.text ?? '').isNotEmpty) {
out.add(MessageSegment.text(s.text!)); out.add(MessageSegment.text(s.text!));
textBuf.write(s.text); textBuf.write(s.text);
textSegments.add(s.text!);
} }
} }
} }
@@ -187,6 +191,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
if (tSegs == null || tSegs.isEmpty) { if (tSegs == null || tSegs.isEmpty) {
out.add(MessageSegment.text(t)); out.add(MessageSegment.text(t));
textBuf.write(t); textBuf.write(t);
textSegments.add(t);
} else { } else {
for (final s in tSegs) { for (final s in tSegs) {
if (s.isToolCall && s.entry != null) { if (s.isToolCall && s.entry != null) {
@@ -194,6 +199,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
} else if ((s.text ?? '').isNotEmpty) { } else if ((s.text ?? '').isNotEmpty) {
out.add(MessageSegment.text(s.text!)); out.add(MessageSegment.text(s.text!));
textBuf.write(s.text); textBuf.write(s.text);
textSegments.add(s.text!);
} }
} }
} }
@@ -202,8 +208,19 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
} }
final segments = out.isEmpty ? [MessageSegment.text(raw)] : out; final segments = out.isEmpty ? [MessageSegment.text(raw)] : out;
final speechText = _buildTtsPlainText(segments, raw); String speechText;
try {
final worker = ref.read(workerManagerProvider);
speechText = await worker.schedule<Map<String, dynamic>, String>(
_buildTtsPlainTextWorker,
{'segments': textSegments, 'fallback': raw},
debugLabel: 'tts_plain_text',
);
} catch (_) {
speechText = _buildTtsPlainTextFallback(textSegments, raw);
}
if (!mounted) return;
setState(() { setState(() {
_segments = segments; _segments = segments;
_ttsPlainText = speechText; _ttsPlainText = speechText;
@@ -248,18 +265,14 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
} }
} }
String _buildTtsPlainText(List<MessageSegment> segments, String fallback) { String _buildTtsPlainTextFallback(List<String> segments, String fallback) {
if (segments.isEmpty) { if (segments.isEmpty) {
return MarkdownToText.convert(fallback); return MarkdownToText.convert(fallback);
} }
final buffer = StringBuffer(); final buffer = StringBuffer();
for (final segment in segments) { for (final segment in segments) {
if (!segment.isText) { final sanitized = MarkdownToText.convert(segment);
continue;
}
final text = segment.text ?? '';
final sanitized = MarkdownToText.convert(text);
if (sanitized.isEmpty) { if (sanitized.isEmpty) {
continue; continue;
} }
@@ -1157,7 +1170,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
} else if (_activeVersionIndex > 0) { } else if (_activeVersionIndex > 0) {
_activeVersionIndex -= 1; _activeVersionIndex -= 1;
} }
_reparseSections(); unawaited(_reparseSections());
}); });
}, },
), ),
@@ -1177,7 +1190,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
} else { } else {
_activeVersionIndex = -1; // move to live _activeVersionIndex = -1; // move to live
} }
_reparseSections(); unawaited(_reparseSections());
}); });
}, },
), ),
@@ -1329,6 +1342,34 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
} }
} }
String _buildTtsPlainTextWorker(Map<String, dynamic> payload) {
final rawSegments = payload['segments'];
final fallback = payload['fallback'] as String? ?? '';
final segments = rawSegments is List ? rawSegments.cast<dynamic>() : const [];
if (segments.isEmpty) {
return MarkdownToText.convert(fallback);
}
final buffer = StringBuffer();
for (final segment in segments) {
if (segment is! String || segment.isEmpty) continue;
final sanitized = MarkdownToText.convert(segment);
if (sanitized.isEmpty) continue;
if (buffer.isNotEmpty) {
buffer.writeln();
buffer.writeln();
}
buffer.write(sanitized);
}
final result = buffer.toString().trim();
if (result.isEmpty) {
return MarkdownToText.convert(fallback);
}
return result;
}
class StatusHistoryTimeline extends StatefulWidget { class StatusHistoryTimeline extends StatefulWidget {
const StatusHistoryTimeline({ const StatusHistoryTimeline({
super.key, super.key,

View File

@@ -9,6 +9,7 @@ import 'package:share_plus/share_plus.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'dart:io'; import 'dart:io';
import 'dart:convert'; import 'dart:convert';
import '../../../core/services/worker_manager.dart';
class EnhancedAttachment extends ConsumerStatefulWidget { class EnhancedAttachment extends ConsumerStatefulWidget {
final String attachmentId; final String attachmentId;
@@ -102,12 +103,14 @@ class _EnhancedAttachmentState extends ConsumerState<EnhancedAttachment> {
final dir = await getTemporaryDirectory(); final dir = await getTemporaryDirectory();
final filePath = '${dir.path}/$filename'; final filePath = '${dir.path}/$filename';
final worker = ref.read(workerManagerProvider);
try { try {
if (content.length > 128 && if (_looksLikeBase64(content)) {
RegExp( final bytes = await worker.schedule<String, Uint8List>(
r'^[A-Za-z0-9+/=\r\n]+$', _decodeAttachmentBase64,
).hasMatch(content.replaceAll('\n', ''))) { content,
final bytes = base64Decode(content.replaceAll('\n', '')); debugLabel: 'attachment_decode_bytes',
);
await File(filePath).writeAsBytes(bytes, flush: true); await File(filePath).writeAsBytes(bytes, flush: true);
} else { } else {
await File(filePath).writeAsString(content, flush: true); await File(filePath).writeAsString(content, flush: true);
@@ -291,3 +294,14 @@ class _EnhancedAttachmentState extends ConsumerState<EnhancedAttachment> {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} }
} }
bool _looksLikeBase64(String content) {
if (content.length <= 128) return false;
final sanitized = content.replaceAll('\n', '');
return RegExp(r'^[A-Za-z0-9+/=]+$').hasMatch(sanitized);
}
Uint8List _decodeAttachmentBase64(String raw) {
final sanitized = raw.replaceAll('\n', '');
return base64Decode(sanitized);
}

View File

@@ -1,6 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
@@ -15,6 +15,7 @@ import '../../auth/providers/unified_auth_providers.dart';
import '../../../core/utils/debug_logger.dart'; import '../../../core/utils/debug_logger.dart';
import '../../../core/network/self_signed_image_cache_manager.dart'; import '../../../core/network/self_signed_image_cache_manager.dart';
import '../../../core/network/image_header_utils.dart'; import '../../../core/network/image_header_utils.dart';
import '../../../core/services/worker_manager.dart';
// Simple global cache to prevent reloading // Simple global cache to prevent reloading
final _globalImageCache = <String, String>{}; final _globalImageCache = <String, String>{};
@@ -23,13 +24,6 @@ final _globalErrorStates = <String, String>{};
final _globalImageBytesCache = <String, Uint8List>{}; final _globalImageBytesCache = <String, Uint8List>{};
final _base64WhitespacePattern = RegExp(r'\s'); final _base64WhitespacePattern = RegExp(r'\s');
Future<Uint8List> _decodeImageDataAsync(String data) async {
if (kIsWeb) {
return _decodeImageData(data);
}
return compute(_decodeImageData, data);
}
Uint8List _decodeImageData(String data) { Uint8List _decodeImageData(String data) {
var payload = data; var payload = data;
if (payload.startsWith('data:')) { if (payload.startsWith('data:')) {
@@ -233,7 +227,12 @@ class _EnhancedImageAttachmentState
if (_isDecoding) return; if (_isDecoding) return;
_isDecoding = true; _isDecoding = true;
try { try {
final bytes = await _decodeImageDataAsync(data); final worker = ref.read(workerManagerProvider);
final bytes = await worker.schedule(
_decodeImageData,
data,
debugLabel: 'decode_image',
);
_globalImageBytesCache[widget.attachmentId] = bytes; _globalImageBytesCache[widget.attachmentId] = bytes;
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {