feat(l10n): Add localization for code interpreter states
This commit is contained in:
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -314,6 +314,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"analyzing": "Analysiere…",
|
||||
"analyzed": "Analysiert",
|
||||
"appCustomization": "Anpassung",
|
||||
"appCustomizationSubtitle": "Design, Sprache, Stimme und Quick Pills",
|
||||
"quickActionsDescription": "Schnellzugriffe im Chat",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -314,6 +314,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"analyzing": "Analizando…",
|
||||
"analyzed": "Analizado",
|
||||
"appCustomization": "Personalización",
|
||||
"appCustomizationSubtitle": "Tema, idioma, voz y quickpills",
|
||||
"quickActionsDescription": "Accesos directos en chat",
|
||||
|
||||
@@ -314,6 +314,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"analyzing": "Analyse…",
|
||||
"analyzed": "Analysé",
|
||||
"appCustomization": "Personnalisation",
|
||||
"appCustomizationSubtitle": "Thème, langue, voix et quickpills",
|
||||
"quickActionsDescription": "Raccourcis dans le chat",
|
||||
|
||||
@@ -314,6 +314,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"analyzing": "Analisi…",
|
||||
"analyzed": "Analizzato",
|
||||
"appCustomization": "Personalizzazione",
|
||||
"appCustomizationSubtitle": "Tema, lingua, voce e quickpills",
|
||||
"quickActionsDescription": "Scorciatoie nella chat",
|
||||
|
||||
@@ -429,6 +429,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"analyzing": "분석 중…",
|
||||
"analyzed": "분석 완료",
|
||||
"appCustomization": "사용자 정의",
|
||||
"appCustomizationSubtitle": "테마, 언어, 음성 및 빠른 액션",
|
||||
"quickActionsDescription": "채팅의 빠른 액션",
|
||||
|
||||
@@ -314,6 +314,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"analyzing": "Analyseren…",
|
||||
"analyzed": "Geanalyseerd",
|
||||
"appCustomization": "Aanpassing",
|
||||
"appCustomizationSubtitle": "Thema, taal, stem en quickpills",
|
||||
"quickActionsDescription": "Snelkoppelingen in chat",
|
||||
|
||||
@@ -314,6 +314,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"analyzing": "Анализирую…",
|
||||
"analyzed": "Проанализировано",
|
||||
"appCustomization": "Настройка",
|
||||
"appCustomizationSubtitle": "Тема, язык, голос и quickpills",
|
||||
"quickActionsDescription": "Быстрые клавиши в чате",
|
||||
|
||||
@@ -314,6 +314,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"analyzing": "分析中…",
|
||||
"analyzed": "已分析",
|
||||
"appCustomization": "自定义",
|
||||
"appCustomizationSubtitle": "主题、语言、语音和 quickpills",
|
||||
"quickActionsDescription": "聊天快捷方式",
|
||||
|
||||
@@ -314,6 +314,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"analyzing": "分析中…",
|
||||
"analyzed": "已分析",
|
||||
"appCustomization": "自定義",
|
||||
"appCustomizationSubtitle": "主題、語言、語音和 quickpills",
|
||||
"quickActionsDescription": "聊天快捷方式",
|
||||
|
||||
Reference in New Issue
Block a user