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 'toggle_filter.dart';
|
||||
|
||||
part 'model.freezed.dart';
|
||||
|
||||
@@ -17,6 +18,10 @@ sealed class Model with _$Model {
|
||||
Map<String, dynamic>? metadata,
|
||||
List<String>? supportedParameters,
|
||||
List<String>? toolIds,
|
||||
|
||||
/// Toggleable filters that can be enabled/disabled per chat.
|
||||
/// These come from OpenWebUI filters with `toggle = True`.
|
||||
List<ToggleFilter>? filters,
|
||||
}) = _Model;
|
||||
|
||||
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 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;
|
||||
|
||||
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));
|
||||
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<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)
|
||||
// keepAlive to maintain listener throughout app lifecycle
|
||||
final defaultModelAutoSelectionProvider = Provider<void>((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<AppSettings>(appSettingsProvider, (previous, next) {
|
||||
// Only react when default model value changes
|
||||
|
||||
@@ -2687,6 +2687,7 @@ class ApiService {
|
||||
required String model,
|
||||
String? conversationId,
|
||||
List<String>? toolIds,
|
||||
List<String>? filterIds,
|
||||
bool enableWebSearch = false,
|
||||
bool enableImageGeneration = false,
|
||||
Map<String, dynamic>? 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;
|
||||
|
||||
Reference in New Issue
Block a user