refactor(markdown): remove deprecated stream formatter and enhance preprocessor
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user