import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'dart:convert';
import 'dart:async';
import 'dart:io' show Platform;
import '../../../shared/theme/theme_extensions.dart';
import '../../../shared/widgets/markdown/streaming_markdown_widget.dart';
import '../../../core/utils/reasoning_parser.dart';
import '../../../core/utils/message_segments.dart';
import '../../../core/utils/tool_calls_parser.dart';
import '../../../core/models/chat_message.dart';
import '../../../core/utils/markdown_to_text.dart';
import '../providers/text_to_speech_provider.dart';
import 'enhanced_image_attachment.dart';
import 'package:conduit/l10n/app_localizations.dart';
import 'enhanced_attachment.dart';
import 'package:conduit/shared/widgets/chat_action_button.dart';
import '../../../shared/widgets/model_avatar.dart';
import '../../../shared/widgets/conduit_components.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../providers/chat_providers.dart' show sendMessageWithContainer;
import '../../../core/utils/debug_logger.dart';
import 'sources/openwebui_sources.dart';
import '../providers/assistant_response_builder_provider.dart';
import '../../../core/services/worker_manager.dart';
// Pre-compiled regex patterns for image processing (performance optimization)
final _base64ImagePattern = RegExp(r'data:image/[^;]+;base64,[A-Za-z0-9+/]+=*');
final _fileIdPattern = RegExp(r'/api/v1/files/([^/]+)/content');
// Pre-compiled regex patterns for content sanitization (performance optimization)
final _detailsOpenPattern = RegExp(r']*>');
final _detailsClosePattern = RegExp(r' ');
final _inlineDetailsPattern = RegExp(
r']*)>((?:(?! ).)*)',
dotAll: true,
);
// Patterns for balancing and tags (similar to )
final _thinkOpenPattern = RegExp(r'');
final _thinkClosePattern = RegExp(r'');
final _reasoningOpenPattern = RegExp(r'');
final _reasoningClosePattern = RegExp(r'');
/// Sanitizes content to handle malformed HTML-like tags that might cause
/// parsing issues, particularly with Pipe Functions (e.g., Gemini).
///
/// This function:
/// - Ensures all ``, ``, and `` tags are properly
/// closed
/// - Converts inline `... ` to multi-line format for proper
/// block-level parsing
/// - Removes orphan closing tags (those without matching opening tags)
/// - Adds missing closing tags for unclosed opening tags
/// - Prevents infinite loops in parsers caused by malformed content
String sanitizeContentForParsing(String content) {
if (content.isEmpty) return content;
String result = content;
// Check which tag types are present and need balancing
final hasDetails =
content.contains('');
final hasThink = content.contains('') || content.contains('');
final hasReasoning =
content.contains('') || content.contains('');
// Quick check: skip if no relevant tags present
if (!hasDetails && !hasThink && !hasReasoning) {
return content;
}
// Step 1: Convert inline ... to multi-line format
// This ensures the markdown block parser can properly detect them
if (hasDetails) {
result = result.replaceAllMapped(_inlineDetailsPattern, (match) {
final attrs = match.group(1) ?? '';
final inner = match.group(2) ?? '';
// Only convert if the inner content doesn't already span multiple lines
if (!inner.contains('\n')) {
return '\n$inner\n ';
}
return match.group(0)!;
});
}
// Step 2: Balance tags by removing orphan closing tags and adding
// missing closing tags using depth tracking
if (hasDetails) {
result = _balanceTags(
result,
_detailsOpenPattern,
_detailsClosePattern,
' ',
);
}
if (hasThink) {
result = _balanceTags(
result,
_thinkOpenPattern,
_thinkClosePattern,
'',
);
}
if (hasReasoning) {
result = _balanceTags(
result,
_reasoningOpenPattern,
_reasoningClosePattern,
' ',
);
}
return result;
}
/// Balances tags by removing orphan closing tags and adding missing closing
/// tags. Uses depth tracking to properly handle nested tags and identify
/// orphans anywhere in the content.
String _balanceTags(
String content,
RegExp openPattern,
RegExp closePattern,
String closeTag,
) {
final openMatches = openPattern.allMatches(content).toList();
final closeMatches = closePattern.allMatches(content).toList();
if (openMatches.isEmpty && closeMatches.isEmpty) return content;
// Build sorted list of all tags: (start, end, isOpen)
final tags = <({int start, int end, bool isOpen})>[];
for (final m in openMatches) {
tags.add((start: m.start, end: m.end, isOpen: true));
}
for (final m in closeMatches) {
tags.add((start: m.start, end: m.end, isOpen: false));
}
tags.sort((a, b) => a.start.compareTo(b.start));
// Find orphan closing tags using depth tracking
// An orphan is a closing tag encountered when depth is already 0
final orphanRanges = <(int, int)>[];
int depth = 0;
for (final tag in tags) {
if (tag.isOpen) {
depth++;
} else {
if (depth > 0) {
depth--;
} else {
// Orphan closing tag - no matching opening tag
orphanRanges.add((tag.start, tag.end));
}
}
}
// Remove orphan closing tags from end to start to preserve indices
var result = content;
for (final range in orphanRanges.reversed) {
result = result.substring(0, range.$1) + result.substring(range.$2);
}
// Add missing closing tags for unclosed opening tags
if (depth > 0) {
result += '\n$closeTag' * depth;
}
return result;
}
class AssistantMessageWidget extends ConsumerStatefulWidget {
final dynamic message;
final bool isStreaming;
final bool showFollowUps;
final String? modelName;
final String? modelIconUrl;
final VoidCallback? onCopy;
final VoidCallback? onRegenerate;
final VoidCallback? onLike;
final VoidCallback? onDislike;
const AssistantMessageWidget({
super.key,
required this.message,
this.isStreaming = false,
this.showFollowUps = true,
this.modelName,
this.modelIconUrl,
this.onCopy,
this.onRegenerate,
this.onLike,
this.onDislike,
});
@override
ConsumerState createState() =>
_AssistantMessageWidgetState();
}
class _AssistantMessageWidgetState extends ConsumerState
with TickerProviderStateMixin {
late AnimationController _fadeController;
late AnimationController _slideController;
// Unified content segments (text, tool-calls, reasoning)
List _segments = const [];
final Set _expandedToolIds = {};
final Set _expandedReasoning = {};
Widget? _cachedAvatar;
bool _allowTypingIndicator = false;
Timer? _typingGateTimer;
String _ttsPlainText = '';
Timer? _ttsPlainTextDebounce;
Map? _pendingTtsPlainTextPayload;
String? _pendingTtsPlainTextSource;
String? _lastAppliedTtsPlainTextSource;
int _ttsPlainTextRequestId = 0;
// Active version index (-1 means current/live content)
int _activeVersionIndex = -1;
// press state handled by shared ChatActionButton
Future _handleFollowUpTap(String suggestion) async {
final trimmed = suggestion.trim();
if (trimmed.isEmpty || widget.isStreaming) {
return;
}
try {
final container = ProviderScope.containerOf(context, listen: false);
await sendMessageWithContainer(container, trimmed, null);
} catch (err, stack) {
DebugLogger.log(
'Failed to send follow-up: $err',
scope: 'chat/assistant',
);
debugPrintStack(stackTrace: stack);
}
}
@override
void initState() {
super.initState();
_fadeController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_slideController = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
// Parse reasoning and tool-calls sections
unawaited(_reparseSections());
_updateTypingIndicatorGate();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Build cached avatar when theme context is available
_buildCachedAvatar();
}
@override
void didUpdateWidget(AssistantMessageWidget oldWidget) {
super.didUpdateWidget(oldWidget);
// Re-parse sections when message content changes
if (oldWidget.message.content != widget.message.content) {
unawaited(_reparseSections());
_updateTypingIndicatorGate();
}
// Update typing indicator gate when message properties that affect emptiness change
if (oldWidget.message.statusHistory != widget.message.statusHistory ||
oldWidget.message.files != widget.message.files ||
oldWidget.message.attachmentIds != widget.message.attachmentIds ||
oldWidget.message.followUps != widget.message.followUps ||
oldWidget.message.codeExecutions != widget.message.codeExecutions) {
_updateTypingIndicatorGate();
}
// Rebuild cached avatar if model name or icon changes
if (oldWidget.modelName != widget.modelName ||
oldWidget.modelIconUrl != widget.modelIconUrl) {
_buildCachedAvatar();
}
}
Future _reparseSections() async {
final raw0 = _activeVersionIndex >= 0
? (widget.message.versions[_activeVersionIndex].content as String?) ??
''
: widget.message.content ?? '';
// Strip any leftover placeholders from content before parsing
const ti = '[TYPING_INDICATOR]';
const searchBanner = '🔍 Searching the web...';
String raw = raw0;
if (raw.startsWith(ti)) {
raw = raw.substring(ti.length);
}
if (raw.startsWith(searchBanner)) {
raw = raw.substring(searchBanner.length);
}
// Sanitize content to handle malformed HTML-like tags from Pipe Functions
// (e.g., Gemini) that might cause parsing issues or infinite loops.
// Only sanitize when NOT streaming to avoid interfering with partial content.
if (!widget.isStreaming) {
raw = sanitizeContentForParsing(raw);
}
// Do not truncate content during streaming; segmented parser skips
// incomplete details blocks and tiles will render once complete.
final rSegs = ReasoningParser.segments(raw);
final out = [];
final textSegments = [];
if (rSegs == null || rSegs.isEmpty) {
final tSegs = ToolCallsParser.segments(raw);
if (tSegs == null || tSegs.isEmpty) {
out.add(MessageSegment.text(raw));
textSegments.add(raw);
} else {
for (final s in tSegs) {
if (s.isToolCall && s.entry != null) {
out.add(MessageSegment.tool(s.entry!));
} else if ((s.text ?? '').isNotEmpty) {
out.add(MessageSegment.text(s.text!));
textSegments.add(s.text!);
}
}
}
} else {
for (final rs in rSegs) {
if (rs.isReasoning && rs.entry != null) {
out.add(MessageSegment.reason(rs.entry!));
} else if ((rs.text ?? '').isNotEmpty) {
final t = rs.text!;
final tSegs = ToolCallsParser.segments(t);
if (tSegs == null || tSegs.isEmpty) {
out.add(MessageSegment.text(t));
textSegments.add(t);
} else {
for (final s in tSegs) {
if (s.isToolCall && s.entry != null) {
out.add(MessageSegment.tool(s.entry!));
} else if ((s.text ?? '').isNotEmpty) {
out.add(MessageSegment.text(s.text!));
textSegments.add(s.text!);
}
}
}
}
}
}
final segments = out.isEmpty ? [MessageSegment.text(raw)] : out;
if (!mounted) return;
setState(() {
_segments = segments;
});
_scheduleTtsPlainTextBuild(
List.from(textSegments, growable: false),
raw,
);
_updateTypingIndicatorGate();
}
void _updateTypingIndicatorGate() {
_typingGateTimer?.cancel();
if (_shouldShowTypingIndicator) {
if (_allowTypingIndicator) {
return;
}
_typingGateTimer = Timer(const Duration(milliseconds: 150), () {
if (!mounted || !_shouldShowTypingIndicator) {
return;
}
setState(() {
_allowTypingIndicator = true;
});
});
} else if (_allowTypingIndicator) {
if (mounted) {
setState(() {
_allowTypingIndicator = false;
});
} else {
_allowTypingIndicator = false;
}
}
}
String get _messageId {
try {
final dynamic idValue = widget.message.id;
if (idValue == null) {
return '';
}
return idValue.toString();
} catch (_) {
return '';
}
}
String _buildTtsPlainTextFallback(List segments, String fallback) {
if (segments.isEmpty) {
return MarkdownToText.convert(fallback);
}
final buffer = StringBuffer();
for (final segment in segments) {
final sanitized = MarkdownToText.convert(segment);
if (sanitized.isEmpty) {
continue;
}
if (buffer.isNotEmpty) {
buffer.writeln();
buffer.writeln();
}
buffer.write(sanitized);
}
final result = buffer.toString().trim();
if (result.isEmpty) {
return MarkdownToText.convert(fallback);
}
return result;
}
void _scheduleTtsPlainTextBuild(List segments, String raw) {
final hasContent =
segments.any((segment) => segment.trim().isNotEmpty) ||
raw.trim().isNotEmpty;
if (!hasContent) {
_pendingTtsPlainTextPayload = null;
_pendingTtsPlainTextSource = null;
_lastAppliedTtsPlainTextSource = '';
if (_ttsPlainText.isNotEmpty && mounted) {
setState(() {
_ttsPlainText = '';
});
}
return;
}
if (_pendingTtsPlainTextPayload == null &&
raw == _lastAppliedTtsPlainTextSource) {
return;
}
if (raw == _pendingTtsPlainTextSource &&
_pendingTtsPlainTextPayload != null) {
return;
}
final pendingSegments = List.from(segments, growable: false);
_pendingTtsPlainTextPayload = {
'segments': pendingSegments,
'fallback': raw,
};
_pendingTtsPlainTextSource = raw;
final delay = widget.isStreaming
? const Duration(milliseconds: 250)
: Duration.zero;
_ttsPlainTextDebounce?.cancel();
if (delay == Duration.zero) {
_runPendingTtsPlainTextBuild();
} else {
_ttsPlainTextDebounce = Timer(delay, _runPendingTtsPlainTextBuild);
}
}
void _runPendingTtsPlainTextBuild() {
_ttsPlainTextDebounce?.cancel();
_ttsPlainTextDebounce = null;
final payload = _pendingTtsPlainTextPayload;
final source = _pendingTtsPlainTextSource;
if (payload == null || source == null) {
return;
}
_pendingTtsPlainTextPayload = null;
_pendingTtsPlainTextSource = null;
final requestId = ++_ttsPlainTextRequestId;
unawaited(_executeTtsPlainTextBuild(payload, source, requestId));
}
Future _executeTtsPlainTextBuild(
Map payload,
String raw,
int requestId,
) async {
final segments = (payload['segments'] as List).cast();
String speechText;
try {
final worker = ref.read(workerManagerProvider);
speechText = await worker.schedule