Merge pull request #224 from cogwheel0/add-model-filters-support

feat(models): Add filters support and auto-validation for model-specific filters
This commit is contained in:
cogwheel
2025-12-05 22:20:47 +05:30
committed by GitHub
18 changed files with 469 additions and 4 deletions

View File

@@ -1,4 +1,5 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'toggle_filter.dart';
part 'model.freezed.dart'; part 'model.freezed.dart';
@@ -17,6 +18,10 @@ sealed class Model with _$Model {
Map<String, dynamic>? metadata, Map<String, dynamic>? metadata,
List<String>? supportedParameters, List<String>? supportedParameters,
List<String>? toolIds, List<String>? toolIds,
/// Toggleable filters that can be enabled/disabled per chat.
/// These come from OpenWebUI filters with `toggle = True`.
List<ToggleFilter>? filters,
}) = _Model; }) = _Model;
factory Model.fromJson(Map<String, dynamic> json) { factory Model.fromJson(Map<String, dynamic> 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<ToggleFilter>? filters;
final filtersData = json['filters'];
if (filtersData is List && filtersData.isNotEmpty) {
filters = filtersData
.whereType<Map<String, dynamic>>()
.map((f) => ToggleFilter.fromJson(f))
.toList();
}
final idRaw = json['id']; final idRaw = json['id'];
final id = idRaw?.toString(); final id = idRaw?.toString();
if (id == null || id.isEmpty) { if (id == null || id.isEmpty) {
@@ -174,6 +190,7 @@ sealed class Model with _$Model {
}, },
metadata: mergedMetadata, metadata: mergedMetadata,
toolIds: toolIds, toolIds: toolIds,
filters: filters,
); );
} }
@@ -190,6 +207,7 @@ sealed class Model with _$Model {
'metadata': metadata, 'metadata': metadata,
'architecture': capabilities?['architecture'], 'architecture': capabilities?['architecture'],
'toolIds': toolIds, 'toolIds': toolIds,
'filters': filters?.map((f) => f.toJson()).toList(),
}; };
data.removeWhere((_, value) => value == null); data.removeWhere((_, value) => value == null);
return data; return data;

View File

@@ -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<String, dynamic> 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<String, dynamic> toJson() => {
'id': id,
'name': name,
if (description != null) 'description': description,
if (icon != null) 'icon': icon,
'has_user_valves': hasUserValves,
};
}

View File

@@ -750,6 +750,34 @@ class Models extends _$Models {
final result = await AsyncValue.guard(() => _load(api)); final result = await AsyncValue.guard(() => _load(api));
if (!ref.mounted) return; if (!ref.mounted) return;
state = result; 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<List<Model>> _load(ApiService api) async { Future<List<Model>> _load(ApiService api) async {
@@ -939,14 +967,58 @@ final modelToolsAutoSelectionProvider = Provider<void>((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<void>((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<Model?>(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) // Auto-apply default model from settings when it changes (and not manually overridden)
// keepAlive to maintain listener throughout app lifecycle // keepAlive to maintain listener throughout app lifecycle
final defaultModelAutoSelectionProvider = Provider<void>((ref) { final defaultModelAutoSelectionProvider = Provider<void>((ref) {
// Prevent disposal so listeners remain active throughout app lifecycle // Prevent disposal so listeners remain active throughout app lifecycle
ref.keepAlive(); ref.keepAlive();
// Initialize the model tools auto-selection // Initialize the model tools and filters auto-selection
ref.watch(modelToolsAutoSelectionProvider); ref.watch(modelToolsAutoSelectionProvider);
ref.watch(modelFiltersAutoSelectionProvider);
ref.listen<AppSettings>(appSettingsProvider, (previous, next) { ref.listen<AppSettings>(appSettingsProvider, (previous, next) {
// Only react when default model value changes // Only react when default model value changes

View File

@@ -2687,6 +2687,7 @@ class ApiService {
required String model, required String model,
String? conversationId, String? conversationId,
List<String>? toolIds, List<String>? toolIds,
List<String>? filterIds,
bool enableWebSearch = false, bool enableWebSearch = false,
bool enableImageGeneration = false, bool enableImageGeneration = false,
Map<String, dynamic>? modelItem, Map<String, dynamic>? modelItem,
@@ -2797,6 +2798,12 @@ class ApiService {
// No default reasoning parameters included; providers handle thinking UIs natively. // 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) // Add tool_ids if provided (Open-WebUI expects tool_ids as array of strings)
if (toolIds != null && toolIds.isNotEmpty) { if (toolIds != null && toolIds.isNotEmpty) {
data['tool_ids'] = toolIds; data['tool_ids'] = toolIds;

View File

@@ -1335,6 +1335,8 @@ Future<void> regenerateMessage(
// Include selected tool ids so provider-native tool calling is triggered // Include selected tool ids so provider-native tool calling is triggered
final selectedToolIds = ref.read(selectedToolIdsProvider); 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) // Get conversation history for context (excluding the removed assistant message)
final List<ChatMessage> messages = ref.read(chatMessagesProvider); final List<ChatMessage> messages = ref.read(chatMessagesProvider);
final List<Map<String, dynamic>> conversationMessages = final List<Map<String, dynamic>> conversationMessages =
@@ -1609,6 +1611,7 @@ Future<void> regenerateMessage(
model: selectedModel.id, model: selectedModel.id,
conversationId: activeConversation.id, conversationId: activeConversation.id,
toolIds: selectedToolIds.isNotEmpty ? selectedToolIds : null, toolIds: selectedToolIds.isNotEmpty ? selectedToolIds : null,
filterIds: selectedFilterIds.isNotEmpty ? selectedFilterIds : null,
enableWebSearch: webSearchEnabled, enableWebSearch: webSearchEnabled,
enableImageGeneration: imageGenerationEnabled, enableImageGeneration: imageGenerationEnabled,
modelItem: modelItem, modelItem: modelItem,
@@ -2011,6 +2014,11 @@ Future<void> _sendMessageInternal(
? toolIds ? toolIds
: null; : null;
// Get selected toggle filter IDs
final selectedFilterIds = ref.read(selectedFilterIdsProvider);
final List<String>? filterIdsForApi =
selectedFilterIds.isNotEmpty ? selectedFilterIds : null;
try { try {
// Pre-seed assistant skeleton on server to ensure correct chain // Pre-seed assistant skeleton on server to ensure correct chain
// Generate assistant message id now (must be consistent across client/server) // Generate assistant message id now (must be consistent across client/server)
@@ -2234,6 +2242,7 @@ Future<void> _sendMessageInternal(
model: selectedModel.id, model: selectedModel.id,
conversationId: activeConversation?.id, conversationId: activeConversation?.id,
toolIds: toolIdsForApi, toolIds: toolIdsForApi,
filterIds: filterIdsForApi,
enableWebSearch: webSearchEnabled, enableWebSearch: webSearchEnabled,
// Enable image generation on the server when requested // Enable image generation on the server when requested
enableImageGeneration: imageGenerationEnabled, enableImageGeneration: imageGenerationEnabled,

View File

@@ -20,6 +20,7 @@ import '../../tools/providers/tools_providers.dart';
import '../../prompts/providers/prompts_providers.dart'; import '../../prompts/providers/prompts_providers.dart';
import '../../../core/models/tool.dart'; import '../../../core/models/tool.dart';
import '../../../core/models/prompt.dart'; import '../../../core/models/prompt.dart';
import '../../../core/models/toggle_filter.dart';
import '../../../core/providers/app_providers.dart'; import '../../../core/providers/app_providers.dart';
import '../../../core/services/settings_service.dart'; import '../../../core/services/settings_service.dart';
import '../../chat/services/voice_input_service.dart'; import '../../chat/services/voice_input_service.dart';
@@ -901,6 +902,11 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
orElse: () => false, orElse: () => false,
); );
final selectedToolIds = ref.watch(selectedToolIdsProvider); 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 focusTick = ref.watch(inputFocusTriggerProvider);
final autofocusEnabled = ref.watch(composerAutofocusEnabledProvider); final autofocusEnabled = ref.watch(composerAutofocusEnabledProvider);
@@ -974,7 +980,39 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
onTap: widget.enabled && !_isRecording ? handleTap : null, 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 { } else {
// Handle tool quick pills
Tool? tool; Tool? tool;
for (final t in availableTools) { for (final t in availableTools) {
if (t.id == id) { if (t.id == id) {
@@ -1064,6 +1102,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
webSearchActive: webSearchEnabled, webSearchActive: webSearchEnabled,
imageGenerationActive: imageGenEnabled, imageGenerationActive: imageGenEnabled,
toolsActive: selectedToolIds.isNotEmpty, toolsActive: selectedToolIds.isNotEmpty,
filtersActive: selectedFilterIds.isNotEmpty,
), ),
const SizedBox(width: Spacing.sm), const SizedBox(width: Spacing.sm),
Expanded( Expanded(
@@ -1176,6 +1215,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
webSearchActive: webSearchEnabled, webSearchActive: webSearchEnabled,
imageGenerationActive: imageGenEnabled, imageGenerationActive: imageGenEnabled,
toolsActive: selectedToolIds.isNotEmpty, toolsActive: selectedToolIds.isNotEmpty,
filtersActive: selectedFilterIds.isNotEmpty,
), ),
const SizedBox(width: Spacing.xs), const SizedBox(width: Spacing.xs),
Expanded( Expanded(
@@ -1459,6 +1499,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
required bool webSearchActive, required bool webSearchActive,
required bool imageGenerationActive, required bool imageGenerationActive,
required bool toolsActive, required bool toolsActive,
required bool filtersActive,
}) { }) {
final bool enabled = widget.enabled && !_isRecording; final bool enabled = widget.enabled && !_isRecording;
@@ -1473,6 +1514,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
} else if (toolsActive) { } else if (toolsActive) {
icon = Platform.isIOS ? CupertinoIcons.wrench : Icons.build; icon = Platform.isIOS ? CupertinoIcons.wrench : Icons.build;
activeColor = context.conduitTheme.buttonPrimary; activeColor = context.conduitTheme.buttonPrimary;
} else if (filtersActive) {
icon = Platform.isIOS ? CupertinoIcons.sparkles : Icons.auto_awesome;
activeColor = context.conduitTheme.buttonPrimary;
} else { } else {
icon = Platform.isIOS ? CupertinoIcons.add : Icons.add; icon = Platform.isIOS ? CupertinoIcons.add : Icons.add;
activeColor = null; activeColor = null;
@@ -1826,6 +1870,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
required String label, required String label,
required bool isActive, required bool isActive,
VoidCallback? onTap, VoidCallback? onTap,
String? iconUrl,
}) { }) {
final bool enabled = onTap != null; final bool enabled = onTap != null;
final Brightness brightness = Theme.of(context).brightness; final Brightness brightness = Theme.of(context).brightness;
@@ -1910,7 +1955,24 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
AnimatedContainer( AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic, 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), const SizedBox(width: Spacing.xs + 1),
AnimatedDefaultTextStyle( AnimatedDefaultTextStyle(
@@ -2106,6 +2168,38 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
..add(_buildSectionLabel(l10n.tools)) ..add(_buildSectionLabel(l10n.tools))
..add(toolsSection); ..add(toolsSection);
// Add filters section (like tools section)
final modalSelectedModel = modalRef.watch(selectedModelProvider);
final modalToggleFilters =
modalSelectedModel?.filters ?? const <ToggleFilter>[];
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 // Measure content height and cap the sheet's max size to avoid extra blank space
final GlobalKey sheetContentKey = GlobalKey(); final GlobalKey sheetContentKey = GlobalKey();
double? measuredContentHeight; double? measuredContentHeight;
@@ -2244,6 +2338,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
String? subtitle, String? subtitle,
required bool value, required bool value,
required ValueChanged<bool> onChanged, required ValueChanged<bool> onChanged,
String? iconUrl,
}) { }) {
final theme = context.conduitTheme; final theme = context.conduitTheme;
final brightness = Theme.of(context).brightness; final brightness = Theme.of(context).brightness;
@@ -2287,7 +2382,17 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ 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), const SizedBox(width: Spacing.xs),
Expanded( Expanded(
child: Column( child: Column(
@@ -2421,6 +2526,99 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
); );
} }
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({ Widget _buildToolGlyph({
required IconData icon, required IconData icon,
required bool selected, required bool selected,
@@ -2498,6 +2696,57 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
return null; 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({ Widget _buildTogglePill({
required bool isOn, required bool isOn,
required ConduitThemeExtension theme, required ConduitThemeExtension theme,

View File

@@ -306,7 +306,18 @@ class AppCustomizationPage extends ConsumerWidget {
data: (value) => value, data: (value) => value,
orElse: () => const <Tool>[], orElse: () => const <Tool>[],
); );
final allowed = <String>{'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 = <String>{
'web',
'image',
...tools.map((t) => t.id),
...filters.map((f) => 'filter:${f.id}'),
};
final selected = selectedRaw final selected = selectedRaw
.where((id) => allowed.contains(id)) .where((id) => allowed.contains(id))
@@ -344,6 +355,20 @@ class AppCustomizationPage extends ConsumerWidget {
}).toList(); }).toList();
} }
List<Widget> 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 l10n = AppLocalizations.of(context)!;
final selectedCountText = l10n.quickActionsSelectedCount(selectedCount); final selectedCountText = l10n.quickActionsSelectedCount(selectedCount);
@@ -378,6 +403,7 @@ class AppCustomizationPage extends ConsumerWidget {
: null, : null,
), ),
...buildToolChips(), ...buildToolChips(),
...buildFilterChips(),
if (selected.isNotEmpty) if (selected.isNotEmpty)
ConduitChip( ConduitChip(
label: l10n.clear, label: l10n.clear,

View File

@@ -63,3 +63,26 @@ class SelectedToolIds extends _$SelectedToolIds {
void set(List<String> ids) => state = List<String>.from(ids); void set(List<String> ids) => state = List<String>.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<String> build() => [];
void set(List<String> ids) => state = List<String>.from(ids);
void toggle(String id) {
if (state.contains(id)) {
state = state.where((i) => i != id).toList();
} else {
state = [...state, id];
}
}
void clear() => state = [];
}

View File

@@ -106,6 +106,7 @@
"onboardQuickBullet2": "Neuer Chat starten oder Modelle oben verwalten", "onboardQuickBullet2": "Neuer Chat starten oder Modelle oben verwalten",
"attachmentLabel": "Anhang", "attachmentLabel": "Anhang",
"tools": "Werkzeuge", "tools": "Werkzeuge",
"filters": "Filter",
"voiceInput": "Spracheingabe", "voiceInput": "Spracheingabe",
"voice": "Sprache", "voice": "Sprache",
"voiceStatusListening": "Hört zu…", "voiceStatusListening": "Hört zu…",

View File

@@ -462,6 +462,10 @@
"@tools": { "@tools": {
"description": "Header for a tools/actions section." "description": "Header for a tools/actions section."
}, },
"filters": "Filters",
"@filters": {
"description": "Header for toggle filters section."
},
"voiceInput": "Voice input", "voiceInput": "Voice input",
"@voiceInput": { "@voiceInput": {
"description": "Label for voice input feature." "description": "Label for voice input feature."

View File

@@ -106,6 +106,7 @@
"onboardQuickBullet2": "Inicia Nueva conversación o gestiona modelos desde la barra superior", "onboardQuickBullet2": "Inicia Nueva conversación o gestiona modelos desde la barra superior",
"attachmentLabel": "Adjunto", "attachmentLabel": "Adjunto",
"tools": "Herramientas", "tools": "Herramientas",
"filters": "Filtros",
"voiceInput": "Entrada de voz", "voiceInput": "Entrada de voz",
"voice": "Voz", "voice": "Voz",
"voiceStatusListening": "Escuchando...", "voiceStatusListening": "Escuchando...",

View File

@@ -106,6 +106,7 @@
"onboardQuickBullet2": "Lancez Nouveau chat ou gérez les modèles depuis la barre", "onboardQuickBullet2": "Lancez Nouveau chat ou gérez les modèles depuis la barre",
"attachmentLabel": "Pièce jointe", "attachmentLabel": "Pièce jointe",
"tools": "Outils", "tools": "Outils",
"filters": "Filtres",
"voiceInput": "Entrée vocale", "voiceInput": "Entrée vocale",
"voice": "Voix", "voice": "Voix",
"voiceStatusListening": "Écoute…", "voiceStatusListening": "Écoute…",

View File

@@ -106,6 +106,7 @@
"onboardQuickBullet2": "Avvia Nuova chat o gestisci i modelli dalla barra", "onboardQuickBullet2": "Avvia Nuova chat o gestisci i modelli dalla barra",
"attachmentLabel": "Allegato", "attachmentLabel": "Allegato",
"tools": "Strumenti", "tools": "Strumenti",
"filters": "Filtri",
"voiceInput": "Input vocale", "voiceInput": "Input vocale",
"voice": "Voce", "voice": "Voce",
"voiceStatusListening": "In ascolto…", "voiceStatusListening": "In ascolto…",

View File

@@ -156,6 +156,7 @@
"onboardQuickBullet2": "상단 바에서 새 채팅 시작 또는 모델 관리", "onboardQuickBullet2": "상단 바에서 새 채팅 시작 또는 모델 관리",
"attachmentLabel": "첨부", "attachmentLabel": "첨부",
"tools": "도구", "tools": "도구",
"filters": "필터",
"voiceInput": "음성 입력", "voiceInput": "음성 입력",
"voice": "음성", "voice": "음성",
"voiceStatusListening": "듣는 중…", "voiceStatusListening": "듣는 중…",

View File

@@ -106,6 +106,7 @@
"onboardQuickBullet2": "Start Nieuwe chat of beheer modellen vanuit de bovenbalk", "onboardQuickBullet2": "Start Nieuwe chat of beheer modellen vanuit de bovenbalk",
"attachmentLabel": "Bijlage", "attachmentLabel": "Bijlage",
"tools": "Hulpmiddelen", "tools": "Hulpmiddelen",
"filters": "Filters",
"voiceInput": "Spraakinvoer", "voiceInput": "Spraakinvoer",
"voice": "Stem", "voice": "Stem",
"voiceStatusListening": "Luisteren...", "voiceStatusListening": "Luisteren...",

View File

@@ -106,6 +106,7 @@
"onboardQuickBullet2": "Начните новый чат или управляйте моделями из верхней панели", "onboardQuickBullet2": "Начните новый чат или управляйте моделями из верхней панели",
"attachmentLabel": "Вложение", "attachmentLabel": "Вложение",
"tools": "Инструменты", "tools": "Инструменты",
"filters": "Фильтры",
"voiceInput": "Голосовой ввод", "voiceInput": "Голосовой ввод",
"voice": "Голос", "voice": "Голос",
"voiceStatusListening": "Слушаю...", "voiceStatusListening": "Слушаю...",

View File

@@ -106,6 +106,7 @@
"onboardQuickBullet2": "从顶部栏开始新对话或管理模型", "onboardQuickBullet2": "从顶部栏开始新对话或管理模型",
"attachmentLabel": "附件", "attachmentLabel": "附件",
"tools": "工具", "tools": "工具",
"filters": "过滤器",
"voiceInput": "语音输入", "voiceInput": "语音输入",
"voice": "语音", "voice": "语音",
"voiceStatusListening": "正在听...", "voiceStatusListening": "正在听...",

View File

@@ -106,6 +106,7 @@
"onboardQuickBullet2": "從頂部欄開始新對話或管理模型", "onboardQuickBullet2": "從頂部欄開始新對話或管理模型",
"attachmentLabel": "附件", "attachmentLabel": "附件",
"tools": "工具", "tools": "工具",
"filters": "過濾器",
"voiceInput": "語音輸入", "voiceInput": "語音輸入",
"voice": "語音", "voice": "語音",
"voiceStatusListening": "正在聽...", "voiceStatusListening": "正在聽...",