diff --git a/packages/feature_mind/lib/feature_mind.dart b/packages/feature_mind/lib/feature_mind.dart index fc3420d36..9f0704817 100644 --- a/packages/feature_mind/lib/feature_mind.dart +++ b/packages/feature_mind/lib/feature_mind.dart @@ -129,6 +129,8 @@ export 'src/assistant/presentation/screens/prompt_lab_screen.dart'; // Agent chat + model management export 'src/agent_chat/application/assistant_model_preferences.dart'; +export 'src/agent_chat/application/chat_model_config_preferences.dart'; +export 'src/agent_chat/domain/models/chat_model_config.dart'; export 'src/agent_chat/application/assistant_runtime_readiness.dart'; export 'src/agent_chat/data/services/agent_notification_scheduler.dart'; export 'src/agent_chat/data/services/assistant_runtime_service.dart'; diff --git a/packages/feature_mind/lib/src/agent_chat/application/chat_model_config_preferences.dart b/packages/feature_mind/lib/src/agent_chat/application/chat_model_config_preferences.dart new file mode 100644 index 000000000..eb616c7d7 --- /dev/null +++ b/packages/feature_mind/lib/src/agent_chat/application/chat_model_config_preferences.dart @@ -0,0 +1,64 @@ +import 'package:flutter_riverpod/legacy.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../domain/models/chat_model_config.dart'; + +const String chatModelConfigMaxTokensKey = 'chat_model_config.max_tokens'; +const String chatModelConfigTopKKey = 'chat_model_config.top_k'; +const String chatModelConfigTopPKey = 'chat_model_config.top_p'; +const String chatModelConfigTemperatureKey = 'chat_model_config.temperature'; +const String chatModelConfigAcceleratorKey = 'chat_model_config.accelerator'; +const String chatModelConfigSystemPromptKey = 'chat_model_config.system_prompt'; + +final chatModelConfigProvider = + StateNotifierProvider((ref) { + return ChatModelConfigNotifier(); + }); + +class ChatModelConfigNotifier extends StateNotifier { + ChatModelConfigNotifier() : super(ChatModelConfig.defaults) { + _load(); + } + + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + state = ChatModelConfig( + maxTokens: + prefs.getInt(chatModelConfigMaxTokensKey) ?? + ChatModelConfig.defaults.maxTokens, + topK: + prefs.getInt(chatModelConfigTopKKey) ?? ChatModelConfig.defaults.topK, + topP: + prefs.getDouble(chatModelConfigTopPKey) ?? + ChatModelConfig.defaults.topP, + temperature: + prefs.getDouble(chatModelConfigTemperatureKey) ?? + ChatModelConfig.defaults.temperature, + accelerator: _acceleratorFromName( + prefs.getString(chatModelConfigAcceleratorKey), + ), + systemPrompt: + prefs.getString(chatModelConfigSystemPromptKey) ?? + ChatModelConfig.defaults.systemPrompt, + ).normalized(); + } + + Future save(ChatModelConfig config) async { + final next = config.normalized(); + state = next; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(chatModelConfigMaxTokensKey, next.maxTokens); + await prefs.setInt(chatModelConfigTopKKey, next.topK); + await prefs.setDouble(chatModelConfigTopPKey, next.topP); + await prefs.setDouble(chatModelConfigTemperatureKey, next.temperature); + await prefs.setString(chatModelConfigAcceleratorKey, next.accelerator.name); + await prefs.setString(chatModelConfigSystemPromptKey, next.systemPrompt); + } + + static ChatAccelerator _acceleratorFromName(String? raw) { + return ChatAccelerator.values.firstWhere( + (value) => value.name == raw, + orElse: () => ChatModelConfig.defaults.accelerator, + ); + } +} diff --git a/packages/feature_mind/lib/src/agent_chat/data/services/assistant_runtime_service.dart b/packages/feature_mind/lib/src/agent_chat/data/services/assistant_runtime_service.dart index a65696c5c..09d8cecc5 100644 --- a/packages/feature_mind/lib/src/agent_chat/data/services/assistant_runtime_service.dart +++ b/packages/feature_mind/lib/src/agent_chat/data/services/assistant_runtime_service.dart @@ -692,6 +692,11 @@ class AssistantRuntimeService { required String prompt, String? systemPrompt, String? grammar, + int? maxOutputTokens, + double? temperature, + double? topP, + int? topK, + bool preferGpu = true, }) async { await _ensureCheckpointsHydrated(); lastGenerationStats = null; @@ -794,6 +799,11 @@ class AssistantRuntimeService { package: package, prompt: prompt, systemPrompt: systemPrompt, + maxOutputTokens: maxOutputTokens, + temperature: temperature, + topP: topP, + topK: topK, + preferGpu: preferGpu, emitRequestTrace: false, grammar: constrained ? grammar : null, )) { @@ -835,6 +845,10 @@ class AssistantRuntimeService { required String prompt, String? systemPrompt, int? maxOutputTokens, + double? temperature, + double? topP, + int? topK, + bool preferGpu = true, GenerationConstraint? constraint, }) async* { await _ensureCheckpointsHydrated(); @@ -854,6 +868,10 @@ class AssistantRuntimeService { prompt: prompt, systemPrompt: systemPrompt, maxOutputTokens: maxOutputTokens, + temperature: temperature, + topP: topP, + topK: topK, + preferGpu: preferGpu, assistantPrefill: constraint?.forcedPrefix, ); return; @@ -863,6 +881,11 @@ class AssistantRuntimeService { selectedModelId: runtimeId, prompt: constrainedPrompt, systemPrompt: systemPrompt, + maxOutputTokens: maxOutputTokens, + temperature: temperature, + topP: topP, + topK: topK, + preferGpu: preferGpu, ); return; } @@ -914,6 +937,10 @@ class AssistantRuntimeService { required String prompt, String? systemPrompt, int? maxOutputTokens, + double? temperature, + double? topP, + int? topK, + bool preferGpu = true, bool emitRequestTrace = true, String? grammar, String? assistantPrefill, @@ -930,7 +957,7 @@ class AssistantRuntimeService { ), ); } - await _ensureGgufReady(runtimeId, package); + await _ensureGgufReady(runtimeId, package, preferGpu: preferGpu); final instructPrompt = formatGgufInstructPrompt( prompt: prompt, systemPrompt: systemPrompt, @@ -950,6 +977,9 @@ class AssistantRuntimeService { await for (final token in _llamaGguf.generate( prompt: instructPrompt, maxTokens: maxOutputTokens ?? ggufMaxOutputTokens(package), + temperature: temperature ?? 0.7, + topP: topP ?? 0.9, + topK: topK ?? 40, grammar: grammar, )) { accumulated += token; @@ -997,8 +1027,9 @@ class AssistantRuntimeService { Future _ensureGgufReady( String runtimeId, - OfflineModelInfo package, - ) async { + OfflineModelInfo package, { + bool preferGpu = true, + }) async { if (!await _llamaGguf.isAvailable()) { throw AssistantRuntimeUnavailableException( runtimeId, @@ -1008,6 +1039,7 @@ class AssistantRuntimeService { final loaded = await _llamaGguf.loadModelOutcome( package, contextSize: _effectiveContextLength(package), + preferGpu: preferGpu, ); if (loaded.succeeded) return; final copy = GgufLoadDiagnostics.describe( diff --git a/packages/feature_mind/lib/src/agent_chat/domain/models/chat_model_config.dart b/packages/feature_mind/lib/src/agent_chat/domain/models/chat_model_config.dart new file mode 100644 index 000000000..6e79f75cd --- /dev/null +++ b/packages/feature_mind/lib/src/agent_chat/domain/models/chat_model_config.dart @@ -0,0 +1,92 @@ +import 'package:equatable/equatable.dart'; + +/// CPU vs GPU preference for on-device chat generation. +enum ChatAccelerator { cpu, gpu } + +/// Per-conversation sampling and prompt overrides for the chat screen. +class ChatModelConfig extends Equatable { + const ChatModelConfig({ + required this.maxTokens, + required this.topK, + required this.topP, + required this.temperature, + required this.accelerator, + required this.systemPrompt, + }); + + static const int minMaxTokens = 64; + static const int maxMaxTokens = 8192; + static const int minTopK = 1; + static const int maxTopK = 128; + static const double minTopP = 0; + static const double maxTopP = 1; + static const double minTemperature = 0; + static const double maxTemperature = 2; + + /// Defaults match the on-device Gemma gallery config the chat UI copies. + static const ChatModelConfig defaults = ChatModelConfig( + maxTokens: 4000, + topK: 1, + topP: 0.95, + temperature: 1, + accelerator: ChatAccelerator.gpu, + systemPrompt: '', + ); + + final int maxTokens; + final int topK; + final double topP; + final double temperature; + final ChatAccelerator accelerator; + final String systemPrompt; + + bool get preferGpu => accelerator == ChatAccelerator.gpu; + + ChatModelConfig copyWith({ + int? maxTokens, + int? topK, + double? topP, + double? temperature, + ChatAccelerator? accelerator, + String? systemPrompt, + }) { + return ChatModelConfig( + maxTokens: maxTokens ?? this.maxTokens, + topK: topK ?? this.topK, + topP: topP ?? this.topP, + temperature: temperature ?? this.temperature, + accelerator: accelerator ?? this.accelerator, + systemPrompt: systemPrompt ?? this.systemPrompt, + ).normalized(); + } + + ChatModelConfig normalized() { + return ChatModelConfig( + maxTokens: maxTokens.clamp(minMaxTokens, maxMaxTokens).toInt(), + topK: topK.clamp(minTopK, maxTopK).toInt(), + topP: topP.clamp(minTopP, maxTopP).toDouble(), + temperature: temperature.clamp(minTemperature, maxTemperature).toDouble(), + accelerator: accelerator, + systemPrompt: systemPrompt, + ); + } + + /// Prepends a user-authored system prompt without replacing assembled context. + String mergeSystemPrompt(String assembled) { + final custom = systemPrompt.trim(); + final existing = assembled.trim(); + if (custom.isEmpty) return assembled; + if (existing.isEmpty) return custom; + return '$custom\n\n$assembled'; + } + + @override + List get props => [ + maxTokens, + topK, + topP, + temperature, + accelerator, + systemPrompt, + ]; +} diff --git a/packages/feature_mind/lib/src/agent_chat/presentation/screens/chat_screen.dart b/packages/feature_mind/lib/src/agent_chat/presentation/screens/chat_screen.dart index 42bc4053d..d988bda91 100644 --- a/packages/feature_mind/lib/src/agent_chat/presentation/screens/chat_screen.dart +++ b/packages/feature_mind/lib/src/agent_chat/presentation/screens/chat_screen.dart @@ -39,6 +39,7 @@ import '../../../agent_chat/data/services/preferences_reliability_checkpoint_sto import '../../../agent_chat/data/services/gguf_instruct_prompt.dart'; import '../../../agent_chat/data/services/selected_runtime_agent_skill_model_client.dart'; import '../../../agent_chat/application/assistant_model_preferences.dart'; +import '../../../agent_chat/application/chat_model_config_preferences.dart'; import '../../../agent_chat/domain/models/agent_plugin_catalog.dart'; import '../../../agent_chat/domain/models/agent_skill.dart'; import '../../../agent_chat/domain/models/assistant_runtime_ids.dart'; @@ -71,6 +72,7 @@ import '../../../agent_chat/presentation/widgets/fallback_notification.dart'; import '../../../agent_chat/presentation/widgets/grounded_answer_block.dart'; import '../../../agent_chat/presentation/widgets/manage_skills_sheet.dart'; import '../../../agent_chat/presentation/widgets/mind_safety_banner.dart'; +import '../../../agent_chat/presentation/widgets/chat_model_config_dialog.dart'; import '../../../agent_chat/presentation/widgets/pick_assistant_sheet.dart'; import '../../../agent_chat/presentation/widgets/skill_action_trace_card.dart'; import '../../../reasoning/chat_reasoning_request.dart'; @@ -1326,6 +1328,12 @@ class _ChatScreenState extends ConsumerState { onPressed: _openChatCustomize, icon: const Icon(Icons.tune, size: 20), ), + IconButton( + key: const Key('agent_chat_model_config_button'), + tooltip: 'Model config', + onPressed: _openModelConfig, + icon: const Icon(Icons.settings_outlined, size: 20), + ), IconButton( key: const Key('agent_chat_copy_transcript_button'), tooltip: 'Copy transcript', @@ -1899,7 +1907,8 @@ class _ChatScreenState extends ConsumerState { historyEmpty: _messages.where((m) => m.isUser).length <= 1, estimatedTokens: TokenCounter.estimate('$systemPrompt\n$message'), modelContextLimit: _selectedContextLimit(), - definition: addonPlan?.reliabilityDefinition() ?? + definition: + addonPlan?.reliabilityDefinition() ?? (selectedModelIdForGate != null && _shouldUseReasoning(selectedModelIdForGate) ? AiroPromptRegistry.reasoningEngine @@ -2085,9 +2094,7 @@ class _ChatScreenState extends ConsumerState { ); }); if (checkpoint.state != ResearchPhase.paused) { - unawaited( - _runDeepResearch(checkpoint.question, resumeFrom: checkpoint), - ); + unawaited(_runDeepResearch(checkpoint.question, resumeFrom: checkpoint)); } } @@ -2584,7 +2591,8 @@ class _ChatScreenState extends ConsumerState { historyEmpty: false, estimatedTokens: estimated, modelContextLimit: contextLimit, - definition: addonPlan?.reliabilityDefinition() ?? + definition: + addonPlan?.reliabilityDefinition() ?? (_shouldUseReasoning(selectedModelId) ? AiroPromptRegistry.reasoningEngine : _personaSession.isPinned @@ -2620,11 +2628,16 @@ class _ChatScreenState extends ConsumerState { stopwatch: stopwatch, ); } + final modelConfig = ref.read(chatModelConfigProvider); await for (final chunk in _assistantRuntime.generateTextStream( selectedModelId: selectedModelId, prompt: modelPrompt, systemPrompt: systemPrompt, - maxOutputTokens: addonPlan?.maxOutputTokens, + maxOutputTokens: addonPlan?.maxOutputTokens ?? modelConfig.maxTokens, + temperature: modelConfig.temperature, + topP: modelConfig.topP, + topK: modelConfig.topK, + preferGpu: modelConfig.preferGpu, constraint: generationConstraint, )) { timeToFirstTokenMs ??= stopwatch.elapsedMilliseconds; @@ -3108,35 +3121,37 @@ class _ChatScreenState extends ConsumerState { final history = _chatHistoryMessages(); final addonPlan = _generativePlan(currentPrompt, history); final session = _personaSession; - if (session.isPinned) { - final pinnedAddonApplies = - addonPlan != null && session.pinnedId == addonPlan.identity.id.value; - return contextBuilder.buildSystemPrompt( - currentUserPrompt: pinnedAddonApplies - ? addonPlan.prompt.userPrompt - : currentPrompt, - compact: useCompact, - pluginPlaybooks: session.playbooks(), - pinnedPersonaIdentity: session.identityPreamble(), - history: pinnedAddonApplies - ? addonPlan.contextHistory(history) - : history, - ); - } - return contextBuilder.buildSystemPrompt( - currentUserPrompt: addonPlan?.prompt.userPrompt ?? currentPrompt, - compact: useCompact, - pluginPlaybooks: _enabledGenerativePluginPlaybooks( - currentPrompt: currentPrompt, - history: history, - activeAddonId: addonPlan?.identity.id.value, - ), - history: addonPlan != null - ? addonPlan.contextHistory(history) - : ref - .read(generativeAddonCoordinatorProvider) - .collapseThreadHistory(history), - ); + final assembled = session.isPinned + ? contextBuilder.buildSystemPrompt( + currentUserPrompt: + addonPlan != null && + session.pinnedId == addonPlan.identity.id.value + ? addonPlan.prompt.userPrompt + : currentPrompt, + compact: useCompact, + pluginPlaybooks: session.playbooks(), + pinnedPersonaIdentity: session.identityPreamble(), + history: + addonPlan != null && + session.pinnedId == addonPlan.identity.id.value + ? addonPlan.contextHistory(history) + : history, + ) + : contextBuilder.buildSystemPrompt( + currentUserPrompt: addonPlan?.prompt.userPrompt ?? currentPrompt, + compact: useCompact, + pluginPlaybooks: _enabledGenerativePluginPlaybooks( + currentPrompt: currentPrompt, + history: history, + activeAddonId: addonPlan?.identity.id.value, + ), + history: addonPlan != null + ? addonPlan.contextHistory(history) + : ref + .read(generativeAddonCoordinatorProvider) + .collapseThreadHistory(history), + ); + return ref.read(chatModelConfigProvider).mergeSystemPrompt(assembled); } List _enabledGenerativePluginPlaybooks({ @@ -3451,6 +3466,15 @@ class _ChatScreenState extends ConsumerState { ); } + Future _openModelConfig() async { + final next = await showChatModelConfigDialog( + context: context, + initial: ref.read(chatModelConfigProvider), + ); + if (next == null || !mounted) return; + await ref.read(chatModelConfigProvider.notifier).save(next); + } + Future _selectAssistantModel(AssistantModelCandidate candidate) async { await ref .read(selectedAssistantModelIdProvider.notifier) diff --git a/packages/feature_mind/lib/src/agent_chat/presentation/widgets/chat_model_config_dialog.dart b/packages/feature_mind/lib/src/agent_chat/presentation/widgets/chat_model_config_dialog.dart new file mode 100644 index 000000000..9059b932a --- /dev/null +++ b/packages/feature_mind/lib/src/agent_chat/presentation/widgets/chat_model_config_dialog.dart @@ -0,0 +1,362 @@ +import 'package:core_ui/core_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../domain/models/chat_model_config.dart'; + +Future showChatModelConfigDialog({ + required BuildContext context, + required ChatModelConfig initial, +}) { + return showDialog( + context: context, + builder: (context) => ChatModelConfigDialog(initial: initial), + ); +} + +/// Configurations sheet: sampling sliders plus an optional system-prompt override. +class ChatModelConfigDialog extends StatefulWidget { + const ChatModelConfigDialog({super.key, required this.initial}); + + final ChatModelConfig initial; + + @override + State createState() => _ChatModelConfigDialogState(); +} + +class _ChatModelConfigDialogState extends State + with SingleTickerProviderStateMixin { + late TabController _tabs; + late ChatModelConfig _draft; + late final TextEditingController _maxTokens; + late final TextEditingController _topK; + late final TextEditingController _topP; + late final TextEditingController _temperature; + late final TextEditingController _systemPrompt; + + @override + void initState() { + super.initState(); + _tabs = TabController(length: 2, vsync: this); + _draft = widget.initial.normalized(); + _maxTokens = TextEditingController(text: '${_draft.maxTokens}'); + _topK = TextEditingController(text: '${_draft.topK}'); + _topP = TextEditingController(text: _formatDouble(_draft.topP, 2)); + _temperature = TextEditingController( + text: _formatDouble(_draft.temperature, 2), + ); + _systemPrompt = TextEditingController(text: _draft.systemPrompt); + } + + @override + void dispose() { + _tabs.dispose(); + _maxTokens.dispose(); + _topK.dispose(); + _topP.dispose(); + _temperature.dispose(); + _systemPrompt.dispose(); + super.dispose(); + } + + void _commit() { + Navigator.of( + context, + ).pop(_draft.copyWith(systemPrompt: _systemPrompt.text).normalized()); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final size = MediaQuery.sizeOf(context); + final maxHeight = size.height * 0.86; + final width = size.width < 560 ? size.width - 40 : 520.0; + return Dialog( + key: const Key('chat_model_config_dialog'), + insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: Material( + type: MaterialType.card, + color: + Theme.of(context).dialogTheme.backgroundColor ?? + Theme.of(context).colorScheme.surface, + child: SizedBox( + width: width, + height: maxHeight.clamp(420, 560).toDouble(), + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 12, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Configurations', style: theme.textTheme.titleLarge), + const SizedBox(height: AiroSpacing.sm), + TabBar( + controller: _tabs, + tabAlignment: TabAlignment.start, + isScrollable: true, + tabs: const [ + Tab(text: 'Model Configs'), + Tab(text: 'System Prompt'), + ], + ), + Expanded( + child: TabBarView( + controller: _tabs, + children: [_buildModelConfigs(theme), _buildSystemPrompt()], + ), + ), + Align( + alignment: Alignment.centerRight, + child: OverflowBar( + children: [ + TextButton( + key: const Key('chat_model_config_cancel'), + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + key: const Key('chat_model_config_ok'), + onPressed: _commit, + child: const Text('OK'), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildModelConfigs(ThemeData theme) { + return ListView( + padding: const EdgeInsets.only(top: AiroSpacing.md, right: 8), + children: [ + _SliderField( + key: const Key('chat_model_config_max_tokens'), + label: 'Max Tokens', + controller: _maxTokens, + value: _draft.maxTokens.toDouble(), + min: ChatModelConfig.minMaxTokens.toDouble(), + max: ChatModelConfig.maxMaxTokens.toDouble(), + divisions: 127, + format: (value) => value.round().toString(), + onChanged: (value) { + setState(() { + _draft = _draft.copyWith(maxTokens: value.round()); + _maxTokens.text = '${_draft.maxTokens}'; + }); + }, + onSubmitted: (raw) { + final parsed = int.tryParse(raw.trim()); + if (parsed == null) { + _maxTokens.text = '${_draft.maxTokens}'; + return; + } + setState(() { + _draft = _draft.copyWith(maxTokens: parsed); + _maxTokens.text = '${_draft.maxTokens}'; + }); + }, + ), + _SliderField( + key: const Key('chat_model_config_topk'), + label: 'TopK', + controller: _topK, + value: _draft.topK.toDouble(), + min: ChatModelConfig.minTopK.toDouble(), + max: ChatModelConfig.maxTopK.toDouble(), + divisions: ChatModelConfig.maxTopK - ChatModelConfig.minTopK, + format: (value) => value.round().toString(), + onChanged: (value) { + setState(() { + _draft = _draft.copyWith(topK: value.round()); + _topK.text = '${_draft.topK}'; + }); + }, + onSubmitted: (raw) { + final parsed = int.tryParse(raw.trim()); + if (parsed == null) { + _topK.text = '${_draft.topK}'; + return; + } + setState(() { + _draft = _draft.copyWith(topK: parsed); + _topK.text = '${_draft.topK}'; + }); + }, + ), + _SliderField( + key: const Key('chat_model_config_topp'), + label: 'TopP', + controller: _topP, + value: _draft.topP, + min: ChatModelConfig.minTopP, + max: ChatModelConfig.maxTopP, + divisions: 100, + format: (value) => _formatDouble(value, 2), + onChanged: (value) { + setState(() { + _draft = _draft.copyWith(topP: value); + _topP.text = _formatDouble(_draft.topP, 2); + }); + }, + onSubmitted: (raw) { + final parsed = double.tryParse(raw.trim()); + if (parsed == null) { + _topP.text = _formatDouble(_draft.topP, 2); + return; + } + setState(() { + _draft = _draft.copyWith(topP: parsed); + _topP.text = _formatDouble(_draft.topP, 2); + }); + }, + ), + _SliderField( + key: const Key('chat_model_config_temperature'), + label: 'Temperature', + controller: _temperature, + value: _draft.temperature, + min: ChatModelConfig.minTemperature, + max: ChatModelConfig.maxTemperature, + divisions: 40, + format: (value) => _formatDouble(value, 2), + onChanged: (value) { + setState(() { + _draft = _draft.copyWith(temperature: value); + _temperature.text = _formatDouble(_draft.temperature, 2); + }); + }, + onSubmitted: (raw) { + final parsed = double.tryParse(raw.trim()); + if (parsed == null) { + _temperature.text = _formatDouble(_draft.temperature, 2); + return; + } + setState(() { + _draft = _draft.copyWith(temperature: parsed); + _temperature.text = _formatDouble(_draft.temperature, 2); + }); + }, + ), + const SizedBox(height: AiroSpacing.md), + Text('Accelerator', style: theme.textTheme.titleSmall), + const SizedBox(height: AiroSpacing.sm), + SegmentedButton( + key: const Key('chat_model_config_accelerator'), + showSelectedIcon: false, + segments: const [ + ButtonSegment(value: ChatAccelerator.cpu, label: Text('CPU')), + ButtonSegment(value: ChatAccelerator.gpu, label: Text('GPU')), + ], + selected: {_draft.accelerator}, + onSelectionChanged: (values) { + setState(() { + _draft = _draft.copyWith(accelerator: values.first); + }); + }, + ), + ], + ); + } + + Widget _buildSystemPrompt() { + return Padding( + padding: const EdgeInsets.only(top: AiroSpacing.md, right: 8, bottom: 8), + child: TextField( + key: const Key('chat_model_config_system_prompt'), + controller: _systemPrompt, + minLines: 8, + maxLines: 16, + decoration: const InputDecoration( + alignLabelWithHint: true, + labelText: 'System Prompt', + hintText: 'Optional instructions prepended to the assembled prompt.', + border: OutlineInputBorder(), + ), + ), + ); + } +} + +class _SliderField extends StatelessWidget { + const _SliderField({ + super.key, + required this.label, + required this.controller, + required this.value, + required this.min, + required this.max, + required this.divisions, + required this.format, + required this.onChanged, + required this.onSubmitted, + }); + + final String label; + final TextEditingController controller; + final double value; + final double min; + final double max; + final int divisions; + final String Function(double value) format; + final ValueChanged onChanged; + final ValueChanged onSubmitted; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: AiroSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.titleSmall), + Row( + children: [ + Expanded( + child: Slider( + value: value.clamp(min, max), + min: min, + max: max, + divisions: divisions, + label: format(value), + onChanged: onChanged, + ), + ), + SizedBox( + width: 76, + child: TextField( + controller: controller, + textAlign: TextAlign.center, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')), + ], + decoration: const InputDecoration( + isDense: true, + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + ), + onSubmitted: onSubmitted, + onEditingComplete: () => onSubmitted(controller.text), + ), + ), + ], + ), + ], + ), + ); + } +} + +String _formatDouble(double value, int fractionDigits) { + return value.toStringAsFixed(fractionDigits); +} diff --git a/packages/feature_mind/lib/src/services/llama_gguf_service.dart b/packages/feature_mind/lib/src/services/llama_gguf_service.dart index 893d4ae15..27f6fcb39 100644 --- a/packages/feature_mind/lib/src/services/llama_gguf_service.dart +++ b/packages/feature_mind/lib/src/services/llama_gguf_service.dart @@ -77,12 +77,14 @@ class LlamaGgufService { int? contextSize, int threads = 4, int memoryBudgetMb = 4096, + bool preferGpu = true, }) async { final outcome = await loadModelOutcome( model, contextSize: contextSize, threads: threads, memoryBudgetMb: memoryBudgetMb, + preferGpu: preferGpu, ); return outcome.succeeded; } @@ -92,6 +94,7 @@ class LlamaGgufService { int? contextSize, int threads = 4, int memoryBudgetMb = 4096, + bool preferGpu = true, }) async { final path = model.filePath?.trim(); if (path == null || path.isEmpty) { @@ -116,7 +119,7 @@ class LlamaGgufService { modelPath: path, threads: threads, contextSize: safeContext, - gpuLayers: gpu.recommendedGpuLayers, + gpuLayers: preferGpu ? gpu.recommendedGpuLayers : 0, ); _loaded = true; return const GgufLoadOutcome.success(); diff --git a/packages/feature_mind/test/agent_chat/application/chat_model_config_preferences_test.dart b/packages/feature_mind/test/agent_chat/application/chat_model_config_preferences_test.dart new file mode 100644 index 000000000..ee7b6009e --- /dev/null +++ b/packages/feature_mind/test/agent_chat/application/chat_model_config_preferences_test.dart @@ -0,0 +1,56 @@ +import 'package:feature_mind/src/agent_chat/application/chat_model_config_preferences.dart'; +import 'package:feature_mind/src/agent_chat/domain/models/chat_model_config.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + test('loads persisted sampling values', () async { + SharedPreferences.setMockInitialValues({ + chatModelConfigMaxTokensKey: 1024, + chatModelConfigTopKKey: 20, + chatModelConfigTopPKey: 0.8, + chatModelConfigTemperatureKey: 0.4, + chatModelConfigAcceleratorKey: ChatAccelerator.cpu.name, + chatModelConfigSystemPromptKey: 'Be terse.', + }); + + final notifier = ChatModelConfigNotifier(); + await Future.delayed(Duration.zero); + + expect( + notifier.state, + const ChatModelConfig( + maxTokens: 1024, + topK: 20, + topP: 0.8, + temperature: 0.4, + accelerator: ChatAccelerator.cpu, + systemPrompt: 'Be terse.', + ), + ); + }); + + test('save writes clamped values to preferences', () async { + SharedPreferences.setMockInitialValues({}); + final notifier = ChatModelConfigNotifier(); + await Future.delayed(Duration.zero); + + await notifier.save( + const ChatModelConfig( + maxTokens: 99999, + topK: 8, + topP: 0.5, + temperature: 0.2, + accelerator: ChatAccelerator.cpu, + systemPrompt: 'Stay local.', + ), + ); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getInt(chatModelConfigMaxTokensKey), 8192); + expect(prefs.getInt(chatModelConfigTopKKey), 8); + expect(prefs.getString(chatModelConfigAcceleratorKey), 'cpu'); + expect(notifier.state.maxTokens, 8192); + expect(notifier.state.systemPrompt, 'Stay local.'); + }); +} diff --git a/packages/feature_mind/test/agent_chat/data/services/assistant_runtime_service_test.dart b/packages/feature_mind/test/agent_chat/data/services/assistant_runtime_service_test.dart index d9b9eecea..72e101444 100644 --- a/packages/feature_mind/test/agent_chat/data/services/assistant_runtime_service_test.dart +++ b/packages/feature_mind/test/agent_chat/data/services/assistant_runtime_service_test.dart @@ -533,6 +533,80 @@ void main() { }, ); + test('forwards chat sampling config to the GGUF generator', () async { + final package = OfflineModelInfo( + id: 'qwen2-1.5b-q4', + name: 'Qwen2 1.5B', + family: ModelFamily.qwen, + fileSizeBytes: 1_100_000_000, + filePath: '/models/qwen2-1.5b-q4.gguf', + provider: AIProvider.gguf, + ); + final runtimeId = assistantModelIdForOfflineModel(package.id); + final llama = _FakeLlamaGgufService( + isAvailableResult: true, + loadModelResult: true, + generatedChunks: ['ok'], + ); + final service = AssistantRuntimeService( + llamaGguf: llama, + loadAssistantModelLibrary: () async => AssistantModelLibraryState( + task: AssistantTask.chat, + deviceLabel: 'Mac', + platformLabel: 'MACOS', + candidates: [ + AssistantModelCandidate( + id: runtimeId, + name: package.name, + runtime: 'GGUF', + description: 'Installed package', + bestFor: const [AssistantTask.chat], + tags: const ['Local'], + privacyLabel: 'Private', + sizeLabel: package.fileSizeDisplay, + available: true, + actionLabel: 'Start', + local: true, + package: package, + ), + ], + recommended: AssistantModelCandidate( + id: runtimeId, + name: package.name, + runtime: 'GGUF', + description: 'Installed package', + bestFor: const [AssistantTask.chat], + tags: const ['Local'], + privacyLabel: 'Private', + sizeLabel: package.fileSizeDisplay, + available: true, + actionLabel: 'Start', + local: true, + package: package, + ), + defaultPackages: const {}, + ), + ); + + await service + .generateTextStream( + selectedModelId: runtimeId, + prompt: 'say hello', + maxOutputTokens: 4000, + temperature: 1, + topP: 0.95, + topK: 1, + preferGpu: false, + ) + .toList(); + + expect(llama.lastMaxTokens, 4000); + expect(llama.lastTemperature, 1); + expect(llama.lastTopP, 0.95); + expect(llama.lastTopK, 1); + expect(llama.loadedPreferGpu, isFalse); + }); + test( 'prefills the assistant turn instead of relying on prefix GBNF', () async { @@ -1814,8 +1888,10 @@ class _FakeLlamaGgufService extends LlamaGgufService { int? contextSize, int threads = 4, int memoryBudgetMb = 4096, + bool preferGpu = true, }) async { loadedContextSize = contextSize; + loadedPreferGpu = preferGpu; return loadModelResult; } @@ -1825,8 +1901,10 @@ class _FakeLlamaGgufService extends LlamaGgufService { int? contextSize, int threads = 4, int memoryBudgetMb = 4096, + bool preferGpu = true, }) async { loadedContextSize = contextSize; + loadedPreferGpu = preferGpu; return loadModelResult ? const GgufLoadOutcome.success() : GgufLoadOutcome.engineError('test_load_failed'); @@ -1843,6 +1921,9 @@ class _FakeLlamaGgufService extends LlamaGgufService { }) { lastPrompt = prompt; lastMaxTokens = maxTokens; + lastTemperature = temperature; + lastTopP = topP; + lastTopK = topK; lastGrammar = grammar; return Stream.fromIterable(generatedChunks); } @@ -1854,7 +1935,11 @@ class _FakeLlamaGgufService extends LlamaGgufService { String? lastPrompt; int? lastMaxTokens; + double? lastTemperature; + double? lastTopP; + int? lastTopK; String? lastGrammar; + bool? loadedPreferGpu; } class _UrlOnlyLiteRtClient implements LiteRtLmClient { diff --git a/packages/feature_mind/test/agent_chat/domain/models/chat_model_config_test.dart b/packages/feature_mind/test/agent_chat/domain/models/chat_model_config_test.dart new file mode 100644 index 000000000..e0dc8411d --- /dev/null +++ b/packages/feature_mind/test/agent_chat/domain/models/chat_model_config_test.dart @@ -0,0 +1,58 @@ +import 'package:feature_mind/src/agent_chat/domain/models/chat_model_config.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('defaults match the gallery model-config screenshot', () { + expect(ChatModelConfig.defaults.maxTokens, 4000); + expect(ChatModelConfig.defaults.topK, 1); + expect(ChatModelConfig.defaults.topP, 0.95); + expect(ChatModelConfig.defaults.temperature, 1); + expect(ChatModelConfig.defaults.accelerator, ChatAccelerator.gpu); + expect(ChatModelConfig.defaults.systemPrompt, isEmpty); + expect(ChatModelConfig.defaults.preferGpu, isTrue); + }); + + test('normalized clamps sampling fields to supported ranges', () { + const raw = ChatModelConfig( + maxTokens: 12, + topK: 400, + topP: 1.4, + temperature: -0.2, + accelerator: ChatAccelerator.cpu, + systemPrompt: 'Stay brief.', + ); + + expect( + raw.normalized(), + const ChatModelConfig( + maxTokens: ChatModelConfig.minMaxTokens, + topK: ChatModelConfig.maxTopK, + topP: ChatModelConfig.maxTopP, + temperature: ChatModelConfig.minTemperature, + accelerator: ChatAccelerator.cpu, + systemPrompt: 'Stay brief.', + ), + ); + }); + + test('mergeSystemPrompt prepends a custom override', () { + const config = ChatModelConfig( + maxTokens: 4000, + topK: 1, + topP: 0.95, + temperature: 1, + accelerator: ChatAccelerator.gpu, + systemPrompt: 'Answer in Hindi.', + ); + + expect( + config.mergeSystemPrompt('You are Airo.'), + 'Answer in Hindi.\n\nYou are Airo.', + ); + expect(config.mergeSystemPrompt(' '), 'Answer in Hindi.'); + expect( + ChatModelConfig.defaults.mergeSystemPrompt('You are Airo.'), + 'You are Airo.', + ); + }); +} diff --git a/packages/feature_mind/test/agent_chat/presentation/screens/chat_screen_model_config_test.dart b/packages/feature_mind/test/agent_chat/presentation/screens/chat_screen_model_config_test.dart new file mode 100644 index 000000000..ef9631e44 --- /dev/null +++ b/packages/feature_mind/test/agent_chat/presentation/screens/chat_screen_model_config_test.dart @@ -0,0 +1,87 @@ +import 'package:feature_mind/src/agent_chat/application/assistant_model_preferences.dart'; +import 'package:feature_mind/src/agent_chat/domain/models/assistant_runtime_ids.dart'; +import 'package:feature_mind/src/agent_chat/presentation/screens/chat_screen.dart'; +import 'package:feature_mind/src/agent_chat/presentation/screens/model_library_screen.dart'; +import 'package:feature_mind/src/host/assistant_host_adapter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../../support/fake_assistant_host_adapter.dart'; +import '../../../support/gemini_nano_channel.dart'; + +void main() { + testWidgets('chat screen settings opens the Configurations dialog', ( + tester, + ) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1200, 1000); + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + stubGeminiNanoChannel(); + SharedPreferences.setMockInitialValues({ + 'selected_assistant_model_id': geminiNanoAssistantModelId, + }); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + assistantHostAdapterProvider.overrideWithValue( + FakeAssistantHostAdapter(), + ), + assistantModelLibraryProvider.overrideWith( + (ref) async => _chatLibraryState, + ), + selectedAssistantModelIdProvider.overrideWith( + (ref) => _SelectedAssistantModelNotifier(), + ), + ], + child: const MaterialApp( + home: ChatScreen(enableAiInitialization: false), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('agent_chat_model_config_button'))); + await tester.pumpAndSettle(); + + expect(find.text('Configurations'), findsOneWidget); + expect(find.text('Model Configs'), findsOneWidget); + expect(find.text('Max Tokens'), findsOneWidget); + expect(find.text('GPU'), findsOneWidget); + }); +} + +class _SelectedAssistantModelNotifier extends SelectedAssistantModelNotifier { + _SelectedAssistantModelNotifier() { + state = geminiNanoAssistantModelId; + } +} + +const _chatCandidate = AssistantModelCandidate( + id: geminiNanoAssistantModelId, + name: 'Gemini Nano', + runtime: 'AICore on-device', + description: 'System runtime', + bestFor: [AssistantTask.chat], + tags: ['Local'], + privacyLabel: 'Prompt stays on device', + sizeLabel: 'System managed', + available: true, + actionLabel: 'Start', + local: true, +); + +const _chatLibraryState = AssistantModelLibraryState( + task: AssistantTask.chat, + deviceLabel: 'Google Pixel 9', + platformLabel: 'ANDROID', + candidates: [_chatCandidate], + recommended: _chatCandidate, + defaultPackages: {}, +); diff --git a/packages/feature_mind/test/agent_chat/presentation/widgets/chat_model_config_dialog_test.dart b/packages/feature_mind/test/agent_chat/presentation/widgets/chat_model_config_dialog_test.dart new file mode 100644 index 000000000..bab18dad7 --- /dev/null +++ b/packages/feature_mind/test/agent_chat/presentation/widgets/chat_model_config_dialog_test.dart @@ -0,0 +1,112 @@ +import 'package:feature_mind/src/agent_chat/domain/models/chat_model_config.dart'; +import 'package:feature_mind/src/agent_chat/presentation/widgets/chat_model_config_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Future _setPhoneSurface(WidgetTester tester) async { + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1200, 1000); + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + } + + testWidgets('shows model config controls and returns edited values', ( + tester, + ) async { + await _setPhoneSurface(tester); + late ChatModelConfig? result; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + body: TextButton( + onPressed: () async { + result = await showChatModelConfigDialog( + context: context, + initial: ChatModelConfig.defaults, + ); + }, + child: const Text('Open'), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Configurations'), findsOneWidget); + expect(find.text('Model Configs'), findsOneWidget); + expect(find.text('System Prompt'), findsOneWidget); + expect(find.text('Max Tokens'), findsOneWidget); + expect(find.text('TopK'), findsOneWidget); + expect(find.text('TopP'), findsOneWidget); + expect(find.text('Temperature'), findsOneWidget); + expect(find.text('Accelerator'), findsOneWidget); + expect(find.text('GPU'), findsOneWidget); + + await tester.tap(find.text('CPU')); + await tester.pumpAndSettle(); + + final maxTokensField = find.descendant( + of: find.byKey(const Key('chat_model_config_max_tokens')), + matching: find.byType(TextField), + ); + await tester.enterText(maxTokensField, '512'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + await tester.tap(find.text('System Prompt')); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('chat_model_config_system_prompt')), + 'Reply in one sentence.', + ); + + await tester.tap(find.byKey(const Key('chat_model_config_ok'))); + await tester.pumpAndSettle(); + + expect(result?.maxTokens, 512); + expect(result?.accelerator, ChatAccelerator.cpu); + expect(result?.systemPrompt, 'Reply in one sentence.'); + }); + + testWidgets('cancel discards draft edits', (tester) async { + await _setPhoneSurface(tester); + ChatModelConfig? result = ChatModelConfig.defaults; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + body: TextButton( + onPressed: () async { + result = await showChatModelConfigDialog( + context: context, + initial: ChatModelConfig.defaults, + ); + }, + child: const Text('Open'), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + await tester.tap(find.text('CPU')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('chat_model_config_cancel'))); + await tester.pumpAndSettle(); + + expect(result, isNull); + }); +} diff --git a/packages/feature_mind/test/provenance/data/local_gguf_ner_complete_test.dart b/packages/feature_mind/test/provenance/data/local_gguf_ner_complete_test.dart index 68d112b45..7c51a4277 100644 --- a/packages/feature_mind/test/provenance/data/local_gguf_ner_complete_test.dart +++ b/packages/feature_mind/test/provenance/data/local_gguf_ner_complete_test.dart @@ -203,6 +203,7 @@ class _FakeLlamaGgufService extends LlamaGgufService { int? contextSize, int threads = 4, int memoryBudgetMb = 4096, + bool preferGpu = true, }) async => loadModelResult; @override @@ -211,6 +212,7 @@ class _FakeLlamaGgufService extends LlamaGgufService { int? contextSize, int threads = 4, int memoryBudgetMb = 4096, + bool preferGpu = true, }) async { return loadModelResult ? const GgufLoadOutcome.success()