diff --git a/lib/core/models/chat_message.dart b/lib/core/models/chat_message.dart index 8111302..424678e 100644 --- a/lib/core/models/chat_message.dart +++ b/lib/core/models/chat_message.dart @@ -114,8 +114,9 @@ abstract class ChatCodeExecution with _$ChatCodeExecution { @JsonKey(fromJson: _nullableString) String? name, @JsonKey(fromJson: _nullableString) String? language, @JsonKey(fromJson: _nullableString) String? code, + @JsonKey(fromJson: _safeCodeExecutionResult) ChatCodeExecutionResult? result, - Map? metadata, + @JsonKey(fromJson: _safeJsonMap) Map? metadata, }) = _ChatCodeExecution; factory ChatCodeExecution.fromJson(Map json) => @@ -125,12 +126,12 @@ abstract class ChatCodeExecution with _$ChatCodeExecution { @freezed abstract class ChatCodeExecutionResult with _$ChatCodeExecutionResult { const factory ChatCodeExecutionResult({ - String? output, - String? error, + @JsonKey(fromJson: _nullableString) String? output, + @JsonKey(fromJson: _nullableString) String? error, @JsonKey(fromJson: _executionFilesFromJson, toJson: _executionFilesToJson) @Default([]) List files, - Map? metadata, + @JsonKey(fromJson: _safeJsonMap) Map? metadata, }) = _ChatCodeExecutionResult; factory ChatCodeExecutionResult.fromJson(Map json) => @@ -142,7 +143,7 @@ abstract class ChatExecutionFile with _$ChatExecutionFile { const factory ChatExecutionFile({ @JsonKey(fromJson: _nullableString) String? name, @JsonKey(fromJson: _nullableString) String? url, - Map? metadata, + @JsonKey(fromJson: _safeJsonMap) Map? metadata, }) = _ChatExecutionFile; factory ChatExecutionFile.fromJson(Map json) => @@ -157,7 +158,7 @@ abstract class ChatSourceReference with _$ChatSourceReference { @JsonKey(fromJson: _nullableString) String? url, @JsonKey(fromJson: _nullableString) String? snippet, @JsonKey(fromJson: _nullableString) String? type, - Map? metadata, + @JsonKey(fromJson: _safeJsonMap) Map? metadata, }) = _ChatSourceReference; factory ChatSourceReference.fromJson(Map json) => @@ -324,3 +325,45 @@ String? _nullableString(dynamic value) { final str = value.toString(); return str.isEmpty ? null : str; } + +/// Safely parse a `Map` from various formats. +/// Returns null if the value cannot be converted to a valid map or is empty. +Map? _safeJsonMap(dynamic value) { + if (value == null) return null; + if (value is Map) { + return value.isEmpty ? null : value; + } + if (value is Map) { + final result = {}; + value.forEach((key, v) { + result[key.toString()] = v; + }); + return result.isEmpty ? null : result; + } + return null; +} + +/// Safely parse a ChatCodeExecutionResult from various formats. +/// Returns null if the value cannot be converted to a valid result. +ChatCodeExecutionResult? _safeCodeExecutionResult(dynamic value) { + if (value == null) return null; + if (value is Map) { + try { + return ChatCodeExecutionResult.fromJson(value); + } catch (_) { + return null; + } + } + if (value is Map) { + try { + final map = {}; + value.forEach((key, v) { + map[key.toString()] = v; + }); + return ChatCodeExecutionResult.fromJson(map); + } catch (_) { + return null; + } + } + return null; +} diff --git a/lib/core/utils/reasoning_parser.dart b/lib/core/utils/reasoning_parser.dart index 6e63bf1..18483d6 100644 --- a/lib/core/utils/reasoning_parser.dart +++ b/lib/core/utils/reasoning_parser.dart @@ -22,20 +22,29 @@ const List<(String, String)> defaultReasoningTagPairs = [ ('◁think▷', '◁/think▷'), ]; +/// Type of collapsible block (reasoning vs code_interpreter). +enum CollapsibleBlockType { reasoning, codeInterpreter } + /// Lightweight reasoning block for segmented rendering. class ReasoningEntry { final String reasoning; final String summary; final int duration; final bool isDone; + final CollapsibleBlockType blockType; const ReasoningEntry({ required this.reasoning, required this.summary, required this.duration, required this.isDone, + this.blockType = CollapsibleBlockType.reasoning, }); + /// Whether this is a code interpreter block. + bool get isCodeInterpreter => + blockType == CollapsibleBlockType.codeInterpreter; + String get formattedDuration => ReasoningParser.formatDuration(duration); /// Gets the cleaned reasoning text (removes leading '>' from blockquote format). @@ -273,15 +282,17 @@ class ReasoningParser { final openTag = content.substring(startIdx, openTagEnd + 1); - // Parse attributes + // Parse attributes - use non-greedy match to handle attributes correctly + // Mirrors Open WebUI's parseAttributes: /(\w+)="(.*?)"/g final attrs = {}; - final attrRegex = RegExp(r'(\w+)="([^"]*)"'); + final attrRegex = RegExp(r'(\w+)="(.*?)"'); for (final m in attrRegex.allMatches(openTag)) { attrs[m.group(1)!] = m.group(2) ?? ''; } - final type = attrs['type'] ?? ''; - final isDone = (attrs['done'] ?? 'true') == 'true'; + final type = attrs['type']?.toLowerCase() ?? ''; + // Open WebUI treats done as string comparison: done === 'true' + final isDone = attrs['done'] == 'true'; final duration = int.tryParse(attrs['duration'] ?? '0') ?? 0; // Find matching closing tag with nesting support @@ -300,14 +311,21 @@ class ReasoningParser { } } + // Determine block type based on type attribute + final blockType = type == 'code_interpreter' + ? CollapsibleBlockType.codeInterpreter + : CollapsibleBlockType.reasoning; + if (depth != 0) { // Incomplete block (streaming) final innerContent = content.substring(openTagEnd + 1); final summaryResult = _extractSummary(innerContent); // Determine if this is reasoning based on type or summary + // Also treat code_interpreter as reasoning-like (collapsible thinking) final isReasoning = type == 'reasoning' || + type == 'code_interpreter' || (type.isEmpty && _reasoningSummaryPattern.hasMatch(summaryResult.summary)); @@ -322,6 +340,7 @@ class ReasoningParser { summary: HtmlUtils.unescapeHtml(summaryResult.summary), duration: effectiveDuration, isDone: false, + blockType: blockType, ), endIndex: content.length, isComplete: false, @@ -335,8 +354,10 @@ class ReasoningParser { final summaryResult = _extractSummary(innerContent); // Determine if this is reasoning based on type or summary + // Also treat code_interpreter as reasoning-like (collapsible thinking) final isReasoning = type == 'reasoning' || + type == 'code_interpreter' || (type.isEmpty && _reasoningSummaryPattern.hasMatch(summaryResult.summary)); @@ -351,6 +372,7 @@ class ReasoningParser { summary: HtmlUtils.unescapeHtml(summaryResult.summary), duration: effectiveDuration, isDone: isDone, + blockType: blockType, ), endIndex: i, isComplete: true, @@ -478,8 +500,18 @@ class ReasoningParser { /// Checks if a message contains reasoning content. static bool hasReasoningContent(String content) { - // Check for
with reasoning-like summary if (content.contains('= 60: humanized (e.g., "2 minutes") static String formatDuration(int seconds) { - if (seconds <= 0) return 'instant'; + if (seconds < 1) return 'less than a second'; if (seconds < 60) return '$seconds second${seconds == 1 ? '' : 's'}'; final minutes = seconds ~/ 60; @@ -512,6 +548,7 @@ class ReasoningParser { return '$minutes minute${minutes == 1 ? '' : 's'}'; } + // For mixed minutes and seconds, use abbreviated format return '$minutes min ${remainingSeconds}s'; } } diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index e593075..ebb443b 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -526,12 +526,65 @@ class _AssistantMessageWidgetState extends ConsumerState : CrossFadeState.showFirst, duration: const Duration(milliseconds: 200), ), + + // Render file images when tool call is done + // Mirrors Open WebUI's Collapsible.svelte file rendering + if (tc.done && tc.files != null) ...[ + _buildToolCallFiles(tc.files!), + ], ], ), ), ); } + /// Builds image widgets from tool call files array. + /// Mirrors Open WebUI's Collapsible.svelte file rendering logic: + /// - String starting with 'data:image/' -> base64 image + /// - Object with type='image' and url -> network image + Widget _buildToolCallFiles(List files) { + final imageUrls = []; + + for (final file in files) { + if (file is String) { + // Base64 image data URL + if (file.startsWith('data:image/')) { + imageUrls.add(file); + } + } else if (file is Map) { + // Object with type and url + final type = file['type']?.toString(); + final url = file['url']?.toString(); + if (type == 'image' && url != null && url.isNotEmpty) { + imageUrls.add(url); + } + } + } + + if (imageUrls.isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(top: Spacing.sm), + child: Wrap( + spacing: Spacing.sm, + runSpacing: Spacing.sm, + children: imageUrls.map((url) { + return EnhancedImageAttachment( + attachmentId: url, + isMarkdownFormat: true, + constraints: BoxConstraints( + maxWidth: imageUrls.length == 1 ? 400 : 200, + maxHeight: imageUrls.length == 1 ? 300 : 150, + ), + disableAnimation: false, + ); + }).toList(), + ), + ); + } + Widget _buildSegmentedContent() { final children = []; bool firstToolSpacerAdded = false; @@ -1323,21 +1376,43 @@ class _AssistantMessageWidgetState extends ConsumerState Widget _buildReasoningTile(ReasoningEntry rc, int index) { final isExpanded = _expandedReasoning.contains(index); final theme = context.conduitTheme; - // Show shimmer when streaming and this is an active/incomplete reasoning - final showShimmer = widget.isStreaming && rc.duration == 0; + // Show shimmer when reasoning is not done (mirrors OpenWebUI's done !== 'true') + final showShimmer = !rc.isDone; String headerText() { final l10n = AppLocalizations.of(context)!; final hasSummary = rc.summary.isNotEmpty; - final isThinkingSummary = - rc.summary.trim().toLowerCase() == 'thinking…' || - rc.summary.trim().toLowerCase() == 'thinking...'; - if (widget.isStreaming) { - return hasSummary ? rc.summary : l10n.thinking; + final summaryLower = rc.summary.trim().toLowerCase(); + + // Mirror Open WebUI's Collapsible.svelte logic for different block types + if (rc.isCodeInterpreter) { + // Code interpreter: "Analyzing..." -> "Analyzed" + if (!rc.isDone) { + return l10n.analyzing; + } + return l10n.analyzed; } + + // Reasoning block + final isThinkingSummary = + summaryLower == 'thinking…' || + summaryLower == 'thinking...' || + summaryLower.startsWith('thinking'); + + // - 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 (!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) { return l10n.thoughtForDuration(rc.formattedDuration); } + + // No duration - use custom summary if meaningful, else default if (!hasSummary || isThinkingSummary) { return l10n.thoughts; } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 8e1bf87..49ada17 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -314,6 +314,8 @@ } } }, + "analyzing": "Analysiere…", + "analyzed": "Analysiert", "appCustomization": "Anpassung", "appCustomizationSubtitle": "Design, Sprache, Stimme und Quick Pills", "quickActionsDescription": "Schnellzugriffe im Chat", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5ad189b..20032f0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1233,6 +1233,14 @@ } } }, + "analyzing": "Analyzing…", + "@analyzing": { + "description": "Label shown while code interpreter is processing." + }, + "analyzed": "Analyzed", + "@analyzed": { + "description": "Label shown after code interpreter has finished." + }, "appCustomization": "Customization", "@appCustomization": { "description": "Title of the customization settings page." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 9d55839..bd33bfb 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -314,6 +314,8 @@ } } }, + "analyzing": "Analizando…", + "analyzed": "Analizado", "appCustomization": "Personalización", "appCustomizationSubtitle": "Tema, idioma, voz y quickpills", "quickActionsDescription": "Accesos directos en chat", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 4210baf..e3aa888 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -314,6 +314,8 @@ } } }, + "analyzing": "Analyse…", + "analyzed": "Analysé", "appCustomization": "Personnalisation", "appCustomizationSubtitle": "Thème, langue, voix et quickpills", "quickActionsDescription": "Raccourcis dans le chat", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 1dc20d2..6d8bedd 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -314,6 +314,8 @@ } } }, + "analyzing": "Analisi…", + "analyzed": "Analizzato", "appCustomization": "Personalizzazione", "appCustomizationSubtitle": "Tema, lingua, voce e quickpills", "quickActionsDescription": "Scorciatoie nella chat", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 49436cd..9dee5cf 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -429,6 +429,8 @@ } } }, + "analyzing": "분석 중…", + "analyzed": "분석 완료", "appCustomization": "사용자 정의", "appCustomizationSubtitle": "테마, 언어, 음성 및 빠른 액션", "quickActionsDescription": "채팅의 빠른 액션", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index d26423e..9a333ad 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -314,6 +314,8 @@ } } }, + "analyzing": "Analyseren…", + "analyzed": "Geanalyseerd", "appCustomization": "Aanpassing", "appCustomizationSubtitle": "Thema, taal, stem en quickpills", "quickActionsDescription": "Snelkoppelingen in chat", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 643037c..1627f20 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -314,6 +314,8 @@ } } }, + "analyzing": "Анализирую…", + "analyzed": "Проанализировано", "appCustomization": "Настройка", "appCustomizationSubtitle": "Тема, язык, голос и quickpills", "quickActionsDescription": "Быстрые клавиши в чате", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 35d6eeb..a8e3905 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -314,6 +314,8 @@ } } }, + "analyzing": "分析中…", + "analyzed": "已分析", "appCustomization": "自定义", "appCustomizationSubtitle": "主题、语言、语音和 quickpills", "quickActionsDescription": "聊天快捷方式", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index 4713d59..38e4632 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -314,6 +314,8 @@ } } }, + "analyzing": "分析中…", + "analyzed": "已分析", "appCustomization": "自定義", "appCustomizationSubtitle": "主題、語言、語音和 quickpills", "quickActionsDescription": "聊天快捷方式",