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..4390404 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -876,6 +876,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. diff --git a/lib/shared/widgets/markdown/citation_badge.dart b/lib/shared/widgets/markdown/citation_badge.dart new file mode 100644 index 0000000..5e13edb --- /dev/null +++ b/lib/shared/widgets/markdown/citation_badge.dart @@ -0,0 +1,302 @@ +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. + static String getSourceTitle(ChatSourceReference source, int index) { + if (source.title != null && source.title!.isNotEmpty) { + return source.title!; + } + final url = getSourceUrl(source); + if (url != null) { + return _extractDomain(url); + } + if (source.id != null && source.id!.isNotEmpty) { + return source.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; + } + } + + /// 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 badge showing a citation reference that links to a source. +/// +/// Mirrors OpenWebUI's Source.svelte and SourceToken.svelte behavior. +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) { + // Invalid source index - show placeholder + return _buildBadge( + theme: theme, + displayNumber: sourceIndex + 1, + isValid: false, + ); + } + + final source = sources[sourceIndex]; + final url = _SourceHelper.getSourceUrl(source); + final title = _SourceHelper.getSourceTitle(source, sourceIndex); + + 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), + ), + ), + ), + ); + } +} + +/// A grouped citation badge for multiple sources like [1,2,3]. +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, + ); + } + + // For multiple citations, show grouped badge + final theme = context.conduitTheme; + final validCount = sourceIndices + .where((i) => i >= 0 && i < sources.length) + .length; + + return PopupMenuButton( + tooltip: 'View sources', + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + position: PopupMenuPosition.under, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + color: theme.surfaceBackground, + itemBuilder: (context) { + return sourceIndices.map((index) { + final isValid = index >= 0 && index < sources.length; + final title = isValid + ? _SourceHelper.getSourceTitle(sources[index], index) + : 'Invalid source'; + + 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, + ), + ), + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + title, + style: TextStyle(fontSize: 13, color: theme.textSecondary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }).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: 6, vertical: 1), + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: theme.surfaceContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: theme.dividerColor.withValues(alpha: 0.3), + width: 0.5, + ), + ), + 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), + ), + ), + if (validCount > 1) ...[ + Text( + '+${validCount - 1}', + style: TextStyle( + fontSize: 9, + color: theme.textSecondary.withValues(alpha: 0.7), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/markdown/streaming_markdown_widget.dart b/lib/shared/widgets/markdown/streaming_markdown_widget.dart index 52469ab..734640a 100644 --- a/lib/shared/widgets/markdown/streaming_markdown_widget.dart +++ b/lib/shared/widgets/markdown/streaming_markdown_widget.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import '../../../core/models/chat_message.dart'; +import '../../../core/utils/citation_parser.dart'; +import 'citation_badge.dart'; import 'markdown_config.dart'; import 'markdown_preprocessor.dart'; @@ -16,6 +19,8 @@ class StreamingMarkdownWidget extends StatelessWidget { required this.isStreaming, this.onTapLink, this.imageBuilderOverride, + this.sources, + this.onSourceTap, }); final String content; @@ -24,6 +29,13 @@ class StreamingMarkdownWidget extends StatelessWidget { final Widget Function(Uri uri, String? title, String? alt)? imageBuilderOverride; + /// Sources for inline citation badge rendering. + /// When provided, [1] patterns will be rendered as clickable badges. + final List? sources; + + /// Callback when a source badge is tapped. + final void Function(int sourceIndex)? onSourceTap; + @override Widget build(BuildContext context) { if (content.trim().isEmpty) { @@ -69,12 +81,7 @@ class StreamingMarkdownWidget extends StatelessWidget { specialBlocks.sort((a, b) => a.start.compareTo(b.start)); Widget buildMarkdown(String data) { - return ConduitMarkdown.build( - context: context, - data: data, - onTapLink: onTapLink, - imageBuilderOverride: imageBuilderOverride, - ); + return _buildMarkdownWithCitations(context, data); } Widget result; @@ -126,6 +133,105 @@ class StreamingMarkdownWidget extends StatelessWidget { return SelectionArea(child: result); } + + /// Builds markdown content with citation source references. + /// + /// 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. + Widget _buildMarkdownWithCitations(BuildContext context, String data) { + // If no sources provided, render plain markdown + if (sources == null || sources!.isEmpty) { + return ConduitMarkdown.build( + context: context, + data: data, + onTapLink: onTapLink, + imageBuilderOverride: imageBuilderOverride, + ); + } + + // Check if content has citations + if (!CitationParser.hasCitations(data)) { + return ConduitMarkdown.build( + context: context, + data: data, + onTapLink: onTapLink, + imageBuilderOverride: imageBuilderOverride, + ); + } + + // Extract unique source IDs referenced in the content + final referencedIds = CitationParser.extractSourceIds(data); + if (referencedIds.isEmpty) { + return ConduitMarkdown.build( + context: context, + data: data, + onTapLink: onTapLink, + imageBuilderOverride: imageBuilderOverride, + ); + } + + // Render markdown content as-is (preserving all formatting) + // and add a source references footer + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ConduitMarkdown.build( + context: context, + data: data, + onTapLink: onTapLink, + imageBuilderOverride: imageBuilderOverride, + ), + _SourceReferencesFooter( + referencedIds: referencedIds, + 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, + }); + + /// 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, + ), + ], + ), + ); + } } /// Types of special blocks that need custom rendering @@ -151,11 +257,15 @@ extension StreamingMarkdownExtension on String { required BuildContext context, bool isStreaming = false, MarkdownLinkTapCallback? onTapLink, + List? sources, + void Function(int sourceIndex)? onSourceTap, }) { return StreamingMarkdownWidget( content: this, isStreaming: isStreaming, onTapLink: onTapLink, + sources: sources, + onSourceTap: onSourceTap, ); } }