Merge pull request #261 from cogwheel0/add-safe-json-parsing-localization

add-safe-json-parsing-localization
This commit is contained in:
cogwheel
2025-12-10 22:14:03 +08:00
committed by GitHub
13 changed files with 201 additions and 20 deletions

View File

@@ -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<String, dynamic>? metadata,
@JsonKey(fromJson: _safeJsonMap) Map<String, dynamic>? metadata,
}) = _ChatCodeExecution;
factory ChatCodeExecution.fromJson(Map<String, dynamic> 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(<ChatExecutionFile>[])
List<ChatExecutionFile> files,
Map<String, dynamic>? metadata,
@JsonKey(fromJson: _safeJsonMap) Map<String, dynamic>? metadata,
}) = _ChatCodeExecutionResult;
factory ChatCodeExecutionResult.fromJson(Map<String, dynamic> json) =>
@@ -142,7 +143,7 @@ abstract class ChatExecutionFile with _$ChatExecutionFile {
const factory ChatExecutionFile({
@JsonKey(fromJson: _nullableString) String? name,
@JsonKey(fromJson: _nullableString) String? url,
Map<String, dynamic>? metadata,
@JsonKey(fromJson: _safeJsonMap) Map<String, dynamic>? metadata,
}) = _ChatExecutionFile;
factory ChatExecutionFile.fromJson(Map<String, dynamic> 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<String, dynamic>? metadata,
@JsonKey(fromJson: _safeJsonMap) Map<String, dynamic>? metadata,
}) = _ChatSourceReference;
factory ChatSourceReference.fromJson(Map<String, dynamic> json) =>
@@ -324,3 +325,45 @@ String? _nullableString(dynamic value) {
final str = value.toString();
return str.isEmpty ? null : str;
}
/// Safely parse a `Map<String, dynamic>` from various formats.
/// Returns null if the value cannot be converted to a valid map or is empty.
Map<String, dynamic>? _safeJsonMap(dynamic value) {
if (value == null) return null;
if (value is Map<String, dynamic>) {
return value.isEmpty ? null : value;
}
if (value is Map) {
final result = <String, dynamic>{};
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<String, dynamic>) {
try {
return ChatCodeExecutionResult.fromJson(value);
} catch (_) {
return null;
}
}
if (value is Map) {
try {
final map = <String, dynamic>{};
value.forEach((key, v) {
map[key.toString()] = v;
});
return ChatCodeExecutionResult.fromJson(map);
} catch (_) {
return null;
}
}
return null;
}

View File

@@ -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 = <String, String>{};
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 <details type="reasoning"
if (content.contains('type="reasoning"')) return true;
// Check for <details type="reasoning" (case-insensitive)
if (RegExp(r'type="reasoning"', caseSensitive: false).hasMatch(content)) {
return true;
}
// Check for <details type="code_interpreter" (case-insensitive)
if (RegExp(
r'type="code_interpreter"',
caseSensitive: false,
).hasMatch(content)) {
return true;
}
// Check for <details> with reasoning-like summary
if (content.contains('<details')) {
@@ -501,8 +533,12 @@ class ReasoningParser {
}
/// Formats the duration for display.
/// Mirrors Open WebUI's formatting:
/// - < 1: "less than a second"
/// - < 60: "X seconds"
/// - >= 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';
}
}

View File

@@ -526,12 +526,65 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
: 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<dynamic> files) {
final imageUrls = <String>[];
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 = <Widget>[];
bool firstToolSpacerAdded = false;
@@ -1323,21 +1376,43 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
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;
}

View File

@@ -314,6 +314,8 @@
}
}
},
"analyzing": "Analysiere…",
"analyzed": "Analysiert",
"appCustomization": "Anpassung",
"appCustomizationSubtitle": "Design, Sprache, Stimme und Quick Pills",
"quickActionsDescription": "Schnellzugriffe im Chat",

View File

@@ -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."

View File

@@ -314,6 +314,8 @@
}
}
},
"analyzing": "Analizando…",
"analyzed": "Analizado",
"appCustomization": "Personalización",
"appCustomizationSubtitle": "Tema, idioma, voz y quickpills",
"quickActionsDescription": "Accesos directos en chat",

View File

@@ -314,6 +314,8 @@
}
}
},
"analyzing": "Analyse…",
"analyzed": "Analysé",
"appCustomization": "Personnalisation",
"appCustomizationSubtitle": "Thème, langue, voix et quickpills",
"quickActionsDescription": "Raccourcis dans le chat",

View File

@@ -314,6 +314,8 @@
}
}
},
"analyzing": "Analisi…",
"analyzed": "Analizzato",
"appCustomization": "Personalizzazione",
"appCustomizationSubtitle": "Tema, lingua, voce e quickpills",
"quickActionsDescription": "Scorciatoie nella chat",

View File

@@ -429,6 +429,8 @@
}
}
},
"analyzing": "분석 중…",
"analyzed": "분석 완료",
"appCustomization": "사용자 정의",
"appCustomizationSubtitle": "테마, 언어, 음성 및 빠른 액션",
"quickActionsDescription": "채팅의 빠른 액션",

View File

@@ -314,6 +314,8 @@
}
}
},
"analyzing": "Analyseren…",
"analyzed": "Geanalyseerd",
"appCustomization": "Aanpassing",
"appCustomizationSubtitle": "Thema, taal, stem en quickpills",
"quickActionsDescription": "Snelkoppelingen in chat",

View File

@@ -314,6 +314,8 @@
}
}
},
"analyzing": "Анализирую…",
"analyzed": "Проанализировано",
"appCustomization": "Настройка",
"appCustomizationSubtitle": "Тема, язык, голос и quickpills",
"quickActionsDescription": "Быстрые клавиши в чате",

View File

@@ -314,6 +314,8 @@
}
}
},
"analyzing": "分析中…",
"analyzed": "已分析",
"appCustomization": "自定义",
"appCustomizationSubtitle": "主题、语言、语音和 quickpills",
"quickActionsDescription": "聊天快捷方式",

View File

@@ -314,6 +314,8 @@
}
}
},
"analyzing": "分析中…",
"analyzed": "已分析",
"appCustomization": "自定義",
"appCustomizationSubtitle": "主題、語言、語音和 quickpills",
"quickActionsDescription": "聊天快捷方式",