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:
@@ -1335,6 +1335,8 @@ Future<void> 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<ChatMessage> messages = ref.read(chatMessagesProvider);
|
||||
final List<Map<String, dynamic>> conversationMessages =
|
||||
@@ -1609,6 +1611,7 @@ Future<void> 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<void> _sendMessageInternal(
|
||||
? toolIds
|
||||
: null;
|
||||
|
||||
// Get selected toggle filter IDs
|
||||
final selectedFilterIds = ref.read(selectedFilterIdsProvider);
|
||||
final List<String>? 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<void> _sendMessageInternal(
|
||||
model: selectedModel.id,
|
||||
conversationId: activeConversation?.id,
|
||||
toolIds: toolIdsForApi,
|
||||
filterIds: filterIdsForApi,
|
||||
enableWebSearch: webSearchEnabled,
|
||||
// Enable image generation on the server when requested
|
||||
enableImageGeneration: imageGenerationEnabled,
|
||||
|
||||
@@ -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<ModernChatInput>
|
||||
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<ModernChatInput>
|
||||
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<ModernChatInput>
|
||||
webSearchActive: webSearchEnabled,
|
||||
imageGenerationActive: imageGenEnabled,
|
||||
toolsActive: selectedToolIds.isNotEmpty,
|
||||
filtersActive: selectedFilterIds.isNotEmpty,
|
||||
),
|
||||
const SizedBox(width: Spacing.sm),
|
||||
Expanded(
|
||||
@@ -1176,6 +1215,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
webSearchActive: webSearchEnabled,
|
||||
imageGenerationActive: imageGenEnabled,
|
||||
toolsActive: selectedToolIds.isNotEmpty,
|
||||
filtersActive: selectedFilterIds.isNotEmpty,
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Expanded(
|
||||
@@ -1459,6 +1499,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
required bool webSearchActive,
|
||||
required bool imageGenerationActive,
|
||||
required bool toolsActive,
|
||||
required bool filtersActive,
|
||||
}) {
|
||||
final bool enabled = widget.enabled && !_isRecording;
|
||||
|
||||
@@ -1473,6 +1514,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
} 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;
|
||||
@@ -1826,6 +1870,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
required String label,
|
||||
required bool isActive,
|
||||
VoidCallback? onTap,
|
||||
String? iconUrl,
|
||||
}) {
|
||||
final bool enabled = onTap != null;
|
||||
final Brightness brightness = Theme.of(context).brightness;
|
||||
@@ -1910,7 +1955,24 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
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(
|
||||
@@ -2106,6 +2168,38 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
..add(_buildSectionLabel(l10n.tools))
|
||||
..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
|
||||
final GlobalKey sheetContentKey = GlobalKey();
|
||||
double? measuredContentHeight;
|
||||
@@ -2244,6 +2338,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
String? subtitle,
|
||||
required bool value,
|
||||
required ValueChanged<bool> onChanged,
|
||||
String? iconUrl,
|
||||
}) {
|
||||
final theme = context.conduitTheme;
|
||||
final brightness = Theme.of(context).brightness;
|
||||
@@ -2287,7 +2382,17 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
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(
|
||||
@@ -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({
|
||||
required IconData icon,
|
||||
required bool selected,
|
||||
@@ -2498,6 +2696,57 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
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,
|
||||
|
||||
@@ -306,7 +306,18 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
data: (value) => value,
|
||||
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
|
||||
.where((id) => allowed.contains(id))
|
||||
@@ -344,6 +355,20 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
}).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 selectedCountText = l10n.quickActionsSelectedCount(selectedCount);
|
||||
|
||||
@@ -378,6 +403,7 @@ class AppCustomizationPage extends ConsumerWidget {
|
||||
: null,
|
||||
),
|
||||
...buildToolChips(),
|
||||
...buildFilterChips(),
|
||||
if (selected.isNotEmpty)
|
||||
ConduitChip(
|
||||
label: l10n.clear,
|
||||
|
||||
@@ -63,3 +63,26 @@ class SelectedToolIds extends _$SelectedToolIds {
|
||||
|
||||
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 = [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user