refactor(markdown): remove deprecated stream formatter and enhance preprocessor

This commit is contained in:
cogwheel
2025-12-22 14:07:04 +05:30
parent 653162cb76
commit 5fd68f86fe
12 changed files with 347 additions and 505 deletions

View File

@@ -16,7 +16,6 @@ import '../../../core/services/streaming_helper.dart';
import '../../../core/services/streaming_response_controller.dart';
import '../../../core/services/worker_manager.dart';
import '../../../core/utils/debug_logger.dart';
import '../../../core/utils/markdown_stream_formatter.dart';
import '../../../core/utils/tool_calls_parser.dart';
import '../models/chat_context_attachment.dart';
import '../providers/context_attachments_provider.dart';
@@ -109,9 +108,6 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
bool _taskStatusCheckInFlight = false;
bool _observedRemoteTask = false;
MarkdownStreamFormatter? _markdownFormatter;
String? _activeStreamingMessageId;
bool _initialized = false;
@override
@@ -180,7 +176,6 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
// Cancel any existing message stream when switching conversations
_cancelMessageStream();
_clearStreamingFormatter(); // Explicitly clear formatter on conversation switch
_stopRemoteTaskMonitor();
if (next != null) {
@@ -222,16 +217,10 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
if (controller != null && controller.isActive) {
unawaited(controller.cancel());
}
_clearStreamingFormatter();
cancelSocketSubscriptions();
_stopRemoteTaskMonitor();
}
void _clearStreamingFormatter() {
_markdownFormatter = null;
_activeStreamingMessageId = null;
}
/// Checks if streaming cleanup is needed when adopting server messages.
/// Must be called BEFORE updating state, as it compares current local state
/// with incoming server state.
@@ -397,39 +386,6 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
}
}
void _ensureFormatterForMessage(ChatMessage message) {
// If we're switching to a different message, clear the old formatter first
if (_markdownFormatter != null && _activeStreamingMessageId != message.id) {
DebugLogger.log(
'Clearing formatter for message switch: $_activeStreamingMessageId -> ${message.id}',
scope: 'chat/providers',
);
_clearStreamingFormatter();
}
// If formatter already exists for this message, reuse it
if (_markdownFormatter != null && _activeStreamingMessageId == message.id) {
return;
}
// Create new formatter
final formatter = MarkdownStreamFormatter();
// Only seed with existing content if this is a resume scenario
// For new messages (empty content), start fresh to avoid duplication
final seed = _stripStreamingPlaceholders(message.content);
if (seed.isNotEmpty && message.content.isNotEmpty) {
DebugLogger.log(
'Seeding formatter with existing content (${seed.length} chars) for message ${message.id}',
scope: 'chat/providers',
);
formatter.seed(seed);
}
_markdownFormatter = formatter;
_activeStreamingMessageId = message.id;
}
String _stripStreamingPlaceholders(String content) {
var result = content;
const ti = '[TYPING_INDICATOR]';
@@ -443,15 +399,6 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
return result;
}
String _finalizeFormatter(String messageId, String fallback) {
if (_markdownFormatter != null && _activeStreamingMessageId == messageId) {
final output = _markdownFormatter!.finalize();
_clearStreamingFormatter();
return output;
}
return fallback;
}
void _touchStreamingActivity() {
_lastStreamingActivity = DateTime.now();
if (_hasStreamingAssistant) {
@@ -728,16 +675,11 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
}
void appendToLastMessage(String content) {
if (state.isEmpty) {
return;
}
if (state.isEmpty) return;
final lastMessage = state.last;
if (lastMessage.role != 'assistant') {
return;
}
if (lastMessage.role != 'assistant') return;
if (!lastMessage.isStreaming) {
// Ignore late chunks when streaming already finished
DebugLogger.log(
'Ignoring late chunk for finished message: ${lastMessage.id}',
scope: 'chat/providers',
@@ -745,52 +687,21 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
return;
}
_ensureFormatterForMessage(lastMessage);
// Defensive check: ensure the formatter is for the correct message
// This prevents cross-message pollution when messages change rapidly
if (_activeStreamingMessageId != lastMessage.id) {
DebugLogger.warning(
'Formatter message ID mismatch: active=$_activeStreamingMessageId, last=${lastMessage.id}. Resetting formatter.',
);
_clearStreamingFormatter();
_ensureFormatterForMessage(lastMessage);
}
final formatter = _markdownFormatter!;
final preview = formatter.ingest(content);
// Append content directly - the widget's normalize() handles incomplete markdown
state = [
...state.sublist(0, state.length - 1),
lastMessage.copyWith(content: preview),
lastMessage.copyWith(content: lastMessage.content + content),
];
_touchStreamingActivity();
}
void replaceLastMessageContent(String content) {
if (state.isEmpty) {
return;
}
if (state.isEmpty) return;
final lastMessage = state.last;
if (lastMessage.role != 'assistant') {
return;
}
_ensureFormatterForMessage(lastMessage);
// Defensive check: ensure the formatter is for the correct message
if (_activeStreamingMessageId != lastMessage.id) {
DebugLogger.warning(
'Formatter message ID mismatch in replace: active=$_activeStreamingMessageId, last=${lastMessage.id}. Resetting formatter.',
);
_clearStreamingFormatter();
_ensureFormatterForMessage(lastMessage);
}
final formatter = _markdownFormatter!;
final sanitized = formatter.replace(_stripStreamingPlaceholders(content));
if (lastMessage.role != 'assistant') return;
final sanitized = _stripStreamingPlaceholders(content);
state = [
...state.sublist(0, state.length - 1),
lastMessage.copyWith(content: sanitized),
@@ -804,8 +715,7 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
final lastMessage = state.last;
if (lastMessage.role != 'assistant' || !lastMessage.isStreaming) return;
final finalized = _finalizeFormatter(lastMessage.id, lastMessage.content);
final cleaned = _stripStreamingPlaceholders(finalized);
final cleaned = _stripStreamingPlaceholders(lastMessage.content);
var updatedLast = lastMessage.copyWith(
isStreaming: false,
@@ -1005,11 +915,7 @@ Future<void> restoreDefaultModel(dynamic ref) async {
try {
await ref.read(defaultModelProvider.future);
} catch (e) {
DebugLogger.error(
'restore-default-failed',
scope: 'chat/model',
error: e,
);
DebugLogger.error('restore-default-failed', scope: 'chat/model', error: e);
}
}

View File

@@ -4,7 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/services/settings_service.dart';
import '../../../core/providers/app_providers.dart';
import '../../../core/utils/markdown_to_text.dart';
import '../../../shared/widgets/markdown/markdown_preprocessor.dart';
import '../services/text_to_speech_service.dart';
enum TtsPlaybackStatus { idle, initializing, loading, speaking, paused, error }
@@ -218,7 +218,7 @@ class TextToSpeechController extends Notifier<TextToSpeechState> {
}
// Prepare sentence split for highlighting
final cleanText = MarkdownToText.convert(text);
final cleanText = ConduitMarkdownPreprocessor.toPlainText(text);
final sentences = _service.splitTextForSpeech(cleanText);
final offsets = _computeOffsets(cleanText, sentences);

View File

@@ -12,7 +12,7 @@ import '../../../core/providers/app_providers.dart';
import '../../../core/services/background_streaming_handler.dart';
import '../../../core/services/callkit_service.dart';
import '../../../core/services/socket_service.dart';
import '../../../core/utils/markdown_to_text.dart';
import '../../../shared/widgets/markdown/markdown_preprocessor.dart';
import '../providers/chat_providers.dart';
import 'text_to_speech_service.dart';
import '../../../core/services/settings_service.dart';
@@ -589,7 +589,7 @@ class VoiceCallService {
void _processSpeakableSegments({required bool isFinalChunk}) {
if (_isDisposed) return;
final cleanText = MarkdownToText.convert(_accumulatedResponse).trim();
final cleanText = ConduitMarkdownPreprocessor.toPlainText(_accumulatedResponse).trim();
if (cleanText.isEmpty) {
return;
}

View File

@@ -18,6 +18,7 @@ import '../providers/chat_providers.dart';
import '../../../core/utils/debug_logger.dart';
import '../../../core/utils/user_display_name.dart';
import '../../../core/utils/model_icon_utils.dart';
import '../../../shared/widgets/markdown/markdown_preprocessor.dart';
import '../../../core/utils/android_assistant_handler.dart';
import '../widgets/modern_chat_input.dart';
import '../widgets/user_message_bubble.dart';
@@ -1205,36 +1206,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
}
void _copyMessage(String content) {
// Strip reasoning details from the copied content
String cleanedContent = content;
// Remove <details type="reasoning"> blocks
cleanedContent = cleanedContent.replaceAll(
RegExp(
r'<details\s+type="reasoning"[^>]*>[\s\S]*?<\/details>',
multiLine: true,
dotAll: true,
),
'',
);
// Remove raw reasoning tags
cleanedContent = cleanedContent.replaceAll(
RegExp(r'<think>[\s\S]*?<\/think>', multiLine: true, dotAll: true),
'',
);
cleanedContent = cleanedContent.replaceAll(
RegExp(
r'<reasoning>[\s\S]*?<\/reasoning>',
multiLine: true,
dotAll: true,
),
'',
);
// Clean up any extra whitespace
cleanedContent = cleanedContent.trim();
// Strip reasoning blocks and annotations from copied content
final cleanedContent = ConduitMarkdownPreprocessor.sanitize(content);
Clipboard.setData(ClipboardData(text: cleanedContent));
}

View File

@@ -7,7 +7,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers/app_providers.dart';
import '../../../core/utils/markdown_to_text.dart';
import '../../../shared/widgets/markdown/markdown_preprocessor.dart';
import '../../../l10n/app_localizations.dart';
import '../../../shared/widgets/conduit_components.dart';
import '../providers/chat_providers.dart';
@@ -335,7 +335,7 @@ class _VoiceCallPageState extends ConsumerState<VoiceCallPage>
} else if (_currentState == VoiceCallState.speaking &&
_currentResponse.isNotEmpty) {
// Convert markdown to clean text for display
displayText = MarkdownToText.convert(_currentResponse);
displayText = ConduitMarkdownPreprocessor.toPlainText(_currentResponse);
}
if (displayText.isEmpty) {

View File

@@ -11,7 +11,7 @@ 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 '../../../shared/widgets/markdown/markdown_preprocessor.dart';
import '../providers/text_to_speech_provider.dart';
import 'enhanced_image_attachment.dart';
import 'package:conduit/l10n/app_localizations.dart';
@@ -166,6 +166,10 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
raw = raw.substring(searchBanner.length);
}
// Note: Link reference definitions (including OpenAI annotations like
// [openai_responses:v2:reasoning:ID]: #) are stripped by the markdown
// preprocessor using the `markdown` package for proper CommonMark handling.
// Do not truncate content during streaming; segmented parser skips
// incomplete details blocks and tiles will render once complete.
final rSegs = ReasoningParser.segments(raw);
@@ -263,12 +267,12 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
String _buildTtsPlainTextFallback(List<String> segments, String fallback) {
if (segments.isEmpty) {
return MarkdownToText.convert(fallback);
return ConduitMarkdownPreprocessor.toPlainText(fallback);
}
final buffer = StringBuffer();
for (final segment in segments) {
final sanitized = MarkdownToText.convert(segment);
final sanitized = ConduitMarkdownPreprocessor.toPlainText(segment);
if (sanitized.isEmpty) {
continue;
}
@@ -281,7 +285,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
final result = buffer.toString().trim();
if (result.isEmpty) {
return MarkdownToText.convert(fallback);
return ConduitMarkdownPreprocessor.toPlainText(fallback);
}
return result;
}
@@ -1738,24 +1742,32 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
summaryLower == 'thinking...' ||
summaryLower.startsWith('thinking');
// Check if summary contains server-formatted duration (e.g., "(0s)", "for 0 secs")
final hasDurationInSummary = RegExp(
r'\(\d+s\)|\bfor \d+ secs?\b',
caseSensitive: false,
).hasMatch(rc.summary);
// - If not done (streaming): show "Thinking..."
// - If done with duration: show "Thought for X seconds"
// - If done without duration: show "Thoughts" or custom summary
// - If done: show humanized "Thought for X" (uses our formatDuration)
// - If done without duration and has custom summary: show summary
if (!rc.isDone) {
// Still thinking - use summary if available, else default
return hasSummary && !isThinkingSummary ? rc.summary : l10n.thinking;
}
// Done thinking - check duration
if (rc.duration > 0) {
// Done thinking - always use humanized duration format
// This ensures "less than a second" instead of "0 secs" from server
if (rc.duration >= 0 && (rc.duration > 0 || hasDurationInSummary || isThinkingSummary)) {
return l10n.thoughtForDuration(rc.formattedDuration);
}
// No duration - use custom summary if meaningful, else default
if (!hasSummary || isThinkingSummary) {
return l10n.thoughts;
// Has custom summary that's not a duration - show it
if (hasSummary && !isThinkingSummary) {
return rc.summary;
}
return rc.summary;
return l10n.thoughts;
}
Widget buildHeader() {
@@ -1863,13 +1875,13 @@ String _buildTtsPlainTextWorker(Map<String, dynamic> payload) {
final segments = rawSegments is List ? rawSegments.cast<dynamic>() : const [];
if (segments.isEmpty) {
return MarkdownToText.convert(fallback);
return ConduitMarkdownPreprocessor.toPlainText(fallback);
}
final buffer = StringBuffer();
for (final segment in segments) {
if (segment is! String || segment.isEmpty) continue;
final sanitized = MarkdownToText.convert(segment);
final sanitized = ConduitMarkdownPreprocessor.toPlainText(segment);
if (sanitized.isEmpty) continue;
if (buffer.isNotEmpty) {
buffer.writeln();
@@ -1880,7 +1892,7 @@ String _buildTtsPlainTextWorker(Map<String, dynamic> payload) {
final result = buffer.toString().trim();
if (result.isEmpty) {
return MarkdownToText.convert(fallback);
return ConduitMarkdownPreprocessor.toPlainText(fallback);
}
return result;
}