feat: trigger reasoning and parse reasoning content
This commit is contained in:
@@ -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<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
|
||||
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<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')) {
|
||||
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<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
|
||||
|
||||
// File upload for RAG
|
||||
|
||||
@@ -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 <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
|
||||
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;
|
||||
|
||||
// Check if content contains reasoning
|
||||
if (!content.contains('<details type="reasoning"')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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>',
|
||||
// 1) Prefer server-emitted <details type="reasoning"> blocks
|
||||
final detailsRegex = RegExp(
|
||||
r'<details\s+type="reasoning"(?:\s+done="(true|false)")?(?:\s+duration="(\d+)")?[^>]*>\s*<summary>([^<]*)<\/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 <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();
|
||||
|
||||
// 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<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
|
||||
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
|
||||
|
||||
@@ -227,9 +227,24 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
|
||||
),
|
||||
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<AssistantMessageWidget>
|
||||
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<AssistantMessageWidget>
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user