From b58725f0795aeb9577f1c26493231027bb02c91f Mon Sep 17 00:00:00 2001 From: Borja Balsera Date: Tue, 6 Jan 2026 02:20:13 +0100 Subject: [PATCH] feat(assistant): Add model selection for Android Assistant --- lib/core/services/settings_service.dart | 42 ++++- lib/core/utils/android_assistant_handler.dart | 29 +++- .../chat/providers/chat_providers.dart | 27 ++- .../profile/views/app_customization_page.dart | 155 ++++++++++++++++++ 4 files changed, 239 insertions(+), 14 deletions(-) diff --git a/lib/core/services/settings_service.dart b/lib/core/services/settings_service.dart index fd567d0c2..526cc1f8d 100644 --- a/lib/core/services/settings_service.dart +++ b/lib/core/services/settings_service.dart @@ -60,6 +60,7 @@ class SettingsService { PreferenceKeys.voiceSilenceDuration; static const String _androidAssistantTriggerKey = PreferenceKeys.androidAssistantTrigger; + static const String _androidAssistantModelKey = 'android_assistant_model'; static Box _preferencesBox() => Hive.box(HiveBoxNames.preferences); @@ -145,6 +146,23 @@ class SettingsService { return box.delete(_defaultModelKey); } + /// Get Android Assistant Model preference + static Future getAndroidAssistantModelId() { + final value = _preferencesBox().get(_androidAssistantModelKey) as String?; + return Future.value(value); + } + + + + /// Set Android Assistant Model preference + static Future setAndroidAssistantModelId(String? modelId) { + final box = _preferencesBox(); + if (modelId != null) { + return box.put(_androidAssistantModelKey, modelId); + } + return box.delete(_androidAssistantModelKey); + } + /// Load all settings static Future loadSettings() { final box = _preferencesBox(); @@ -184,6 +202,12 @@ class SettingsService { await box.delete(_defaultModelKey); } + if (settings.androidAssistantModelId != null) { + await box.put(_androidAssistantModelKey, settings.androidAssistantModelId); + } else { + await box.delete(_androidAssistantModelKey); + } + if (settings.voiceLocaleId != null && settings.voiceLocaleId!.isNotEmpty) { await box.put(_voiceLocaleKey, settings.voiceLocaleId); } else { @@ -436,6 +460,7 @@ class SettingsService { androidAssistantTrigger: _parseAndroidAssistantTrigger( box.get(_androidAssistantTriggerKey) as String?, ), + androidAssistantModelId: box.get(_androidAssistantModelKey) as String?, voiceSilenceDuration: (box.get(_voiceSilenceDurationKey) as int? ?? 2000) .clamp(300, 3000), ); @@ -471,6 +496,7 @@ class AppSettings { final String? ttsServerVoiceId; final String? ttsServerVoiceName; final AndroidAssistantTrigger androidAssistantTrigger; + final String? androidAssistantModelId; final int voiceSilenceDuration; const AppSettings({ this.reduceMotion = false, @@ -495,6 +521,7 @@ class AppSettings { this.ttsServerVoiceId, this.ttsServerVoiceName, this.androidAssistantTrigger = AndroidAssistantTrigger.overlay, + this.androidAssistantModelId, this.voiceSilenceDuration = 2000, }); @@ -522,6 +549,7 @@ class AppSettings { Object? ttsServerVoiceName = const _DefaultValue(), int? voiceSilenceDuration, AndroidAssistantTrigger? androidAssistantTrigger, + Object? androidAssistantModelId = const _DefaultValue(), }) { return AppSettings( reduceMotion: reduceMotion ?? this.reduceMotion, @@ -555,6 +583,9 @@ class AppSettings { : ttsServerVoiceName as String?, androidAssistantTrigger: androidAssistantTrigger ?? this.androidAssistantTrigger, + androidAssistantModelId: androidAssistantModelId is _DefaultValue + ? this.androidAssistantModelId + : androidAssistantModelId as String?, voiceSilenceDuration: voiceSilenceDuration ?? this.voiceSilenceDuration, ); } @@ -581,8 +612,10 @@ class AppSettings { other.ttsVolume == ttsVolume && other.ttsEngine == ttsEngine && other.ttsServerVoiceId == ttsServerVoiceId && - other.ttsServerVoiceName == ttsServerVoiceName && + + other.androidAssistantTrigger == androidAssistantTrigger && + other.androidAssistantModelId == androidAssistantModelId && other.voiceSilenceDuration == voiceSilenceDuration && _listEquals(other.quickPills, quickPills); // socketTransportMode intentionally not included in == to avoid frequent rebuilds @@ -611,7 +644,9 @@ class AppSettings { ttsEngine, ttsServerVoiceId, ttsServerVoiceName, + androidAssistantTrigger, + androidAssistantModelId, voiceSilenceDuration, Object.hashAllUnordered(quickPills), ]); @@ -796,6 +831,11 @@ class AppSettingsNotifier extends _$AppSettingsNotifier { await SettingsService.setAndroidAssistantTrigger(trigger); } + Future setAndroidAssistantModelId(String? modelId) async { + state = state.copyWith(androidAssistantModelId: modelId); + await SettingsService.setAndroidAssistantModelId(modelId); + } + Future resetToDefaults() async { const defaultSettings = AppSettings(); await SettingsService.saveSettings(defaultSettings); diff --git a/lib/core/utils/android_assistant_handler.dart b/lib/core/utils/android_assistant_handler.dart index 83fbfd456..2d77e20a5 100644 --- a/lib/core/utils/android_assistant_handler.dart +++ b/lib/core/utils/android_assistant_handler.dart @@ -11,6 +11,7 @@ import '../services/navigation_service.dart'; import '../../shared/services/tasks/task_queue.dart'; import '../providers/app_providers.dart'; import '../../features/auth/providers/unified_auth_providers.dart'; +import '../services/settings_service.dart'; import 'debug_logger.dart'; final androidAssistantProvider = Provider( @@ -78,8 +79,11 @@ class AndroidAssistantHandler { return; } - // Start a fresh chat context - startNewChat(_ref); + // Start a fresh chat context with specific model preference + final settings = _ref.read(appSettingsProvider); + final specificModelId = settings.androidAssistantModelId; + + startNewChat(_ref, specificModelId: specificModelId); // Add screenshot as attachment final file = File(screenshotPath); @@ -189,11 +193,14 @@ class AndroidAssistantHandler { DebugLogger.log('Starting new chat from assistant', scope: 'assistant'); final navState = _ref.read(authNavigationStateProvider); - final model = _ref.read(selectedModelProvider); - - if (navState != AuthNavigationState.authenticated || model == null) { - DebugLogger.log('App not ready for new chat', scope: 'assistant'); - return; + // final model = _ref.read(selectedModelProvider); // We verify model availability inside startNewChat or by checking if we have *any* model later, + // but for "readiness" we mainly care about auth. + // Actually, existing check wanted `model != null`. + // If we are overriding, we might not have a selected model yet. + + if (navState != AuthNavigationState.authenticated) { + DebugLogger.log('App not ready for new chat (auth)', scope: 'assistant'); + return; } final isOnChatRoute = NavigationService.currentRoute == Routes.chat; @@ -201,8 +208,12 @@ class AndroidAssistantHandler { await NavigationService.navigateToChat(); } - startNewChat(_ref); - DebugLogger.log('New chat started from assistant', scope: 'assistant'); + // Check for specific assistant model preference + final settings = _ref.read(appSettingsProvider); + final specificModelId = settings.androidAssistantModelId; + + startNewChat(_ref, specificModelId: specificModelId); + DebugLogger.log('New chat started from assistant (model: $specificModelId)', scope: 'assistant'); } catch (e) { DebugLogger.log('Failed to start new chat: $e', scope: 'assistant'); } diff --git a/lib/features/chat/providers/chat_providers.dart b/lib/features/chat/providers/chat_providers.dart index 86d7c5df4..f4f2edd0a 100644 --- a/lib/features/chat/providers/chat_providers.dart +++ b/lib/features/chat/providers/chat_providers.dart @@ -10,6 +10,7 @@ import 'package:yaml/yaml.dart' as yaml; import '../../../core/auth/auth_state_manager.dart'; import '../../../core/models/chat_message.dart'; import '../../../core/models/conversation.dart'; +import '../../../core/models/model.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/services/conversation_delta_listener.dart'; import '../../../core/services/settings_service.dart'; @@ -887,7 +888,7 @@ String? _extractSystemPromptFromSettings(Map? settings) { } // Start a new chat (unified function for both "New Chat" button and home screen) -void startNewChat(dynamic ref) { +void startNewChat(dynamic ref, {String? specificModelId}) { // Clear active conversation ref.read(activeConversationProvider.notifier).clear(); @@ -901,15 +902,33 @@ void startNewChat(dynamic ref) { ref.read(pendingFolderIdProvider.notifier).clear(); // Reset to default model for new conversations (fixes #296) - restoreDefaultModel(ref); + restoreDefaultModel(ref, specificModelId: specificModelId); } -/// Restores the selected model to the user's configured default model. +/// Restores the selected model to the user's configured default model or a specific one. /// Call this when starting a new conversation or when settings change. -Future restoreDefaultModel(dynamic ref) async { +Future restoreDefaultModel(dynamic ref, {String? specificModelId}) async { // Mark that this is not a manual selection ref.read(isManualModelSelectionProvider.notifier).set(false); + if (specificModelId != null) { + // Attempt to set specific model + final modelsAsync = ref.read(modelsProvider); + List models = []; + if (modelsAsync.hasValue) { + models = modelsAsync.value!; + } else { + models = await ref.read(modelsProvider.future); + } + + final specificModel = models.where((m) => m.id == specificModelId).firstOrNull; + if (specificModel != null) { + ref.read(selectedModelProvider.notifier).set(specificModel); + return; + } + // Fallback if specific model not found: continue to standard default logic + } + // If auto-select (no explicit default), clear the cached default model // so defaultModelProvider will fetch from server final settingsDefault = ref.read(appSettingsProvider).defaultModel; diff --git a/lib/features/profile/views/app_customization_page.dart b/lib/features/profile/views/app_customization_page.dart index 311cd833b..08986f56b 100644 --- a/lib/features/profile/views/app_customization_page.dart +++ b/lib/features/profile/views/app_customization_page.dart @@ -12,6 +12,7 @@ import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/theme/tweakcn_themes.dart'; import '../../tools/providers/tools_providers.dart'; import '../../../core/models/tool.dart'; +import '../../../core/models/model.dart'; import '../../../shared/widgets/conduit_components.dart'; import '../../../shared/utils/ui_utils.dart'; import '../../../core/providers/app_providers.dart'; @@ -489,6 +490,18 @@ class AppCustomizationPage extends ConsumerWidget { onTap: () => _showAndroidAssistantTriggerSheet(context, ref, settings), ), + const SizedBox(height: Spacing.sm), + _CustomizationTile( + leading: _buildIconBadge( + context, + Icons.smart_toy_outlined, + color: theme.buttonPrimary, + ), + title: 'Assistant Model', + subtitle: _getAssistantModelName(ref, settings.androidAssistantModelId), + onTap: () => + _showAndroidAssistantModelSheet(context, ref, settings), + ), ], ], ); @@ -2747,3 +2760,145 @@ class _MetricTile extends StatelessWidget { ); } } + + String _getAssistantModelName(WidgetRef ref, String? modelId) { + if (modelId == null) return 'Default (same as chat)'; + final modelsAsync = ref.read(modelsProvider); + if (modelsAsync.hasValue) { + final model = modelsAsync.value!.where((m) => m.id == modelId).firstOrNull; + if (model != null) return model.name; + } + return 'Default (same as chat)'; + } + + Future _showAndroidAssistantModelSheet( + BuildContext context, + WidgetRef ref, + AppSettings settings, + ) async { + final theme = context.conduitTheme; + // final l10n = AppLocalizations.of(context)!; // unused for now + + // Fetch models + final modelsAsync = ref.read(modelsProvider); + List models = []; + if (modelsAsync.hasValue) { + models = modelsAsync.value!; + } else { + models = await ref.read(modelsProvider.future); + } + + if (!context.mounted) return; + + await showModalBottomSheet( + context: context, + backgroundColor: theme.sidebarBackground, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(AppBorderRadius.modal), + ), + ), + builder: (sheetContext) { + return SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.lg, + vertical: Spacing.md, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Assistant Model', + style: theme.headingSmall?.copyWith( + color: theme.sidebarForeground, + ) ?? + TextStyle( + color: theme.sidebarForeground, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: Spacing.xs), + Text( + 'Select which model the Android Assistant should use.', + style: theme.bodySmall?.copyWith( + color: theme.sidebarForeground.withValues( + alpha: 0.7, + ), + ), + ), + ], + ), + ), + const SizedBox(width: Spacing.md), + IconButton( + icon: Icon( + Icons.close, + color: theme.sidebarForeground, + ), + onPressed: () => Navigator.pop(sheetContext), + ), + ], + ), + ), + const Divider(height: 1), + Flexible( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Default Option + ListTile( + leading: Radio( + value: null, + groupValue: settings.androidAssistantModelId, + onChanged: (value) { + ref.read(appSettingsProvider.notifier).setAndroidAssistantModelId(null); + Navigator.pop(sheetContext); + }, + ), + title: const Text('Default (same as chat)'), + onTap: () { + ref.read(appSettingsProvider.notifier).setAndroidAssistantModelId(null); + Navigator.pop(sheetContext); + }, + ), + // Model Options + ...models.map((Model model) { + return ListTile( + leading: Radio( + value: model.id, + groupValue: settings.androidAssistantModelId, + onChanged: (value) { + if (value != null) { + ref.read(appSettingsProvider.notifier).setAndroidAssistantModelId(value); + Navigator.pop(sheetContext); + } + }, + ), + title: Text(model.name), + onTap: () { + ref.read(appSettingsProvider.notifier).setAndroidAssistantModelId(model.id); + Navigator.pop(sheetContext); + }, + ); + }), + SizedBox(height: MediaQuery.paddingOf(sheetContext).bottom + Spacing.md), + ], + ), + ), + ), + ], + ), + ); + }, + ); + }