From e9de32fbeccfb88ec0e8534b5fb14ebf71e7bcbf Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Sun, 7 Dec 2025 23:19:22 +0530 Subject: [PATCH] refactor(chat): Replace StatusHistoryTimeline with StreamingStatusWidget --- .../widgets/assistant_message_widget.dart | 650 +-------------- .../chat/widgets/streaming_status_widget.dart | 776 ++++++++++++++++++ .../widgets/markdown/citation_badge.dart | 303 ++++--- .../markdown/inline_citation_text.dart | 92 +++ .../markdown/streaming_markdown_widget.dart | 302 +++++-- 5 files changed, 1293 insertions(+), 830 deletions(-) create mode 100644 lib/features/chat/widgets/streaming_status_widget.dart create mode 100644 lib/shared/widgets/markdown/inline_citation_text.dart diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 4390404..53a8d66 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+/]+=*'); @@ -766,11 +767,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), ], @@ -1456,649 +1455,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/streaming_status_widget.dart b/lib/features/chat/widgets/streaming_status_widget.dart new file mode 100644 index 0000000..690297c --- /dev/null +++ b/lib/features/chat/widgets/streaming_status_widget.dart @@ -0,0 +1,776 @@ +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 '../../../shared/theme/theme_extensions.dart'; +import '../../../core/utils/debug_logger.dart'; + +/// A modern, mobile-first streaming status widget that displays +/// live status updates during AI response generation. +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 + with SingleTickerProviderStateMixin { + bool _expanded = false; + late AnimationController _shimmerController; + + @override + void initState() { + super.initState(); + _shimmerController = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + ); + if (widget.isStreaming) { + _shimmerController.repeat(); + } + } + + @override + void didUpdateWidget(covariant StreamingStatusWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isStreaming && !_shimmerController.isAnimating) { + _shimmerController.repeat(); + } else if (!widget.isStreaming && _shimmerController.isAnimating) { + _shimmerController.stop(); + } + } + + @override + void dispose() { + _shimmerController.dispose(); + super.dispose(); + } + + @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 theme = context.conduitTheme; + + return InkWell( + onTap: hasPrevious ? () => setState(() => _expanded = !_expanded) : null, + 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: [ + // Current status header (always visible) + _StatusRow( + update: current, + isPending: current.done != true && widget.isStreaming, + shimmerController: _shimmerController, + showExpandIcon: hasPrevious, + isExpanded: _expanded, + ), + + // Expandable timeline with full history + AnimatedCrossFade( + firstChild: const SizedBox.shrink(), + secondChild: hasPrevious + ? _HistoryTimeline( + updates: visible, + shimmerController: _shimmerController, + isStreaming: widget.isStreaming, + ) + : const SizedBox.shrink(), + crossFadeState: _expanded + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 200), + ), + ], + ), + ), + ); + } +} + +/// Status row with expand chevron (header when collapsed). +class _StatusRow extends StatelessWidget { + const _StatusRow({ + required this.update, + required this.isPending, + required this.shimmerController, + this.showExpandIcon = false, + this.isExpanded = false, + }); + + final ChatStatusUpdate update; + final bool isPending; + final AnimationController shimmerController; + final bool showExpandIcon; + final bool isExpanded; + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + final queries = _collectQueries(update); + final links = _collectLinks(update); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header row + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Expand chevron + if (showExpandIcon) + Icon( + isExpanded + ? Icons.expand_less_rounded + : Icons.expand_more_rounded, + size: 16, + color: theme.textSecondary, + ), + if (showExpandIcon) const SizedBox(width: Spacing.xs), + // Status icon + _StatusIcon( + action: update.action, + isPending: isPending, + shimmerController: shimmerController, + ), + const SizedBox(width: Spacing.xs), + // Status text + Flexible( + child: _StatusText( + update: update, + isPending: isPending, + shimmerController: shimmerController, + ), + ), + ], + ), + + // Query pills (only when collapsed) + if (!isExpanded && queries.isNotEmpty) ...[ + const SizedBox(height: Spacing.xs), + _QueryChips(queries: queries), + ], + + // Source links (only when collapsed) + if (!isExpanded && links.isNotEmpty) ...[ + const SizedBox(height: Spacing.xs), + _SourceLinks(links: links), + ], + ], + ); + } +} + +/// Full history timeline matching the web client. +class _HistoryTimeline extends StatelessWidget { + const _HistoryTimeline({ + required this.updates, + required this.shimmerController, + required this.isStreaming, + }); + + final List updates; + final AnimationController shimmerController; + final bool isStreaming; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: Spacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + 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; + + return _TimelineItem( + update: update, + isPending: isPending, + shimmerController: shimmerController, + isLast: isLast, + ); + }), + ], + ), + ); + } +} + +/// Single timeline item with dot and connecting line. +class _TimelineItem extends StatelessWidget { + const _TimelineItem({ + required this.update, + required this.isPending, + required this.shimmerController, + required this.isLast, + }); + + final ChatStatusUpdate update; + final bool isPending; + final AnimationController shimmerController; + final bool isLast; + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + final queries = _collectQueries(update); + final links = _collectLinks(update); + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Timeline indicator (dot + line) + SizedBox( + width: 16, + child: Column( + children: [ + // Dot + Container( + margin: const EdgeInsets.only(top: Spacing.xs), + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isPending + ? theme.buttonPrimary + : theme.textTertiary.withValues(alpha: 0.6), + ), + ), + // Connecting line (not on last item) + if (!isLast) + Expanded( + child: Container( + width: 1, + margin: const EdgeInsets.symmetric(vertical: 2), + color: theme.dividerColor.withValues(alpha: 0.5), + ), + ), + ], + ), + ), + const SizedBox(width: Spacing.xs), + // Content + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: Spacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status text + _StatusText( + update: update, + isPending: isPending, + shimmerController: shimmerController, + ), + // Query chips + if (queries.isNotEmpty) ...[ + const SizedBox(height: Spacing.xs), + _QueryChips(queries: queries), + ], + // Source links + if (links.isNotEmpty) ...[ + const SizedBox(height: Spacing.xs), + _SourceLinks(links: links), + ], + ], + ), + ), + ), + ], + ), + ); + } +} + +/// Status icon matching the reasoning tile style. +class _StatusIcon extends StatelessWidget { + const _StatusIcon({ + required this.action, + required this.isPending, + required this.shimmerController, + }); + + final String? action; + final bool isPending; + final AnimationController shimmerController; + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + final iconData = _getIconForAction(action); + final iconColor = theme.buttonPrimary; + + // Simple icon matching reasoning tile (14px, primary color) + if (!isPending) { + return Icon(iconData, size: 14, color: iconColor); + } + + // Subtle pulse animation for pending state + return AnimatedBuilder( + animation: shimmerController, + builder: (context, child) { + final opacity = 0.6 + (0.4 * (1.0 - shimmerController.value)); + return Icon( + iconData, + size: 14, + color: iconColor.withValues(alpha: opacity), + ); + }, + ); + } + + IconData _getIconForAction(String? action) { + switch (action) { + case 'web_search': + case 'web_search_queries_generated': + case 'queries_generated': + return Icons.search_rounded; + case 'knowledge_search': + return Icons.menu_book_rounded; + case 'sources_retrieved': + return Icons.source_rounded; + case 'generating': + return Icons.edit_note_rounded; + default: + return Icons.bolt_rounded; + } + } +} + +/// Status text matching the reasoning tile style. +class _StatusText extends StatelessWidget { + const _StatusText({ + required this.update, + required this.isPending, + required this.shimmerController, + }); + + final ChatStatusUpdate update; + final bool isPending; + final AnimationController shimmerController; + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + final description = _resolveStatusDescription(update); + + // Match reasoning tile text style + final textStyle = TextStyle( + fontSize: AppTypography.bodySmall, + color: theme.textSecondary, + fontWeight: FontWeight.w500, + ); + + if (!isPending) { + return Text( + description, + overflow: TextOverflow.ellipsis, + style: textStyle, + ); + } + + // Shimmer effect for pending state + return AnimatedBuilder( + animation: shimmerController, + builder: (context, child) { + final opacity = 0.5 + (0.5 * (1.0 - shimmerController.value)); + return Text( + description, + overflow: TextOverflow.ellipsis, + style: textStyle.copyWith( + color: theme.textSecondary.withValues(alpha: opacity), + ), + ); + }, + ); + } +} + +/// Horizontally scrollable query chips. +class _QueryChips extends StatelessWidget { + const _QueryChips({required this.queries}); + + final List queries; + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + + return SizedBox( + height: 32, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: queries.length, + separatorBuilder: (_, index) => const SizedBox(width: Spacing.xs), + itemBuilder: (context, index) { + final query = queries[index]; + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _launchSearch(query), + borderRadius: BorderRadius.circular(AppBorderRadius.pill), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: theme.buttonPrimary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(AppBorderRadius.pill), + border: Border.all( + color: theme.buttonPrimary.withValues(alpha: 0.2), + width: BorderWidth.thin, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.search_rounded, + size: 14, + color: theme.buttonPrimary, + ), + const SizedBox(width: Spacing.xxs), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 180), + child: Text( + query, + style: TextStyle( + fontSize: AppTypography.labelSmall, + fontWeight: FontWeight.w500, + color: theme.buttonPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ) + .animate() + .fadeIn(duration: 200.ms, delay: (50 * index).ms) + .slideX( + begin: 0.1, + end: 0, + duration: 200.ms, + delay: (50 * index).ms, + ); + }, + ), + ); + } + + 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'); + } + } +} + +/// Source link chips with favicons. +class _SourceLinks extends StatelessWidget { + const _SourceLinks({required this.links}); + + final List<_LinkData> links; + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + + // Show max 4 links, with a "+N more" indicator + final displayLinks = links.take(4).toList(); + final remaining = links.length - 4; + + return Wrap( + spacing: Spacing.xs, + runSpacing: Spacing.xs, + children: [ + ...displayLinks.asMap().entries.map((entry) { + final index = entry.key; + final link = entry.value; + return _SourceLinkChip(link: link) + .animate() + .fadeIn(duration: 200.ms, delay: (50 * index).ms) + .scale( + begin: const Offset(0.9, 0.9), + end: const Offset(1, 1), + duration: 200.ms, + delay: (50 * index).ms, + ); + }), + if (remaining > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: theme.surfaceContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + ), + child: Text( + '+$remaining', + style: TextStyle( + fontSize: AppTypography.labelSmall, + color: theme.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ).animate().fadeIn( + duration: 200.ms, + delay: (50 * displayLinks.length).ms, + ), + ], + ); + } +} + +/// Individual source link chip with favicon. +class _SourceLinkChip extends StatelessWidget { + const _SourceLinkChip({required this.link}); + + final _LinkData link; + + @override + Widget build(BuildContext context) { + final theme = context.conduitTheme; + final domain = _extractDomain(link.url); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _launchUrl(link.url), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + decoration: BoxDecoration( + color: theme.surfaceContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(AppBorderRadius.sm), + border: Border.all( + color: theme.dividerColor.withValues(alpha: 0.3), + width: BorderWidth.thin, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Favicon + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.network( + 'https://www.google.com/s2/favicons?sz=32&domain=$domain', + width: 14, + height: 14, + errorBuilder: (context, error, stackTrace) => Icon( + Icons.public_rounded, + size: 14, + color: theme.textSecondary, + ), + ), + ), + const SizedBox(width: Spacing.xs), + // Domain or title + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 120), + child: Text( + link.title ?? domain, + style: TextStyle( + fontSize: AppTypography.labelSmall, + color: theme.textSecondary, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: Spacing.xxs), + Icon( + Icons.open_in_new_rounded, + size: 10, + color: theme.textTertiary, + ), + ], + ), + ), + ), + ); + } + + 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>[]; + + // Collect from items + for (final item in update.items) { + final url = item.link; + if (url != null && url.isNotEmpty) { + links.add(_LinkData(url: url, title: item.title)); + } + } + + // Collect from urls + 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(); + + // Match OpenWebUI copy exactly + 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'; + } + + // Handle description with placeholders + if (description != null && description.isNotEmpty) { + // Handle known OpenWebUI descriptions + 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) { + // Convert action to readable text + 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 index 5e13edb..49b1ca0 100644 --- a/lib/shared/widgets/markdown/citation_badge.dart +++ b/lib/shared/widgets/markdown/citation_badge.dart @@ -5,8 +5,8 @@ import '../../../core/models/chat_message.dart'; import '../../theme/theme_extensions.dart'; /// Helper utilities for working with source references. -class _SourceHelper { - const _SourceHelper._(); +class SourceHelper { + const SourceHelper._(); /// Extracts a URL from a source reference, checking multiple fields. static String? getSourceUrl(ChatSourceReference source) { @@ -27,22 +27,39 @@ class _SourceHelper { } /// 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) { - if (source.title != null && source.title!.isNotEmpty) { - return source.title!; - } + // For web sources, prefer showing the URL domain final url = getSourceUrl(source); if (url != null) { - return _extractDomain(url); + 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) { - return source.id!; + 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) { + static String extractDomain(String url) { try { final uri = Uri.parse(url); String domain = uri.host; @@ -55,6 +72,16 @@ class _SourceHelper { } } + /// 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 { @@ -68,9 +95,9 @@ class _SourceHelper { } } -/// A compact badge showing a citation reference that links to a source. +/// A compact inline citation badge showing source domain/title. /// -/// Mirrors OpenWebUI's Source.svelte and SourceToken.svelte behavior. +/// Uses the app's design system for consistency with other chips and badges. class CitationBadge extends StatelessWidget { const CitationBadge({ super.key, @@ -94,65 +121,63 @@ class CitationBadge extends StatelessWidget { // Check if index is valid if (sourceIndex < 0 || sourceIndex >= sources.length) { - // Invalid source index - show placeholder - return _buildBadge( - theme: theme, - displayNumber: sourceIndex + 1, - isValid: false, - ); + return const SizedBox.shrink(); } final source = sources[sourceIndex]; - final url = _SourceHelper.getSourceUrl(source); - final title = _SourceHelper.getSourceTitle(source, sourceIndex); + final url = SourceHelper.getSourceUrl(source); + final title = SourceHelper.getSourceTitle(source, sourceIndex); + final displayTitle = SourceHelper.formatDisplayTitle(title); return Tooltip( message: title, preferBelow: false, - child: _buildBadge( - theme: theme, - displayNumber: sourceIndex + 1, - isValid: true, - onTap: () { - if (onTap != null) { - onTap!(); - } else if (url != null) { - _SourceHelper.launchSourceUrl(url); - } - }, - ), - ); - } - - Widget _buildBadge({ - required ConduitThemeExtension theme, - required int displayNumber, - required bool isValid, - VoidCallback? onTap, - }) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - margin: const EdgeInsets.symmetric(horizontal: 1), - decoration: BoxDecoration( - color: isValid - ? theme.surfaceContainer.withValues(alpha: 0.6) - : theme.surfaceContainer.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: theme.dividerColor.withValues(alpha: 0.3), - width: 0.5, - ), - ), - child: Text( - displayNumber.toString(), - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: isValid - ? theme.textPrimary.withValues(alpha: 0.8) - : theme.textSecondary.withValues(alpha: 0.5), + 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, + ), + ], + ), ), ), ), @@ -161,6 +186,8 @@ class CitationBadge extends StatelessWidget { } /// 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, @@ -195,105 +222,131 @@ class CitationBadgeGroup extends StatelessWidget { ); } - // For multiple citations, show grouped badge final theme = context.conduitTheme; - final validCount = sourceIndices - .where((i) => i >= 0 && i < sources.length) - .length; + + // 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(8)), + 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; - final title = isValid - ? _SourceHelper.getSourceTitle(sources[index], index) - : 'Invalid source'; + return sourceIndices + .map((index) { + final isValid = index >= 0 && index < sources.length; + if (!isValid) return null; - return PopupMenuItem( - value: index, - height: 36, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: theme.surfaceContainer, - borderRadius: BorderRadius.circular(6), - ), - child: Center( - child: Text( - (index + 1).toString(), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: theme.textPrimary, + 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, ), ), - ), + ], ), - const SizedBox(width: 8), - Flexible( - child: Text( - title, - style: TextStyle(fontSize: 13, color: theme.textSecondary), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - }).toList(); + ); + }) + .whereType>() + .toList(); }, onSelected: (index) { if (onSourceTap != null) { onSourceTap!(index); } else if (index >= 0 && index < sources.length) { - final url = _SourceHelper.getSourceUrl(sources[index]); + final url = SourceHelper.getSourceUrl(sources[index]); if (url != null) { - _SourceHelper.launchSourceUrl(url); + SourceHelper.launchSourceUrl(url); } } }, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - margin: const EdgeInsets.symmetric(horizontal: 1), + 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(10), + borderRadius: BorderRadius.circular(AppBorderRadius.chip), border: Border.all( - color: theme.dividerColor.withValues(alpha: 0.3), - width: 0.5, + color: theme.cardBorder.withValues(alpha: 0.5), + width: BorderWidth.thin, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - (sourceIndices.first + 1).toString(), - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: theme.textPrimary.withValues(alpha: 0.8), - ), + Icon( + Icons.link_rounded, + size: 10, + color: theme.textSecondary.withValues(alpha: 0.7), ), - if (validCount > 1) ...[ - Text( - '+${validCount - 1}', + 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, - color: theme.textSecondary.withValues(alpha: 0.7), + fontWeight: FontWeight.w600, + color: theme.buttonPrimary, ), ), - ], + ), ], ), ), 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/streaming_markdown_widget.dart b/lib/shared/widgets/markdown/streaming_markdown_widget.dart index 734640a..4c21fa3 100644 --- a/lib/shared/widgets/markdown/streaming_markdown_widget.dart +++ b/lib/shared/widgets/markdown/streaming_markdown_widget.dart @@ -2,6 +2,7 @@ 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'; import 'markdown_config.dart'; import 'markdown_preprocessor.dart'; @@ -134,11 +135,10 @@ class StreamingMarkdownWidget extends StatelessWidget { return SelectionArea(child: result); } - /// Builds markdown content with citation source references. + /// Builds markdown content with inline citation badges. /// - /// Citations like [1], [2] are kept as text in the markdown to preserve - /// inline formatting. A source reference footer is added when citations - /// are detected, providing clickable access to sources. + /// Citations like [1], [2] are rendered as clickable badges inline + /// within the text, matching OpenWebUI's behavior. Widget _buildMarkdownWithCitations(BuildContext context, String data) { // If no sources provided, render plain markdown if (sources == null || sources!.isEmpty) { @@ -160,9 +160,43 @@ class StreamingMarkdownWidget extends StatelessWidget { ); } - // Extract unique source IDs referenced in the content - final referencedIds = CitationParser.extractSourceIds(data); - if (referencedIds.isEmpty) { + // Render content with inline citation badges + return _InlineCitationMarkdown( + data: data, + sources: sources!, + onTapLink: onTapLink, + onSourceTap: onSourceTap, + imageBuilderOverride: imageBuilderOverride, + ); + } +} + +/// Widget that renders markdown with inline citation badges. +/// +/// Parses the markdown content, identifies citation patterns, and renders +/// them as clickable badges inline with the text. +class _InlineCitationMarkdown extends StatelessWidget { + const _InlineCitationMarkdown({ + required this.data, + required this.sources, + this.onTapLink, + this.onSourceTap, + this.imageBuilderOverride, + }); + + final String data; + final List sources; + final MarkdownLinkTapCallback? onTapLink; + final void Function(int sourceIndex)? onSourceTap; + final Widget Function(Uri uri, String? title, String? alt)? + imageBuilderOverride; + + @override + Widget build(BuildContext context) { + // Split content into lines/paragraphs for processing + final segments = _parseContentWithCitations(data); + + if (segments.isEmpty) { return ConduitMarkdown.build( context: context, data: data, @@ -171,67 +205,219 @@ class StreamingMarkdownWidget extends StatelessWidget { ); } - // Render markdown content as-is (preserving all formatting) - // and add a source references footer - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ + // Build widgets for each segment + final children = []; + final buffer = StringBuffer(); + + for (final segment in segments) { + if (segment.hasCitations) { + // Flush any accumulated non-citation content + if (buffer.isNotEmpty) { + children.add( + ConduitMarkdown.build( + context: context, + data: buffer.toString(), + onTapLink: onTapLink, + imageBuilderOverride: imageBuilderOverride, + ), + ); + buffer.clear(); + } + + // Render this segment with inline citations + children.add(_buildParagraphWithCitations(context, segment.text)); + } else { + // Accumulate non-citation content + if (buffer.isNotEmpty) { + buffer.writeln(); + } + buffer.write(segment.text); + } + } + + // Flush remaining content + if (buffer.isNotEmpty) { + children.add( ConduitMarkdown.build( context: context, - data: data, + data: buffer.toString(), onTapLink: onTapLink, imageBuilderOverride: imageBuilderOverride, ), - _SourceReferencesFooter( - referencedIds: referencedIds, - sources: sources!, - onSourceTap: onSourceTap, - ), - ], + ); + } + + if (children.isEmpty) { + return const SizedBox.shrink(); + } + + if (children.length == 1) { + return children.first; + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: children, + ); + } + + /// Parses content into segments, identifying which have citations. + List<_ContentSegment> _parseContentWithCitations(String content) { + final segments = <_ContentSegment>[]; + + // Split by double newlines (paragraphs) while preserving structure + final paragraphs = content.split(RegExp(r'\n\n+')); + + for (final paragraph in paragraphs) { + if (paragraph.trim().isEmpty) continue; + + final hasCitations = CitationParser.hasCitations(paragraph); + segments.add( + _ContentSegment(text: paragraph, hasCitations: hasCitations), + ); + } + + return segments; + } + + /// Builds a paragraph widget with inline citation badges. + Widget _buildParagraphWithCitations(BuildContext context, String text) { + final theme = context.conduitTheme; + final segments = CitationParser.parse(text); + + if (segments == null || segments.isEmpty) { + return Text(text); + } + + final baseStyle = AppTypography.bodyMediumStyle.copyWith( + color: theme.textPrimary, + height: 1.45, + ); + + final spans = []; + + for (final segment in segments) { + if (segment.isText && segment.text != null) { + // Process text for basic markdown formatting + spans.add(_buildTextSpan(segment.text!, baseStyle, theme)); + } 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); + } + + /// Builds a text span with basic markdown formatting support. + InlineSpan _buildTextSpan( + String text, + TextStyle baseStyle, + ConduitThemeExtension theme, + ) { + // Handle basic inline markdown: **bold**, *italic*, `code` + final spans = []; + + // Pattern for bold, italic, and code + final pattern = RegExp(r'(\*\*(.+?)\*\*)|(\*(.+?)\*)|(`(.+?)`)'); + + var lastEnd = 0; + for (final match in pattern.allMatches(text)) { + // Add text before match + if (match.start > lastEnd) { + spans.add( + TextSpan( + text: text.substring(lastEnd, match.start), + style: baseStyle, + ), + ); + } + + if (match.group(1) != null) { + // Bold **text** + spans.add( + TextSpan( + text: match.group(2), + style: baseStyle.copyWith(fontWeight: FontWeight.bold), + ), + ); + } else if (match.group(3) != null) { + // Italic *text* + spans.add( + TextSpan( + text: match.group(4), + style: baseStyle.copyWith(fontStyle: FontStyle.italic), + ), + ); + } else if (match.group(5) != null) { + // Code `text` + spans.add( + TextSpan( + text: match.group(6), + style: baseStyle.copyWith( + fontFamily: AppTypography.monospaceFontFamily, + backgroundColor: theme.surfaceContainer.withValues(alpha: 0.3), + ), + ), + ); + } + + lastEnd = match.end; + } + + // Add remaining text + if (lastEnd < text.length) { + spans.add(TextSpan(text: text.substring(lastEnd), style: baseStyle)); + } + + if (spans.isEmpty) { + return TextSpan(text: text, style: baseStyle); + } + + if (spans.length == 1) { + return spans.first; + } + + return TextSpan(children: spans); + } + + /// Builds a citation badge widget. + 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, ); } } -/// Footer widget showing source references with clickable badges. -class _SourceReferencesFooter extends StatelessWidget { - const _SourceReferencesFooter({ - required this.referencedIds, - required this.sources, - this.onSourceTap, - }); +/// A segment of content that may or may not contain citations. +class _ContentSegment { + final String text; + final bool hasCitations; - /// 1-based source IDs that are referenced in the content. - final List referencedIds; - - /// All available sources. - final List sources; - - /// Callback when a source is tapped. - final void Function(int sourceIndex)? onSourceTap; - - @override - Widget build(BuildContext context) { - if (referencedIds.isEmpty) { - return const SizedBox.shrink(); - } - - return Padding( - padding: const EdgeInsets.only(top: 8), - child: Wrap( - spacing: 4, - runSpacing: 4, - children: [ - for (final id in referencedIds) - CitationBadge( - sourceIndex: id - 1, // Convert to 0-based - sources: sources, - onTap: onSourceTap != null ? () => onSourceTap!(id - 1) : null, - ), - ], - ), - ); - } + const _ContentSegment({required this.text, required this.hasCitations}); } /// Types of special blocks that need custom rendering