diff --git a/lib/core/utils/citation_parser.dart b/lib/core/utils/citation_parser.dart new file mode 100644 index 0000000..8398838 --- /dev/null +++ b/lib/core/utils/citation_parser.dart @@ -0,0 +1,140 @@ +/// Utility class for parsing inline citation references like [1], [1,2,3]. +/// +/// This matches OpenWebUI's citation-extension.ts behavior where adjacent +/// citation brackets are merged and parsed into source indices. +/// +/// Reference: openwebui-src/src/lib/utils/marked/citation-extension.ts +library; + +/// Represents a parsed citation with one or more source IDs. +class Citation { + /// 1-based source indices referenced by this citation. + final List sourceIds; + + /// The raw text that was matched (e.g., "[1]" or "[1,2,3]"). + final String raw; + + const Citation({required this.sourceIds, required this.raw}); + + /// Converts to 0-based indices for array access. + List get zeroBasedIndices => + sourceIds.map((id) => id - 1).toList(growable: false); +} + +/// A segment of content that is either plain text or a citation. +class CitationSegment { + final String? text; + final Citation? citation; + + const CitationSegment._({this.text, this.citation}); + + factory CitationSegment.text(String text) => CitationSegment._(text: text); + factory CitationSegment.citation(Citation citation) => + CitationSegment._(citation: citation); + + bool get isText => text != null; + bool get isCitation => citation != null; +} + +/// Parser for inline citations in markdown content. +class CitationParser { + const CitationParser._(); + + // Matches one or more adjacent [N] or [N,M,...] blocks + // Examples: "[1]", "[1,2,3]", "[1][2]", "[1,2][3,4]" + static final _citationPattern = RegExp(r'(\[(?:\d[\d,\s]*)\])+'); + + // Matches individual bracket groups within a citation match + static final _bracketGroupPattern = RegExp(r'\[([\d,\s]+)\]'); + + // Avoids matching footnotes like [^1] + static final _footnotePattern = RegExp(r'^\[\^'); + + /// Parses content and returns segments of text and citations. + /// + /// Returns null if no citations are found. + static List? parse(String content) { + if (content.isEmpty) return null; + + final segments = []; + int lastEnd = 0; + + for (final match in _citationPattern.allMatches(content)) { + // Check if this looks like a footnote reference + final beforeMatch = match.start > 0 + ? content.substring(match.start - 1, match.start) + : ''; + if (beforeMatch == '^') continue; + + // Check the matched content for footnote pattern + final raw = match.group(0)!; + if (_footnotePattern.hasMatch(raw)) continue; + + // Add text before this citation + if (match.start > lastEnd) { + final textBefore = content.substring(lastEnd, match.start); + if (textBefore.isNotEmpty) { + segments.add(CitationSegment.text(textBefore)); + } + } + + // Parse the citation IDs + final ids = []; + for (final bracketMatch in _bracketGroupPattern.allMatches(raw)) { + final idsStr = bracketMatch.group(1) ?? ''; + final parsed = idsStr + .split(',') + .map((s) => int.tryParse(s.trim())) + .whereType() + .where((n) => n > 0) // Only positive indices + .toList(); + ids.addAll(parsed); + } + + if (ids.isNotEmpty) { + segments.add( + CitationSegment.citation(Citation(sourceIds: ids, raw: raw)), + ); + } else { + // No valid IDs found, treat as text + segments.add(CitationSegment.text(raw)); + } + + lastEnd = match.end; + } + + // Add remaining text + if (lastEnd < content.length) { + final remaining = content.substring(lastEnd); + if (remaining.isNotEmpty) { + segments.add(CitationSegment.text(remaining)); + } + } + + // Return null if no citations were found + final hasCitations = segments.any((s) => s.isCitation); + return hasCitations ? segments : null; + } + + /// Checks if content contains any citation patterns. + static bool hasCitations(String content) { + if (content.isEmpty) return false; + // The regex already excludes footnotes like [^1] since it requires + // a digit immediately after the opening bracket. + return _citationPattern.hasMatch(content); + } + + /// Extracts all unique source IDs from content (1-based). + static List extractSourceIds(String content) { + final segments = parse(content); + if (segments == null) return const []; + + final ids = {}; + for (final segment in segments) { + if (segment.isCitation) { + ids.addAll(segment.citation!.sourceIds); + } + } + return ids.toList()..sort(); + } +} diff --git a/lib/core/utils/reasoning_parser.dart b/lib/core/utils/reasoning_parser.dart index eee8efb..6e63bf1 100644 --- a/lib/core/utils/reasoning_parser.dart +++ b/lib/core/utils/reasoning_parser.dart @@ -124,10 +124,18 @@ class ReasoningContent { /// Utility class for parsing and extracting reasoning/thinking content. class ReasoningParser { + /// Patterns that indicate a details block is reasoning content. + /// Used when the `type` attribute is missing. + static final _reasoningSummaryPattern = RegExp( + r'Thought|Thinking|Reasoning', + caseSensitive: false, + ); + /// Splits content into ordered segments of plain text and reasoning entries. /// /// Handles: /// - `
` blocks with optional summary/duration/done + /// - `
` blocks without type but with reasoning-like summary /// - Raw tag pairs like ``, ``, ``, etc. /// - Incomplete/streaming cases by emitting a partial reasoning entry static List? segments( @@ -150,14 +158,14 @@ class ReasoningParser { int index = 0; while (index < content.length) { - // Find the earliest match: either
]*type="reasoning"', + r')', ).firstMatch(content.substring(index)); if (detailsMatch != null) { nextDetailsIdx = index + detailsMatch.start; @@ -203,9 +211,19 @@ class ReasoningParser { } if (kind == 'details') { - // Parse
block and extract ReasoningEntry - final result = _parseDetailsReasoning(content, nextIdx); - segments.add(ReasoningSegment.entry(result.entry)); + // Parse
block and check if it's reasoning content + final result = _parseDetailsBlock(content, nextIdx); + + // Only add as reasoning if it's a reasoning type or looks like reasoning + if (result.isReasoning) { + segments.add(ReasoningSegment.entry(result.entry)); + } else { + // Not a reasoning block, treat as text + final detailsText = content.substring(nextIdx, result.endIndex); + if (detailsText.trim().isNotEmpty) { + segments.add(ReasoningSegment.text(detailsText)); + } + } if (!result.isComplete) { // Incomplete block, stop here @@ -233,13 +251,14 @@ class ReasoningParser { return segments.isEmpty ? null : segments; } - /// Parse a `
` block starting at the given index. - static _ReasoningResult _parseDetailsReasoning(String content, int startIdx) { + /// Parse a `
` block starting at the given index. + /// Returns whether the block is reasoning content based on type or summary. + static _DetailsResult _parseDetailsBlock(String content, int startIdx) { // Find the opening tag end final openTagEnd = content.indexOf('>', startIdx); if (openTagEnd == -1) { - // Incomplete opening tag - return _ReasoningResult( + // Incomplete opening tag - assume reasoning for streaming + return _DetailsResult( entry: ReasoningEntry( reasoning: '', summary: '', @@ -248,6 +267,7 @@ class ReasoningParser { ), endIndex: content.length, isComplete: false, + isReasoning: true, ); } @@ -260,6 +280,7 @@ class ReasoningParser { attrs[m.group(1)!] = m.group(2) ?? ''; } + final type = attrs['type'] ?? ''; final isDone = (attrs['done'] ?? 'true') == 'true'; final duration = int.tryParse(attrs['duration'] ?? '0') ?? 0; @@ -284,15 +305,27 @@ class ReasoningParser { final innerContent = content.substring(openTagEnd + 1); final summaryResult = _extractSummary(innerContent); - return _ReasoningResult( + // Determine if this is reasoning based on type or summary + final isReasoning = + type == 'reasoning' || + (type.isEmpty && + _reasoningSummaryPattern.hasMatch(summaryResult.summary)); + + // Extract duration from summary if not in attributes + final effectiveDuration = duration > 0 + ? duration + : _extractDurationFromSummary(summaryResult.summary); + + return _DetailsResult( entry: ReasoningEntry( reasoning: HtmlUtils.unescapeHtml(summaryResult.remaining), summary: HtmlUtils.unescapeHtml(summaryResult.summary), - duration: duration, + duration: effectiveDuration, isDone: false, ), endIndex: content.length, isComplete: false, + isReasoning: isReasoning, ); } @@ -301,15 +334,27 @@ class ReasoningParser { final innerContent = content.substring(openTagEnd + 1, closeIdx); final summaryResult = _extractSummary(innerContent); - return _ReasoningResult( + // Determine if this is reasoning based on type or summary + final isReasoning = + type == 'reasoning' || + (type.isEmpty && + _reasoningSummaryPattern.hasMatch(summaryResult.summary)); + + // Extract duration from summary if not in attributes + final effectiveDuration = duration > 0 + ? duration + : _extractDurationFromSummary(summaryResult.summary); + + return _DetailsResult( entry: ReasoningEntry( reasoning: HtmlUtils.unescapeHtml(summaryResult.remaining), summary: HtmlUtils.unescapeHtml(summaryResult.summary), - duration: duration, + duration: effectiveDuration, isDone: isDone, ), endIndex: i, isComplete: true, + isReasoning: isReasoning, ); } @@ -369,6 +414,30 @@ class ReasoningParser { return _SummaryResult(summary: '', remaining: content.trim()); } + /// Extract duration from summary text like "Thought (1s)" or "Thinking (2m 30s)". + static int _extractDurationFromSummary(String summary) { + // Match patterns like "(1s)", "(30s)", "(1m)", "(2m 30s)", "(1m30s)" + // Supports minutes-only "(1m)", seconds-only "(30s)", or both "(2m 30s)" + final durationRegex = RegExp( + r'\((\d+)m(?:\s*(\d+)s)?\)|\((\d+)s\)', + caseSensitive: false, + ); + final match = durationRegex.firstMatch(summary); + if (match != null) { + // Check if it's a minutes pattern (groups 1 and 2) or seconds-only (group 3) + if (match.group(1) != null) { + // Minutes pattern: "(Xm)" or "(Xm Ys)" + final minutes = int.tryParse(match.group(1) ?? '0') ?? 0; + final seconds = int.tryParse(match.group(2) ?? '0') ?? 0; + return minutes * 60 + seconds; + } else if (match.group(3) != null) { + // Seconds-only pattern: "(Xs)" + return int.tryParse(match.group(3) ?? '0') ?? 0; + } + } + return 0; + } + /// Parses a message and extracts the first reasoning content block. /// Returns null if no reasoning content is found. static ReasoningContent? parseReasoningContent( @@ -412,6 +481,17 @@ class ReasoningParser { // Check for
with reasoning-like summary + if (content.contains('([^<]*)', + ).firstMatch(content); + if (summaryMatch != null) { + final summary = summaryMatch.group(1) ?? ''; + if (_reasoningSummaryPattern.hasMatch(summary)) return true; + } + } + // Check for raw tag pairs for (final pair in defaultReasoningTagPairs) { if (content.contains(pair.$1)) return true; @@ -448,6 +528,20 @@ class _ReasoningResult { }); } +class _DetailsResult { + final ReasoningEntry entry; + final int endIndex; + final bool isComplete; + final bool isReasoning; + + const _DetailsResult({ + required this.entry, + required this.endIndex, + required this.isComplete, + required this.isReasoning, + }); +} + class _SummaryResult { final String summary; final String remaining; diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index b67714d..47c34c7 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -25,6 +25,7 @@ import '../../../core/utils/debug_logger.dart'; import 'sources/openwebui_sources.dart'; import '../providers/assistant_response_builder_provider.dart'; import '../../../core/services/worker_manager.dart'; +import 'streaming_status_widget.dart'; // Pre-compiled regex patterns for image processing (performance optimization) final _base64ImagePattern = RegExp(r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*'); @@ -375,9 +376,12 @@ class _AssistantMessageWidgetState extends ConsumerState // No streaming-specific markdown fixes needed here; handled by Markdown widget + // Tool call tile - minimal design inspired by OpenWebUI Widget _buildToolCallTile(ToolCallEntry tc) { final isExpanded = _expandedToolIds.contains(tc.id); final theme = context.conduitTheme; + // Show shimmer when streaming and tool call is not done + final showShimmer = widget.isStreaming && !tc.done; String pretty(dynamic v, {int max = 1200}) { try { @@ -391,9 +395,47 @@ class _AssistantMessageWidgetState extends ConsumerState } } + Widget buildHeader() { + final headerWidget = Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + isExpanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 14, + color: theme.textPrimary.withValues(alpha: 0.8), + ), + const SizedBox(width: 2), + Flexible( + child: Text( + tc.done ? 'Used ${tc.name}' : 'Running ${tc.name}…', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: AppTypography.bodySmall, + color: theme.textPrimary.withValues(alpha: 0.8), + height: 1.3, + ), + ), + ), + ], + ); + + if (showShimmer) { + return headerWidget + .animate(onPlay: (controller) => controller.repeat()) + .shimmer( + duration: 1500.ms, + color: theme.shimmerHighlight.withValues(alpha: 0.6), + ); + } + return headerWidget; + } + return Padding( padding: const EdgeInsets.only(bottom: Spacing.xs), - child: InkWell( + child: GestureDetector( onTap: () { setState(() { if (isExpanded) { @@ -403,127 +445,87 @@ class _AssistantMessageWidgetState extends ConsumerState } }); }, - borderRadius: BorderRadius.circular(AppBorderRadius.small), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - color: theme.surfaceContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(AppBorderRadius.small), - border: Border.all( - color: theme.dividerColor.withValues(alpha: 0.5), - width: BorderWidth.thin, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isExpanded - ? Icons.expand_less_rounded - : Icons.expand_more_rounded, - size: 16, - color: theme.textSecondary, - ), - const SizedBox(width: Spacing.xs), - Icon( - tc.done - ? Icons.build_circle_outlined - : Icons.play_circle_outline, - size: 14, - color: theme.buttonPrimary, - ), - const SizedBox(width: Spacing.xs), - Flexible( - child: Text( - tc.done - ? 'Tool Executed: ${tc.name}' - : 'Running tool: ${tc.name}…', - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: theme.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), + behavior: HitTestBehavior.opaque, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Minimal header - just text with chevron + buildHeader(), - AnimatedCrossFade( - firstChild: const SizedBox.shrink(), - secondChild: Container( - margin: const EdgeInsets.only(top: Spacing.sm), - padding: const EdgeInsets.all(Spacing.sm), - decoration: BoxDecoration( - color: theme.surfaceContainer.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(AppBorderRadius.small), - border: Border.all( - color: theme.dividerColor.withValues(alpha: 0.5), - width: BorderWidth.thin, + // Expanded content with left border accent + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: Container( + margin: const EdgeInsets.only(top: Spacing.xs, left: 16), + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: theme.surfaceContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(4), + border: Border( + left: BorderSide( + color: theme.dividerColor.withValues(alpha: 0.4), + width: 2, ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (tc.arguments != null) ...[ - Text( - 'Arguments', - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: theme.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: Spacing.xxs), - SelectableText( - pretty(tc.arguments), - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: theme.textSecondary, - fontFamily: AppTypography.monospaceFontFamily, - height: 1.35, - ), - ), - const SizedBox(height: Spacing.sm), - ], - - if (tc.result != null) ...[ - Text( - 'Result', - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: theme.textSecondary, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: Spacing.xxs), - SelectableText( - pretty(tc.result), - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: theme.textSecondary, - fontFamily: AppTypography.monospaceFontFamily, - height: 1.35, - ), - ), - ], - ], - ), ), - crossFadeState: isExpanded - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: const Duration(milliseconds: 200), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (tc.arguments != null) ...[ + Text( + 'Arguments', + style: TextStyle( + fontSize: AppTypography.labelSmall, + color: theme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + SelectableText( + pretty(tc.arguments), + style: TextStyle( + fontSize: AppTypography.bodySmall, + color: theme.textSecondary, + fontFamily: AppTypography.monospaceFontFamily, + height: 1.35, + ), + ), + if (tc.result != null) const SizedBox(height: Spacing.xs), + ], + + if (tc.result != null) ...[ + Text( + 'Result', + style: TextStyle( + fontSize: AppTypography.labelSmall, + color: theme.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + SelectableText( + pretty(tc.result), + style: TextStyle( + fontSize: AppTypography.bodySmall, + color: theme.textSecondary, + fontFamily: AppTypography.monospaceFontFamily, + height: 1.35, + ), + ), + ], + ], + ), ), - ], - ), + crossFadeState: isExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), + ), + ], ), ), ); @@ -532,6 +534,7 @@ class _AssistantMessageWidgetState extends ConsumerState Widget _buildSegmentedContent() { final children = []; bool firstToolSpacerAdded = false; + bool hasNonTextSegment = false; int idx = 0; for (final seg in _segments) { if (seg.isTool && seg.toolCall != null) { @@ -541,9 +544,16 @@ class _AssistantMessageWidgetState extends ConsumerState firstToolSpacerAdded = true; } children.add(_buildToolCallTile(seg.toolCall!)); + hasNonTextSegment = true; } else if (seg.isReasoning && seg.reasoning != null) { children.add(_buildReasoningTile(seg.reasoning!, idx)); + hasNonTextSegment = true; } else if ((seg.text ?? '').trim().isNotEmpty) { + // Add spacing before text content if it follows non-text segments + if (hasNonTextSegment) { + children.add(const SizedBox(height: Spacing.sm)); + hasNonTextSegment = false; + } children.add(_buildEnhancedMarkdownContent(seg.text!)); } idx++; @@ -766,11 +776,9 @@ class _AssistantMessageWidgetState extends ConsumerState ], if (hasStatusTimeline) ...[ - StatusHistoryTimeline( + StreamingStatusWidget( updates: visibleStatusHistory, - initiallyExpanded: widget.message.content - .trim() - .isEmpty, + isStreaming: widget.isStreaming, ), const SizedBox(height: Spacing.xs), ], @@ -876,6 +884,7 @@ class _AssistantMessageWidgetState extends ConsumerState content: processedContent, isStreaming: widget.isStreaming, onTapLink: (url, _) => _launchUri(url), + sources: widget.message.sources, imageBuilderOverride: (uri, title, alt) { // Route markdown images through the enhanced image widget so they // get caching, auth headers, fullscreen viewer, and sharing. @@ -1307,10 +1316,12 @@ class _AssistantMessageWidgetState extends ConsumerState return ChatActionButton(icon: icon, label: label, onTap: onTap); } - // Reasoning tile rendered inline at the position it appears + // Reasoning tile rendered inline - minimal design inspired by OpenWebUI Widget _buildReasoningTile(ReasoningEntry rc, int index) { final isExpanded = _expandedReasoning.contains(index); final theme = context.conduitTheme; + // Show shimmer when streaming and this is an active/incomplete reasoning + final showShimmer = widget.isStreaming && rc.duration == 0; String headerText() { final l10n = AppLocalizations.of(context)!; @@ -1330,9 +1341,47 @@ class _AssistantMessageWidgetState extends ConsumerState return rc.summary; } + Widget buildHeader() { + final headerWidget = Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + isExpanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 14, + color: theme.textPrimary.withValues(alpha: 0.8), + ), + const SizedBox(width: 2), + Flexible( + child: Text( + headerText(), + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: AppTypography.bodySmall, + color: theme.textPrimary.withValues(alpha: 0.8), + height: 1.3, + ), + ), + ), + ], + ); + + if (showShimmer) { + return headerWidget + .animate(onPlay: (controller) => controller.repeat()) + .shimmer( + duration: 1500.ms, + color: theme.shimmerHighlight.withValues(alpha: 0.6), + ); + } + return headerWidget; + } + return Padding( padding: const EdgeInsets.only(bottom: Spacing.xs), - child: InkWell( + child: GestureDetector( onTap: () { setState(() { if (isExpanded) { @@ -1342,85 +1391,49 @@ class _AssistantMessageWidgetState extends ConsumerState } }); }, - borderRadius: BorderRadius.circular(AppBorderRadius.small), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - color: theme.surfaceContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(AppBorderRadius.small), - border: Border.all( - color: theme.dividerColor.withValues(alpha: 0.5), - width: BorderWidth.thin, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - isExpanded - ? Icons.expand_less_rounded - : Icons.expand_more_rounded, - size: 16, - color: theme.textSecondary, - ), - const SizedBox(width: Spacing.xs), - Icon( - Icons.psychology_outlined, - size: 14, - color: theme.buttonPrimary, - ), - const SizedBox(width: Spacing.xs), - Flexible( - child: Text( - headerText(), - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: theme.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), + behavior: HitTestBehavior.opaque, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Minimal header - just text with chevron + buildHeader(), - AnimatedCrossFade( - firstChild: const SizedBox.shrink(), - secondChild: Container( - margin: const EdgeInsets.only(top: Spacing.sm), - padding: const EdgeInsets.all(Spacing.sm), - decoration: BoxDecoration( - color: theme.surfaceContainer.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(AppBorderRadius.small), - border: Border.all( - color: theme.dividerColor.withValues(alpha: 0.5), - width: BorderWidth.thin, - ), - ), - child: SelectableText( - rc.cleanedReasoning, - style: TextStyle( - fontSize: AppTypography.bodySmall, - color: theme.textSecondary, - fontFamily: AppTypography.monospaceFontFamily, - height: 1.4, + // Expanded content - subtle background only when shown + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: Container( + margin: const EdgeInsets.only(top: Spacing.xs, left: 16), + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: theme.surfaceContainer.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(4), + border: Border( + left: BorderSide( + color: theme.dividerColor.withValues(alpha: 0.4), + width: 2, ), ), ), - crossFadeState: isExpanded - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: const Duration(milliseconds: 200), + child: SelectableText( + rc.cleanedReasoning, + style: TextStyle( + fontSize: AppTypography.bodySmall, + color: theme.textSecondary, + fontFamily: AppTypography.monospaceFontFamily, + height: 1.4, + ), + ), ), - ], - ), + crossFadeState: isExpanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), + ), + ], ), ), ); @@ -1455,649 +1468,6 @@ String _buildTtsPlainTextWorker(Map payload) { return result; } -class StatusHistoryTimeline extends StatefulWidget { - const StatusHistoryTimeline({ - super.key, - required this.updates, - this.initiallyExpanded = false, - }); - - final List updates; - final bool initiallyExpanded; - - @override - State createState() => _StatusHistoryTimelineState(); -} - -class _StatusHistoryTimelineState extends State { - late bool _expanded; - - @override - void initState() { - super.initState(); - _expanded = widget.initiallyExpanded; - } - - @override - void didUpdateWidget(covariant StatusHistoryTimeline oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.initiallyExpanded != oldWidget.initiallyExpanded) { - _expanded = widget.initiallyExpanded; - } - } - - @override - Widget build(BuildContext context) { - final theme = context.conduitTheme; - final visible = widget.updates - .where((update) => update.hidden != true) - .toList(); - if (visible.isEmpty) { - return const SizedBox.shrink(); - } - - final previous = visible.length > 1 - ? visible.sublist(0, visible.length - 1) - : const []; - final current = visible.last; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AnimatedSize( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - child: !_expanded || previous.isEmpty - ? const SizedBox.shrink() - : Column( - children: previous - .map( - (update) => _TimelineRow( - update: update, - theme: theme, - showTail: true, - forceDone: true, - ), - ) - .toList(growable: false), - ), - ), - _TimelineRow( - update: current, - theme: theme, - showTail: false, - forceDone: current.done == true ? true : null, - onTap: previous.isNotEmpty - ? () => setState(() => _expanded = !_expanded) - : null, - showChevron: previous.isNotEmpty, - expanded: _expanded, - ), - ], - ); - } -} - -class _TimelineRow extends StatelessWidget { - const _TimelineRow({ - required this.update, - required this.theme, - required this.showTail, - this.forceDone, - this.onTap, - this.showChevron = false, - this.expanded = false, - }); - - final ChatStatusUpdate update; - final ConduitThemeExtension theme; - final bool showTail; - final bool? forceDone; - final VoidCallback? onTap; - final bool showChevron; - final bool expanded; - - bool get _isPending { - final resolved = forceDone ?? update.done; - return resolved != true; - } - - @override - Widget build(BuildContext context) { - final resolved = forceDone ?? update.done; - final dotColor = _indicatorColor(theme, resolved); - final content = _StatusHistoryContent( - update: update, - theme: theme, - isPending: _isPending, - ); - - final row = IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _TimelineIndicator( - color: dotColor, - showTail: showTail, - animatePulse: _isPending, - theme: theme, - ), - const SizedBox(width: Spacing.xs), - Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: content), - if (showChevron) - Padding( - padding: const EdgeInsets.only(left: Spacing.xs, top: 4), - child: AnimatedRotation( - turns: expanded ? 0.5 : 0.0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - child: Icon( - Icons.expand_more, - size: 16, - color: theme.textSecondary.withValues(alpha: 0.6), - ), - ), - ), - ], - ), - ), - ], - ), - ); - - final wrapped = Padding( - padding: const EdgeInsets.symmetric(vertical: Spacing.xxs), - child: row, - ); - - if (onTap == null) { - return wrapped; - } - - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(AppBorderRadius.small), - child: wrapped, - ); - } - - Color _indicatorColor(ConduitThemeExtension theme, bool? done) { - if (done == false) { - return theme.iconPrimary; - } - if (done == true) { - return theme.success; - } - return theme.iconSecondary.withValues(alpha: 0.7); - } -} - -class _TimelineIndicator extends StatefulWidget { - const _TimelineIndicator({ - required this.color, - required this.showTail, - required this.animatePulse, - required this.theme, - }); - - final Color color; - final bool showTail; - final bool animatePulse; - final ConduitThemeExtension theme; - - @override - State<_TimelineIndicator> createState() => _TimelineIndicatorState(); -} - -class _TimelineIndicatorState extends State<_TimelineIndicator> - with SingleTickerProviderStateMixin { - late final AnimationController _controller; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - duration: const Duration(milliseconds: 1200), - vsync: this, - ); - if (widget.animatePulse) { - _controller.repeat(); - } - } - - @override - void didUpdateWidget(covariant _TimelineIndicator oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.animatePulse && !_controller.isAnimating) { - _controller.repeat(); - } else if (!widget.animatePulse && _controller.isAnimating) { - _controller.stop(); - _controller.reset(); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final lineColor = widget.theme.dividerColor.withValues(alpha: 0.5); - - return SizedBox( - width: 18, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - SizedBox( - height: 16, - width: 16, - child: Stack( - alignment: Alignment.center, - children: [ - if (widget.animatePulse) - FadeTransition( - opacity: _controller.drive( - Tween(begin: 0.45, end: 0.0), - ), - child: ScaleTransition( - scale: _controller.drive( - Tween( - begin: 1.0, - end: 2.2, - ).chain(CurveTween(curve: Curves.easeOutCubic)), - ), - child: DecoratedBox( - decoration: BoxDecoration( - color: widget.color.withValues(alpha: 0.18), - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - DecoratedBox( - decoration: BoxDecoration( - color: widget.color, - borderRadius: BorderRadius.circular(8), - ), - child: const SizedBox.square(dimension: 8), - ), - ], - ), - ), - if (widget.showTail) - Expanded( - child: Align( - alignment: Alignment.topCenter, - child: Container( - margin: const EdgeInsets.only(top: Spacing.xxs), - width: 1, - color: lineColor, - ), - ), - ), - ], - ), - ); - } -} - -class _StatusHistoryContent extends StatelessWidget { - const _StatusHistoryContent({ - required this.update, - required this.theme, - required this.isPending, - }); - - final ChatStatusUpdate update; - final ConduitThemeExtension theme; - final bool isPending; - - @override - Widget build(BuildContext context) { - final description = _resolveStatusDescription(update); - final queries = _collectQueries(update); - final linkChips = _buildLinkChips(update); - - final headlineStyle = TextStyle( - fontSize: AppTypography.bodySmall, - fontWeight: FontWeight.w600, - height: 1.3, - color: isPending ? theme.textPrimary : theme.textSecondary, - ); - - final content = [Text(description, style: headlineStyle)]; - - if (update.count != null && update.action != 'sources_retrieved') { - content.add( - Text( - update.count == 1 - ? 'Retrieved 1 source' - : 'Retrieved ${update.count} sources', - style: TextStyle( - fontSize: AppTypography.labelSmall, - color: theme.textSecondary.withValues(alpha: 0.75), - ), - ), - ); - } - - if (queries.isNotEmpty) { - content.add(_QueryPills(queries: queries, theme: theme)); - } - - if (linkChips.isNotEmpty) { - content.add(_LinkPills(items: linkChips, theme: theme)); - } - - final timestamp = update.occurredAt; - if (timestamp != null) { - content.add( - Text( - _relativeTime(timestamp), - style: TextStyle( - fontSize: AppTypography.labelSmall, - color: theme.textSecondary.withValues(alpha: 0.55), - ), - ), - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var i = 0; i < content.length; i++) - Padding( - padding: EdgeInsets.only(top: i == 0 ? 0 : Spacing.xxs), - child: content[i], - ), - ], - ); - } -} - -class _QueryPills extends StatelessWidget { - const _QueryPills({required this.queries, required this.theme}); - - final List queries; - final ConduitThemeExtension theme; - - @override - Widget build(BuildContext context) { - final iconColor = theme.iconSecondary; - final textStyle = TextStyle( - fontSize: AppTypography.labelSmall, - color: theme.textSecondary, - ); - - return Wrap( - spacing: Spacing.xs, - runSpacing: Spacing.xs, - children: queries - .map( - (query) => InkWell( - onTap: () => _launchUri( - 'https://www.google.com/search?q=${Uri.encodeComponent(query)}', - ), - borderRadius: BorderRadius.circular(AppBorderRadius.small), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - color: theme.surfaceContainer.withValues(alpha: 0.25), - borderRadius: BorderRadius.circular(AppBorderRadius.small), - border: Border.all( - color: theme.dividerColor.withValues(alpha: 0.3), - width: BorderWidth.thin, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - Icons.search, - size: AppTypography.labelSmall + 2, - color: iconColor, - ), - const SizedBox(width: 6), - Flexible( - child: Text( - query, - style: textStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ), - ) - .toList(growable: false), - ); - } -} - -class _LinkPills extends StatelessWidget { - const _LinkPills({required this.items, required this.theme}); - - final List<_LinkChipData> items; - final ConduitThemeExtension theme; - - @override - Widget build(BuildContext context) { - final iconColor = theme.iconPrimary; - final textStyle = TextStyle( - fontSize: AppTypography.labelSmall, - color: theme.buttonPrimary, - fontWeight: FontWeight.w600, - ); - - return Wrap( - spacing: Spacing.xs, - runSpacing: Spacing.xs, - children: items - .map( - (item) => InkWell( - onTap: item.url != null ? () => _launchUri(item.url!) : null, - borderRadius: BorderRadius.circular(AppBorderRadius.small), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.sm, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - color: theme.surfaceContainer.withValues(alpha: 0.25), - borderRadius: BorderRadius.circular(AppBorderRadius.small), - border: Border.all( - color: theme.dividerColor.withValues(alpha: 0.3), - width: BorderWidth.thin, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - item.icon, - size: AppTypography.labelSmall + 2, - color: iconColor, - ), - const SizedBox(width: 6), - Flexible( - child: Text( - item.label, - style: textStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (item.url != null) ...[ - const SizedBox(width: 4), - Icon( - Icons.open_in_new, - size: 11, - color: iconColor.withValues(alpha: 0.7), - ), - ], - ], - ), - ), - ), - ) - .toList(growable: false), - ); - } -} - -class _LinkChipData { - const _LinkChipData({required this.label, required this.icon, this.url}); - - final String label; - final IconData icon; - final String? url; -} - -List _collectQueries(ChatStatusUpdate update) { - final merged = []; - for (final query in update.queries) { - final trimmed = query.trim(); - if (trimmed.isNotEmpty) { - merged.add(trimmed); - } - } - final single = update.query?.trim(); - if (single != null && single.isNotEmpty && !merged.contains(single)) { - merged.add(single); - } - return merged; -} - -List<_LinkChipData> _buildLinkChips(ChatStatusUpdate update) { - final chips = <_LinkChipData>[]; - if (update.items.isNotEmpty) { - for (final item in update.items) { - final title = item.title?.trim(); - final label = (title != null && title.isNotEmpty) - ? title - : (item.link != null ? _extractHost(item.link!) : 'Result'); - chips.add( - _LinkChipData(label: label, icon: Icons.public, url: item.link), - ); - } - } else if (update.urls.isNotEmpty) { - for (final url in update.urls) { - chips.add( - _LinkChipData(label: _extractHost(url), icon: Icons.public, url: url), - ); - } - } - return chips; -} - -String _resolveStatusDescription(ChatStatusUpdate update) { - final description = update.description?.trim(); - final action = update.action?.trim(); - - if (action == 'knowledge_search' && update.query?.isNotEmpty == true) { - return 'Searching knowledge for "${update.query}"'; - } - - if (action == 'web_search_queries_generated' || - action == 'queries_generated') { - return 'Searching'; - } - - if (action == 'sources_retrieved') { - final count = update.count; - if (count == null) { - return 'Retrieved sources'; - } - if (count == 0) { - return 'No sources found'; - } - if (count == 1) { - return 'Retrieved 1 source'; - } - return 'Retrieved $count sources'; - } - - if (description != null && description.isNotEmpty) { - return _replaceStatusPlaceholders(description, update); - } - - if (action != null && action.isNotEmpty) { - return action.replaceAll('_', ' '); - } - - return 'Processing'; -} - -String _replaceStatusPlaceholders(String template, ChatStatusUpdate update) { - var result = template; - - if (result.contains('{{count}}')) { - final fallback = update.count ?? _inferCount(update); - result = result.replaceAll( - '{{count}}', - fallback != null ? fallback.toString() : 'multiple', - ); - } - - if (result.contains('{{searchQuery}}')) { - final query = update.query?.trim(); - if (query != null && query.isNotEmpty) { - result = result.replaceAll('{{searchQuery}}', query); - } - } - - return result; -} - -int? _inferCount(ChatStatusUpdate update) { - if (update.urls.isNotEmpty) { - return update.urls.length; - } - if (update.items.isNotEmpty) { - return update.items.length; - } - if (update.queries.isNotEmpty) { - return update.queries.length; - } - return null; -} - -String _relativeTime(DateTime timestamp) { - final local = timestamp.toLocal(); - final now = DateTime.now(); - final difference = now.difference(local); - if (difference.inMinutes < 1) { - return 'Just now'; - } - if (difference.inHours < 1) { - final minutes = difference.inMinutes; - return minutes == 1 ? '1 minute ago' : '$minutes minutes ago'; - } - return '${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}'; -} - -String _extractHost(String url) { - final uri = Uri.tryParse(url); - if (uri == null || uri.host.isEmpty) { - return url; - } - return uri.host; -} - class CodeExecutionListView extends StatelessWidget { const CodeExecutionListView({super.key, required this.executions}); diff --git a/lib/features/chat/widgets/sources/openwebui_sources.dart b/lib/features/chat/widgets/sources/openwebui_sources.dart index 808051e..72bc4e0 100644 --- a/lib/features/chat/widgets/sources/openwebui_sources.dart +++ b/lib/features/chat/widgets/sources/openwebui_sources.dart @@ -68,8 +68,8 @@ class _OpenWebUISourcesWidgetState extends State { splashColor: theme.surfaceContainer.withValues(alpha: 0.2), child: Container( padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 8, + horizontal: 10, + vertical: 5, ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), @@ -196,19 +196,12 @@ class _OpenWebUISourcesWidgetState extends State { // Debug: debugPrint('Building source item $index: $displayText'); - // Determine display text + // Determine display text - for URL sources, show just the URL String displayText; - String? title = source.title; - - // If no direct title, check metadata - if ((title == null || title.isEmpty) && source.metadata != null) { - title = source.metadata!['title']?.toString(); - } - - if (title != null && title.isNotEmpty) { - displayText = title; - } else if (isUrl) { - displayText = _extractDomain(url); + if (isUrl) { + displayText = url; + } else if (source.title != null && source.title!.isNotEmpty) { + displayText = source.title!; } else if (source.id != null && source.id!.isNotEmpty) { displayText = source.id!; } else { diff --git a/lib/features/chat/widgets/streaming_status_widget.dart b/lib/features/chat/widgets/streaming_status_widget.dart new file mode 100644 index 0000000..6dfcee4 --- /dev/null +++ b/lib/features/chat/widgets/streaming_status_widget.dart @@ -0,0 +1,539 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +import '../../../core/models/chat_message.dart'; +import '../../../core/utils/debug_logger.dart'; +import '../../../shared/theme/theme_extensions.dart'; + +/// A minimal, unobtrusive streaming status widget inspired by OpenWebUI. +/// Displays live status updates during AI response generation without +/// drawing focus away from the actual response content. +class StreamingStatusWidget extends StatefulWidget { + const StreamingStatusWidget({ + super.key, + required this.updates, + this.isStreaming = true, + }); + + final List updates; + final bool isStreaming; + + @override + State createState() => _StreamingStatusWidgetState(); +} + +class _StreamingStatusWidgetState extends State { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final visible = widget.updates + .where((u) => u.hidden != true) + .toList(growable: false); + if (visible.isEmpty) return const SizedBox.shrink(); + + final current = visible.last; + final hasPrevious = visible.length > 1; + final isPending = current.done != true && widget.isStreaming; + + return GestureDetector( + onTap: hasPrevious ? () => setState(() => _expanded = !_expanded) : null, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.only(bottom: Spacing.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Current status (always visible) - minimal text only + _MinimalStatusRow( + update: current, + isPending: isPending, + hasPrevious: hasPrevious, + isExpanded: _expanded, + ), + + // Expanded history timeline + if (_expanded && hasPrevious) + _MinimalHistoryTimeline( + updates: visible, + isStreaming: widget.isStreaming, + ), + ], + ), + ), + ); + } +} + +/// Minimal status row - just text with optional chevron. +class _MinimalStatusRow extends StatelessWidget { + const _MinimalStatusRow({ + required this.update, + required this.isPending, + required this.hasPrevious, + required this.isExpanded, + }); + + final ChatStatusUpdate update; + final bool isPending; + final bool hasPrevious; + final bool isExpanded; + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + final queries = _collectQueries(update); + final links = _collectLinks(update); + final description = _resolveStatusDescription(update); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Main status text + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (hasPrevious) ...[ + Icon( + isExpanded + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 14, + color: theme.textPrimary.withValues(alpha: 0.8), + ), + const SizedBox(width: 2), + ], + Flexible(child: _buildStatusText(context, description, isPending)), + ], + ), + + // Query pills (inline, compact) + if (queries.isNotEmpty && !isExpanded) ...[ + const SizedBox(height: Spacing.xxs), + _MinimalQueryChips(queries: queries), + ], + + // Source links (inline, compact) + if (links.isNotEmpty && !isExpanded) ...[ + const SizedBox(height: Spacing.xxs), + _MinimalSourceLinks(links: links), + ], + ], + ); + } + + Widget _buildStatusText( + BuildContext context, + String description, + bool isPending, + ) { + final theme = context.conduitTheme; + final baseColor = theme.textPrimary.withValues(alpha: 0.8); + final baseStyle = TextStyle( + fontSize: AppTypography.bodySmall, + color: baseColor, + height: 1.3, + ); + + if (!isPending) { + return Text(description, style: baseStyle, maxLines: 1); + } + + // Shimmer effect for pending state + return Text(description, style: baseStyle, maxLines: 1) + .animate(onPlay: (controller) => controller.repeat()) + .shimmer( + duration: 1500.ms, + color: theme.shimmerHighlight.withValues(alpha: 0.6), + ); + } +} + +/// Minimal timeline for expanded history - small dots like OpenWebUI. +class _MinimalHistoryTimeline extends StatelessWidget { + const _MinimalHistoryTimeline({ + required this.updates, + required this.isStreaming, + }); + + final List updates; + final bool isStreaming; + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + + return Padding( + padding: const EdgeInsets.only(top: Spacing.xs, left: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: updates.asMap().entries.map((entry) { + final index = entry.key; + final update = entry.value; + final isLast = index == updates.length - 1; + final isPending = isLast && update.done != true && isStreaming; + final description = _resolveStatusDescription(update); + final queries = _collectQueries(update); + final links = _collectLinks(update); + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Timeline dot and line + SizedBox( + width: 12, + child: Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 5), + width: 5, + height: 5, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.textSecondary.withValues(alpha: 0.6), + ), + ), + if (!isLast) + Expanded( + child: Container( + width: 0.5, + margin: const EdgeInsets.symmetric(vertical: 2), + color: theme.dividerColor.withValues(alpha: 0.4), + ), + ), + ], + ), + ), + const SizedBox(width: Spacing.xs), + // Content + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: Spacing.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildStatusText(context, description, isPending), + if (queries.isNotEmpty) ...[ + const SizedBox(height: 2), + _MinimalQueryChips(queries: queries), + ], + if (links.isNotEmpty) ...[ + const SizedBox(height: 2), + _MinimalSourceLinks(links: links), + ], + ], + ), + ), + ), + ], + ), + ); + }).toList(), + ), + ); + } + + Widget _buildStatusText( + BuildContext context, + String description, + bool isPending, + ) { + final theme = context.conduitTheme; + final baseColor = theme.textPrimary.withValues(alpha: 0.8); + final baseStyle = TextStyle( + fontSize: AppTypography.bodySmall, + color: baseColor, + height: 1.3, + ); + + if (!isPending) { + return Text(description, style: baseStyle); + } + + // Shimmer effect for pending state + return Text(description, style: baseStyle) + .animate(onPlay: (controller) => controller.repeat()) + .shimmer( + duration: 1500.ms, + color: theme.shimmerHighlight.withValues(alpha: 0.6), + ); + } +} + +/// Minimal query chips - smaller, less prominent. +class _MinimalQueryChips extends StatelessWidget { + const _MinimalQueryChips({required this.queries}); + + final List queries; + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + + return Wrap( + spacing: 4, + runSpacing: 4, + children: queries.asMap().entries.map((entry) { + final index = entry.key; + final query = entry.value; + return GestureDetector( + onTap: () => _launchSearch(query), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: theme.surfaceContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.search_rounded, + size: 11, + color: theme.textSecondary, + ), + const SizedBox(width: 3), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 150), + child: Text( + query, + style: TextStyle( + fontSize: AppTypography.labelSmall, + color: theme.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ).animate().fadeIn(duration: 150.ms, delay: (30 * index).ms), + ); + }).toList(), + ); + } + + void _launchSearch(String query) async { + final url = 'https://www.google.com/search?q=${Uri.encodeComponent(query)}'; + try { + await launchUrlString(url, mode: LaunchMode.externalApplication); + } catch (e) { + DebugLogger.log('Failed to launch search: $e', scope: 'status'); + } + } +} + +/// Minimal source links - smaller, less prominent. +class _MinimalSourceLinks extends StatelessWidget { + const _MinimalSourceLinks({required this.links}); + + final List<_LinkData> links; + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + final displayLinks = links.take(4).toList(); + final remaining = links.length - 4; + + return Wrap( + spacing: 4, + runSpacing: 4, + children: [ + ...displayLinks.asMap().entries.map((entry) { + final index = entry.key; + final link = entry.value; + final domain = _extractDomain(link.url); + + return GestureDetector( + onTap: () => _launchUrl(link.url), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: theme.surfaceContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: Image.network( + 'https://www.google.com/s2/favicons?sz=16&domain=$domain', + width: 12, + height: 12, + errorBuilder: (context, error, stackTrace) => Icon( + Icons.public_rounded, + size: 12, + color: theme.textSecondary, + ), + ), + ), + const SizedBox(width: 4), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 100), + child: Text( + link.title ?? domain, + style: TextStyle( + fontSize: AppTypography.labelSmall, + color: theme.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ).animate().fadeIn(duration: 150.ms, delay: (30 * index).ms), + ); + }), + if (remaining > 0) + Text( + '+$remaining', + style: TextStyle( + fontSize: AppTypography.labelSmall, + color: theme.textSecondary, + ), + ).animate().fadeIn( + duration: 150.ms, + delay: (30 * displayLinks.length).ms, + ), + ], + ); + } + + void _launchUrl(String url) async { + try { + await launchUrlString(url, mode: LaunchMode.externalApplication); + } catch (e) { + DebugLogger.log('Failed to launch URL: $e', scope: 'status'); + } + } + + String _extractDomain(String url) { + final uri = Uri.tryParse(url); + if (uri == null || uri.host.isEmpty) return url; + var host = uri.host; + if (host.startsWith('www.')) host = host.substring(4); + return host; + } +} + +// Helper classes and functions + +class _LinkData { + const _LinkData({required this.url, this.title}); + final String url; + final String? title; +} + +List _collectQueries(ChatStatusUpdate update) { + final merged = []; + for (final query in update.queries) { + final trimmed = query.trim(); + if (trimmed.isNotEmpty && !merged.contains(trimmed)) { + merged.add(trimmed); + } + } + final single = update.query?.trim(); + if (single != null && single.isNotEmpty && !merged.contains(single)) { + merged.add(single); + } + return merged; +} + +List<_LinkData> _collectLinks(ChatStatusUpdate update) { + final links = <_LinkData>[]; + + for (final item in update.items) { + final url = item.link; + if (url != null && url.isNotEmpty) { + links.add(_LinkData(url: url, title: item.title)); + } + } + + for (final url in update.urls) { + if (url.isNotEmpty && !links.any((l) => l.url == url)) { + links.add(_LinkData(url: url)); + } + } + + return links; +} + +String _resolveStatusDescription(ChatStatusUpdate update) { + final description = update.description?.trim(); + final action = update.action?.trim(); + + if (action == 'knowledge_search' && update.query?.isNotEmpty == true) { + return 'Searching Knowledge for "${update.query}"'; + } + + if (action == 'web_search_queries_generated' && update.queries.isNotEmpty) { + return 'Searching'; + } + + if (action == 'queries_generated' && update.queries.isNotEmpty) { + return 'Querying'; + } + + if (action == 'sources_retrieved' && update.count != null) { + final count = update.count!; + if (count == 0) return 'No sources found'; + if (count == 1) return 'Retrieved 1 source'; + return 'Retrieved $count sources'; + } + + if (description != null && description.isNotEmpty) { + if (description == 'Generating search query') { + return 'Generating search query'; + } + if (description == 'No search query generated') { + return 'No search query generated'; + } + if (description == 'Searching the web') { + return 'Searching the web'; + } + return _replaceStatusPlaceholders(description, update); + } + + if (action != null && action.isNotEmpty) { + return action.replaceAll('_', ' ').capitalize(); + } + + return 'Processing'; +} + +String _replaceStatusPlaceholders(String template, ChatStatusUpdate update) { + var result = template; + + if (result.contains('{{count}}')) { + final count = update.count ?? update.urls.length + update.items.length; + result = result.replaceAll( + '{{count}}', + count > 0 ? count.toString() : 'multiple', + ); + } + + if (result.contains('{{searchQuery}}')) { + final query = update.query?.trim(); + if (query != null && query.isNotEmpty) { + result = result.replaceAll('{{searchQuery}}', query); + } + } + + return result; +} + +extension _StringExtension on String { + String capitalize() { + if (isEmpty) return this; + return '${this[0].toUpperCase()}${substring(1)}'; + } +} diff --git a/lib/shared/widgets/markdown/citation_badge.dart b/lib/shared/widgets/markdown/citation_badge.dart new file mode 100644 index 0000000..49b1ca0 --- /dev/null +++ b/lib/shared/widgets/markdown/citation_badge.dart @@ -0,0 +1,355 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../core/models/chat_message.dart'; +import '../../theme/theme_extensions.dart'; + +/// Helper utilities for working with source references. +class SourceHelper { + const SourceHelper._(); + + /// Extracts a URL from a source reference, checking multiple fields. + static String? getSourceUrl(ChatSourceReference source) { + String? url = source.url; + if (url == null || url.isEmpty) { + if (source.id != null && source.id!.startsWith('http')) { + url = source.id; + } else if (source.title != null && source.title!.startsWith('http')) { + url = source.title; + } else if (source.metadata != null) { + url = + source.metadata!['url']?.toString() ?? + source.metadata!['source']?.toString() ?? + source.metadata!['link']?.toString(); + } + } + return (url != null && url.startsWith('http')) ? url : null; + } + + /// Gets a display title for a source. + /// + /// For web sources (with URLs), shows the domain name like "wikipedia.org". + /// This matches OpenWebUI's behavior where web search results show domains. + static String getSourceTitle(ChatSourceReference source, int index) { + // For web sources, prefer showing the URL domain + final url = getSourceUrl(source); + if (url != null) { + return extractDomain(url); + } + + // If title is a URL, extract domain + if (source.title != null && source.title!.isNotEmpty) { + final title = source.title!; + if (title.startsWith('http')) { + return extractDomain(title); + } + return title; + } + + // Check if ID is a URL + if (source.id != null && source.id!.isNotEmpty) { + final id = source.id!; + if (id.startsWith('http')) { + return extractDomain(id); + } + return id; + } + + return 'Source ${index + 1}'; + } + + /// Extracts the domain from a URL for display. + static String extractDomain(String url) { + try { + final uri = Uri.parse(url); + String domain = uri.host; + if (domain.startsWith('www.')) { + domain = domain.substring(4); + } + return domain; + } catch (e) { + return url; + } + } + + /// Formats a title for display, truncating if needed. + /// Matches OpenWebUI's getDisplayTitle behavior. + static String formatDisplayTitle(String title) { + if (title.isEmpty) return 'N/A'; + if (title.length > 25) { + return '${title.substring(0, 12)}…${title.substring(title.length - 8)}'; + } + return title; + } + + /// Launches a URL in an external browser. + static Future launchSourceUrl(String url) async { + try { + final uri = Uri.parse(url); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } catch (e) { + // Handle error silently + } + } +} + +/// A compact inline citation badge showing source domain/title. +/// +/// Uses the app's design system for consistency with other chips and badges. +class CitationBadge extends StatelessWidget { + const CitationBadge({ + super.key, + required this.sourceIndex, + required this.sources, + this.onTap, + }); + + /// 0-based index into the sources list. + final int sourceIndex; + + /// List of sources from the message. + final List sources; + + /// Optional tap callback. If null, will try to launch URL. + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + + // Check if index is valid + if (sourceIndex < 0 || sourceIndex >= sources.length) { + return const SizedBox.shrink(); + } + + final source = sources[sourceIndex]; + final url = SourceHelper.getSourceUrl(source); + final title = SourceHelper.getSourceTitle(source, sourceIndex); + final displayTitle = SourceHelper.formatDisplayTitle(title); + + return Tooltip( + message: title, + preferBelow: false, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + if (onTap != null) { + onTap!(); + } else if (url != null) { + SourceHelper.launchSourceUrl(url); + } + }, + borderRadius: BorderRadius.circular(AppBorderRadius.chip), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xxs, + ), + margin: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: theme.surfaceContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(AppBorderRadius.chip), + border: Border.all( + color: theme.cardBorder.withValues(alpha: 0.5), + width: BorderWidth.thin, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.link_rounded, + size: 10, + color: theme.textSecondary.withValues(alpha: 0.7), + ), + const SizedBox(width: Spacing.xxs), + Text( + displayTitle, + style: TextStyle( + fontSize: AppTypography.labelSmall, + fontWeight: FontWeight.w500, + color: theme.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ), + ); + } +} + +/// A grouped citation badge for multiple sources like [1,2,3]. +/// +/// Shows first source with +N indicator for additional sources. +class CitationBadgeGroup extends StatelessWidget { + const CitationBadgeGroup({ + super.key, + required this.sourceIndices, + required this.sources, + this.onSourceTap, + }); + + /// 0-based indices into the sources list. + final List sourceIndices; + + /// List of sources from the message. + final List sources; + + /// Optional callback when a source is tapped. + final void Function(int index)? onSourceTap; + + @override + Widget build(BuildContext context) { + if (sourceIndices.isEmpty) { + return const SizedBox.shrink(); + } + + // For single citation, use simple badge + if (sourceIndices.length == 1) { + return CitationBadge( + sourceIndex: sourceIndices.first, + sources: sources, + onTap: onSourceTap != null + ? () => onSourceTap!(sourceIndices.first) + : null, + ); + } + + final theme = context.conduitTheme; + + // Get first valid source for display + final firstIndex = sourceIndices.first; + final isFirstValid = firstIndex >= 0 && firstIndex < sources.length; + + if (!isFirstValid) { + return const SizedBox.shrink(); + } + + final firstSource = sources[firstIndex]; + final firstTitle = SourceHelper.getSourceTitle(firstSource, firstIndex); + final displayTitle = SourceHelper.formatDisplayTitle(firstTitle); + final additionalCount = sourceIndices.length - 1; + + return PopupMenuButton( + tooltip: 'View sources', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + position: PopupMenuPosition.under, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + color: theme.surfaceBackground, + surfaceTintColor: Colors.transparent, + elevation: Elevation.medium, + itemBuilder: (context) { + return sourceIndices + .map((index) { + final isValid = index >= 0 && index < sources.length; + if (!isValid) return null; + + final source = sources[index]; + final title = SourceHelper.getSourceTitle(source, index); + + return PopupMenuItem( + value: index, + height: 40, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.link_rounded, + size: 14, + color: theme.textSecondary.withValues(alpha: 0.7), + ), + const SizedBox(width: Spacing.sm), + Flexible( + child: Text( + SourceHelper.formatDisplayTitle(title), + style: TextStyle( + fontSize: AppTypography.bodySmall, + fontWeight: FontWeight.w500, + color: theme.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }) + .whereType>() + .toList(); + }, + onSelected: (index) { + if (onSourceTap != null) { + onSourceTap!(index); + } else if (index >= 0 && index < sources.length) { + final url = SourceHelper.getSourceUrl(sources[index]); + if (url != null) { + SourceHelper.launchSourceUrl(url); + } + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xxs, + ), + margin: const EdgeInsets.symmetric(horizontal: 2), + decoration: BoxDecoration( + color: theme.surfaceContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(AppBorderRadius.chip), + border: Border.all( + color: theme.cardBorder.withValues(alpha: 0.5), + width: BorderWidth.thin, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.link_rounded, + size: 10, + color: theme.textSecondary.withValues(alpha: 0.7), + ), + const SizedBox(width: Spacing.xxs), + Text( + displayTitle, + style: TextStyle( + fontSize: AppTypography.labelSmall, + fontWeight: FontWeight.w500, + color: theme.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(width: Spacing.xxs), + Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: theme.buttonPrimary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(AppBorderRadius.small), + ), + child: Text( + '+$additionalCount', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: theme.buttonPrimary, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/markdown/code_block_header.dart b/lib/shared/widgets/markdown/code_block_header.dart deleted file mode 100644 index 000d306..0000000 --- a/lib/shared/widgets/markdown/code_block_header.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; -import '../../theme/theme_extensions.dart'; - -class CodeBlockHeader extends StatelessWidget { - const CodeBlockHeader({ - super.key, - required this.language, - required this.onCopy, - }); - - final String language; - final VoidCallback onCopy; - - @override - Widget build(BuildContext context) { - final theme = context.conduitTheme; - final materialTheme = Theme.of(context); - final isDark = materialTheme.brightness == Brightness.dark; - final label = language.isEmpty ? 'plaintext' : language; - - // Match GitHub/Atom theme colors - final backgroundColor = isDark - ? const Color(0xFF282c34) // Atom One Dark header - : const Color(0xFFf6f8fa); // GitHub light header - final textColor = isDark - ? const Color(0xFF9da5b4) // Muted text for dark - : const Color(0xFF57606a); // GitHub gray for light - - return Container( - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.xs, - ), - decoration: BoxDecoration( - color: backgroundColor, - border: Border( - bottom: BorderSide( - color: theme.cardBorder.withValues(alpha: 0.15), - width: 1, - ), - ), - ), - child: Row( - children: [ - Text( - label, - style: AppTypography.codeStyle.copyWith( - color: textColor, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - const Spacer(), - Material( - color: Colors.transparent, - child: InkWell( - onTap: onCopy, - borderRadius: BorderRadius.circular(4), - child: Padding( - padding: const EdgeInsets.all(6), - child: Icon( - Icons.content_copy_rounded, - size: 16, - color: textColor, - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/shared/widgets/markdown/inline_citation_text.dart b/lib/shared/widgets/markdown/inline_citation_text.dart new file mode 100644 index 0000000..04f24fc --- /dev/null +++ b/lib/shared/widgets/markdown/inline_citation_text.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import '../../../core/models/chat_message.dart'; +import '../../../core/utils/citation_parser.dart'; +import '../../theme/theme_extensions.dart'; +import 'citation_badge.dart'; + +/// Renders text with inline citation badges. +/// +/// Parses citation patterns like [1], [2,3] and renders them as clickable +/// badges showing source titles inline with the surrounding text. +class InlineCitationText extends StatelessWidget { + const InlineCitationText({ + super.key, + required this.text, + required this.sources, + this.style, + this.onSourceTap, + }); + + /// The text content that may contain citation patterns like [1], [2,3]. + final String text; + + /// Available sources for citation lookup. + final List sources; + + /// Base text style. + final TextStyle? style; + + /// Callback when a source badge is tapped. + final void Function(int sourceIndex)? onSourceTap; + + @override + Widget build(BuildContext context) { + final segments = CitationParser.parse(text); + + // If no citations found, render as plain text + if (segments == null || segments.isEmpty) { + return Text(text, style: style); + } + + final theme = context.conduitTheme; + final baseStyle = + style ?? + TextStyle( + color: theme.textPrimary, + fontSize: AppTypography.bodyMedium, + height: 1.45, + ); + + final spans = []; + + for (final segment in segments) { + if (segment.isText && segment.text != null) { + spans.add(TextSpan(text: segment.text, style: baseStyle)); + } else if (segment.isCitation && segment.citation != null) { + final citation = segment.citation!; + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: _buildCitationBadge(context, citation.sourceIds), + ), + ); + } + } + + return Text.rich(TextSpan(children: spans), style: baseStyle); + } + + Widget _buildCitationBadge(BuildContext context, List sourceIds) { + if (sourceIds.isEmpty) { + return const SizedBox.shrink(); + } + + // Convert to 0-based indices + final indices = sourceIds.map((id) => id - 1).toList(); + + if (indices.length == 1) { + return CitationBadge( + sourceIndex: indices.first, + sources: sources, + onTap: onSourceTap != null ? () => onSourceTap!(indices.first) : null, + ); + } + + return CitationBadgeGroup( + sourceIndices: indices, + sources: sources, + onSourceTap: onSourceTap, + ); + } +} diff --git a/lib/shared/widgets/markdown/markdown_config.dart b/lib/shared/widgets/markdown/markdown_config.dart index 58597b6..0c28f29 100644 --- a/lib/shared/widgets/markdown/markdown_config.dart +++ b/lib/shared/widgets/markdown/markdown_config.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:convert'; import 'package:cached_network_image/cached_network_image.dart'; @@ -6,17 +5,18 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_highlight/flutter_highlight.dart'; +import 'package:flutter_highlight/themes/atom-one-dark.dart'; +import 'package:flutter_highlight/themes/github.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_math_fork/flutter_math.dart'; -import 'package:markdown/markdown.dart' as md; +import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:webview_flutter/webview_flutter.dart'; import 'package:conduit/l10n/app_localizations.dart'; import '../../theme/color_tokens.dart'; import '../../theme/theme_extensions.dart'; -import 'code_block_header.dart'; import 'package:conduit/core/network/self_signed_image_cache_manager.dart'; import 'package:conduit/core/network/image_header_utils.dart'; @@ -29,146 +29,289 @@ class ConduitMarkdown { required BuildContext context, required String data, MarkdownLinkTapCallback? onTapLink, - bool selectable = true, - bool shrinkWrap = false, - ScrollPhysics? physics, Widget Function(Uri uri, String? title, String? alt)? imageBuilderOverride, }) { - return MarkdownBody( - data: data, - selectable: selectable, - shrinkWrap: shrinkWrap, - styleSheet: _buildStyleSheet(context), - builders: _buildCustomBuilders(context, onTapLink), - // Allow callers to override how markdown images render (e.g., to use - // EnhancedImageAttachment in assistant views). Fallback to default. - imageBuilder: (uri, title, alt) => imageBuilderOverride != null - ? imageBuilderOverride(uri, title, alt) - : _ImageBuilder(context).buildFromUri(uri), - extensionSet: md.ExtensionSet.gitHubFlavored, - onTapLink: onTapLink != null - ? (text, href, title) => onTapLink(href ?? '', title) - : null, - syntaxHighlighter: _CodeSyntaxHighlighter(context), - inlineSyntaxes: _buildInlineSyntaxes(), - blockSyntaxes: _buildBlockSyntaxes(), - ); - } - - static Widget buildBlock({ - required BuildContext context, - required String data, - MarkdownLinkTapCallback? onTapLink, - bool selectable = true, - Widget Function(Uri uri, String? title, String? alt)? imageBuilderOverride, - }) { - return build( - context: context, - data: data, - onTapLink: onTapLink, - selectable: selectable, - shrinkWrap: true, - imageBuilderOverride: imageBuilderOverride, - ); - } - - static MarkdownStyleSheet _buildStyleSheet(BuildContext context) { final theme = context.conduitTheme; final material = Theme.of(context); - final baseBody = AppTypography.bodyMediumStyle.copyWith( + final baseTextStyle = AppTypography.bodyMediumStyle.copyWith( color: theme.textPrimary, height: 1.45, ); - final secondaryBody = AppTypography.bodySmallStyle.copyWith( - color: theme.textSecondary, - height: 1.45, - ); - final codeBackground = theme.surfaceContainer.withValues(alpha: 0.55); - final borderColor = theme.cardBorder.withValues(alpha: 0.25); - - final tableBorderColor = theme.textSecondary.withValues(alpha: 0.5); - - return MarkdownStyleSheet( - p: baseBody, + final gptThemeData = GptMarkdownThemeData( + brightness: material.brightness, h1: AppTypography.headlineLargeStyle.copyWith(color: theme.textPrimary), h2: AppTypography.headlineMediumStyle.copyWith(color: theme.textPrimary), h3: AppTypography.headlineSmallStyle.copyWith(color: theme.textPrimary), h4: AppTypography.bodyLargeStyle.copyWith(color: theme.textPrimary), - h5: baseBody.copyWith(fontWeight: FontWeight.w600), - h6: secondaryBody, - a: baseBody.copyWith( - color: material.colorScheme.primary, - decoration: TextDecoration.underline, - decorationColor: material.colorScheme.primary, + h5: baseTextStyle.copyWith(fontWeight: FontWeight.w600), + h6: AppTypography.bodySmallStyle.copyWith(color: theme.textSecondary), + linkColor: material.colorScheme.primary, + linkHoverColor: material.colorScheme.primary.withValues(alpha: 0.7), + hrLineColor: theme.dividerColor, + hrLineThickness: BorderWidth.small, + highlightColor: material.colorScheme.primary.withValues(alpha: 0.2), + ); + + return GptMarkdownTheme( + gptThemeData: gptThemeData, + child: GptMarkdown( + data, + style: baseTextStyle, + useDollarSignsForLatex: true, + onLinkTap: onTapLink, + codeBuilder: (context, language, code, closed) => _buildCodeBlock( + context: context, + code: code, + language: language, + theme: theme, + ), + latexBuilder: (context, tex, textStyle, isInline) { + final math = Math.tex(tex, textStyle: textStyle); + if (isInline) return math; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: math, + ); + }, + imageBuilder: (context, url) { + final uri = Uri.tryParse(url); + if (uri == null) { + return _buildImageError(context, theme); + } + if (imageBuilderOverride != null) { + return imageBuilderOverride(uri, null, null); + } + return _buildImage(context, uri, theme); + }, ), - code: AppTypography.codeStyle.copyWith( - color: theme.codeText, - backgroundColor: codeBackground, - ), - codeblockDecoration: BoxDecoration( + ); + } + + static Widget _buildCodeBlock({ + required BuildContext context, + required String code, + required String language, + required ConduitThemeExtension theme, + }) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final normalizedLanguage = language.trim().isEmpty + ? 'plaintext' + : language.trim(); + + // Map common language aliases to highlight.js recognized names + final highlightLanguage = _mapLanguage(normalizedLanguage); + + // Use Atom One Dark for dark mode, GitHub for light mode + // These colors must match the highlight themes for visual consistency + final highlightTheme = isDark ? atomOneDarkTheme : githubTheme; + final codeBackground = isDark + ? const Color(0xFF282c34) // Atom One Dark + : const Color(0xFFF6F8FA); // GitHub light + + // Derive border color from background for consistency + final borderColor = isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.1); + + return Container( + margin: const EdgeInsets.symmetric(vertical: Spacing.sm), + decoration: BoxDecoration( color: codeBackground, borderRadius: BorderRadius.circular(AppBorderRadius.sm), - border: Border.all(color: borderColor, width: BorderWidth.micro), + border: Border.all(color: borderColor, width: BorderWidth.thin), + boxShadow: isDark + ? null + : [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], ), - codeblockPadding: const EdgeInsets.all(Spacing.sm), - blockquoteDecoration: BoxDecoration( - border: Border( - left: BorderSide( - color: material.colorScheme.primary.withValues(alpha: 0.35), - width: BorderWidth.small, + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _CodeBlockHeader( + language: normalizedLanguage, + backgroundColor: codeBackground, + borderColor: borderColor, + isDark: isDark, + onCopy: () async { + await Clipboard.setData(ClipboardData(text: code)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + final l10n = AppLocalizations.of(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l10n?.codeCopiedToClipboard ?? 'Code copied to clipboard.', + ), + ), + ); + }, + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.sm + 4, + ), + child: HighlightView( + code, + language: highlightLanguage, + theme: highlightTheme, + padding: EdgeInsets.zero, + textStyle: AppTypography.codeStyle.copyWith( + fontFamily: AppTypography.monospaceFontFamily, + fontSize: 13, + height: 1.55, + ), + ), + ), + ], + ), + ); + } + + /// Maps common language names/aliases to highlight.js recognized names. + static String _mapLanguage(String language) { + final lower = language.toLowerCase(); + + // Common language aliases mapping + const languageMap = { + 'js': 'javascript', + 'ts': 'typescript', + 'py': 'python', + 'rb': 'ruby', + 'sh': 'bash', + 'shell': 'bash', + 'zsh': 'bash', + 'yml': 'yaml', + 'dockerfile': 'docker', + 'kt': 'kotlin', + 'cs': 'csharp', + 'c++': 'cpp', + 'objc': 'objectivec', + 'objective-c': 'objectivec', + 'txt': 'plaintext', + 'text': 'plaintext', + 'md': 'markdown', + }; + + return languageMap[lower] ?? lower; + } + + static Widget _buildImage( + BuildContext context, + Uri uri, + ConduitThemeExtension theme, + ) { + if (uri.scheme == 'data') { + return _buildBase64Image(uri.toString(), context, theme); + } + if (uri.scheme.isEmpty || uri.scheme == 'http' || uri.scheme == 'https') { + return _buildNetworkImage(uri.toString(), context, theme); + } + return _buildImageError(context, theme); + } + + static Widget _buildBase64Image( + String dataUrl, + BuildContext context, + ConduitThemeExtension theme, + ) { + try { + final commaIndex = dataUrl.indexOf(','); + if (commaIndex == -1) { + throw FormatException( + AppLocalizations.of(context)?.invalidDataUrl ?? + 'Invalid data URL format', + ); + } + + final base64String = dataUrl.substring(commaIndex + 1); + final imageBytes = base64.decode(base64String); + + return Container( + margin: const EdgeInsets.symmetric(vertical: Spacing.sm), + constraints: const BoxConstraints(maxWidth: 480, maxHeight: 480), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + child: Image.memory( + imageBytes, + fit: BoxFit.contain, + errorBuilder: (context, error, stackTrace) { + return _buildImageError(context, theme); + }, + ), + ), + ); + } catch (_) { + return _buildImageError(context, theme); + } + } + + static Widget _buildNetworkImage( + String url, + BuildContext context, + ConduitThemeExtension theme, + ) { + // Read headers and optional self-signed cache manager from Riverpod + final container = ProviderScope.containerOf(context, listen: false); + final headers = buildImageHeadersFromContainer(container); + final cacheManager = container.read(selfSignedImageCacheManagerProvider); + + return CachedNetworkImage( + imageUrl: url, + cacheManager: cacheManager, + httpHeaders: headers, + placeholder: (context, _) => Container( + height: 200, + decoration: BoxDecoration( + color: theme.surfaceBackground.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + ), + child: Center( + child: CircularProgressIndicator( + color: theme.loadingIndicator, + strokeWidth: 2, ), ), ), - blockquotePadding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.sm, - ), - blockquote: secondaryBody, - listBullet: baseBody, - listIndent: Spacing.lg, - tableHead: secondaryBody.copyWith(fontWeight: FontWeight.w600), - tableBody: secondaryBody, - tableBorder: TableBorder.all( - color: tableBorderColor, - width: BorderWidth.thin, - ), - tableHeadAlign: TextAlign.start, - // Use IntrinsicColumnWidth so columns size to content instead of being - // squashed. Tables are wrapped in horizontal scroll for overflow. - tableColumnWidth: const IntrinsicColumnWidth(), - tableCellsPadding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.sm, - ), - horizontalRuleDecoration: BoxDecoration( - border: Border( - top: BorderSide(color: theme.dividerColor, width: BorderWidth.small), + errorWidget: (context, url, error) => _buildImageError(context, theme), + imageBuilder: (context, imageProvider) => Container( + margin: const EdgeInsets.symmetric(vertical: Spacing.sm), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppBorderRadius.md), + image: DecorationImage(image: imageProvider, fit: BoxFit.contain), ), ), ); } - static Map _buildCustomBuilders( + static Widget _buildImageError( BuildContext context, - MarkdownLinkTapCallback? onTapLink, + ConduitThemeExtension theme, ) { - return { - 'code': _CodeBlockBuilder(context), - 'mermaid': _MermaidBuilder(context), - 'latex': _LatexBuilder(context), - 'details': _DetailsBuilder(context), - 'table': _TableBuilder(context), - }; - } - - static List _buildInlineSyntaxes() { - return [_LatexInlineSyntax()]; - } - - static List _buildBlockSyntaxes() { - return [_DetailsBlockSyntax()]; + return Container( + height: 120, + decoration: BoxDecoration( + color: theme.surfaceBackground.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(AppBorderRadius.md), + border: Border.all( + color: theme.cardBorder.withValues(alpha: 0.4), + width: BorderWidth.micro, + ), + ), + child: Center( + child: Icon(Icons.broken_image_outlined, color: theme.iconSecondary), + ), + ); } static Widget buildMermaidBlock(BuildContext context, String code) { @@ -272,6 +415,17 @@ class ConduitMarkdown { return html.contains('new Chart(') || html.contains('Chart.'); } + /// Converts a Color to a hex string for use in HTML/CSS. + static String colorToHex(Color color) { + int channel(double value) => (value * 255).round().clamp(0, 255); + final rgba = + (channel(color.r) << 24) | + (channel(color.g) << 16) | + (channel(color.b) << 8) | + channel(color.a); + return '#${rgba.toRadixString(16).padLeft(8, '0')}'; + } + /// Builds a ChartJS block for rendering in a WebView. static Widget buildChartJsBlock(BuildContext context, String htmlContent) { final conduitTheme = context.conduitTheme; @@ -351,83 +505,138 @@ class ConduitMarkdown { } } -// Code syntax highlighting -class _CodeSyntaxHighlighter extends SyntaxHighlighter { - _CodeSyntaxHighlighter(this.context); +/// Internal code block header with consistent styling. +class _CodeBlockHeader extends StatefulWidget { + const _CodeBlockHeader({ + required this.language, + required this.backgroundColor, + required this.borderColor, + required this.isDark, + required this.onCopy, + }); - final BuildContext context; + final String language; + final Color backgroundColor; + final Color borderColor; + final bool isDark; + final VoidCallback onCopy; @override - TextSpan format(String source) { - final theme = context.conduitTheme; - - return TextSpan( - style: AppTypography.codeStyle.copyWith(color: theme.codeText), - children: [TextSpan(text: source)], - ); - } + State<_CodeBlockHeader> createState() => _CodeBlockHeaderState(); } -// Custom code block builder with header -class _CodeBlockBuilder extends MarkdownElementBuilder { - _CodeBlockBuilder(this.context); +class _CodeBlockHeaderState extends State<_CodeBlockHeader> { + bool _isHovering = false; + bool _isCopied = false; - final BuildContext context; + void _handleCopy() { + widget.onCopy(); + setState(() => _isCopied = true); + Future.delayed(const Duration(seconds: 2), () { + if (mounted) setState(() => _isCopied = false); + }); + } @override - Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { - final theme = context.conduitTheme; - final isDark = Theme.of(context).brightness == Brightness.dark; - final code = element.textContent; - final language = - element.attributes['class']?.replaceFirst('language-', '') ?? - 'plaintext'; - final normalizedLanguage = language.trim().isEmpty - ? 'plaintext' - : language.trim(); + Widget build(BuildContext context) { + final label = widget.language.isEmpty ? 'plaintext' : widget.language; - // Match GitHub/Atom theme colors for code block container - final codeBackground = isDark - ? const Color(0xFF282c34) // Atom One Dark background - : const Color(0xFFfafbfc); // GitHub light background + // Colors derived from the code block theme for consistency + final labelColor = widget.isDark + ? const Color(0xFF9DA5B4) // Atom One Dark muted + : const Color(0xFF57606A); // GitHub muted + + final iconColor = _isHovering + ? (widget.isDark ? const Color(0xFFABB2BF) : const Color(0xFF24292F)) + : labelColor; + + final successColor = widget.isDark + ? const Color(0xFF98C379) + : const Color(0xFF1A7F37); return Container( - margin: const EdgeInsets.symmetric(vertical: Spacing.sm), - decoration: BoxDecoration( - color: codeBackground, - borderRadius: BorderRadius.circular(6), + padding: const EdgeInsets.symmetric( + horizontal: Spacing.md, + vertical: Spacing.xs + 2, ), - clipBehavior: Clip.antiAlias, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - CodeBlockHeader( - language: normalizedLanguage, - onCopy: () async { - await Clipboard.setData(ClipboardData(text: code)); - if (!context.mounted) { - return; - } - ScaffoldMessenger.of(context).hideCurrentSnackBar(); - final l10n = AppLocalizations.of(context); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - l10n?.codeCopiedToClipboard ?? 'Code copied to clipboard.', - ), - ), - ); - }, + decoration: BoxDecoration( + color: widget.backgroundColor, + border: Border( + bottom: BorderSide( + color: widget.borderColor, + width: BorderWidth.thin, ), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.all(Spacing.md), - child: SelectableText( - code, - style: AppTypography.codeStyle.copyWith( - color: theme.codeText, - fontFamily: AppTypography.monospaceFontFamily, + ), + ), + child: Row( + children: [ + // Language icon + Icon( + _getLanguageIcon(label), + size: 14, + color: labelColor.withValues(alpha: 0.7), + ), + const SizedBox(width: Spacing.xs), + // Language label + Text( + label, + style: AppTypography.codeStyle.copyWith( + color: labelColor, + fontSize: 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.3, + ), + ), + const Spacer(), + // Copy button with hover effect + MouseRegion( + onEnter: (_) => setState(() => _isHovering = true), + onExit: (_) => setState(() => _isHovering = false), + child: GestureDetector( + onTap: _handleCopy, + child: AnimatedContainer( + duration: AnimationDuration.fast, + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: _isHovering + ? widget.borderColor.withValues(alpha: 0.5) + : Colors.transparent, + borderRadius: BorderRadius.circular(AppBorderRadius.xs), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedSwitcher( + duration: AnimationDuration.fast, + child: Icon( + _isCopied + ? Icons.check_rounded + : Icons.content_copy_rounded, + key: ValueKey(_isCopied), + size: 14, + color: _isCopied ? successColor : iconColor, + ), + ), + if (_isHovering || _isCopied) ...[ + const SizedBox(width: Spacing.xs), + AnimatedOpacity( + duration: AnimationDuration.fast, + opacity: 1.0, + child: Text( + _isCopied ? 'Copied!' : 'Copy', + style: AppTypography.codeStyle.copyWith( + color: _isCopied ? successColor : iconColor, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), ), ), ), @@ -435,389 +644,24 @@ class _CodeBlockBuilder extends MarkdownElementBuilder { ), ); } -} -// Custom table builder for horizontally scrollable tables -class _TableBuilder extends MarkdownElementBuilder { - _TableBuilder(this.context); - - final BuildContext context; - - @override - Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { - final theme = context.conduitTheme; - final tableBorderColor = theme.textSecondary.withValues(alpha: 0.5); - final headerBgColor = theme.surfaceContainer.withValues(alpha: 0.4); - - // Collect row data first to determine max column count - final rowData = <_TableRowData>[]; - - // Parse table structure - for (final child in element.children ?? []) { - if (child is! md.Element) continue; - - final isHeader = child.tag == 'thead'; - final bodyElement = child.tag == 'tbody' ? child : null; - - // Handle thead - if (isHeader) { - for (final row in child.children ?? []) { - if (row is! md.Element || row.tag != 'tr') continue; - rowData.add(_parseTableRow(row, isHeader: true)); - } - } - - // Handle tbody - if (bodyElement != null) { - for (final row in bodyElement.children ?? []) { - if (row is! md.Element || row.tag != 'tr') continue; - rowData.add(_parseTableRow(row, isHeader: false)); - } - } - - // Handle direct tr children (some markdown parsers) - if (child.tag == 'tr') { - final hasHeaderCells = (child.children ?? []).any( - (c) => c is md.Element && c.tag == 'th', - ); - rowData.add(_parseTableRow(child, isHeader: hasHeaderCells)); - } - } - - if (rowData.isEmpty) return null; - - // Find max column count to ensure all rows have same cell count - final maxColumns = rowData.fold( - 0, - (max, row) => row.cells.length > max ? row.cells.length : max, - ); - - if (maxColumns == 0) return null; - - // Build TableRows, padding shorter rows with empty cells - final rows = rowData.map((data) { - return _buildTableRow( - data, - maxColumns: maxColumns, - headerBgColor: headerBgColor, - ); - }).toList(); - - // Use symmetric borders for internal cell dividers only; - // the Container provides the outer border with rounded corners - final cellBorder = BorderSide( - color: tableBorderColor, - width: BorderWidth.thin, - ); - final table = Table( - border: TableBorder.symmetric(inside: cellBorder), - defaultColumnWidth: const IntrinsicColumnWidth(), - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - children: rows, - ); - - // Wrap in horizontal scroll for tables that overflow - return Container( - margin: const EdgeInsets.symmetric(vertical: Spacing.sm), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppBorderRadius.xs), - border: Border.all(color: tableBorderColor, width: BorderWidth.thin), - ), - clipBehavior: Clip.antiAlias, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: table, - ), - ); - } - - /// Parses a table row element into cell data without building widgets yet. - _TableRowData _parseTableRow(md.Element row, {required bool isHeader}) { - final cells = []; - for (final cell in row.children ?? []) { - if (cell is! md.Element) continue; - if (cell.tag != 'th' && cell.tag != 'td') continue; - cells.add(_extractText(cell)); - } - return _TableRowData(cells: cells, isHeader: isHeader); - } - - /// Builds a TableRow from parsed data, padding with empty cells if needed. - TableRow _buildTableRow( - _TableRowData data, { - required int maxColumns, - Color? headerBgColor, - }) { - final theme = context.conduitTheme; - final cells = []; - - final textStyle = data.isHeader - ? AppTypography.bodySmallStyle.copyWith( - color: theme.textSecondary, - fontWeight: FontWeight.w600, - ) - : AppTypography.bodySmallStyle.copyWith(color: theme.textSecondary); - - // Build cells from parsed data - for (final cellText in data.cells) { - cells.add( - Container( - color: data.isHeader ? headerBgColor : null, - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.sm, - ), - child: Text(cellText, style: textStyle, softWrap: false), - ), - ); - } - - // Pad with empty cells if this row has fewer columns than max - while (cells.length < maxColumns) { - cells.add( - Container( - color: data.isHeader ? headerBgColor : null, - padding: const EdgeInsets.symmetric( - horizontal: Spacing.md, - vertical: Spacing.sm, - ), - child: Text('', style: textStyle), - ), - ); - } - - return TableRow(children: cells); - } - - String _extractText(md.Element element) { - final buffer = StringBuffer(); - for (final node in element.children ?? []) { - if (node is md.Text) { - buffer.write(node.text); - } else if (node is md.Element) { - buffer.write(_extractText(node)); - } - } - return buffer.toString(); - } -} - -/// Intermediate data structure for table row parsing. -class _TableRowData { - const _TableRowData({required this.cells, required this.isHeader}); - - final List cells; - final bool isHeader; -} - -// Custom image builder -class _ImageBuilder extends MarkdownElementBuilder { - _ImageBuilder(this.context); - - final BuildContext context; - - @override - Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { - final url = element.attributes['src'] ?? ''; - final uri = Uri.tryParse(url); - if (uri == null) { - return _buildImageError(context, context.conduitTheme); - } - return buildFromUri(uri); - } - - /// Public helper used by the Markdown `imageBuilder` callback. - Widget buildFromUri(Uri uri) { - final theme = context.conduitTheme; - if (uri.scheme == 'data') { - return _buildBase64Image(uri.toString(), context, theme); - } - if (uri.scheme.isEmpty || uri.scheme == 'http' || uri.scheme == 'https') { - return _buildNetworkImage(uri.toString(), context, theme); - } - return _buildImageError(context, theme); - } - - Widget _buildBase64Image( - String dataUrl, - BuildContext context, - ConduitThemeExtension theme, - ) { - try { - final commaIndex = dataUrl.indexOf(','); - if (commaIndex == -1) { - throw FormatException( - AppLocalizations.of(context)?.invalidDataUrl ?? - 'Invalid data URL format', - ); - } - - final base64String = dataUrl.substring(commaIndex + 1); - final imageBytes = base64.decode(base64String); - - return Container( - margin: const EdgeInsets.symmetric(vertical: Spacing.sm), - constraints: const BoxConstraints(maxWidth: 480, maxHeight: 480), - child: ClipRRect( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - child: Image.memory( - imageBytes, - fit: BoxFit.contain, - errorBuilder: (context, error, stackTrace) { - return _buildImageError(context, theme); - }, - ), - ), - ); - } catch (_) { - return _buildImageError(context, theme); - } - } - - Widget _buildNetworkImage( - String url, - BuildContext context, - ConduitThemeExtension theme, - ) { - // Read headers and optional self-signed cache manager from Riverpod - final container = ProviderScope.containerOf(context, listen: false); - final headers = buildImageHeadersFromContainer(container); - final cacheManager = container.read(selfSignedImageCacheManagerProvider); - - return CachedNetworkImage( - imageUrl: url, - cacheManager: cacheManager, - httpHeaders: headers, - placeholder: (context, _) => Container( - height: 200, - decoration: BoxDecoration( - color: theme.surfaceBackground.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(AppBorderRadius.md), - ), - child: Center( - child: CircularProgressIndicator( - color: theme.loadingIndicator, - strokeWidth: 2, - ), - ), - ), - errorWidget: (context, url, error) => _buildImageError(context, theme), - imageBuilder: (context, imageProvider) => Container( - margin: const EdgeInsets.symmetric(vertical: Spacing.sm), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppBorderRadius.md), - image: DecorationImage(image: imageProvider, fit: BoxFit.contain), - ), - ), - ); - } - - Widget _buildImageError(BuildContext context, ConduitThemeExtension theme) { - return Container( - height: 120, - decoration: BoxDecoration( - color: theme.surfaceBackground.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(AppBorderRadius.md), - border: Border.all( - color: theme.cardBorder.withValues(alpha: 0.4), - width: BorderWidth.micro, - ), - ), - child: Center( - child: Icon(Icons.broken_image_outlined, color: theme.iconSecondary), - ), - ); - } -} - -// Mermaid diagram builder -class _MermaidBuilder extends MarkdownElementBuilder { - _MermaidBuilder(this.context); - - final BuildContext context; - - @override - Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { - final code = element.textContent; - return ConduitMarkdown.buildMermaidBlock(context, code); - } -} - -// LaTeX builder -class _LatexBuilder extends MarkdownElementBuilder { - _LatexBuilder(this.context); - - final BuildContext context; - - @override - Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final content = element.textContent.trim(); - final isInline = element.attributes['isInline'] == 'true'; - - final baseStyle = (preferredStyle ?? AppTypography.bodyMediumStyle) - .copyWith(color: isDark ? Colors.white : Colors.black); - - if (content.isEmpty) { - return Text(element.textContent, style: baseStyle); - } - - final mathWidget = Math.tex( - content, - mathStyle: MathStyle.text, - textStyle: baseStyle, - textScaleFactor: 1, - onErrorFallback: (error) { - return Text(content, style: baseStyle.copyWith(color: Colors.red)); - }, - ); - - if (isInline) { - return mathWidget; - } - - // Wrap block math in horizontal scroll for long expressions - return Padding( - padding: const EdgeInsets.symmetric(vertical: Spacing.xs), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: mathWidget, - ), - ); - } -} - -// LaTeX inline syntax -class _LatexInlineSyntax extends md.InlineSyntax { - _LatexInlineSyntax() - : super( - r'(\$\$[\s\S]+?\$\$)|(\$[^\n]+?\$)|(\\\([\s\S]+?\\\))|(\\\[[\s\S]+?\\\])', - ); - - @override - bool onMatch(md.InlineParser parser, Match match) { - final raw = match.group(0) ?? ''; - String content = raw; - bool isInline = true; - - if (raw.startsWith(r'$$') && raw.endsWith(r'$$') && raw.length > 4) { - content = raw.substring(2, raw.length - 2); - isInline = false; - } else if (raw.startsWith(r'$') && raw.endsWith(r'$') && raw.length > 2) { - content = raw.substring(1, raw.length - 1); - isInline = true; - } else if (raw.startsWith(r'\(') && raw.endsWith(r'\)') && raw.length > 4) { - content = raw.substring(2, raw.length - 2); - isInline = true; - } else if (raw.startsWith(r'\[') && raw.endsWith(r'\]') && raw.length > 4) { - content = raw.substring(2, raw.length - 2); - isInline = false; - } - - final element = md.Element.text('latex', content); - element.attributes['isInline'] = isInline.toString(); - parser.addNode(element); - return true; + /// Returns an appropriate icon for the language. + IconData _getLanguageIcon(String language) { + final lower = language.toLowerCase(); + return switch (lower) { + 'dart' || 'flutter' => Icons.flutter_dash_rounded, + 'python' || 'py' => Icons.code_rounded, + 'javascript' || 'js' || 'typescript' || 'ts' => Icons.javascript_rounded, + 'html' || 'css' || 'scss' => Icons.html_rounded, + 'json' || 'yaml' || 'yml' => Icons.data_object_rounded, + 'sql' || 'mysql' || 'postgresql' => Icons.storage_rounded, + 'bash' || 'shell' || 'sh' || 'zsh' => Icons.terminal_rounded, + 'markdown' || 'md' => Icons.article_rounded, + 'swift' || 'kotlin' || 'java' => Icons.phone_iphone_rounded, + 'rust' || 'go' || 'c' || 'cpp' || 'c++' => Icons.memory_rounded, + 'docker' || 'dockerfile' => Icons.cloud_rounded, + _ => Icons.code_rounded, + }; } } @@ -913,18 +757,16 @@ class _ChartJsDiagramState extends State { String _buildHtml(String htmlContent, String script) { final isDark = widget.brightness == Brightness.dark; - final background = _toHex( + final background = ConduitMarkdown.colorToHex( isDark ? widget.tokens.codeBackground : Colors.white, ); - final textColor = _toHex(widget.tokens.codeText); - final gridColor = _toHex( + final textColor = ConduitMarkdown.colorToHex(widget.tokens.codeText); + final gridColor = ConduitMarkdown.colorToHex( isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.1), ); - // Process the HTML content to inject Chart.js and configure theme - // The htmlContent contains the full HTML with chart creation code return ''' @@ -965,31 +807,21 @@ class _ChartJsDiagramState extends State {