Merge pull request #248 from cogwheel0/refactor-markdown-library
refactor-markdown-library
This commit is contained in:
140
lib/core/utils/citation_parser.dart
Normal file
140
lib/core/utils/citation_parser.dart
Normal file
@@ -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<int> 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<int> 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<CitationSegment>? parse(String content) {
|
||||||
|
if (content.isEmpty) return null;
|
||||||
|
|
||||||
|
final segments = <CitationSegment>[];
|
||||||
|
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 = <int>[];
|
||||||
|
for (final bracketMatch in _bracketGroupPattern.allMatches(raw)) {
|
||||||
|
final idsStr = bracketMatch.group(1) ?? '';
|
||||||
|
final parsed = idsStr
|
||||||
|
.split(',')
|
||||||
|
.map((s) => int.tryParse(s.trim()))
|
||||||
|
.whereType<int>()
|
||||||
|
.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<int> extractSourceIds(String content) {
|
||||||
|
final segments = parse(content);
|
||||||
|
if (segments == null) return const [];
|
||||||
|
|
||||||
|
final ids = <int>{};
|
||||||
|
for (final segment in segments) {
|
||||||
|
if (segment.isCitation) {
|
||||||
|
ids.addAll(segment.citation!.sourceIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids.toList()..sort();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,10 +124,18 @@ class ReasoningContent {
|
|||||||
|
|
||||||
/// Utility class for parsing and extracting reasoning/thinking content.
|
/// Utility class for parsing and extracting reasoning/thinking content.
|
||||||
class ReasoningParser {
|
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.
|
/// Splits content into ordered segments of plain text and reasoning entries.
|
||||||
///
|
///
|
||||||
/// Handles:
|
/// Handles:
|
||||||
/// - `<details type="reasoning">` blocks with optional summary/duration/done
|
/// - `<details type="reasoning">` blocks with optional summary/duration/done
|
||||||
|
/// - `<details>` blocks without type but with reasoning-like summary
|
||||||
/// - Raw tag pairs like `<think>`, `<thinking>`, `<reasoning>`, etc.
|
/// - Raw tag pairs like `<think>`, `<thinking>`, `<reasoning>`, etc.
|
||||||
/// - Incomplete/streaming cases by emitting a partial reasoning entry
|
/// - Incomplete/streaming cases by emitting a partial reasoning entry
|
||||||
static List<ReasoningSegment>? segments(
|
static List<ReasoningSegment>? segments(
|
||||||
@@ -150,14 +158,14 @@ class ReasoningParser {
|
|||||||
int index = 0;
|
int index = 0;
|
||||||
|
|
||||||
while (index < content.length) {
|
while (index < content.length) {
|
||||||
// Find the earliest match: either <details type="reasoning" or a raw tag
|
// Find the earliest match: either <details (any type) or a raw tag
|
||||||
int nextDetailsIdx = -1;
|
int nextDetailsIdx = -1;
|
||||||
int nextRawIdx = -1;
|
int nextRawIdx = -1;
|
||||||
(String, String)? matchedRawPair;
|
(String, String)? matchedRawPair;
|
||||||
|
|
||||||
// Check for <details type="reasoning"
|
// Check for any <details tag (we'll determine if it's reasoning later)
|
||||||
final detailsMatch = RegExp(
|
final detailsMatch = RegExp(
|
||||||
r'<details\s+[^>]*type="reasoning"',
|
r'<details(?:\s|>)',
|
||||||
).firstMatch(content.substring(index));
|
).firstMatch(content.substring(index));
|
||||||
if (detailsMatch != null) {
|
if (detailsMatch != null) {
|
||||||
nextDetailsIdx = index + detailsMatch.start;
|
nextDetailsIdx = index + detailsMatch.start;
|
||||||
@@ -203,9 +211,19 @@ class ReasoningParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (kind == 'details') {
|
if (kind == 'details') {
|
||||||
// Parse <details type="reasoning"> block and extract ReasoningEntry
|
// Parse <details> block and check if it's reasoning content
|
||||||
final result = _parseDetailsReasoning(content, nextIdx);
|
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));
|
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) {
|
if (!result.isComplete) {
|
||||||
// Incomplete block, stop here
|
// Incomplete block, stop here
|
||||||
@@ -233,13 +251,14 @@ class ReasoningParser {
|
|||||||
return segments.isEmpty ? null : segments;
|
return segments.isEmpty ? null : segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a `<details type="reasoning">` block starting at the given index.
|
/// Parse a `<details>` block starting at the given index.
|
||||||
static _ReasoningResult _parseDetailsReasoning(String content, int startIdx) {
|
/// Returns whether the block is reasoning content based on type or summary.
|
||||||
|
static _DetailsResult _parseDetailsBlock(String content, int startIdx) {
|
||||||
// Find the opening tag end
|
// Find the opening tag end
|
||||||
final openTagEnd = content.indexOf('>', startIdx);
|
final openTagEnd = content.indexOf('>', startIdx);
|
||||||
if (openTagEnd == -1) {
|
if (openTagEnd == -1) {
|
||||||
// Incomplete opening tag
|
// Incomplete opening tag - assume reasoning for streaming
|
||||||
return _ReasoningResult(
|
return _DetailsResult(
|
||||||
entry: ReasoningEntry(
|
entry: ReasoningEntry(
|
||||||
reasoning: '',
|
reasoning: '',
|
||||||
summary: '',
|
summary: '',
|
||||||
@@ -248,6 +267,7 @@ class ReasoningParser {
|
|||||||
),
|
),
|
||||||
endIndex: content.length,
|
endIndex: content.length,
|
||||||
isComplete: false,
|
isComplete: false,
|
||||||
|
isReasoning: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +280,7 @@ class ReasoningParser {
|
|||||||
attrs[m.group(1)!] = m.group(2) ?? '';
|
attrs[m.group(1)!] = m.group(2) ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final type = attrs['type'] ?? '';
|
||||||
final isDone = (attrs['done'] ?? 'true') == 'true';
|
final isDone = (attrs['done'] ?? 'true') == 'true';
|
||||||
final duration = int.tryParse(attrs['duration'] ?? '0') ?? 0;
|
final duration = int.tryParse(attrs['duration'] ?? '0') ?? 0;
|
||||||
|
|
||||||
@@ -284,15 +305,27 @@ class ReasoningParser {
|
|||||||
final innerContent = content.substring(openTagEnd + 1);
|
final innerContent = content.substring(openTagEnd + 1);
|
||||||
final summaryResult = _extractSummary(innerContent);
|
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(
|
entry: ReasoningEntry(
|
||||||
reasoning: HtmlUtils.unescapeHtml(summaryResult.remaining),
|
reasoning: HtmlUtils.unescapeHtml(summaryResult.remaining),
|
||||||
summary: HtmlUtils.unescapeHtml(summaryResult.summary),
|
summary: HtmlUtils.unescapeHtml(summaryResult.summary),
|
||||||
duration: duration,
|
duration: effectiveDuration,
|
||||||
isDone: false,
|
isDone: false,
|
||||||
),
|
),
|
||||||
endIndex: content.length,
|
endIndex: content.length,
|
||||||
isComplete: false,
|
isComplete: false,
|
||||||
|
isReasoning: isReasoning,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,15 +334,27 @@ class ReasoningParser {
|
|||||||
final innerContent = content.substring(openTagEnd + 1, closeIdx);
|
final innerContent = content.substring(openTagEnd + 1, closeIdx);
|
||||||
final summaryResult = _extractSummary(innerContent);
|
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(
|
entry: ReasoningEntry(
|
||||||
reasoning: HtmlUtils.unescapeHtml(summaryResult.remaining),
|
reasoning: HtmlUtils.unescapeHtml(summaryResult.remaining),
|
||||||
summary: HtmlUtils.unescapeHtml(summaryResult.summary),
|
summary: HtmlUtils.unescapeHtml(summaryResult.summary),
|
||||||
duration: duration,
|
duration: effectiveDuration,
|
||||||
isDone: isDone,
|
isDone: isDone,
|
||||||
),
|
),
|
||||||
endIndex: i,
|
endIndex: i,
|
||||||
isComplete: true,
|
isComplete: true,
|
||||||
|
isReasoning: isReasoning,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,6 +414,30 @@ class ReasoningParser {
|
|||||||
return _SummaryResult(summary: '', remaining: content.trim());
|
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.
|
/// Parses a message and extracts the first reasoning content block.
|
||||||
/// Returns null if no reasoning content is found.
|
/// Returns null if no reasoning content is found.
|
||||||
static ReasoningContent? parseReasoningContent(
|
static ReasoningContent? parseReasoningContent(
|
||||||
@@ -412,6 +481,17 @@ class ReasoningParser {
|
|||||||
// Check for <details type="reasoning"
|
// Check for <details type="reasoning"
|
||||||
if (content.contains('type="reasoning"')) return true;
|
if (content.contains('type="reasoning"')) return true;
|
||||||
|
|
||||||
|
// Check for <details> with reasoning-like summary
|
||||||
|
if (content.contains('<details')) {
|
||||||
|
final summaryMatch = RegExp(
|
||||||
|
r'<summary>([^<]*)</summary>',
|
||||||
|
).firstMatch(content);
|
||||||
|
if (summaryMatch != null) {
|
||||||
|
final summary = summaryMatch.group(1) ?? '';
|
||||||
|
if (_reasoningSummaryPattern.hasMatch(summary)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for raw tag pairs
|
// Check for raw tag pairs
|
||||||
for (final pair in defaultReasoningTagPairs) {
|
for (final pair in defaultReasoningTagPairs) {
|
||||||
if (content.contains(pair.$1)) return true;
|
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 {
|
class _SummaryResult {
|
||||||
final String summary;
|
final String summary;
|
||||||
final String remaining;
|
final String remaining;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -68,8 +68,8 @@ class _OpenWebUISourcesWidgetState extends State<OpenWebUISourcesWidget> {
|
|||||||
splashColor: theme.surfaceContainer.withValues(alpha: 0.2),
|
splashColor: theme.surfaceContainer.withValues(alpha: 0.2),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 14,
|
horizontal: 10,
|
||||||
vertical: 8,
|
vertical: 5,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
@@ -196,19 +196,12 @@ class _OpenWebUISourcesWidgetState extends State<OpenWebUISourcesWidget> {
|
|||||||
|
|
||||||
// Debug: debugPrint('Building source item $index: $displayText');
|
// Debug: debugPrint('Building source item $index: $displayText');
|
||||||
|
|
||||||
// Determine display text
|
// Determine display text - for URL sources, show just the URL
|
||||||
String displayText;
|
String displayText;
|
||||||
String? title = source.title;
|
if (isUrl) {
|
||||||
|
displayText = url;
|
||||||
// If no direct title, check metadata
|
} else if (source.title != null && source.title!.isNotEmpty) {
|
||||||
if ((title == null || title.isEmpty) && source.metadata != null) {
|
displayText = source.title!;
|
||||||
title = source.metadata!['title']?.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (title != null && title.isNotEmpty) {
|
|
||||||
displayText = title;
|
|
||||||
} else if (isUrl) {
|
|
||||||
displayText = _extractDomain(url);
|
|
||||||
} else if (source.id != null && source.id!.isNotEmpty) {
|
} else if (source.id != null && source.id!.isNotEmpty) {
|
||||||
displayText = source.id!;
|
displayText = source.id!;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
539
lib/features/chat/widgets/streaming_status_widget.dart
Normal file
539
lib/features/chat/widgets/streaming_status_widget.dart
Normal file
@@ -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<ChatStatusUpdate> updates;
|
||||||
|
final bool isStreaming;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StreamingStatusWidget> createState() => _StreamingStatusWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StreamingStatusWidgetState extends State<StreamingStatusWidget> {
|
||||||
|
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<ChatStatusUpdate> 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<String> 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<String> _collectQueries(ChatStatusUpdate update) {
|
||||||
|
final merged = <String>[];
|
||||||
|
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)}';
|
||||||
|
}
|
||||||
|
}
|
||||||
355
lib/shared/widgets/markdown/citation_badge.dart
Normal file
355
lib/shared/widgets/markdown/citation_badge.dart
Normal file
@@ -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<void> 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<ChatSourceReference> 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<int> sourceIndices;
|
||||||
|
|
||||||
|
/// List of sources from the message.
|
||||||
|
final List<ChatSourceReference> 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<int>(
|
||||||
|
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<int>(
|
||||||
|
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<PopupMenuItem<int>>()
|
||||||
|
.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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
92
lib/shared/widgets/markdown/inline_citation_text.dart
Normal file
92
lib/shared/widgets/markdown/inline_citation_text.dart
Normal file
@@ -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<ChatSourceReference> 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 = <InlineSpan>[];
|
||||||
|
|
||||||
|
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<int> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../../core/models/chat_message.dart';
|
||||||
|
import '../../../core/utils/citation_parser.dart';
|
||||||
import '../../theme/theme_extensions.dart';
|
import '../../theme/theme_extensions.dart';
|
||||||
|
import 'citation_badge.dart';
|
||||||
import 'markdown_config.dart';
|
import 'markdown_config.dart';
|
||||||
import 'markdown_preprocessor.dart';
|
import 'markdown_preprocessor.dart';
|
||||||
|
|
||||||
@@ -17,6 +20,8 @@ class StreamingMarkdownWidget extends StatelessWidget {
|
|||||||
required this.isStreaming,
|
required this.isStreaming,
|
||||||
this.onTapLink,
|
this.onTapLink,
|
||||||
this.imageBuilderOverride,
|
this.imageBuilderOverride,
|
||||||
|
this.sources,
|
||||||
|
this.onSourceTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String content;
|
final String content;
|
||||||
@@ -25,6 +30,13 @@ class StreamingMarkdownWidget extends StatelessWidget {
|
|||||||
final Widget Function(Uri uri, String? title, String? alt)?
|
final Widget Function(Uri uri, String? title, String? alt)?
|
||||||
imageBuilderOverride;
|
imageBuilderOverride;
|
||||||
|
|
||||||
|
/// Sources for inline citation badge rendering.
|
||||||
|
/// When provided, [1] patterns will be rendered as clickable badges.
|
||||||
|
final List<ChatSourceReference>? sources;
|
||||||
|
|
||||||
|
/// Callback when a source badge is tapped.
|
||||||
|
final void Function(int sourceIndex)? onSourceTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (content.trim().isEmpty) {
|
if (content.trim().isEmpty) {
|
||||||
@@ -70,28 +82,14 @@ class StreamingMarkdownWidget extends StatelessWidget {
|
|||||||
specialBlocks.sort((a, b) => a.start.compareTo(b.start));
|
specialBlocks.sort((a, b) => a.start.compareTo(b.start));
|
||||||
|
|
||||||
Widget buildMarkdown(String data) {
|
Widget buildMarkdown(String data) {
|
||||||
return ConduitMarkdown.buildBlock(
|
return _buildMarkdownWithCitations(context, data);
|
||||||
context: context,
|
|
||||||
data: data,
|
|
||||||
onTapLink: onTapLink,
|
|
||||||
selectable: false,
|
|
||||||
imageBuilderOverride: imageBuilderOverride,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget result;
|
||||||
|
|
||||||
if (specialBlocks.isEmpty) {
|
if (specialBlocks.isEmpty) {
|
||||||
return SelectionArea(
|
result = buildMarkdown(normalized);
|
||||||
child: Theme(
|
} else {
|
||||||
data: Theme.of(context).copyWith(
|
|
||||||
textSelectionTheme: TextSelectionThemeData(
|
|
||||||
cursorColor: context.conduitTheme.buttonPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: buildMarkdown(normalized),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final children = <Widget>[];
|
final children = <Widget>[];
|
||||||
var currentIndex = 0;
|
var currentIndex = 0;
|
||||||
for (final block in specialBlocks) {
|
for (final block in specialBlocks) {
|
||||||
@@ -122,20 +120,304 @@ class StreamingMarkdownWidget extends StatelessWidget {
|
|||||||
children.add(buildMarkdown(tail));
|
children.add(buildMarkdown(tail));
|
||||||
}
|
}
|
||||||
|
|
||||||
return SelectionArea(
|
result = Column(
|
||||||
child: Theme(
|
|
||||||
data: Theme.of(context).copyWith(
|
|
||||||
textSelectionTheme: TextSelectionThemeData(
|
|
||||||
cursorColor: context.conduitTheme.buttonPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: children,
|
children: children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only wrap in SelectionArea when not streaming to avoid concurrent
|
||||||
|
// modification errors in Flutter's selection system during rapid updates
|
||||||
|
if (isStreaming) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SelectionArea(child: result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds markdown content with inline citation badges.
|
||||||
|
///
|
||||||
|
/// 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) {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<ChatSourceReference> 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,
|
||||||
|
onTapLink: onTapLink,
|
||||||
|
imageBuilderOverride: imageBuilderOverride,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build widgets for each segment
|
||||||
|
final children = <Widget>[];
|
||||||
|
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: buffer.toString(),
|
||||||
|
onTapLink: onTapLink,
|
||||||
|
imageBuilderOverride: imageBuilderOverride,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = <InlineSpan>[];
|
||||||
|
|
||||||
|
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 = <InlineSpan>[];
|
||||||
|
|
||||||
|
// 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<int> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A segment of content that may or may not contain citations.
|
||||||
|
class _ContentSegment {
|
||||||
|
final String text;
|
||||||
|
final bool hasCitations;
|
||||||
|
|
||||||
|
const _ContentSegment({required this.text, required this.hasCitations});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Types of special blocks that need custom rendering
|
/// Types of special blocks that need custom rendering
|
||||||
@@ -161,11 +443,15 @@ extension StreamingMarkdownExtension on String {
|
|||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
bool isStreaming = false,
|
bool isStreaming = false,
|
||||||
MarkdownLinkTapCallback? onTapLink,
|
MarkdownLinkTapCallback? onTapLink,
|
||||||
|
List<ChatSourceReference>? sources,
|
||||||
|
void Function(int sourceIndex)? onSourceTap,
|
||||||
}) {
|
}) {
|
||||||
return StreamingMarkdownWidget(
|
return StreamingMarkdownWidget(
|
||||||
content: this,
|
content: this,
|
||||||
isStreaming: isStreaming,
|
isStreaming: isStreaming,
|
||||||
onTapLink: onTapLink,
|
onTapLink: onTapLink,
|
||||||
|
sources: sources,
|
||||||
|
onSourceTap: onSourceTap,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
pubspec.lock
32
pubspec.lock
@@ -494,6 +494,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "3.0.0"
|
||||||
|
flutter_highlight:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_highlight
|
||||||
|
sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.0"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -539,14 +547,6 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
flutter_markdown_plus:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: flutter_markdown_plus
|
|
||||||
sha256: "7f349c075157816da399216a4127096108fd08e1ac931e34e72899281db4113c"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.0.5"
|
|
||||||
flutter_math_fork:
|
flutter_math_fork:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -701,6 +701,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "17.0.0"
|
version: "17.0.0"
|
||||||
|
gpt_markdown:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: gpt_markdown
|
||||||
|
sha256: "8174983f2ed7d8576d25810913e3afe3f8ffdaa3172c0c823b7cfc289b67f380"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.4"
|
||||||
graphs:
|
graphs:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -709,6 +717,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
|
highlight:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: highlight
|
||||||
|
sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.0"
|
||||||
hive_ce:
|
hive_ce:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ dependencies:
|
|||||||
|
|
||||||
# UI Components - Markdown Rendering
|
# UI Components - Markdown Rendering
|
||||||
cached_network_image: ^3.3.1
|
cached_network_image: ^3.3.1
|
||||||
flutter_markdown_plus: ^1.0.5
|
gpt_markdown: ^1.1.4
|
||||||
|
flutter_math_fork: ^0.7.2
|
||||||
markdown: ^7.3.0
|
markdown: ^7.3.0
|
||||||
webview_flutter: ^4.7.0
|
webview_flutter: ^4.7.0
|
||||||
socket_io_client: ^3.1.2
|
socket_io_client: ^3.1.2
|
||||||
yaml: ^3.1.2
|
yaml: ^3.1.2
|
||||||
flutter_math_fork: ^0.7.4
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -78,6 +78,7 @@ dependencies:
|
|||||||
flutter_svg: ^2.2.3
|
flutter_svg: ^2.2.3
|
||||||
html_unescape: ^2.0.0
|
html_unescape: ^2.0.0
|
||||||
home_widget: ^0.8.1
|
home_widget: ^0.8.1
|
||||||
|
flutter_highlight: ^0.7.0
|
||||||
|
|
||||||
# Clipboard functionality is available through flutter/services (part of Flutter SDK)
|
# Clipboard functionality is available through flutter/services (part of Flutter SDK)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user