feat(l10n): Add localization for code interpreter states
This commit is contained in:
@@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -429,6 +429,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"analyzing": "분석 중…",
|
||||||
|
"analyzed": "분석 완료",
|
||||||
"appCustomization": "사용자 정의",
|
"appCustomization": "사용자 정의",
|
||||||
"appCustomizationSubtitle": "테마, 언어, 음성 및 빠른 액션",
|
"appCustomizationSubtitle": "테마, 언어, 음성 및 빠른 액션",
|
||||||
"quickActionsDescription": "채팅의 빠른 액션",
|
"quickActionsDescription": "채팅의 빠른 액션",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -314,6 +314,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"analyzing": "Анализирую…",
|
||||||
|
"analyzed": "Проанализировано",
|
||||||
"appCustomization": "Настройка",
|
"appCustomization": "Настройка",
|
||||||
"appCustomizationSubtitle": "Тема, язык, голос и quickpills",
|
"appCustomizationSubtitle": "Тема, язык, голос и quickpills",
|
||||||
"quickActionsDescription": "Быстрые клавиши в чате",
|
"quickActionsDescription": "Быстрые клавиши в чате",
|
||||||
|
|||||||
@@ -314,6 +314,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"analyzing": "分析中…",
|
||||||
|
"analyzed": "已分析",
|
||||||
"appCustomization": "自定义",
|
"appCustomization": "自定义",
|
||||||
"appCustomizationSubtitle": "主题、语言、语音和 quickpills",
|
"appCustomizationSubtitle": "主题、语言、语音和 quickpills",
|
||||||
"quickActionsDescription": "聊天快捷方式",
|
"quickActionsDescription": "聊天快捷方式",
|
||||||
|
|||||||
@@ -314,6 +314,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"analyzing": "分析中…",
|
||||||
|
"analyzed": "已分析",
|
||||||
"appCustomization": "自定義",
|
"appCustomization": "自定義",
|
||||||
"appCustomizationSubtitle": "主題、語言、語音和 quickpills",
|
"appCustomizationSubtitle": "主題、語言、語音和 quickpills",
|
||||||
"quickActionsDescription": "聊天快捷方式",
|
"quickActionsDescription": "聊天快捷方式",
|
||||||
|
|||||||
Reference in New Issue
Block a user