diff --git a/lib/core/models/model.dart b/lib/core/models/model.dart index cb82015..84fd177 100644 --- a/lib/core/models/model.dart +++ b/lib/core/models/model.dart @@ -1,4 +1,5 @@ import 'package:freezed_annotation/freezed_annotation.dart'; +import 'toggle_filter.dart'; part 'model.freezed.dart'; @@ -17,6 +18,10 @@ sealed class Model with _$Model { Map? metadata, List? supportedParameters, List? toolIds, + + /// Toggleable filters that can be enabled/disabled per chat. + /// These come from OpenWebUI filters with `toggle = True`. + List? filters, }) = _Model; factory Model.fromJson(Map json) { @@ -147,6 +152,17 @@ sealed class Model with _$Model { } } + // Extract toggle filters from the model response + // These come from OpenWebUI filters with toggle=True set + List? filters; + final filtersData = json['filters']; + if (filtersData is List && filtersData.isNotEmpty) { + filters = filtersData + .whereType>() + .map((f) => ToggleFilter.fromJson(f)) + .toList(); + } + final idRaw = json['id']; final id = idRaw?.toString(); if (id == null || id.isEmpty) { @@ -174,6 +190,7 @@ sealed class Model with _$Model { }, metadata: mergedMetadata, toolIds: toolIds, + filters: filters, ); } @@ -190,6 +207,7 @@ sealed class Model with _$Model { 'metadata': metadata, 'architecture': capabilities?['architecture'], 'toolIds': toolIds, + 'filters': filters?.map((f) => f.toJson()).toList(), }; data.removeWhere((_, value) => value == null); return data; diff --git a/lib/core/models/toggle_filter.dart b/lib/core/models/toggle_filter.dart new file mode 100644 index 0000000..3d2526a --- /dev/null +++ b/lib/core/models/toggle_filter.dart @@ -0,0 +1,48 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'toggle_filter.freezed.dart'; + +/// Represents a toggleable filter that can be enabled/disabled per chat. +/// +/// These filters are created by OpenWebUI when a filter function has +/// `toggle = True` set in its module. They appear as buttons next to +/// web search, image generation, and code interpreter buttons. +@freezed +sealed class ToggleFilter with _$ToggleFilter { + const ToggleFilter._(); + + const factory ToggleFilter({ + /// Unique identifier for the filter function. + required String id, + + /// Display name for the filter. + required String name, + + /// Optional description of what the filter does. + String? description, + + /// Optional icon URL for the filter. + String? icon, + + /// Whether this filter has user-configurable valves. + @Default(false) bool hasUserValves, + }) = _ToggleFilter; + + factory ToggleFilter.fromJson(Map json) { + return ToggleFilter( + id: json['id'] as String, + name: json['name'] as String, + description: json['description'] as String?, + icon: json['icon'] as String?, + hasUserValves: json['has_user_valves'] as bool? ?? false, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + if (description != null) 'description': description, + if (icon != null) 'icon': icon, + 'has_user_valves': hasUserValves, + }; +} diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index 27e42ec..7e71b01 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -750,6 +750,34 @@ class Models extends _$Models { final result = await AsyncValue.guard(() => _load(api)); if (!ref.mounted) return; state = result; + + // Update selected model with fresh data (e.g., filters) if it exists + // in the new models list + if (result.hasValue) { + final freshModels = result.value!; + final currentSelected = ref.read(selectedModelProvider); + if (currentSelected != null) { + try { + final freshModel = freshModels.firstWhere( + (m) => m.id == currentSelected.id, + ); + // Update selected model with fresh data (filters, etc.) + if (freshModel != currentSelected) { + ref.read(selectedModelProvider.notifier).set(freshModel); + DebugLogger.log( + 'selected-model-refreshed', + scope: 'models', + data: { + 'id': freshModel.id, + 'filters': freshModel.filters?.length ?? 0, + }, + ); + } + } catch (_) { + // Model no longer available - keep current selection + } + } + } } Future> _load(ApiService api) async { @@ -939,14 +967,58 @@ final modelToolsAutoSelectionProvider = Provider((ref) { }); }); +// Auto-clear invalid filter selections when model changes +// Filters are model-specific, so we need to validate selections against new model +final modelFiltersAutoSelectionProvider = Provider((ref) { + // Prevent disposal so listeners remain active throughout app lifecycle + ref.keepAlive(); + + void validateFilters(Model? model) { + final currentFilterIds = ref.read(selectedFilterIdsProvider); + if (currentFilterIds.isEmpty) return; + + // Get available filters from the model + final availableFilters = model?.filters ?? const []; + final validFilterIds = availableFilters.map((f) => f.id).toSet(); + + // Filter out any selected IDs that aren't valid for this model + final validSelection = currentFilterIds + .where((id) => validFilterIds.contains(id)) + .toList(); + + // Only update if something changed + if (validSelection.length != currentFilterIds.length) { + ref.read(selectedFilterIdsProvider.notifier).set(validSelection); + DebugLogger.log( + 'filter-selection-validated', + scope: 'models/filters', + data: { + 'modelId': model?.id, + 'previousCount': currentFilterIds.length, + 'validCount': validSelection.length, + }, + ); + } + } + + // Validate on model change + ref.listen(selectedModelProvider, (previous, next) { + if (previous?.id == next?.id && previous != null) { + return; + } + Future.microtask(() => validateFilters(next)); + }); +}); + // Auto-apply default model from settings when it changes (and not manually overridden) // keepAlive to maintain listener throughout app lifecycle final defaultModelAutoSelectionProvider = Provider((ref) { // Prevent disposal so listeners remain active throughout app lifecycle ref.keepAlive(); - // Initialize the model tools auto-selection + // Initialize the model tools and filters auto-selection ref.watch(modelToolsAutoSelectionProvider); + ref.watch(modelFiltersAutoSelectionProvider); ref.listen(appSettingsProvider, (previous, next) { // Only react when default model value changes diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index 69625e6..54be040 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -2687,6 +2687,7 @@ class ApiService { required String model, String? conversationId, List? toolIds, + List? filterIds, bool enableWebSearch = false, bool enableImageGeneration = false, Map? modelItem, @@ -2797,6 +2798,12 @@ class ApiService { // No default reasoning parameters included; providers handle thinking UIs natively. + // Add filter_ids if provided (Open-WebUI toggle filters) + if (filterIds != null && filterIds.isNotEmpty) { + data['filter_ids'] = filterIds; + _traceApi('Including filter_ids in streaming request: $filterIds'); + } + // Add tool_ids if provided (Open-WebUI expects tool_ids as array of strings) if (toolIds != null && toolIds.isNotEmpty) { data['tool_ids'] = toolIds; diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index 72c0b88..7555aed 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -1335,6 +1335,8 @@ Future regenerateMessage( // Include selected tool ids so provider-native tool calling is triggered final selectedToolIds = ref.read(selectedToolIdsProvider); + // Include selected filter ids (toggle filters enabled by user) + final selectedFilterIds = ref.read(selectedFilterIdsProvider); // Get conversation history for context (excluding the removed assistant message) final List messages = ref.read(chatMessagesProvider); final List> conversationMessages = @@ -1609,6 +1611,7 @@ Future regenerateMessage( model: selectedModel.id, conversationId: activeConversation.id, toolIds: selectedToolIds.isNotEmpty ? selectedToolIds : null, + filterIds: selectedFilterIds.isNotEmpty ? selectedFilterIds : null, enableWebSearch: webSearchEnabled, enableImageGeneration: imageGenerationEnabled, modelItem: modelItem, @@ -2011,6 +2014,11 @@ Future _sendMessageInternal( ? toolIds : null; + // Get selected toggle filter IDs + final selectedFilterIds = ref.read(selectedFilterIdsProvider); + final List? filterIdsForApi = + selectedFilterIds.isNotEmpty ? selectedFilterIds : null; + try { // Pre-seed assistant skeleton on server to ensure correct chain // Generate assistant message id now (must be consistent across client/server) @@ -2234,6 +2242,7 @@ Future _sendMessageInternal( model: selectedModel.id, conversationId: activeConversation?.id, toolIds: toolIdsForApi, + filterIds: filterIdsForApi, enableWebSearch: webSearchEnabled, // Enable image generation on the server when requested enableImageGeneration: imageGenerationEnabled, diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index 8dd5383..cc2319a 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -20,6 +20,7 @@ import '../../tools/providers/tools_providers.dart'; import '../../prompts/providers/prompts_providers.dart'; import '../../../core/models/tool.dart'; import '../../../core/models/prompt.dart'; +import '../../../core/models/toggle_filter.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/services/settings_service.dart'; import '../../chat/services/voice_input_service.dart'; @@ -901,6 +902,11 @@ class _ModernChatInputState extends ConsumerState orElse: () => false, ); final selectedToolIds = ref.watch(selectedToolIdsProvider); + final selectedFilterIds = ref.watch(selectedFilterIdsProvider); + + // Get filters from the selected model for quick pills + final selectedModel = ref.watch(selectedModelProvider); + final availableFilters = selectedModel?.filters ?? const []; final focusTick = ref.watch(inputFocusTriggerProvider); final autofocusEnabled = ref.watch(composerAutofocusEnabledProvider); @@ -974,7 +980,39 @@ class _ModernChatInputState extends ConsumerState onTap: widget.enabled && !_isRecording ? handleTap : null, ), ); + } else if (id.startsWith('filter:')) { + // Handle filter quick pills + final filterId = id.substring(7); // Remove 'filter:' prefix + ToggleFilter? filter; + for (final f in availableFilters) { + if (f.id == filterId) { + filter = f; + break; + } + } + if (filter != null) { + final bool isSelected = selectedFilterIds.contains(filterId); + final String label = filter.name; + final IconData icon = Platform.isIOS + ? CupertinoIcons.sparkles + : Icons.auto_awesome; + + void handleTap() { + ref.read(selectedFilterIdsProvider.notifier).toggle(filterId); + } + + quickPills.add( + _buildPillButton( + icon: icon, + label: label, + isActive: isSelected, + onTap: widget.enabled && !_isRecording ? handleTap : null, + iconUrl: filter.icon, + ), + ); + } } else { + // Handle tool quick pills Tool? tool; for (final t in availableTools) { if (t.id == id) { @@ -1064,6 +1102,7 @@ class _ModernChatInputState extends ConsumerState webSearchActive: webSearchEnabled, imageGenerationActive: imageGenEnabled, toolsActive: selectedToolIds.isNotEmpty, + filtersActive: selectedFilterIds.isNotEmpty, ), const SizedBox(width: Spacing.sm), Expanded( @@ -1176,6 +1215,7 @@ class _ModernChatInputState extends ConsumerState webSearchActive: webSearchEnabled, imageGenerationActive: imageGenEnabled, toolsActive: selectedToolIds.isNotEmpty, + filtersActive: selectedFilterIds.isNotEmpty, ), const SizedBox(width: Spacing.xs), Expanded( @@ -1462,6 +1502,7 @@ class _ModernChatInputState extends ConsumerState required bool webSearchActive, required bool imageGenerationActive, required bool toolsActive, + required bool filtersActive, }) { final bool enabled = widget.enabled && !_isRecording; @@ -1476,6 +1517,9 @@ class _ModernChatInputState extends ConsumerState } else if (toolsActive) { icon = Platform.isIOS ? CupertinoIcons.wrench : Icons.build; activeColor = context.conduitTheme.buttonPrimary; + } else if (filtersActive) { + icon = Platform.isIOS ? CupertinoIcons.sparkles : Icons.auto_awesome; + activeColor = context.conduitTheme.buttonPrimary; } else { icon = Platform.isIOS ? CupertinoIcons.add : Icons.add; activeColor = null; @@ -1829,6 +1873,7 @@ class _ModernChatInputState extends ConsumerState required String label, required bool isActive, VoidCallback? onTap, + String? iconUrl, }) { final bool enabled = onTap != null; final Brightness brightness = Theme.of(context).brightness; @@ -1913,7 +1958,24 @@ class _ModernChatInputState extends ConsumerState AnimatedContainer( duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, - child: Icon(icon, size: IconSize.small + 1, color: iconColor), + child: iconUrl != null && iconUrl.isNotEmpty + ? SizedBox( + width: IconSize.small + 1, + height: IconSize.small + 1, + child: Image.network( + iconUrl, + width: IconSize.small + 1, + height: IconSize.small + 1, + color: iconUrl.endsWith('.svg') ? iconColor : null, + colorBlendMode: BlendMode.srcIn, + errorBuilder: (_, __, ___) => Icon( + icon, + size: IconSize.small + 1, + color: iconColor, + ), + ), + ) + : Icon(icon, size: IconSize.small + 1, color: iconColor), ), const SizedBox(width: Spacing.xs + 1), AnimatedDefaultTextStyle( @@ -2109,6 +2171,38 @@ class _ModernChatInputState extends ConsumerState ..add(_buildSectionLabel(l10n.tools)) ..add(toolsSection); + // Add filters section (like tools section) + final modalSelectedModel = modalRef.watch(selectedModelProvider); + final modalToggleFilters = + modalSelectedModel?.filters ?? const []; + + if (modalToggleFilters.isNotEmpty) { + final modalSelectedFilterIds = modalRef.watch( + selectedFilterIdsProvider, + ); + final filterTiles = modalToggleFilters.map((filter) { + final isSelected = modalSelectedFilterIds.contains(filter.id); + return _buildFilterTile( + filter: filter, + selected: isSelected, + onToggle: () { + modalRef + .read(selectedFilterIdsProvider.notifier) + .toggle(filter.id); + }, + ); + }).toList(); + + bodyChildren + ..add(const SizedBox(height: Spacing.sm)) + ..add(_buildSectionLabel(l10n.filters)) + ..add( + Column( + children: _withVerticalSpacing(filterTiles, Spacing.xxs), + ), + ); + } + // Measure content height and cap the sheet's max size to avoid extra blank space final GlobalKey sheetContentKey = GlobalKey(); double? measuredContentHeight; @@ -2247,6 +2341,7 @@ class _ModernChatInputState extends ConsumerState String? subtitle, required bool value, required ValueChanged onChanged, + String? iconUrl, }) { final theme = context.conduitTheme; final brightness = Theme.of(context).brightness; @@ -2290,7 +2385,17 @@ class _ModernChatInputState extends ConsumerState child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - _buildToolGlyph(icon: icon, selected: value, theme: theme), + iconUrl != null && iconUrl.isNotEmpty + ? _buildFilterGlyph( + iconUrl: iconUrl, + selected: value, + theme: theme, + ) + : _buildToolGlyph( + icon: icon, + selected: value, + theme: theme, + ), const SizedBox(width: Spacing.xs), Expanded( child: Column( @@ -2424,6 +2529,99 @@ class _ModernChatInputState extends ConsumerState ); } + Widget _buildFilterTile({ + required ToggleFilter filter, + required bool selected, + required VoidCallback onToggle, + }) { + final theme = context.conduitTheme; + final brightness = Theme.of(context).brightness; + final description = filter.description ?? ''; + final Color background = selected + ? theme.buttonPrimary.withValues( + alpha: brightness == Brightness.dark ? 0.28 : 0.16, + ) + : theme.surfaceContainer.withValues( + alpha: brightness == Brightness.dark ? 0.32 : 0.12, + ); + final Color borderColor = selected + ? theme.buttonPrimary.withValues(alpha: 0.7) + : theme.cardBorder.withValues(alpha: 0.55); + + return Semantics( + button: true, + toggled: selected, + label: filter.name, + hint: description.isEmpty ? null : description, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(AppBorderRadius.input), + onTap: () { + HapticFeedback.selectionClick(); + onToggle(); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + margin: const EdgeInsets.symmetric(vertical: Spacing.xxs), + padding: const EdgeInsets.all(Spacing.sm), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(AppBorderRadius.input), + border: Border.all(color: borderColor, width: BorderWidth.thin), + boxShadow: selected ? ConduitShadows.low(context) : const [], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildFilterGlyph( + iconUrl: filter.icon, + selected: selected, + theme: theme, + ), + const SizedBox(width: Spacing.xs), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + filter.name, + style: AppTypography.bodySmallStyle.copyWith( + color: theme.textPrimary, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (description.isNotEmpty) ...[ + const SizedBox(height: Spacing.xs), + Text( + description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: AppTypography.captionStyle.copyWith( + color: theme.textSecondary.withValues( + alpha: Alpha.strong, + ), + ), + ), + ], + ], + ), + ), + const SizedBox(width: Spacing.xs), + _buildTogglePill(isOn: selected, theme: theme), + ], + ), + ), + ), + ), + ); + } + Widget _buildToolGlyph({ required IconData icon, required bool selected, @@ -2501,6 +2699,57 @@ class _ModernChatInputState extends ConsumerState return null; } + /// Builds the circular glyph/avatar for a filter tile. + Widget _buildFilterGlyph({ + String? iconUrl, + required bool selected, + required ConduitThemeExtension theme, + }) { + final Color accentStart = theme.buttonPrimary.withValues( + alpha: selected ? Alpha.active : Alpha.hover, + ); + final Color accentEnd = theme.buttonPrimary.withValues( + alpha: selected ? Alpha.highlight : Alpha.focus, + ); + final Color iconColor = selected + ? theme.buttonPrimaryText + : theme.iconPrimary.withValues(alpha: Alpha.strong); + + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [accentStart, accentEnd], + ), + ), + child: iconUrl != null && iconUrl.isNotEmpty + ? ClipOval( + child: Image.network( + iconUrl, + width: 36, + height: 36, + fit: BoxFit.cover, + color: iconUrl.endsWith('.svg') ? iconColor : null, + colorBlendMode: BlendMode.srcIn, + errorBuilder: (_, __, ___) => Icon( + Platform.isIOS ? CupertinoIcons.sparkles : Icons.auto_awesome, + color: iconColor, + size: IconSize.modal, + ), + ), + ) + : Icon( + Platform.isIOS ? CupertinoIcons.sparkles : Icons.auto_awesome, + color: iconColor, + size: IconSize.modal, + ), + ); + } + Widget _buildTogglePill({ required bool isOn, required ConduitThemeExtension theme, diff --git a/lib/features/profile/views/app_customization_page.dart b/lib/features/profile/views/app_customization_page.dart index 37f87cf..10b52f4 100644 --- a/lib/features/profile/views/app_customization_page.dart +++ b/lib/features/profile/views/app_customization_page.dart @@ -306,7 +306,18 @@ class AppCustomizationPage extends ConsumerWidget { data: (value) => value, orElse: () => const [], ); - final allowed = {'web', 'image', ...tools.map((t) => t.id)}; + + // Get filters from the selected model + final selectedModel = ref.watch(selectedModelProvider); + final filters = selectedModel?.filters ?? const []; + + // Include filter IDs in allowed set (prefixed with 'filter:' to avoid collisions) + final allowed = { + 'web', + 'image', + ...tools.map((t) => t.id), + ...filters.map((f) => 'filter:${f.id}'), + }; final selected = selectedRaw .where((id) => allowed.contains(id)) @@ -344,6 +355,20 @@ class AppCustomizationPage extends ConsumerWidget { }).toList(); } + List buildFilterChips() { + return filters.map((filter) { + final filterId = 'filter:${filter.id}'; + final isSelected = selected.contains(filterId); + final canSelect = selectedCount < maxPills || isSelected; + return ConduitChip( + label: filter.name, + icon: Platform.isIOS ? CupertinoIcons.sparkles : Icons.auto_awesome, + isSelected: isSelected, + onTap: canSelect ? () => toggle(filterId) : null, + ); + }).toList(); + } + final l10n = AppLocalizations.of(context)!; final selectedCountText = l10n.quickActionsSelectedCount(selectedCount); @@ -378,6 +403,7 @@ class AppCustomizationPage extends ConsumerWidget { : null, ), ...buildToolChips(), + ...buildFilterChips(), if (selected.isNotEmpty) ConduitChip( label: l10n.clear, diff --git a/lib/features/tools/providers/tools_providers.dart b/lib/features/tools/providers/tools_providers.dart index faf0526..7c7a12d 100644 --- a/lib/features/tools/providers/tools_providers.dart +++ b/lib/features/tools/providers/tools_providers.dart @@ -63,3 +63,26 @@ class SelectedToolIds extends _$SelectedToolIds { void set(List ids) => state = List.from(ids); } + +/// Provider for selected filter IDs (toggle filters enabled by user). +/// +/// These filters are dynamically created by OpenWebUI filters with +/// `toggle = True` set in their module. They appear as toggleable +/// buttons in the chat input UI. +@Riverpod(keepAlive: true) +class SelectedFilterIds extends _$SelectedFilterIds { + @override + List build() => []; + + void set(List ids) => state = List.from(ids); + + void toggle(String id) { + if (state.contains(id)) { + state = state.where((i) => i != id).toList(); + } else { + state = [...state, id]; + } + } + + void clear() => state = []; +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 5455e87..db75c7a 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -106,6 +106,7 @@ "onboardQuickBullet2": "Neuer Chat starten oder Modelle oben verwalten", "attachmentLabel": "Anhang", "tools": "Werkzeuge", + "filters": "Filter", "voiceInput": "Spracheingabe", "voice": "Sprache", "voiceStatusListening": "Hört zu…", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1a9dfbd..c19960d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -462,6 +462,10 @@ "@tools": { "description": "Header for a tools/actions section." }, + "filters": "Filters", + "@filters": { + "description": "Header for toggle filters section." + }, "voiceInput": "Voice input", "@voiceInput": { "description": "Label for voice input feature." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index b8128d7..7250f18 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -106,6 +106,7 @@ "onboardQuickBullet2": "Inicia Nueva conversación o gestiona modelos desde la barra superior", "attachmentLabel": "Adjunto", "tools": "Herramientas", + "filters": "Filtros", "voiceInput": "Entrada de voz", "voice": "Voz", "voiceStatusListening": "Escuchando...", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 2063bed..58417ee 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -106,6 +106,7 @@ "onboardQuickBullet2": "Lancez Nouveau chat ou gérez les modèles depuis la barre", "attachmentLabel": "Pièce jointe", "tools": "Outils", + "filters": "Filtres", "voiceInput": "Entrée vocale", "voice": "Voix", "voiceStatusListening": "Écoute…", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 6d754fe..74e0a96 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -106,6 +106,7 @@ "onboardQuickBullet2": "Avvia Nuova chat o gestisci i modelli dalla barra", "attachmentLabel": "Allegato", "tools": "Strumenti", + "filters": "Filtri", "voiceInput": "Input vocale", "voice": "Voce", "voiceStatusListening": "In ascolto…", diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index c0a5a53..23950ef 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -156,6 +156,7 @@ "onboardQuickBullet2": "상단 바에서 새 채팅 시작 또는 모델 관리", "attachmentLabel": "첨부", "tools": "도구", + "filters": "필터", "voiceInput": "음성 입력", "voice": "음성", "voiceStatusListening": "듣는 중…", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 048151d..43e6400 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -106,6 +106,7 @@ "onboardQuickBullet2": "Start Nieuwe chat of beheer modellen vanuit de bovenbalk", "attachmentLabel": "Bijlage", "tools": "Hulpmiddelen", + "filters": "Filters", "voiceInput": "Spraakinvoer", "voice": "Stem", "voiceStatusListening": "Luisteren...", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 66a963c..a6d53b6 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -106,6 +106,7 @@ "onboardQuickBullet2": "Начните новый чат или управляйте моделями из верхней панели", "attachmentLabel": "Вложение", "tools": "Инструменты", + "filters": "Фильтры", "voiceInput": "Голосовой ввод", "voice": "Голос", "voiceStatusListening": "Слушаю...", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 827d626..a52145a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -106,6 +106,7 @@ "onboardQuickBullet2": "从顶部栏开始新对话或管理模型", "attachmentLabel": "附件", "tools": "工具", + "filters": "过滤器", "voiceInput": "语音输入", "voice": "语音", "voiceStatusListening": "正在听...", diff --git a/lib/l10n/app_zh_Hant.arb b/lib/l10n/app_zh_Hant.arb index eae014e..7cb513f 100644 --- a/lib/l10n/app_zh_Hant.arb +++ b/lib/l10n/app_zh_Hant.arb @@ -106,6 +106,7 @@ "onboardQuickBullet2": "從頂部欄開始新對話或管理模型", "attachmentLabel": "附件", "tools": "工具", + "filters": "過濾器", "voiceInput": "語音輸入", "voice": "語音", "voiceStatusListening": "正在聽...",