feat(l10n): Add localization for code interpreter states

This commit is contained in:
cogwheel0
2025-12-09 23:04:18 +05:30
parent ed6d588518
commit 5b7cd0dd42
12 changed files with 152 additions and 14 deletions

View File

@@ -22,20 +22,29 @@ const List<(String, String)> defaultReasoningTagPairs = [
('◁think▷', '◁/think▷'), ('◁think▷', '◁/think▷'),
]; ];
/// Type of collapsible block (reasoning vs code_interpreter).
enum CollapsibleBlockType { reasoning, codeInterpreter }
/// Lightweight reasoning block for segmented rendering. /// Lightweight reasoning block for segmented rendering.
class ReasoningEntry { class ReasoningEntry {
final String reasoning; final String reasoning;
final String summary; final String summary;
final int duration; final int duration;
final bool isDone; final bool isDone;
final CollapsibleBlockType blockType;
const ReasoningEntry({ const ReasoningEntry({
required this.reasoning, required this.reasoning,
required this.summary, required this.summary,
required this.duration, required this.duration,
required this.isDone, 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); String get formattedDuration => ReasoningParser.formatDuration(duration);
/// Gets the cleaned reasoning text (removes leading '>' from blockquote format). /// Gets the cleaned reasoning text (removes leading '>' from blockquote format).
@@ -273,15 +282,17 @@ class ReasoningParser {
final openTag = content.substring(startIdx, openTagEnd + 1); 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 attrs = <String, String>{};
final attrRegex = RegExp(r'(\w+)="([^"]*)"'); final attrRegex = RegExp(r'(\w+)="(.*?)"');
for (final m in attrRegex.allMatches(openTag)) { for (final m in attrRegex.allMatches(openTag)) {
attrs[m.group(1)!] = m.group(2) ?? ''; attrs[m.group(1)!] = m.group(2) ?? '';
} }
final type = attrs['type'] ?? ''; final type = attrs['type']?.toLowerCase() ?? '';
final isDone = (attrs['done'] ?? 'true') == 'true'; // Open WebUI treats done as string comparison: done === 'true'
final isDone = attrs['done'] == 'true';
final duration = int.tryParse(attrs['duration'] ?? '0') ?? 0; final duration = int.tryParse(attrs['duration'] ?? '0') ?? 0;
// Find matching closing tag with nesting support // 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) { if (depth != 0) {
// Incomplete block (streaming) // Incomplete block (streaming)
final innerContent = content.substring(openTagEnd + 1); final innerContent = content.substring(openTagEnd + 1);
final summaryResult = _extractSummary(innerContent); final summaryResult = _extractSummary(innerContent);
// Determine if this is reasoning based on type or summary // Determine if this is reasoning based on type or summary
// Also treat code_interpreter as reasoning-like (collapsible thinking)
final isReasoning = final isReasoning =
type == 'reasoning' || type == 'reasoning' ||
type == 'code_interpreter' ||
(type.isEmpty && (type.isEmpty &&
_reasoningSummaryPattern.hasMatch(summaryResult.summary)); _reasoningSummaryPattern.hasMatch(summaryResult.summary));
@@ -322,6 +340,7 @@ class ReasoningParser {
summary: HtmlUtils.unescapeHtml(summaryResult.summary), summary: HtmlUtils.unescapeHtml(summaryResult.summary),
duration: effectiveDuration, duration: effectiveDuration,
isDone: false, isDone: false,
blockType: blockType,
), ),
endIndex: content.length, endIndex: content.length,
isComplete: false, isComplete: false,
@@ -335,8 +354,10 @@ class ReasoningParser {
final summaryResult = _extractSummary(innerContent); final summaryResult = _extractSummary(innerContent);
// Determine if this is reasoning based on type or summary // Determine if this is reasoning based on type or summary
// Also treat code_interpreter as reasoning-like (collapsible thinking)
final isReasoning = final isReasoning =
type == 'reasoning' || type == 'reasoning' ||
type == 'code_interpreter' ||
(type.isEmpty && (type.isEmpty &&
_reasoningSummaryPattern.hasMatch(summaryResult.summary)); _reasoningSummaryPattern.hasMatch(summaryResult.summary));
@@ -351,6 +372,7 @@ class ReasoningParser {
summary: HtmlUtils.unescapeHtml(summaryResult.summary), summary: HtmlUtils.unescapeHtml(summaryResult.summary),
duration: effectiveDuration, duration: effectiveDuration,
isDone: isDone, isDone: isDone,
blockType: blockType,
), ),
endIndex: i, endIndex: i,
isComplete: true, isComplete: true,
@@ -478,8 +500,18 @@ class ReasoningParser {
/// Checks if a message contains reasoning content. /// Checks if a message contains reasoning content.
static bool hasReasoningContent(String content) { static bool hasReasoningContent(String content) {
// Check for <details type="reasoning" // Check for <details type="reasoning" (case-insensitive)
if (content.contains('type="reasoning"')) return true; 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 // Check for <details> with reasoning-like summary
if (content.contains('<details')) { if (content.contains('<details')) {
@@ -501,8 +533,12 @@ class ReasoningParser {
} }
/// Formats the duration for display. /// 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) { 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'}'; if (seconds < 60) return '$seconds second${seconds == 1 ? '' : 's'}';
final minutes = seconds ~/ 60; final minutes = seconds ~/ 60;
@@ -512,6 +548,7 @@ class ReasoningParser {
return '$minutes minute${minutes == 1 ? '' : 's'}'; return '$minutes minute${minutes == 1 ? '' : 's'}';
} }
// For mixed minutes and seconds, use abbreviated format
return '$minutes min ${remainingSeconds}s'; return '$minutes min ${remainingSeconds}s';
} }
} }

View File

@@ -526,12 +526,65 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
: CrossFadeState.showFirst, : CrossFadeState.showFirst,
duration: const Duration(milliseconds: 200), 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() { Widget _buildSegmentedContent() {
final children = <Widget>[]; final children = <Widget>[];
bool firstToolSpacerAdded = false; bool firstToolSpacerAdded = false;
@@ -1323,21 +1376,43 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
Widget _buildReasoningTile(ReasoningEntry rc, int index) { Widget _buildReasoningTile(ReasoningEntry rc, int index) {
final isExpanded = _expandedReasoning.contains(index); final isExpanded = _expandedReasoning.contains(index);
final theme = context.conduitTheme; final theme = context.conduitTheme;
// Show shimmer when streaming and this is an active/incomplete reasoning // Show shimmer when reasoning is not done (mirrors OpenWebUI's done !== 'true')
final showShimmer = widget.isStreaming && rc.duration == 0; final showShimmer = !rc.isDone;
String headerText() { String headerText() {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final hasSummary = rc.summary.isNotEmpty; final hasSummary = rc.summary.isNotEmpty;
final isThinkingSummary = final summaryLower = rc.summary.trim().toLowerCase();
rc.summary.trim().toLowerCase() == 'thinking…' ||
rc.summary.trim().toLowerCase() == 'thinking...'; // Mirror Open WebUI's Collapsible.svelte logic for different block types
if (widget.isStreaming) { if (rc.isCodeInterpreter) {
return hasSummary ? rc.summary : l10n.thinking; // 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) { if (rc.duration > 0) {
return l10n.thoughtForDuration(rc.formattedDuration); return l10n.thoughtForDuration(rc.formattedDuration);
} }
// No duration - use custom summary if meaningful, else default
if (!hasSummary || isThinkingSummary) { if (!hasSummary || isThinkingSummary) {
return l10n.thoughts; return l10n.thoughts;
} }

View File

@@ -314,6 +314,8 @@
} }
} }
}, },
"analyzing": "Analysiere…",
"analyzed": "Analysiert",
"appCustomization": "Anpassung", "appCustomization": "Anpassung",
"appCustomizationSubtitle": "Design, Sprache, Stimme und Quick Pills", "appCustomizationSubtitle": "Design, Sprache, Stimme und Quick Pills",
"quickActionsDescription": "Schnellzugriffe im Chat", "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": "Customization",
"@appCustomization": { "@appCustomization": {
"description": "Title of the customization settings page." "description": "Title of the customization settings page."

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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