Merge pull request #248 from cogwheel0/refactor-markdown-library

refactor-markdown-library
This commit is contained in:
cogwheel
2025-12-08 13:27:57 +08:00
committed by GitHub
12 changed files with 2227 additions and 1709 deletions

View 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();
}
}

View File

@@ -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

View File

@@ -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 {

View 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)}';
}
}

View 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,
),
),
),
],
),
),
);
}
}

View File

@@ -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,
),
),
),
),
],
),
);
}
}

View 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

View File

@@ -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,
); );
} }
} }

View File

@@ -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:

View File

@@ -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)