feat: trigger reasoning and parse reasoning content

This commit is contained in:
cogwheel0
2025-08-29 00:20:34 +05:30
parent 86d18ee0fb
commit 6b66b304b4
4 changed files with 184 additions and 36 deletions

View File

@@ -2362,9 +2362,7 @@ class ApiService {
final messageId = const Uuid().v4(); final messageId = const Uuid().v4();
final sessionId = const Uuid().v4().substring(0, 20); final sessionId = const Uuid().v4().substring(0, 20);
// Check if this is a Gemini model that requires special handling // NOTE: Previously used to branch for Gemini-specific handling; not needed now.
final isGeminiModel = model.toLowerCase().contains('gemini');
debugPrint('DEBUG: Is Gemini model: $isGeminiModel');
// Process messages to match OpenWebUI format // Process messages to match OpenWebUI format
final processedMessages = messages.map((message) { 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) // Add tool_ids if provided (Open-WebUI expects tool_ids as array of strings)
if (toolIds != null && toolIds.isNotEmpty) { if (toolIds != null && toolIds.isNotEmpty) {
data['tool_ids'] = toolIds; data['tool_ids'] = toolIds;
@@ -2826,6 +2832,8 @@ class ApiService {
// Handle completion signal // Handle completion signal
if (event.data == '[DONE]') { if (event.data == '[DONE]') {
debugPrint('Persistent: SSE stream finished with [DONE]'); debugPrint('Persistent: SSE stream finished with [DONE]');
// Ensure any open reasoning block is closed
_closeReasoningBlockIfOpen(streamController, persistentStreamId);
if (!streamController.isClosed) { if (!streamController.isClosed) {
streamController.close(); streamController.close();
} }
@@ -2852,12 +2860,28 @@ class ApiService {
if (choice.containsKey('delta')) { if (choice.containsKey('delta')) {
final delta = choice['delta'] as Map<String, dynamic>; final delta = choice['delta'] as Map<String, dynamic>;
// 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 // Extract content
if (delta.containsKey('content')) { if (delta.containsKey('content')) {
final content = delta['content'] as String?; final content = delta['content'] as String?;
if (content != null && content.isNotEmpty) { if (content != null && content.isNotEmpty) {
debugPrint('Persistent: SSE content chunk: "$content"'); debugPrint('Persistent: SSE content chunk: "$content"');
// Close any open reasoning block before normal content begins
_closeReasoningBlockIfOpen(streamController, persistentStreamId);
// Add content to stream // Add content to stream
if (!streamController.isClosed) { if (!streamController.isClosed) {
streamController.add(content); streamController.add(content);
@@ -2880,6 +2904,8 @@ class ApiService {
debugPrint( debugPrint(
'Persistent: Stream finished with reason: $finishReason', 'Persistent: Stream finished with reason: $finishReason',
); );
// Ensure reasoning block is closed when finishing
_closeReasoningBlockIfOpen(streamController, persistentStreamId);
if (!streamController.isClosed) { if (!streamController.isClosed) {
streamController.close(); streamController.close();
} }
@@ -2892,6 +2918,7 @@ class ApiService {
debugPrint( debugPrint(
'Persistent: Stream finished with reason: $finishReason', 'Persistent: Stream finished with reason: $finishReason',
); );
_closeReasoningBlockIfOpen(streamController, persistentStreamId);
if (!streamController.isClosed) { if (!streamController.isClosed) {
streamController.close(); streamController.close();
} }
@@ -2927,11 +2954,21 @@ class ApiService {
// Handle OpenRouter-style streaming // Handle OpenRouter-style streaming
if (json.containsKey('message')) { if (json.containsKey('message')) {
final message = json['message'] as Map<String, dynamic>; final message = json['message'] as Map<String, dynamic>;
// 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')) { if (message.containsKey('content')) {
final content = message['content'] as String?; final content = message['content'] as String?;
if (content != null && content.isNotEmpty) { if (content != null && content.isNotEmpty) {
debugPrint('Persistent: Message content: "$content"'); debugPrint('Persistent: Message content: "$content"');
_closeReasoningBlockIfOpen(streamController, persistentStreamId);
if (!streamController.isClosed) { if (!streamController.isClosed) {
streamController.add(content); streamController.add(content);
} }
@@ -2950,6 +2987,34 @@ class ApiService {
} }
} }
// ===== Reasoning block helpers =====
// Track open reasoning blocks by stream id
final Map<String, bool> _reasoningOpen = {};
void _openReasoningBlockIfNeeded(
StreamController<String> streamController,
String persistentStreamId,
) {
if (_reasoningOpen[persistentStreamId] == true) return;
_reasoningOpen[persistentStreamId] = true;
if (!streamController.isClosed) {
// Minimal details block (parser supports missing attrs)
streamController.add('<details type="reasoning"><summary>Thinking…</summary>\n');
}
}
void _closeReasoningBlockIfOpen(
StreamController<String> streamController,
String persistentStreamId,
) {
if (_reasoningOpen[persistentStreamId] == true) {
_reasoningOpen[persistentStreamId] = false;
if (!streamController.isClosed) {
streamController.add('\n</details>\n');
}
}
}
// Legacy Socket.IO and older SSE methods removed // Legacy Socket.IO and older SSE methods removed
// File upload for RAG // File upload for RAG

View File

@@ -1,33 +1,38 @@
/// Utility class for parsing and extracting reasoning/thinking content from messages /// Utility class for parsing and extracting reasoning/thinking content from messages
class ReasoningParser { class ReasoningParser {
/// Default tag pairs to detect raw reasoning blocks when providers don't emit <details>
/// This mirrors Open WebUI defaults: <think>...</think>, <reasoning>...</reasoning>
static const List<List<String>> defaultReasoningTagPairs = <List<String>>[
['<think>', '</think>'],
['<reasoning>', '</reasoning>'],
];
/// Parses a message and extracts reasoning content /// Parses a message and extracts reasoning content
static ReasoningContent? parseReasoningContent(String content) { /// Supports:
/// - <details type="reasoning" ...> blocks (server-emitted)
/// - Raw tag pairs like <think>...</think> or <reasoning>...</reasoning>
/// - Optional custom tag pair override
static ReasoningContent? parseReasoningContent(
String content, {
List<String>? customTagPair,
bool detectDefaultTags = true,
}) {
if (content.isEmpty) return null; if (content.isEmpty) return null;
// Check if content contains reasoning // 1) Prefer server-emitted <details type="reasoning"> blocks
if (!content.contains('<details type="reasoning"')) { final detailsRegex = RegExp(
return null; r'<details\s+type="reasoning"(?:\s+done="(true|false)")?(?:\s+duration="(\d+)")?[^>]*>\s*<summary>([^<]*)<\/summary>\s*([\s\S]*?)<\/details>',
}
// Match the <details> tag with type="reasoning"
final reasoningRegex = RegExp(
r'<details\s+type="reasoning"\s+done="(true|false)"\s+duration="(\d+)"[^>]*>\s*<summary>([^<]*)</summary>\s*(.*?)\s*</details>',
multiLine: true, multiLine: true,
dotAll: 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); final mainContent = content.replaceAll(detailsRegex, '').trim();
if (match == null) {
return null;
}
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() ?? '';
// Remove the reasoning section from the main content
final mainContent = content.replaceAll(reasoningRegex, '').trim();
return ReasoningContent( return ReasoningContent(
reasoning: reasoning, reasoning: reasoning,
@@ -39,9 +44,69 @@ class ReasoningParser {
); );
} }
// 2) Handle partially streamed <details> (opening present, no closing yet)
final openingIdx = content.indexOf('<details type="reasoning"');
if (openingIdx >= 0 && !content.contains('</details>')) {
final after = content.substring(openingIdx);
// Try to extract optional summary
final summaryMatch = RegExp(r'<summary>([^<]*)<\/summary>').firstMatch(after);
final summary = (summaryMatch?.group(1) ?? '').trim();
final reasoning = after
.replaceAll(RegExp(r'^<details[^>]*>'), '')
.replaceAll(RegExp(r'<summary>[\s\S]*?<\/summary>'), '')
.trim();
final mainContent = content.substring(0, openingIdx).trim();
return ReasoningContent(
reasoning: reasoning,
summary: summary,
duration: 0,
isDone: false,
mainContent: mainContent,
originalContent: content,
);
}
// 3) Otherwise, look for raw tag pairs
List<List<String>> 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 /// Checks if a message contains reasoning content
static bool hasReasoningContent(String content) { static bool hasReasoningContent(String content) {
return content.contains('<details type="reasoning"'); if (content.contains('<details type="reasoning"')) return true;
for (final pair in defaultReasoningTagPairs) {
if (content.contains(pair[0]) && content.contains(pair[1])) return true;
}
return false;
} }
/// Formats the duration for display /// Formats the duration for display

View File

@@ -227,9 +227,24 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
), ),
const SizedBox(width: Spacing.xs), const SizedBox(width: Spacing.xs),
Text( Text(
_reasoningContent!.summary.isNotEmpty () {
? _reasoningContent!.summary final rc = _reasoningContent!;
: 'Thought for ${_reasoningContent!.formattedDuration}', 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( style: TextStyle(
fontSize: AppTypography.bodySmall, fontSize: AppTypography.bodySmall,
color: context.conduitTheme.textSecondary, color: context.conduitTheme.textSecondary,
@@ -273,7 +288,7 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
), ),
const SizedBox(height: Spacing.md), const SizedBox(height: 0),
], ],
// Documentation-style content without heavy bubble; premium markdown // Documentation-style content without heavy bubble; premium markdown
@@ -303,8 +318,10 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
else if (widget.isStreaming && else if (widget.isStreaming &&
widget.message.content.isNotEmpty && widget.message.content.isNotEmpty &&
widget.message.content != '[TYPING_INDICATOR]') widget.message.content != '[TYPING_INDICATOR]')
// While streaming, render markdown with throttling and safety fixes // While streaming, render only main content (strip reasoning details to avoid flashing tags)
_buildEnhancedMarkdownContent(_renderedContent) _buildEnhancedMarkdownContent(
_reasoningContent?.mainContent ?? _renderedContent,
)
else else
// After streaming finishes (or static content), render full markdown // After streaming finishes (or static content), render full markdown
_buildEnhancedMarkdownContent( _buildEnhancedMarkdownContent(

1
tmp_openwebui Submodule

Submodule tmp_openwebui added at 2407d9b905