From 6b66b304b40478f13e621b6816f0886ce67f5bc9 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Fri, 29 Aug 2025 00:20:34 +0530 Subject: [PATCH] feat: trigger reasoning and parse reasoning content --- lib/core/services/api_service.dart | 71 ++++++++++- lib/core/utils/reasoning_parser.dart | 119 ++++++++++++++---- .../widgets/assistant_message_widget.dart | 29 ++++- tmp_openwebui | 1 + 4 files changed, 184 insertions(+), 36 deletions(-) create mode 160000 tmp_openwebui diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index cda3e0a..489c6b2 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -2362,9 +2362,7 @@ class ApiService { final messageId = const Uuid().v4(); final sessionId = const Uuid().v4().substring(0, 20); - // Check if this is a Gemini model that requires special handling - final isGeminiModel = model.toLowerCase().contains('gemini'); - debugPrint('DEBUG: Is Gemini model: $isGeminiModel'); + // NOTE: Previously used to branch for Gemini-specific handling; not needed now. // Process messages to match OpenWebUI format final processedMessages = messages.map((message) { @@ -2445,6 +2443,14 @@ class ApiService { }; } + // Hint the server to emit reasoning details blocks when supported. + // This mirrors Open WebUI "reasoning_tags" behavior (default enabled). + // It allows the client to display a collapsible "Thinking" section. + data['params'] = { + 'reasoning_tags': true, + 'reasoning_effort': 'medium', // Safe default; providers ignore if unsupported + }; + // Add tool_ids if provided (Open-WebUI expects tool_ids as array of strings) if (toolIds != null && toolIds.isNotEmpty) { data['tool_ids'] = toolIds; @@ -2826,6 +2832,8 @@ class ApiService { // Handle completion signal if (event.data == '[DONE]') { debugPrint('Persistent: SSE stream finished with [DONE]'); + // Ensure any open reasoning block is closed + _closeReasoningBlockIfOpen(streamController, persistentStreamId); if (!streamController.isClosed) { streamController.close(); } @@ -2852,12 +2860,28 @@ class ApiService { if (choice.containsKey('delta')) { final delta = choice['delta'] as Map; + // 1) Handle provider-native reasoning deltas (common keys) + final reasoning = delta['reasoning'] ?? delta['reasoning_content']; + if (reasoning is String && reasoning.isNotEmpty) { + // Open a reasoning block if not yet opened for this stream + _openReasoningBlockIfNeeded(streamController, persistentStreamId); + + if (!streamController.isClosed) { + streamController.add(reasoning); + } + + // We do NOT return here; model can send content alongside reasoning later + } + // Extract content if (delta.containsKey('content')) { final content = delta['content'] as String?; if (content != null && content.isNotEmpty) { debugPrint('Persistent: SSE content chunk: "$content"'); + // Close any open reasoning block before normal content begins + _closeReasoningBlockIfOpen(streamController, persistentStreamId); + // Add content to stream if (!streamController.isClosed) { streamController.add(content); @@ -2880,6 +2904,8 @@ class ApiService { debugPrint( 'Persistent: Stream finished with reason: $finishReason', ); + // Ensure reasoning block is closed when finishing + _closeReasoningBlockIfOpen(streamController, persistentStreamId); if (!streamController.isClosed) { streamController.close(); } @@ -2892,6 +2918,7 @@ class ApiService { debugPrint( 'Persistent: Stream finished with reason: $finishReason', ); + _closeReasoningBlockIfOpen(streamController, persistentStreamId); if (!streamController.isClosed) { streamController.close(); } @@ -2927,11 +2954,21 @@ class ApiService { // Handle OpenRouter-style streaming if (json.containsKey('message')) { final message = json['message'] as Map; + // Providers like Ollama may stream a separate thinking field + final thinking = message['thinking']; + if (thinking is String && thinking.isNotEmpty) { + _openReasoningBlockIfNeeded(streamController, persistentStreamId); + if (!streamController.isClosed) { + streamController.add(thinking); + } + } if (message.containsKey('content')) { final content = message['content'] as String?; if (content != null && content.isNotEmpty) { debugPrint('Persistent: Message content: "$content"'); + _closeReasoningBlockIfOpen(streamController, persistentStreamId); + if (!streamController.isClosed) { streamController.add(content); } @@ -2950,6 +2987,34 @@ class ApiService { } } + // ===== Reasoning block helpers ===== + // Track open reasoning blocks by stream id + final Map _reasoningOpen = {}; + + void _openReasoningBlockIfNeeded( + StreamController streamController, + String persistentStreamId, + ) { + if (_reasoningOpen[persistentStreamId] == true) return; + _reasoningOpen[persistentStreamId] = true; + if (!streamController.isClosed) { + // Minimal details block (parser supports missing attrs) + streamController.add('
Thinking…\n'); + } + } + + void _closeReasoningBlockIfOpen( + StreamController streamController, + String persistentStreamId, + ) { + if (_reasoningOpen[persistentStreamId] == true) { + _reasoningOpen[persistentStreamId] = false; + if (!streamController.isClosed) { + streamController.add('\n
\n'); + } + } + } + // Legacy Socket.IO and older SSE methods removed // File upload for RAG diff --git a/lib/core/utils/reasoning_parser.dart b/lib/core/utils/reasoning_parser.dart index 809ba4b..e670f57 100644 --- a/lib/core/utils/reasoning_parser.dart +++ b/lib/core/utils/reasoning_parser.dart @@ -1,47 +1,112 @@ /// Utility class for parsing and extracting reasoning/thinking content from messages class ReasoningParser { + /// Default tag pairs to detect raw reasoning blocks when providers don't emit
+ /// This mirrors Open WebUI defaults: ..., ... + static const List> defaultReasoningTagPairs = >[ + ['', ''], + ['', ''], + ]; + /// Parses a message and extracts reasoning content - static ReasoningContent? parseReasoningContent(String content) { + /// Supports: + /// -
blocks (server-emitted) + /// - Raw tag pairs like ... or ... + /// - Optional custom tag pair override + static ReasoningContent? parseReasoningContent( + String content, { + List? customTagPair, + bool detectDefaultTags = true, + }) { if (content.isEmpty) return null; - // Check if content contains reasoning - if (!content.contains('
tag with type="reasoning" - final reasoningRegex = RegExp( - r']*>\s*([^<]*)\s*(.*?)\s*
', + // 1) Prefer server-emitted
blocks + final detailsRegex = RegExp( + r']*>\s*([^<]*)<\/summary>\s*([\s\S]*?)<\/details>', multiLine: true, dotAll: true, ); + final detailsMatch = detailsRegex.firstMatch(content); + if (detailsMatch != null) { + final isDone = (detailsMatch.group(1) ?? 'true') == 'true'; + final duration = int.tryParse(detailsMatch.group(2) ?? '0') ?? 0; + final summary = (detailsMatch.group(3) ?? '').trim(); + final reasoning = (detailsMatch.group(4) ?? '').trim(); - final match = reasoningRegex.firstMatch(content); - if (match == null) { - return null; + final mainContent = content.replaceAll(detailsRegex, '').trim(); + + return ReasoningContent( + reasoning: reasoning, + summary: summary, + duration: duration, + isDone: isDone, + mainContent: mainContent, + originalContent: content, + ); } - final isDone = match.group(1) == 'true'; - final duration = int.tryParse(match.group(2) ?? '0') ?? 0; - final summary = match.group(3)?.trim() ?? ''; - final reasoning = match.group(4)?.trim() ?? ''; + // 2) Handle partially streamed
(opening present, no closing yet) + final openingIdx = content.indexOf('
= 0 && !content.contains('
')) { + final after = content.substring(openingIdx); + // Try to extract optional summary + final summaryMatch = RegExp(r'([^<]*)<\/summary>').firstMatch(after); + final summary = (summaryMatch?.group(1) ?? '').trim(); + final reasoning = after + .replaceAll(RegExp(r'^]*>'), '') + .replaceAll(RegExp(r'[\s\S]*?<\/summary>'), '') + .trim(); - // Remove the reasoning section from the main content - final mainContent = content.replaceAll(reasoningRegex, '').trim(); + final mainContent = content.substring(0, openingIdx).trim(); - return ReasoningContent( - reasoning: reasoning, - summary: summary, - duration: duration, - isDone: isDone, - mainContent: mainContent, - originalContent: content, - ); + return ReasoningContent( + reasoning: reasoning, + summary: summary, + duration: 0, + isDone: false, + mainContent: mainContent, + originalContent: content, + ); + } + + // 3) Otherwise, look for raw tag pairs + List> tagPairs = []; + if (customTagPair != null && customTagPair.length == 2) { + tagPairs.add(customTagPair); + } + if (detectDefaultTags) { + tagPairs.addAll(defaultReasoningTagPairs); + } + + for (final pair in tagPairs) { + final start = RegExp.escape(pair[0]); + final end = RegExp.escape(pair[1]); + final tagRegex = RegExp('($start)([\n\r\s\S]*?)($end)', multiLine: true, dotAll: true); + final match = tagRegex.firstMatch(content); + if (match != null) { + final reasoning = (match.group(2) ?? '').trim(); + final mainContent = content.replaceAll(tagRegex, '').trim(); + + return ReasoningContent( + reasoning: reasoning, + summary: '', // no summary available for raw tags + duration: 0, + isDone: true, + mainContent: mainContent, + originalContent: content, + ); + } + } + + return null; } /// Checks if a message contains reasoning content static bool hasReasoningContent(String content) { - return content.contains('
), const SizedBox(width: Spacing.xs), Text( - _reasoningContent!.summary.isNotEmpty - ? _reasoningContent!.summary - : 'Thought for ${_reasoningContent!.formattedDuration}', + () { + final rc = _reasoningContent!; + final hasSummary = rc.summary.isNotEmpty; + final isThinkingSummary = rc.summary.trim().toLowerCase() == 'thinking…' || rc.summary.trim().toLowerCase() == 'thinking...'; + if (widget.isStreaming) { + // During streaming, prefer showing Thinking… + return hasSummary ? rc.summary : 'Thinking…'; + } + // After streaming ends: + if (rc.duration > 0) { + return 'Thought for ${rc.formattedDuration}'; + } + // If summary was just the placeholder 'Thinking…', replace with a neutral title + if (!hasSummary || isThinkingSummary) { + return 'Thoughts'; + } + return rc.summary; + }(), style: TextStyle( fontSize: AppTypography.bodySmall, color: context.conduitTheme.textSecondary, @@ -273,7 +288,7 @@ class _AssistantMessageWidgetState extends ConsumerState duration: const Duration(milliseconds: 200), ), - const SizedBox(height: Spacing.md), + const SizedBox(height: 0), ], // Documentation-style content without heavy bubble; premium markdown @@ -303,8 +318,10 @@ class _AssistantMessageWidgetState extends ConsumerState else if (widget.isStreaming && widget.message.content.isNotEmpty && widget.message.content != '[TYPING_INDICATOR]') - // While streaming, render markdown with throttling and safety fixes - _buildEnhancedMarkdownContent(_renderedContent) + // While streaming, render only main content (strip reasoning details to avoid flashing tags) + _buildEnhancedMarkdownContent( + _reasoningContent?.mainContent ?? _renderedContent, + ) else // After streaming finishes (or static content), render full markdown _buildEnhancedMarkdownContent( diff --git a/tmp_openwebui b/tmp_openwebui new file mode 160000 index 0000000..2407d9b --- /dev/null +++ b/tmp_openwebui @@ -0,0 +1 @@ +Subproject commit 2407d9b905978d68619bdce4021e424046ec8df9