feat: trigger reasoning and parse reasoning content
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -1,47 +1,112 @@
|
|||||||
/// 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;
|
return ReasoningContent(
|
||||||
|
reasoning: reasoning,
|
||||||
|
summary: summary,
|
||||||
|
duration: duration,
|
||||||
|
isDone: isDone,
|
||||||
|
mainContent: mainContent,
|
||||||
|
originalContent: content,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final isDone = match.group(1) == 'true';
|
// 2) Handle partially streamed <details> (opening present, no closing yet)
|
||||||
final duration = int.tryParse(match.group(2) ?? '0') ?? 0;
|
final openingIdx = content.indexOf('<details type="reasoning"');
|
||||||
final summary = match.group(3)?.trim() ?? '';
|
if (openingIdx >= 0 && !content.contains('</details>')) {
|
||||||
final reasoning = match.group(4)?.trim() ?? '';
|
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.substring(0, openingIdx).trim();
|
||||||
final mainContent = content.replaceAll(reasoningRegex, '').trim();
|
|
||||||
|
|
||||||
return ReasoningContent(
|
return ReasoningContent(
|
||||||
reasoning: reasoning,
|
reasoning: reasoning,
|
||||||
summary: summary,
|
summary: summary,
|
||||||
duration: duration,
|
duration: 0,
|
||||||
isDone: isDone,
|
isDone: false,
|
||||||
mainContent: mainContent,
|
mainContent: mainContent,
|
||||||
originalContent: content,
|
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
|
||||||
|
|||||||
@@ -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
1
tmp_openwebui
Submodule
Submodule tmp_openwebui added at 2407d9b905
Reference in New Issue
Block a user