feat(models): Add filters support and auto-validation for model-specific filters
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
48
lib/core/models/toggle_filter.dart
Normal file
48
lib/core/models/toggle_filter.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
@@ -1462,6 +1502,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;
|
||||||
|
|
||||||
@@ -1476,6 +1517,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;
|
||||||
@@ -1829,6 +1873,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;
|
||||||
@@ -1913,7 +1958,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(
|
||||||
@@ -2109,6 +2171,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;
|
||||||
@@ -2247,6 +2341,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;
|
||||||
@@ -2290,7 +2385,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(
|
||||||
@@ -2424,6 +2529,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,
|
||||||
@@ -2501,6 +2699,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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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...",
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -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…",
|
||||||
|
|||||||
@@ -156,6 +156,7 @@
|
|||||||
"onboardQuickBullet2": "상단 바에서 새 채팅 시작 또는 모델 관리",
|
"onboardQuickBullet2": "상단 바에서 새 채팅 시작 또는 모델 관리",
|
||||||
"attachmentLabel": "첨부",
|
"attachmentLabel": "첨부",
|
||||||
"tools": "도구",
|
"tools": "도구",
|
||||||
|
"filters": "필터",
|
||||||
"voiceInput": "음성 입력",
|
"voiceInput": "음성 입력",
|
||||||
"voice": "음성",
|
"voice": "음성",
|
||||||
"voiceStatusListening": "듣는 중…",
|
"voiceStatusListening": "듣는 중…",
|
||||||
|
|||||||
@@ -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...",
|
||||||
|
|||||||
@@ -106,6 +106,7 @@
|
|||||||
"onboardQuickBullet2": "Начните новый чат или управляйте моделями из верхней панели",
|
"onboardQuickBullet2": "Начните новый чат или управляйте моделями из верхней панели",
|
||||||
"attachmentLabel": "Вложение",
|
"attachmentLabel": "Вложение",
|
||||||
"tools": "Инструменты",
|
"tools": "Инструменты",
|
||||||
|
"filters": "Фильтры",
|
||||||
"voiceInput": "Голосовой ввод",
|
"voiceInput": "Голосовой ввод",
|
||||||
"voice": "Голос",
|
"voice": "Голос",
|
||||||
"voiceStatusListening": "Слушаю...",
|
"voiceStatusListening": "Слушаю...",
|
||||||
|
|||||||
@@ -106,6 +106,7 @@
|
|||||||
"onboardQuickBullet2": "从顶部栏开始新对话或管理模型",
|
"onboardQuickBullet2": "从顶部栏开始新对话或管理模型",
|
||||||
"attachmentLabel": "附件",
|
"attachmentLabel": "附件",
|
||||||
"tools": "工具",
|
"tools": "工具",
|
||||||
|
"filters": "过滤器",
|
||||||
"voiceInput": "语音输入",
|
"voiceInput": "语音输入",
|
||||||
"voice": "语音",
|
"voice": "语音",
|
||||||
"voiceStatusListening": "正在听...",
|
"voiceStatusListening": "正在听...",
|
||||||
|
|||||||
@@ -106,6 +106,7 @@
|
|||||||
"onboardQuickBullet2": "從頂部欄開始新對話或管理模型",
|
"onboardQuickBullet2": "從頂部欄開始新對話或管理模型",
|
||||||
"attachmentLabel": "附件",
|
"attachmentLabel": "附件",
|
||||||
"tools": "工具",
|
"tools": "工具",
|
||||||
|
"filters": "過濾器",
|
||||||
"voiceInput": "語音輸入",
|
"voiceInput": "語音輸入",
|
||||||
"voice": "語音",
|
"voice": "語音",
|
||||||
"voiceStatusListening": "正在聽...",
|
"voiceStatusListening": "正在聽...",
|
||||||
|
|||||||
Reference in New Issue
Block a user