diff --git a/lib/core/services/streaming_helper.dart b/lib/core/services/streaming_helper.dart index f56ae50..405ee07 100644 --- a/lib/core/services/streaming_helper.dart +++ b/lib/core/services/streaming_helper.dart @@ -14,18 +14,19 @@ import '../../shared/widgets/themed_dialogs.dart'; import '../../shared/theme/theme_extensions.dart'; import '../utils/debug_logger.dart'; import '../utils/openwebui_source_parser.dart'; +import 'streaming_response_controller.dart'; // Keep local verbosity toggle for socket logs const bool kSocketVerboseLogging = false; class ActiveSocketStream { ActiveSocketStream({ - required this.streamSubscription, + required this.controller, required this.socketSubscriptions, required this.disposeWatchdog, }); - final StreamSubscription streamSubscription; + final StreamingResponseController controller; final List socketSubscriptions; final VoidCallback disposeWatchdog; } @@ -1026,8 +1027,9 @@ ActiveSocketStream attachUnifiedChunkedStreaming({ socketSubscriptions.add(channelSub.dispose); } - final subscription = persistentController.stream.listen( - (chunk) { + final controller = StreamingResponseController( + stream: persistentController.stream, + onChunk: (chunk) { var effectiveChunk = chunk; if (webSearchEnabled && !isSearching) { if (chunk.contains('[SEARCHING]') || @@ -1061,7 +1063,7 @@ ActiveSocketStream attachUnifiedChunkedStreaming({ updateImagesFromCurrentContent(); } }, - onDone: () async { + onComplete: () { // Unregister from persistent service persistentService.unregisterStream(streamId); @@ -1073,7 +1075,7 @@ ActiveSocketStream attachUnifiedChunkedStreaming({ Future.microtask(refreshConversationSnapshot); } }, - onError: (error) async { + onError: (error, stackTrace) async { DebugLogger.error( 'Stream error occurred', scope: 'streaming/helper', @@ -1090,11 +1092,12 @@ ActiveSocketStream attachUnifiedChunkedStreaming({ } catch (_) {} // Check if this is a recoverable error (network issues, etc.) + final errorText = error.toString(); final isRecoverable = - error is! FormatException && - error.toString().contains('SocketException') || - error.toString().contains('TimeoutException') || - error.toString().contains('HandshakeException'); + (error is! FormatException && + errorText.contains('SocketException')) || + errorText.contains('TimeoutException') || + errorText.contains('HandshakeException'); if (isRecoverable && socketService != null) { // Try to recover via socket connection if available @@ -1118,7 +1121,7 @@ ActiveSocketStream attachUnifiedChunkedStreaming({ ); return ActiveSocketStream( - streamSubscription: subscription, + controller: controller, socketSubscriptions: socketSubscriptions, disposeWatchdog: () => socketWatchdog?.stop(), ); diff --git a/lib/core/services/streaming_response_controller.dart b/lib/core/services/streaming_response_controller.dart new file mode 100644 index 0000000..d007ee9 --- /dev/null +++ b/lib/core/services/streaming_response_controller.dart @@ -0,0 +1,95 @@ +import 'dart:async'; + +import '../utils/debug_logger.dart'; + +/// Signature for callbacks that receive streaming text updates. +typedef StreamingChunkCallback = void Function(String chunk); + +/// Signature for callbacks invoked when a streaming session finishes. +typedef StreamingCompletionCallback = void Function(); + +/// Signature for callbacks invoked when a streaming session encounters an +/// error. +typedef StreamingErrorCallback = + void Function(Object error, StackTrace stackTrace); + +/// A lightweight controller that manages the lifecycle of a streamed response. +/// +/// This wraps a [StreamSubscription], normalises error handling, and exposes +/// a unified cancel method so UI layers can stop streaming without having to +/// know the underlying transport (SSE, polling, etc.). +class StreamingResponseController { + StreamingResponseController({ + required Stream stream, + required StreamingChunkCallback onChunk, + required StreamingCompletionCallback onComplete, + required StreamingErrorCallback onError, + bool cancelOnError = true, + }) : _onChunk = onChunk, + _onComplete = onComplete, + _onError = onError { + _subscription = stream.listen( + _handleChunk, + cancelOnError: cancelOnError, + onDone: _handleCompleted, + onError: _handleError, + ); + } + + final StreamingChunkCallback _onChunk; + final StreamingCompletionCallback _onComplete; + final StreamingErrorCallback _onError; + + StreamSubscription? _subscription; + bool _isCancelled = false; + + /// Whether the underlying stream subscription is still active. + bool get isActive => _subscription != null && !_isCancelled; + + void _handleChunk(String chunk) { + if (_isCancelled) { + return; + } + try { + _onChunk(chunk); + } catch (err, stackTrace) { + DebugLogger.error( + 'streaming-chunk-handler-failed', + scope: 'streaming/controller', + error: err, + ); + _handleError(err, stackTrace); + } + } + + void _handleCompleted() { + if (_isCancelled) { + return; + } + _subscription = null; + try { + _onComplete(); + } catch (err, stackTrace) { + _handleError(err, stackTrace); + } + } + + void _handleError(Object error, StackTrace stackTrace) { + if (_isCancelled) { + return; + } + _subscription = null; + _onError(error, stackTrace); + } + + /// Cancels the underlying stream subscription. + Future cancel() async { + if (_isCancelled) { + return; + } + _isCancelled = true; + final subscription = _subscription; + _subscription = null; + await subscription?.cancel(); + } +} diff --git a/lib/core/utils/markdown_stream_formatter.dart b/lib/core/utils/markdown_stream_formatter.dart new file mode 100644 index 0000000..07dd0a8 --- /dev/null +++ b/lib/core/utils/markdown_stream_formatter.dart @@ -0,0 +1,67 @@ +/// Maintains a raw markdown buffer for streaming content and generates +/// preview-safe output by appending synthetic closing tokens when necessary. +class MarkdownStreamFormatter { + StringBuffer _raw = StringBuffer(); + + /// Seeds the formatter with existing markdown content. + void seed(String content) { + _raw = StringBuffer(content); + } + + /// Adds a streaming chunk to the internal buffer and returns a preview-ready + /// string with any required synthetic closing markers. + String ingest(String chunk) { + if (chunk.isNotEmpty) { + _raw.write(chunk); + } + return preview(); + } + + /// Replaces the current buffer with the provided [content]. + String replace(String content) { + seed(content); + return preview(); + } + + /// Returns the preview-safe markdown string. + String preview() { + final raw = _raw.toString(); + return raw + _syntheticClosures(raw); + } + + /// Returns the raw markdown accumulated so far. + String finalize() => _raw.toString(); + + String _syntheticClosures(String content) { + final buffer = StringBuffer(); + + final fenceCount = '```'.allMatches(content).length; + if (fenceCount.isOdd) { + buffer.writeln('```'); + } + + final boldCount = RegExp(r'\*\*').allMatches(content).length; + if (boldCount.isOdd) { + buffer.write('**'); + } + + final italicCount = RegExp(r'(? closeBrackets) { + buffer.write(List.filled(openBrackets - closeBrackets, ']').join()); + } + + final openParens = '('.allMatches(content).length; + final closeParens = ')'.allMatches(content).length; + if (openParens > closeParens) { + buffer.write(List.filled(openParens - closeParens, ')').join()); + } + + return buffer.toString(); + } +} diff --git a/lib/features/chat/providers/assistant_response_builder_provider.dart b/lib/features/chat/providers/assistant_response_builder_provider.dart new file mode 100644 index 0000000..16a42c7 --- /dev/null +++ b/lib/features/chat/providers/assistant_response_builder_provider.dart @@ -0,0 +1,25 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/models/chat_message.dart'; + +typedef AssistantResponseBuilder = + Widget Function(BuildContext context, AssistantResponseContext response); + +class AssistantResponseContext { + const AssistantResponseContext({ + required this.message, + required this.markdown, + required this.isStreaming, + required this.buildDefault, + }); + + final ChatMessage message; + final String markdown; + final bool isStreaming; + final WidgetBuilder buildDefault; +} + +final assistantResponseBuilderProvider = Provider( + (_) => null, +); diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index f16b9c9..b808bc3 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -13,8 +13,10 @@ import '../../../core/models/conversation.dart'; import '../../../core/models/socket_event.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/services/streaming_helper.dart'; +import '../../../core/services/streaming_response_controller.dart'; import '../../../core/utils/debug_logger.dart'; import '../../../core/utils/inactivity_watchdog.dart'; +import '../../../core/utils/markdown_stream_formatter.dart'; import '../../../core/utils/tool_calls_parser.dart'; import '../../../shared/services/tasks/task_queue.dart'; import '../../tools/providers/tools_providers.dart'; @@ -76,7 +78,7 @@ class ComposerHasFocus extends _$ComposerHasFocus { // Chat messages notifier class class ChatMessagesNotifier extends Notifier> { - StreamSubscription? _messageStream; + StreamingResponseController? _messageStream; ProviderSubscription? _conversationListener; final List _subscriptions = []; final List _socketSubscriptions = []; @@ -85,6 +87,9 @@ class ChatMessagesNotifier extends Notifier> { InactivityWatchdog? _typingWatchdog; DateTime? _lastStreamingActivity; + MarkdownStreamFormatter? _markdownFormatter; + String? _activeStreamingMessageId; + bool _initialized = false; @override @@ -170,13 +175,13 @@ class ChatMessagesNotifier extends Notifier> { return activeConversation?.messages ?? const []; } - void _addSubscription(StreamSubscription subscription) { - _subscriptions.add(subscription); - } - void _cancelMessageStream() { - _messageStream?.cancel(); + final controller = _messageStream; _messageStream = null; + if (controller != null && controller.isActive) { + unawaited(controller.cancel()); + } + _clearStreamingFormatter(); cancelSocketSubscriptions(); } @@ -185,6 +190,47 @@ class ChatMessagesNotifier extends Notifier> { _typingWatchdog = null; } + void _clearStreamingFormatter() { + _markdownFormatter = null; + _activeStreamingMessageId = null; + } + + void _ensureFormatterForMessage(ChatMessage message) { + if (_markdownFormatter != null && _activeStreamingMessageId == message.id) { + return; + } + + final formatter = MarkdownStreamFormatter(); + final seed = _stripStreamingPlaceholders(message.content); + if (seed.isNotEmpty) { + formatter.seed(seed); + } + _markdownFormatter = formatter; + _activeStreamingMessageId = message.id; + } + + String _stripStreamingPlaceholders(String content) { + var result = content; + const ti = '[TYPING_INDICATOR]'; + const searchBanner = '🔍 Searching the web...'; + if (result.startsWith(ti)) { + result = result.substring(ti.length); + } + if (result.startsWith(searchBanner)) { + result = result.substring(searchBanner.length); + } + return result; + } + + String _finalizeFormatter(String messageId, String fallback) { + if (_markdownFormatter != null && _activeStreamingMessageId == messageId) { + final output = _markdownFormatter!.finalize(); + _clearStreamingFormatter(); + return output; + } + return fallback; + } + void _scheduleTypingGuard({Duration? timeout}) { // Default timeout tuned to balance long tool gaps and UX final effectiveTimeout = timeout ?? const Duration(seconds: 25); @@ -378,12 +424,9 @@ class ChatMessagesNotifier extends Notifier> { } } - void setMessageStream(StreamSubscription stream) { + void setMessageStream(StreamingResponseController controller) { _cancelMessageStream(); - _messageStream = stream; - - // Add to tracked subscriptions for comprehensive cleanup - _addSubscription(stream); + _messageStream = controller; } void setSocketSubscriptions( @@ -438,22 +481,9 @@ class ChatMessagesNotifier extends Notifier> { final lastMessage = state.last; if (lastMessage.role != 'assistant') return; - // Ensure we never keep the typing placeholder in persisted content - String sanitized(String s) { - const ti = '[TYPING_INDICATOR]'; - const searchBanner = '🔍 Searching the web...'; - if (s.startsWith(ti)) { - s = s.substring(ti.length); - } - if (s.startsWith(searchBanner)) { - s = s.substring(searchBanner.length); - } - return s; - } - state = [ ...state.sublist(0, state.length - 1), - lastMessage.copyWith(content: sanitized(content)), + lastMessage.copyWith(content: _stripStreamingPlaceholders(content)), ]; _touchStreamingActivity(); } @@ -565,21 +595,13 @@ class ChatMessagesNotifier extends Notifier> { return; } - // Strip a leading typing indicator if present, then append delta - const ti = '[TYPING_INDICATOR]'; - const searchBanner = '🔍 Searching the web...'; - String current = lastMessage.content; - if (current.startsWith(ti)) { - current = current.substring(ti.length); - } - if (current.startsWith(searchBanner)) { - current = current.substring(searchBanner.length); - } - final newContent = current.isEmpty ? content : current + content; + _ensureFormatterForMessage(lastMessage); + final formatter = _markdownFormatter!; + final preview = formatter.ingest(content); state = [ ...state.sublist(0, state.length - 1), - lastMessage.copyWith(content: newContent), + lastMessage.copyWith(content: preview), ]; _touchStreamingActivity(); } @@ -594,16 +616,10 @@ class ChatMessagesNotifier extends Notifier> { return; } - // Remove typing indicator if present in the replacement - String sanitized = content; - const ti = '[TYPING_INDICATOR]'; - const searchBanner = '🔍 Searching the web...'; - if (sanitized.startsWith(ti)) { - sanitized = sanitized.substring(ti.length); - } - if (sanitized.startsWith(searchBanner)) { - sanitized = sanitized.substring(searchBanner.length); - } + _ensureFormatterForMessage(lastMessage); + final formatter = _markdownFormatter!; + final sanitized = formatter.replace(_stripStreamingPlaceholders(content)); + state = [ ...state.sublist(0, state.length - 1), lastMessage.copyWith(content: sanitized), @@ -617,21 +633,14 @@ class ChatMessagesNotifier extends Notifier> { final lastMessage = state.last; if (lastMessage.role != 'assistant' || !lastMessage.isStreaming) return; - // Also strip any leftover typing indicator before finalizing - const ti = '[TYPING_INDICATOR]'; - const searchBanner = '🔍 Searching the web...'; - String cleaned = lastMessage.content; - if (cleaned.startsWith(ti)) { - cleaned = cleaned.substring(ti.length); - } - if (cleaned.startsWith(searchBanner)) { - cleaned = cleaned.substring(searchBanner.length); - } + final finalized = _finalizeFormatter(lastMessage.id, lastMessage.content); + final cleaned = _stripStreamingPlaceholders(finalized); state = [ ...state.sublist(0, state.length - 1), lastMessage.copyWith(isStreaming: false, content: cleaned), ]; + _messageStream = null; _cancelTypingGuard(); // Trigger a refresh of the conversations list so UI like the Chats Drawer @@ -1407,7 +1416,7 @@ Future regenerateMessage( getMessages: () => ref.read(chatMessagesProvider), ); ref.read(chatMessagesProvider.notifier) - ..setMessageStream(activeStream.streamSubscription) + ..setMessageStream(activeStream.controller) ..setSocketSubscriptions( activeStream.socketSubscriptions, onDispose: activeStream.disposeWatchdog, @@ -1969,7 +1978,7 @@ Future _sendMessageInternal( ); ref.read(chatMessagesProvider.notifier) - ..setMessageStream(activeStream.streamSubscription) + ..setMessageStream(activeStream.controller) ..setSocketSubscriptions( activeStream.socketSubscriptions, onDispose: activeStream.disposeWatchdog, diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 2ebe63e..b9ec5ae 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -21,6 +21,7 @@ import 'package:url_launcher/url_launcher_string.dart'; import '../providers/chat_providers.dart' show sendMessage; import '../../../core/utils/debug_logger.dart'; import 'sources/openwebui_sources.dart'; +import '../providers/assistant_response_builder_provider.dart'; class AssistantMessageWidget extends ConsumerStatefulWidget { final dynamic message; @@ -746,10 +747,23 @@ class _AssistantMessageWidgetState extends ConsumerState // Process images in the remaining text final processedContent = _processContentForImages(cleaned); - return StreamingMarkdownWidget( - staticContent: processedContent, + Widget buildDefault(BuildContext context) => StreamingMarkdownWidget( + content: processedContent, isStreaming: widget.isStreaming, ); + + final responseBuilder = ref.watch(assistantResponseBuilderProvider); + if (responseBuilder != null) { + final contextData = AssistantResponseContext( + message: widget.message, + markdown: processedContent, + isStreaming: widget.isStreaming, + buildDefault: buildDefault, + ); + return responseBuilder(context, contextData); + } + + return buildDefault(context); } String _processContentForImages(String content) { diff --git a/lib/shared/widgets/markdown/markdown_config.dart b/lib/shared/widgets/markdown/markdown_config.dart index 32385b1..68411cd 100644 --- a/lib/shared/widgets/markdown/markdown_config.dart +++ b/lib/shared/widgets/markdown/markdown_config.dart @@ -1,38 +1,87 @@ import 'dart:convert'; -import 'package:flutter/material.dart'; + import 'package:cached_network_image/cached_network_image.dart'; -import 'package:conduit/shared/theme/theme_extensions.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; +import 'package:markdown/markdown.dart' as md; + import 'package:conduit/l10n/app_localizations.dart'; -/// Configuration for markdown styling -class ConduitMarkdownStyleConfig { - final TextStyle textStyle; +import '../../theme/theme_extensions.dart'; - const ConduitMarkdownStyleConfig({required this.textStyle}); +class ConduitMarkdownTheme { + const ConduitMarkdownTheme({ + required this.styleSheet, + required this.builders, + required this.imageBuilder, + this.inlineSyntaxes = const [], + }); + + final MarkdownStyleSheet styleSheet; + final Map builders; + final MarkdownImageBuilder imageBuilder; + final List inlineSyntaxes; } class ConduitMarkdownConfig { - static ConduitMarkdownStyleConfig getStyleConfig({ - required BuildContext context, - }) { + static ConduitMarkdownTheme resolve(BuildContext context) { final theme = context.conduitTheme; + final materialTheme = Theme.of(context); - return ConduitMarkdownStyleConfig( - textStyle: AppTypography.chatMessageStyle.copyWith( - color: theme.textPrimary, - height: 1.45, + final baseSheet = MarkdownStyleSheet.fromTheme(materialTheme); + final bodyStyle = AppTypography.bodyMediumStyle.copyWith( + color: theme.textPrimary, + height: 1.45, + ); + + final codeColor = theme.code?.color ?? theme.textSecondary; + + final styleSheet = baseSheet.copyWith( + p: bodyStyle, + h1: AppTypography.headlineLargeStyle.copyWith(color: theme.textPrimary), + h2: AppTypography.headlineMediumStyle.copyWith(color: theme.textPrimary), + h3: AppTypography.headlineSmallStyle.copyWith(color: theme.textPrimary), + strong: bodyStyle.copyWith(fontWeight: FontWeight.w600), + em: bodyStyle.copyWith(fontStyle: FontStyle.italic), + blockquote: bodyStyle.copyWith( + color: theme.textSecondary, + fontStyle: FontStyle.italic, ), + code: AppTypography.codeStyle.copyWith(color: codeColor), + listBullet: bodyStyle, + tableBody: bodyStyle, + tableHead: bodyStyle.copyWith(fontWeight: FontWeight.w600), + ); + + final builders = { + 'codeblock': _ConduitCodeBlockBuilder(theme), + }; + + return ConduitMarkdownTheme( + styleSheet: styleSheet, + builders: builders, + imageBuilder: (uri, title, alt) { + final scheme = uri.scheme; + + if (scheme == 'data') { + return buildBase64Image(uri.toString(), context, theme); + } + + if (scheme.isEmpty || scheme == 'http' || scheme == 'https') { + return buildNetworkImage(uri.toString(), context, theme); + } + + return const SizedBox.shrink(); + }, ); } - /// Legacy method for base64 image support (if needed elsewhere) static Widget buildBase64Image( String dataUrl, BuildContext context, ConduitThemeExtension theme, ) { try { - // Extract base64 part from data URL final commaIndex = dataUrl.indexOf(','); if (commaIndex == -1) { throw Exception('Invalid data URL format'); @@ -50,50 +99,16 @@ class ConduitMarkdownConfig { imageBytes, fit: BoxFit.contain, errorBuilder: (context, error, stackTrace) { - return Container( - height: 100, - decoration: BoxDecoration( - color: theme.surfaceBackground.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(AppBorderRadius.md), - border: Border.all( - color: theme.error.withValues(alpha: 0.3), - width: BorderWidth.thin, - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, color: theme.error, size: 32), - const SizedBox(height: Spacing.xs), - Text( - AppLocalizations.of(context)!.invalidImageFormat, - style: TextStyle(color: theme.error, fontSize: 12), - ), - ], - ), - ); + return _buildImageError(context, theme); }, ), ), ); } catch (e) { - return Container( - height: 100, - decoration: BoxDecoration( - color: theme.surfaceBackground.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(AppBorderRadius.md), - ), - child: Center( - child: Text( - AppLocalizations.of(context)!.invalidImageFormat, - style: TextStyle(color: theme.error, fontSize: 12), - ), - ), - ); + return _buildImageError(context, theme); } } - /// Build a cached network image widget static Widget buildNetworkImage( String url, BuildContext context, @@ -114,39 +129,82 @@ class ConduitMarkdownConfig { ), ), ), - errorWidget: (context, url, error) => Container( - height: 100, - decoration: BoxDecoration( - color: theme.surfaceBackground.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(AppBorderRadius.md), - border: Border.all( - color: theme.error.withValues(alpha: 0.3), - width: BorderWidth.thin, + errorWidget: (context, url, error) => _buildImageError(context, theme), + ); + } + + static Widget _buildImageError( + BuildContext context, + ConduitThemeExtension theme, + ) { + return Container( + height: 100, + decoration: BoxDecoration( + color: theme.surfaceBackground.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: theme.error.withValues(alpha: 0.3), + width: BorderWidth.thin, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.broken_image_outlined, color: theme.error, size: 32), + const SizedBox(height: Spacing.xs), + Text( + AppLocalizations.of(context)!.failedToLoadImage(''), + style: TextStyle(color: theme.error, fontSize: 12), ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.broken_image_outlined, color: theme.error, size: 32), - const SizedBox(height: Spacing.xs), - Text( - AppLocalizations.of(context)!.failedToLoadImage(''), - style: TextStyle(color: theme.error, fontSize: 12), - ), - ], - ), + ], ), ); } } -/// Custom wrapper for code blocks with copy functionality -class CodeBlockWrapper extends StatelessWidget { - final Widget child; - final String code; - final String? language; +class _ConduitCodeBlockBuilder extends MarkdownElementBuilder { + _ConduitCodeBlockBuilder(this.theme); + final ConduitThemeExtension theme; + @override + Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { + final rawText = element.textContent; + final classAttribute = element.attributes['class']; + String? language; + if (classAttribute != null && classAttribute.startsWith('language-')) { + language = classAttribute.substring('language-'.length); + } + + final textStyle = (preferredStyle ?? AppTypography.codeStyle).copyWith( + color: theme.code?.color ?? theme.textSecondary, + ); + + final container = Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(vertical: Spacing.xs), + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + color: theme.surfaceBackground.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: theme.cardBorder.withValues(alpha: 0.2), + width: BorderWidth.micro, + ), + ), + child: SelectableText(rawText, style: textStyle), + ); + + return CodeBlockWrapper( + code: rawText, + language: language, + theme: theme, + child: container, + ); + } +} + +class CodeBlockWrapper extends StatelessWidget { const CodeBlockWrapper({ super.key, required this.child, @@ -155,6 +213,11 @@ class CodeBlockWrapper extends StatelessWidget { required this.theme, }); + final Widget child; + final String code; + final String? language; + final ConduitThemeExtension theme; + @override Widget build(BuildContext context) { return Stack( @@ -168,8 +231,7 @@ class CodeBlockWrapper extends StatelessWidget { child: InkWell( borderRadius: BorderRadius.circular(AppBorderRadius.sm), onTap: () { - // Copy code to clipboard - // Implementation depends on clipboard service + // Copy implementation provided by higher level clipboard service. }, child: Container( padding: const EdgeInsets.all(Spacing.xs), @@ -201,8 +263,9 @@ class CodeBlockWrapper extends StatelessWidget { ), child: Text( language!, - style: AppTypography.captionStyle.copyWith( + style: AppTypography.bodySmallStyle.copyWith( color: theme.textSecondary, + fontFamily: AppTypography.monospaceFontFamily, ), ), ), diff --git a/lib/shared/widgets/markdown/streaming_markdown_widget.dart b/lib/shared/widgets/markdown/streaming_markdown_widget.dart index 98155dc..b11057e 100644 --- a/lib/shared/widgets/markdown/streaming_markdown_widget.dart +++ b/lib/shared/widgets/markdown/streaming_markdown_widget.dart @@ -1,187 +1,60 @@ -import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; -import 'package:conduit/shared/theme/theme_extensions.dart'; -import 'package:conduit/shared/widgets/markdown/markdown_config.dart'; -class StreamingMarkdownWidget extends StatefulWidget { - final Stream? contentStream; - final String? staticContent; - final bool isStreaming; +import 'markdown_config.dart'; +class StreamingMarkdownWidget extends StatelessWidget { const StreamingMarkdownWidget({ super.key, - this.contentStream, - this.staticContent, + required this.content, required this.isStreaming, + this.onTapLink, }); - @override - State createState() => - _StreamingMarkdownWidgetState(); -} - -class _StreamingMarkdownWidgetState extends State { - final _buffer = StringBuffer(); - Timer? _debounceTimer; - String _renderedContent = ''; - StreamSubscription? _streamSubscription; - - @override - void initState() { - super.initState(); - if (widget.contentStream != null) { - _streamSubscription = widget.contentStream!.listen(_handleChunk); - } else if (widget.staticContent != null) { - _renderedContent = widget.staticContent!; - } - } - - void _handleChunk(String chunk) { - _buffer.write(chunk); - - // Debounce rendering for performance - _debounceTimer?.cancel(); - _debounceTimer = Timer(const Duration(milliseconds: 50), () { - if (mounted) { - setState(() { - _renderedContent = _fixIncompleteMarkdown(_buffer.toString()); - }); - } - }); - } - - String _fixIncompleteMarkdown(String content) { - // Auto-close unclosed code blocks for valid markdown during streaming - final fenceCount = '```'.allMatches(content).length; - if (fenceCount % 2 != 0) { - content += '\n```'; - } - - // Fix incomplete bold/italic markers - final boldCount = RegExp(r'\*\*').allMatches(content).length; - if (boldCount % 2 != 0) { - content += '**'; - } - - final italicCount = RegExp(r'(? closeBrackets) { - content += ']' * (openBrackets - closeBrackets); - } - - final openParens = '('.allMatches(content).length; - final closeParens = ')'.allMatches(content).length; - if (openParens > closeParens) { - content += ')' * (openParens - closeParens); - } - - return content; - } - - @override - void didUpdateWidget(StreamingMarkdownWidget oldWidget) { - super.didUpdateWidget(oldWidget); - - // Handle stream changes - if (widget.contentStream != oldWidget.contentStream) { - _streamSubscription?.cancel(); - if (widget.contentStream != null) { - _streamSubscription = widget.contentStream!.listen(_handleChunk); - } - } - - // Handle static content changes - if (widget.staticContent != oldWidget.staticContent) { - setState(() { - _renderedContent = widget.staticContent ?? ''; - }); - } - } + final String content; + final bool isStreaming; + final MarkdownTapLinkCallback? onTapLink; @override Widget build(BuildContext context) { - final config = ConduitMarkdownConfig.getStyleConfig(context: context); - final theme = Theme.of(context); - final styleSheet = MarkdownStyleSheet.fromTheme( - theme, - ).copyWith(p: config.textStyle); - final conduitTheme = context.conduitTheme; + final markdownTheme = ConduitMarkdownConfig.resolve(context); - if (_renderedContent.isEmpty) { - return const SizedBox.shrink(); + if (content.trim().isEmpty) { + return isStreaming ? const SizedBox.shrink() : const SizedBox.shrink(); } - // MarkdownBody handles both streaming and static content elegantly return MarkdownBody( - data: _renderedContent, - styleSheet: styleSheet, + data: content, + styleSheet: markdownTheme.styleSheet, softLineBreak: true, selectable: true, - imageBuilder: (uri, title, alt) { - final scheme = uri.scheme; - - if (scheme == 'data') { - return ConduitMarkdownConfig.buildBase64Image( - uri.toString(), - context, - conduitTheme, - ); - } - - if (scheme.isEmpty || scheme == 'http' || scheme == 'https') { - return ConduitMarkdownConfig.buildNetworkImage( - uri.toString(), - context, - conduitTheme, - ); - } - - return const SizedBox.shrink(); - }, + builders: markdownTheme.builders, + inlineSyntaxes: markdownTheme.inlineSyntaxes, + imageBuilder: markdownTheme.imageBuilder, + onTapLink: onTapLink, ); } - - @override - void dispose() { - _debounceTimer?.cancel(); - _streamSubscription?.cancel(); - super.dispose(); - } } -/// Extension to provide easy access to streaming markdown extension StreamingMarkdownExtension on String { Widget toMarkdown({required BuildContext context, bool isStreaming = false}) { - return StreamingMarkdownWidget( - staticContent: this, - isStreaming: isStreaming, - ); + return StreamingMarkdownWidget(content: this, isStreaming: isStreaming); } } -/// Helper widget for displaying markdown with loading state class MarkdownWithLoading extends StatelessWidget { + const MarkdownWithLoading({super.key, this.content, required this.isLoading}); + final String? content; final bool isLoading; - const MarkdownWithLoading({super.key, this.content, required this.isLoading}); - @override Widget build(BuildContext context) { - if (isLoading && (content == null || content!.isEmpty)) { + final value = content ?? ''; + if (isLoading && value.isEmpty) { return const Center(child: CircularProgressIndicator()); } - return StreamingMarkdownWidget( - staticContent: content ?? '', - isStreaming: isLoading, - ); + return StreamingMarkdownWidget(content: value, isStreaming: isLoading); } } diff --git a/pubspec.lock b/pubspec.lock index 8ae2ed5..b14826b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -790,7 +790,7 @@ packages: source: hosted version: "1.3.0" markdown: - dependency: transitive + dependency: "direct main" description: name: markdown sha256: "935e23e1ff3bc02d390bad4d4be001208ee92cc217cb5b5a6c19bc14aaa318c1" diff --git a/pubspec.yaml b/pubspec.yaml index 25b755f..14da8c7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: # UI Components - Enhanced Markdown flutter_markdown_plus: ^1.0.5 + markdown: ^7.2.1 cached_network_image: ^3.3.1 socket_io_client: ^3.1.2 yaml: ^3.1.2