@tduncan / Cortana / commits / 1977e40

Add AI user message generation and enhance chat features

Introduces AI-generated user message suggestions, temporary chat mode with visual indicators, conversation sharing/export, and chat renaming. Also improves TTS functionality by fixing seek behavior and player visibility. Includes Android manifest updates for proper share intent handling and new localization strings.

onlypuppy7 committed Jul 1, 2026 at 03:43 UTC 1977e409c798c17f1e3650dfea426a97da06da2f
24 files changed +639 -127
android/app/src/main/AndroidManifest.xml
+4 -1
@@ -28,7 +28,6 @@
28 android:name=".MainActivity"
29 android:exported="true"
30 android:launchMode="singleTop"
31 - android:taskAffinity=""
31 android:theme="@style/LaunchTheme"
32 android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
33 android:hardwareAccelerated="true"
@@ -92,5 +91,9 @@
91 <intent>
92 <action android:name="android.speech.RecognitionService" />
93 </intent>
94 + <intent>
95 + <action android:name="android.intent.action.SEND" />
96 + <data android:mimeType="text/plain" />
97 + </intent>
98 </queries>
99 </manifest>
lib/core/providers/app_providers.dart
+2
@@ -104,6 +104,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
104 _update(state.copyWith(ttsSkipSeconds: value));
105 void setSmartReplyEnabled(bool value) =>
106 _update(state.copyWith(smartReplyEnabled: value));
107 + void setAiUserResponseEnabled(bool value) =>
108 + _update(state.copyWith(aiUserResponseEnabled: value));
109 void setLocaleCode(String? value) =>
110 _update(state.copyWith(localeCode: value));
111 void setHuggingFaceToken(String? value) =>
lib/core/services/share_service.dart
+9 -1
@@ -5,6 +5,14 @@ class ShareService {
5
6 static Future<void> shareText(String text, {String? subject}) async {
7 if (text.trim().isEmpty) return;
8 - await SharePlus.instance.share(ShareParams(text: text, subject: subject));
8 + await SharePlus.instance.share(
9 + ShareParams(
10 + text: text,
11 + subject: subject,
12 + // Avoid download/mail fallbacks that can trap the user in-app on Android.
13 + downloadFallbackEnabled: false,
14 + mailToFallbackEnabled: false,
15 + ),
16 + );
17 }
18 }
lib/features/chat/data/chat_service.dart
+27 -1
@@ -161,7 +161,28 @@ class LMStudioChatService implements ChatService {
161 }) async* {
162 _cancelToken = CancelToken();
163
164 - final formattedInput = await _formatInputWithImages(messages);
164 + final dynamic formattedInput;
165 + if (continueGeneration &&
166 + messages.isNotEmpty &&
167 + messages.last.role == MessageRole.assistant) {
168 + final assistantText = messages.last.content.trim();
169 + final priorMessages = messages.sublist(0, messages.length - 1);
170 + formattedInput = await _formatInputWithImages(priorMessages);
171 + if (assistantText.isNotEmpty) {
172 + formattedInput.add({
173 + 'type': 'text',
174 + 'content':
175 + 'Continue your previous assistant reply from exactly where it stopped. '
176 + 'Write ONLY the next part of the assistant reply. '
177 + 'Do not repeat earlier text, do not write a user message, '
178 + 'and do not restart the reply.\n\n'
179 + 'Assistant reply so far:\n<<<\n$assistantText\n>>>\n\n'
180 + 'Continuation:',
181 + });
182 + }
183 + } else {
184 + formattedInput = await _formatInputWithImages(messages);
185 + }
186
187 final body = <String, dynamic>{
188 'model': modelId,
@@ -504,6 +525,11 @@ class OpenAICompatibleChatService implements ChatService {
525 (apiMessages.last['content'] as String).isEmpty))) {
526 apiMessages.removeLast();
527 }
528 + } else if (apiMessages.isNotEmpty &&
529 + apiMessages.last['role'] == 'assistant') {
530 + // Keep the assistant prefill for continuation; ensure content is a string.
531 + final last = apiMessages.last;
532 + last['content'] = (last['content'] as String?) ?? '';
533 }
534
535 final body = {
lib/features/chat/data/smart_reply_service.dart
+86
@@ -101,6 +101,92 @@ class SmartReplyService {
101 }
102 }
103
104 + Future<String?> generateUserMessage({
105 + required ChatService chatService,
106 + required Server server,
107 + required String modelId,
108 + required List<Message> messages,
109 + required ChatParameters params,
110 + }) async {
111 + if (messages.isEmpty) return null;
112 +
113 + final promptMessage = Message(
114 + id: 'ai-user-response-prompt',
115 + conversationId: messages.last.conversationId,
116 + role: MessageRole.user,
117 + content:
118 + 'Based on the conversation above, write the next user message that a human would naturally send. '
119 + 'Return ONLY the raw message text with no quotes, labels, markdown, or explanation.',
120 + createdAt: DateTime.now(),
121 + );
122 +
123 + final suggestionParams = params.copyWith(
124 + temperature: 0.7,
125 + maxTokens: 256,
126 + systemPrompt:
127 + 'You write realistic user messages for chat conversations. Output only the user message text.',
128 + );
129 +
130 + try {
131 + final completer = Completer<String>();
132 + var accumulatedContent = '';
133 +
134 + final stream = chatService.sendMessage(
135 + server: server,
136 + modelId: modelId,
137 + messages: [...messages, promptMessage],
138 + params: suggestionParams,
139 + );
140 +
141 + StreamSubscription? subscription;
142 + subscription = stream.listen(
143 + (response) {
144 + if (response.type == ChatResponseType.message &&
145 + response.content != null) {
146 + accumulatedContent += response.content!;
147 + } else if (response.type == ChatResponseType.done) {
148 + subscription?.cancel();
149 + if (!completer.isCompleted) {
150 + completer.complete(accumulatedContent);
151 + }
152 + } else if (response.type == ChatResponseType.error) {
153 + subscription?.cancel();
154 + if (!completer.isCompleted) {
155 + completer.completeError(response.content ?? 'Unknown error');
156 + }
157 + }
158 + },
159 + onError: (err) {
160 + subscription?.cancel();
161 + if (!completer.isCompleted) completer.completeError(err);
162 + },
163 + onDone: () {
164 + subscription?.cancel();
165 + if (!completer.isCompleted) completer.complete(accumulatedContent);
166 + },
167 + cancelOnError: true,
168 + );
169 +
170 + final rawResponse = await completer.future.timeout(
171 + const Duration(seconds: 20),
172 + onTimeout: () {
173 + subscription?.cancel();
174 + return accumulatedContent;
175 + },
176 + );
177 +
178 + final cleaned = rawResponse.trim();
179 + if (cleaned.isEmpty) return null;
180 + if (cleaned.startsWith('"') && cleaned.endsWith('"')) {
181 + return cleaned.substring(1, cleaned.length - 1).trim();
182 + }
183 + return cleaned;
184 + } catch (e) {
185 + Log.warning('AI user message generation failed: $e');
186 + return null;
187 + }
188 + }
189 +
190 List<String> _parseSuggestions(String text) {
191 final cleaned = text.trim();
192
lib/features/chat/providers/chat_notifier.dart
+102 -4
@@ -1005,6 +1005,21 @@ class ChatNotifier extends Notifier<ChatState> {
1005 return _truncateToContextWindow(messages, contextLength);
1006 }
1007
1008 + List<Message> _buildMessagesForContinue(
1009 + ModelInfo? selectedModel,
1010 + Message assistantMessage,
1011 + ) {
1012 + final messages = _buildMessagesForApi(selectedModel);
1013 + if (messages.isEmpty) return messages;
1014 + if (messages.last.role != MessageRole.assistant) return messages;
1015 +
1016 + messages[messages.length - 1] = assistantMessage.copyWith(
1017 + status: MessageStatus.complete,
1018 + isProcessing: false,
1019 + );
1020 + return messages;
1021 + }
1022 +
1023 String? _getPersonaSystemPrompt() {
1024 final conversation = ref.read(conv.activeConversationProvider);
1025 final personaId = conversation?.personaId;
@@ -1451,7 +1466,9 @@ class ChatNotifier extends Notifier<ChatState> {
1466 final chatService = ref.read(chatServiceProvider);
1467 if (server == null || chatService == null) return;
1468
1454 - final messagesForApi = _buildMessagesForApi(selectedModel);
1469 + final messagesForApi = continueGeneration
1470 + ? _buildMessagesForContinue(selectedModel, assistantMessage)
1471 + : _buildMessagesForApi(selectedModel);
1472
1473 try {
1474 _streamSubscription?.cancel();
@@ -1460,6 +1477,7 @@ class ChatNotifier extends Notifier<ChatState> {
1477 String reasoningContent = '';
1478 var streamingAssistantMessage = assistantMessage;
1479 _latestStreamingMessage = streamingAssistantMessage;
1480 + var isFirstContinueChunk = continueGeneration;
1481
1482 final mcpConfig = ref.read(chatMcpConfigProvider);
1483 final integrations =
@@ -1507,11 +1525,21 @@ class ChatNotifier extends Notifier<ChatState> {
1525 if (response.content?.isNotEmpty ?? false) {
1526 _noteFirstToken();
1527 }
1528 + var delta = response.content ?? '';
1529 + if (isFirstContinueChunk && delta.isNotEmpty) {
1530 + isFirstContinueChunk = false;
1531 + final existing = streamingAssistantMessage.content;
1532 + if (existing.isNotEmpty &&
1533 + !RegExp(r'[\s\p{P}]$', unicode: true)
1534 + .hasMatch(existing) &&
1535 + !RegExp(r'^[\s\p{P}]', unicode: true)
1536 + .hasMatch(delta)) {
1537 + delta = ' $delta';
1538 + }
1539 + }
1540 streamingAssistantMessage = streamingAssistantMessage
1541 .copyWith(
1512 - content:
1513 - streamingAssistantMessage.content +
1514 - (response.content ?? ''),
1542 + content: streamingAssistantMessage.content + delta,
1543 isProcessing: false,
1544 );
1545 _latestStreamingMessage = streamingAssistantMessage;
@@ -1775,6 +1803,76 @@ class ChatNotifier extends Notifier<ChatState> {
1803 );
1804 }
1805
1806 + Future<void> generateAiUserMessage() async {
1807 + if (state.isStreaming) return;
1808 + if (state.messages.isEmpty) return;
1809 +
1810 + final server = ref.read(activeServerProvider);
1811 + final selectedModel = ref.read(selectedModelProvider);
1812 + final chatService = ref.read(chatServiceProvider);
1813 + if (server == null || chatService == null || selectedModel == null) return;
1814 +
1815 + final generated = await ref.read(smartReplyServiceProvider).generateUserMessage(
1816 + chatService: chatService,
1817 + server: server,
1818 + modelId: selectedModel.id,
1819 + messages: state.messages,
1820 + params: ref.read(chatParamsProvider),
1821 + );
1822 +
1823 + if (generated == null || generated.trim().isEmpty) return;
1824 + await sendMessage(generated.trim());
1825 + }
1826 +
1827 + Future<void> saveTemporaryChatToHistory() async {
1828 + if (!state.isTemporary || state.messages.isEmpty) return;
1829 +
1830 + final server = ref.read(activeServerProvider);
1831 + if (server == null) return;
1832 +
1833 + final settings = ref.read(settingsProvider);
1834 + final firstUser = state.messages
1835 + .where((m) => m.role == MessageRole.user)
1836 + .map((m) => m.content.trim())
1837 + .firstWhere((c) => c.isNotEmpty, orElse: () => '');
1838 +
1839 + final title = settings.autoGenerateTitle
1840 + ? 'New Chat'
1841 + : ref
1842 + .read(titleGenerationServiceProvider)
1843 + .truncateFirstMessageTitle(
1844 + firstUser.isNotEmpty ? firstUser : 'New Chat',
1845 + );
1846 +
1847 + final conversation = await ref
1848 + .read(conv.conversationsProvider.notifier)
1849 + .createConversation(
1850 + title: title,
1851 + serverId: server.id,
1852 + modelId: ref.read(selectedModelProvider)?.id,
1853 + personaId: ref.read(selectedPersonaProvider)?.id,
1854 + systemPrompt: ref.read(selectedPersonaProvider)?.systemPrompt,
1855 + mcpEnabled: settings.newChatMcpEnabled,
1856 + isTemporary: false,
1857 + );
1858 +
1859 + _currentConversationId = conversation.id;
1860 + _ephemeralConversationId = null;
1861 + state = state.copyWith(isTemporary: false);
1862 + ref
1863 + .read(conv.activeConversationProvider.notifier)
1864 + .setActiveConversation(conversation);
1865 +
1866 + final updatedAll = state.allMessages
1867 + .map((m) => m.copyWith(conversationId: conversation.id))
1868 + .toList();
1869 + _setAllMessages(updatedAll);
1870 +
1871 + for (final message in updatedAll) {
1872 + await _saveMessage(message);
1873 + }
1874 + }
1875 +
1876 Future<void> editMessage(String messageId, String newContent) async {
1877 final messageIndex = state.messages.indexWhere((m) => m.id == messageId);
1878 if (messageIndex == -1) return;
lib/features/chat/views/chat_screen.dart
+265 -49
@@ -1,5 +1,7 @@
1 import 'dart:convert';
2 +import 'dart:typed_data';
3
4 +import 'package:file_picker/file_picker.dart';
5 import 'package:flutter/material.dart';
6 import 'package:flutter_riverpod/flutter_riverpod.dart';
7 import 'package:go_router/go_router.dart';
@@ -11,16 +13,19 @@ import 'package:localmind/core/providers/app_providers.dart';
13 import 'package:localmind/core/routes/app_routes.dart';
14 import 'package:localmind/core/utils/system_insets.dart';
15 import 'package:localmind/l10n/app_localizations.dart';
16 +import 'package:localmind/core/providers/storage_providers.dart';
17 +import 'package:localmind/core/services/data_backup_service.dart';
18 +import 'package:localmind/core/services/share_service.dart';
19 +import 'package:localmind/features/conversations/data/models/conversation.dart';
20 import 'package:localmind/features/conversations/providers/conversation_providers.dart'
21 as conv;
22 +import 'package:localmind/features/conversations/views/components/rename_conversation_dialog.dart';
23 import 'package:localmind/features/models/screens/model_picker_sheet.dart';
24 import 'package:localmind/features/personas/providers/personas_providers.dart';
25 import 'package:localmind/features/servers/providers/server_providers.dart';
19 -import 'package:localmind/features/servers/data/models/server.dart';
20 -import 'package:localmind/features/servers/views/components/server_icon_picker.dart';
21 -import 'package:localmind/core/services/share_service.dart';
26 import 'package:localmind/features/saved_messages/views/components/save_message_sheet.dart';
27 import 'package:localmind/features/tts/views/components/tts_player_bar.dart';
28 +import '../data/export_service.dart';
29 import '../data/models/message.dart';
30 import '../providers/chat_mcp_providers.dart';
31 import '../providers/chat_providers.dart';
@@ -116,6 +121,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
121 switch (action) {
122 case 'new_chat':
123 ref.read(chatProvider.notifier).startNewConversation();
124 + case 'share_chat':
125 + _shareConversation(context);
126 case 'persona':
127 _showPersonaPicker(context);
128 case 'remove_persona':
@@ -125,6 +132,13 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
132 .read(conv.conversationsProvider.notifier)
133 .updatePersona(activeConv.id, null, null);
134 }
135 + case 'rename':
136 + final activeConv = ref.read(conv.activeConversationProvider);
137 + if (activeConv != null) {
138 + _showRenameDialog(context, activeConv);
139 + }
140 + case 'export_chat':
141 + _exportConversation(context);
142 case 'clear':
143 showDialog(
144 context: context,
@@ -254,6 +268,72 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
268 );
269 }
270
271 + Future<void> _shareConversation(BuildContext context) async {
272 + final messages = ref.read(chatProvider).messages;
273 + if (messages.isEmpty) return;
274 + final activeConv = ref.read(conv.activeConversationProvider);
275 + final isTemporary = ref.read(chatProvider.select((s) => s.isTemporary));
276 + final title = isTemporary
277 + ? AppLocalizations.of(context)!.temporary_chat
278 + : activeConv?.title;
279 + final text = ExportService.exportAsText(messages, title: title);
280 + await ShareService.shareText(text, subject: title);
281 + }
282 +
283 + Future<void> _exportConversation(BuildContext context) async {
284 + final l10n = AppLocalizations.of(context)!;
285 + final messages = ref.read(chatProvider).messages;
286 + if (messages.isEmpty) return;
287 +
288 + final activeConv = ref.read(conv.activeConversationProvider);
289 + final isTemporary = ref.read(chatProvider.select((s) => s.isTemporary));
290 + final title = isTemporary
291 + ? l10n.temporary_chat
292 + : activeConv?.title;
293 +
294 + if (activeConv != null && !isTemporary) {
295 + final db = ref.read(databaseProvider);
296 + final json = DataBackupService()
297 + .exportConversationAsJson(db.store, activeConv.id);
298 + final saved = await FilePicker.saveFile(
299 + dialogTitle: l10n.export_conversation,
300 + fileName:
301 + 'localmind_${activeConv.title.replaceAll(RegExp(r'[^\w\-]+'), '_')}_${DateTime.now().millisecondsSinceEpoch}.json',
302 + type: FileType.custom,
303 + allowedExtensions: const ['json'],
304 + bytes: Uint8List.fromList(utf8.encode(json)),
305 + );
306 + if (saved != null && context.mounted) {
307 + ScaffoldMessenger.of(context).showSnackBar(
308 + SnackBar(content: Text(l10n.export_data_success)),
309 + );
310 + }
311 + return;
312 + }
313 +
314 + final markdown = await ExportService.exportAsMarkdown(
315 + messages,
316 + title: title,
317 + );
318 + final saved = await FilePicker.saveFile(
319 + dialogTitle: l10n.export_conversation,
320 + fileName:
321 + 'localmind_${(title ?? 'chat').replaceAll(RegExp(r'[^\w\-]+'), '_')}_${DateTime.now().millisecondsSinceEpoch}.md',
322 + type: FileType.custom,
323 + allowedExtensions: const ['md'],
324 + bytes: Uint8List.fromList(utf8.encode(markdown)),
325 + );
326 + if (saved != null && context.mounted) {
327 + ScaffoldMessenger.of(context).showSnackBar(
328 + SnackBar(content: Text(l10n.export_data_success)),
329 + );
330 + }
331 + }
332 +
333 + void _showRenameDialog(BuildContext context, Conversation conversation) {
334 + showRenameConversationDialog(context, ref, conversation: conversation);
335 + }
336 +
337 void _showToolApprovalDialog(
338 BuildContext context,
339 PendingToolApproval approval,
@@ -337,8 +417,8 @@ class _ChatBody extends ConsumerWidget {
417 final errorMessage = ref.watch(chatProvider.select((s) => s.errorMessage));
418 final selectedModel = ref.watch(selectedModelProvider);
419 final connectionStatus = ref.watch(connectionStatusProvider);
340 - final activeServer = ref.watch(activeServerProvider);
420 final activeConversation = ref.watch(conv.activeConversationProvider);
421 + final isTemporary = ref.watch(chatProvider.select((s) => s.isTemporary));
422 final personaId = activeConversation?.personaId;
423 final persona = personaId != null
424 ? ref.watch(personaByIdProvider(personaId))
@@ -365,12 +445,19 @@ class _ChatBody extends ConsumerWidget {
445 children: [
446 ModelTopBar(selectedModel: selectedModel, onModelTap: onModelPicker),
447 _ScreenAppBar(
368 - activeServer: activeServer,
369 - connectionStatus: connectionStatus,
448 + activeConversation: activeConversation,
449 isDark: isDark,
450 persona: persona,
451 + isTemporary: isTemporary,
452 + hasMessages: messages.isNotEmpty,
453 onMenuAction: onMenuAction,
454 onPersonaPicker: () => _openPersonaPicker(context, ref),
455 + onChatModeAction: () => _handleChatModeAction(
456 + context,
457 + ref,
458 + hasMessages: messages.isNotEmpty,
459 + isTemporary: isTemporary,
460 + ),
461 ),
462 const NotificationPermissionBanner(),
463 if (connectionStatus == ConnectionStatus.disconnected ||
@@ -391,9 +478,23 @@ class _ChatBody extends ConsumerWidget {
478 ),
479 const TtsPlayerBar(),
480 Expanded(
394 - child: Stack(
395 - children: [
396 - _MessageArea(
481 + child: DecoratedBox(
482 + decoration: isTemporary
483 + ? BoxDecoration(
484 + color: isDark
485 + ? const Color(0xFF15120C)
486 + : const Color(0xFFFFFBF0),
487 + border: Border.all(
488 + color: isDark
489 + ? const Color(0xFF6B5A2E)
490 + : const Color(0xFFE6C35C),
491 + width: 2,
492 + ),
493 + )
494 + : const BoxDecoration(),
495 + child: Stack(
496 + children: [
497 + _MessageArea(
498 isLoading: isLoading,
499 messages: messages,
500 activeConversation: activeConversation,
@@ -413,6 +514,7 @@ class _ChatBody extends ConsumerWidget {
514 ],
515 ),
516 ),
517 + ),
518 ],
519 );
520 }
@@ -740,22 +842,66 @@ void _openPersonaPickerForPreselection(BuildContext context, WidgetRef ref) {
842 ).then((_) {});
843 }
844
845 +void _handleChatModeAction(
846 + BuildContext context,
847 + WidgetRef ref, {
848 + required bool hasMessages,
849 + required bool isTemporary,
850 +}) {
851 + final l10n = AppLocalizations.of(context)!;
852 +
853 + if (!hasMessages) {
854 + ref.read(chatProvider.notifier).setTemporaryMode(!isTemporary);
855 + return;
856 + }
857 +
858 + if (isTemporary) {
859 + showDialog<void>(
860 + context: context,
861 + builder: (ctx) => AlertDialog(
862 + title: Text(l10n.exit_temporary_chat_title),
863 + content: Text(l10n.exit_temporary_chat_body),
864 + actions: [
865 + TextButton(
866 + onPressed: () => Navigator.pop(ctx),
867 + child: Text(l10n.cancel),
868 + ),
869 + TextButton(
870 + onPressed: () {
871 + Navigator.pop(ctx);
872 + ref.read(chatProvider.notifier).startNewConversation();
873 + },
874 + child: Text(l10n.nav_new_chat),
875 + ),
876 + ],
877 + ),
878 + );
879 + return;
880 + }
881 +
882 + ref.read(chatProvider.notifier).startNewConversation();
883 +}
884 +
885 class _ScreenAppBar extends ConsumerWidget {
886 const _ScreenAppBar({
745 - required this.activeServer,
746 - required this.connectionStatus,
887 + required this.activeConversation,
888 required this.isDark,
889 required this.persona,
890 + required this.isTemporary,
891 + required this.hasMessages,
892 required this.onMenuAction,
893 required this.onPersonaPicker,
894 + required this.onChatModeAction,
895 });
896
753 - final dynamic activeServer;
754 - final ConnectionStatus connectionStatus;
897 + final Conversation? activeConversation;
898 final bool isDark;
899 final dynamic persona;
900 + final bool isTemporary;
901 + final bool hasMessages;
902 final void Function(String) onMenuAction;
903 final VoidCallback onPersonaPicker;
904 + final VoidCallback onChatModeAction;
905
906 @override
907 Widget build(BuildContext context, WidgetRef ref) {
@@ -782,31 +928,13 @@ class _ScreenAppBar extends ConsumerWidget {
928 ),
929 const SizedBox(width: 8),
930 Expanded(
785 - child: Row(
786 - children: [
787 - if (activeServer != null) ...[
788 - _buildServerIcon(context, activeServer as Server?),
789 - const SizedBox(width: 8),
790 - ],
791 - Text(
792 - activeServer?.name ?? l10n.chat_title,
793 - style: const TextStyle(
794 - fontSize: 18,
795 - fontWeight: FontWeight.bold,
796 - ),
797 - ),
798 - const SizedBox(width: 8),
799 - Container(
800 - width: 8,
801 - height: 8,
802 - decoration: BoxDecoration(
803 - shape: BoxShape.circle,
804 - color: connectionStatus == ConnectionStatus.connected
805 - ? Colors.green
806 - : Colors.grey,
807 - ),
808 - ),
809 - ],
931 + child: Text(
932 + _appBarTitle(l10n),
933 + style: const TextStyle(
934 + fontSize: 18,
935 + fontWeight: FontWeight.bold,
936 + ),
937 + overflow: TextOverflow.ellipsis,
938 ),
939 ),
940 Stack(
@@ -839,6 +967,12 @@ class _ScreenAppBar extends ConsumerWidget {
967 ),
968 ],
969 ),
970 + _ChatModeIconButton(
971 + hasMessages: hasMessages,
972 + isTemporary: isTemporary,
973 + isDark: isDark,
974 + onPressed: onChatModeAction,
975 + ),
976 PopupMenuButton<String>(
977 icon: const Icon(Icons.more_vert),
978 onSelected: onMenuAction,
@@ -851,6 +985,33 @@ class _ScreenAppBar extends ConsumerWidget {
985 contentPadding: EdgeInsets.zero,
986 ),
987 ),
988 + if (activeConversation != null && !isTemporary)
989 + PopupMenuItem(
990 + value: 'rename',
991 + child: ListTile(
992 + leading: const Icon(Icons.drive_file_rename_outline),
993 + title: Text(l10n.rename_conversation),
994 + contentPadding: EdgeInsets.zero,
995 + ),
996 + ),
997 + if (hasMessages) ...[
998 + PopupMenuItem(
999 + value: 'export_chat',
1000 + child: ListTile(
1001 + leading: const Icon(Icons.upload_outlined),
1002 + title: Text(l10n.export_conversation),
1003 + contentPadding: EdgeInsets.zero,
1004 + ),
1005 + ),
1006 + PopupMenuItem(
1007 + value: 'share_chat',
1008 + child: ListTile(
1009 + leading: const Icon(Icons.share_outlined),
1010 + title: Text(l10n.share_conversation),
1011 + contentPadding: EdgeInsets.zero,
1012 + ),
1013 + ),
1014 + ],
1015 PopupMenuItem(
1016 value: 'persona',
1017 child: ListTile(
@@ -888,22 +1049,77 @@ class _ScreenAppBar extends ConsumerWidget {
1049 ),
1050 );
1051 }
1052 +
1053 + String _appBarTitle(AppLocalizations l10n) {
1054 + if (isTemporary) return l10n.temporary_chat;
1055 + if (activeConversation?.title.isNotEmpty == true) {
1056 + return activeConversation!.title;
1057 + }
1058 + return l10n.nav_new_chat;
1059 + }
1060 }
1061
893 -Widget _buildServerIcon(BuildContext context, Server? server) {
894 - if (server == null) return const SizedBox.shrink();
1062 +class _ChatModeIconButton extends StatelessWidget {
1063 + const _ChatModeIconButton({
1064 + required this.hasMessages,
1065 + required this.isTemporary,
1066 + required this.isDark,
1067 + required this.onPressed,
1068 + });
1069
896 - final iconData = server.iconName != null
897 - ? getHugeIconByName(server.iconName)
898 - : getDefaultServerIcon(server.type.name);
1070 + final bool hasMessages;
1071 + final bool isTemporary;
1072 + final bool isDark;
1073 + final VoidCallback onPressed;
1074
900 - if (iconData == null) return const Icon(Icons.dns, size: 18);
1075 + @override
1076 + Widget build(BuildContext context) {
1077 + final l10n = AppLocalizations.of(context)!;
1078 + final showGhost = !hasMessages || isTemporary;
1079 + final ghostActive = isTemporary;
1080 +
1081 + if (showGhost) {
1082 + final activeColor =
1083 + isDark ? const Color(0xFFE6C35C) : const Color(0xFF9A7B1A);
1084 + final inactiveColor =
1085 + isDark ? Colors.white54 : Colors.black45;
1086
902 - return HugeIcon(
903 - icon: iconData.icon,
904 - size: 18,
905 - color: Theme.of(context).colorScheme.primary,
906 - );
1087 + return IconButton(
1088 + onPressed: onPressed,
1089 + tooltip: ghostActive
1090 + ? l10n.exit_temporary_chat_title
1091 + : l10n.temporary_chat,
1092 + icon: ghostActive
1093 + ? Container(
1094 + padding: const EdgeInsets.all(4),
1095 + decoration: BoxDecoration(
1096 + color: activeColor.withValues(alpha: 0.25),
1097 + shape: BoxShape.circle,
1098 + ),
1099 + child: Icon(
1100 + LucideIcons.ghost,
1101 + size: 20,
1102 + color: activeColor,
1103 + ),
1104 + )
1105 + : Icon(
1106 + LucideIcons.ghost,
1107 + size: 22,
1108 + color: inactiveColor,
1109 + ),
1110 + );
1111 + }
1112 +
1113 + return IconButton(
1114 + onPressed: onPressed,
1115 + tooltip: l10n.nav_new_chat,
1116 + icon: Icon(
1117 + Icons.add_comment_outlined,
1118 + size: 22,
1119 + color: isDark ? Colors.white70 : Colors.black87,
1120 + ),
1121 + );
1122 + }
1123 }
1124
1125 class _ChatBottomBar extends ConsumerWidget {
lib/features/chat/views/components/chat_input_bar.dart
+47 -1
@@ -24,10 +24,11 @@ import 'package:permission_handler/permission_handler.dart';
24
25 import '../../../../core/models/enums.dart';
26
27 +import '../../../../core/providers/app_providers.dart';
28 import '../../../servers/providers/server_providers.dart';
29
30 import '../../../stt/providers/stt_providers.dart';
30 -
31 +import '../../providers/chat_providers.dart';
32 import '../../../saved_messages/views/components/saved_message_picker_sheet.dart';
33
34
@@ -86,6 +87,8 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
87
88 final List<File> _attachedFiles = [];
89
90 + bool _isGeneratingAiUser = false;
91 +
92 late AnimationController _sendButtonAnimController;
93
94 late Animation<double> _sendButtonScale;
@@ -572,6 +575,43 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
575
576
577
578 + Future<void> _handleGenerateAiUser() async {
579 + if (_isGeneratingAiUser || widget.isStreaming || !widget.enabled) return;
580 + setState(() => _isGeneratingAiUser = true);
581 + try {
582 + await ref.read(chatProvider.notifier).generateAiUserMessage();
583 + } finally {
584 + if (mounted) setState(() => _isGeneratingAiUser = false);
585 + }
586 + }
587 +
588 + Widget _buildAiUserButton(bool isConnected, ThemeData theme) {
589 + final l10n = AppLocalizations.of(context)!;
590 + final enabled = isConnected && widget.enabled && !widget.isStreaming;
591 +
592 + return IconButton(
593 + visualDensity: VisualDensity.compact,
594 + padding: const EdgeInsets.all(8),
595 + constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
596 + tooltip: l10n.ai_user_response_tooltip,
597 + onPressed: enabled && !_isGeneratingAiUser ? _handleGenerateAiUser : null,
598 + icon: _isGeneratingAiUser
599 + ? SizedBox(
600 + width: 18,
601 + height: 18,
602 + child: CircularProgressIndicator(
603 + strokeWidth: 2,
604 + color: theme.colorScheme.primary,
605 + ),
606 + )
607 + : Icon(
608 + Icons.person_outline,
609 + size: 22,
610 + color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
611 + ),
612 + );
613 + }
614 +
615 Widget _buildActionButton(bool canSend, ThemeData theme) {
616
617 final l10n = AppLocalizations.of(context)!;
@@ -753,6 +793,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
793 final connectionStatus = ref.watch(connectionStatusProvider);
794
795 final isConnected = connectionStatus == ConnectionStatus.connected;
796 + final showAiUserButton = ref.watch(settingsProvider).aiUserResponseEnabled;
797
798
799
@@ -1088,6 +1129,11 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
1129
1130 const SizedBox(width: 6),
1131
1132 + if (showAiUserButton) ...[
1133 + _buildAiUserButton(isConnected, theme),
1134 + const SizedBox(width: 6),
1135 + ],
1136 +
1137 _buildActionButton(canSend, theme),
1138
1139 ],
lib/features/chat/views/components/temporary_chat_banner.dart deleted
-69
@@ -1,69 +0,0 @@
1 -import 'package:flutter/material.dart';
2 -import 'package:localmind/l10n/app_localizations.dart';
3 -
4 -class TemporaryChatBanner extends StatelessWidget {
5 - const TemporaryChatBanner({
6 - super.key,
7 - required this.onSaveToHistory,
8 - });
9 -
10 - final VoidCallback onSaveToHistory;
11 -
12 - @override
13 - Widget build(BuildContext context) {
14 - final l10n = AppLocalizations.of(context)!;
15 - final theme = Theme.of(context);
16 - final isDark = theme.brightness == Brightness.dark;
17 -
18 - return Container(
19 - width: double.infinity,
20 - margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
21 - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
22 - decoration: BoxDecoration(
23 - color: isDark
24 - ? const Color(0xFF2A2418)
25 - : const Color(0xFFFFF8E7),
26 - borderRadius: BorderRadius.circular(10),
27 - border: Border.all(
28 - color: isDark
29 - ? const Color(0xFF6B5A2E)
30 - : const Color(0xFFE6C35C),
31 - ),
32 - ),
33 - child: Row(
34 - children: [
35 - Icon(
36 - Icons.hourglass_empty_outlined,
37 - size: 18,
38 - color: isDark ? const Color(0xFFE6C35C) : const Color(0xFF9A7B1A),
39 - ),
40 - const SizedBox(width: 10),
41 - Expanded(
42 - child: Text(
43 - l10n.temporary_chat_banner,
44 - style: TextStyle(
45 - fontSize: 12,
46 - fontWeight: FontWeight.w600,
47 - color: isDark ? const Color(0xFFF5E6B8) : const Color(0xFF5C4A12),
48 - ),
49 - ),
50 - ),
51 - TextButton(
52 - onPressed: onSaveToHistory,
53 - style: TextButton.styleFrom(
54 - visualDensity: VisualDensity.compact,
55 - padding: const EdgeInsets.symmetric(horizontal: 8),
56 - ),
57 - child: Text(
58 - l10n.save_to_history,
59 - style: TextStyle(
60 - fontSize: 12,
61 - color: isDark ? const Color(0xFFE6C35C) : const Color(0xFF9A7B1A),
62 - ),
63 - ),
64 - ),
65 - ],
66 - ),
67 - );
68 - }
69 -}
lib/features/settings/data/models/app_settings.dart
+7
@@ -40,6 +40,7 @@ class AppSettings {
40 final bool ttsProcessMarkdown;
41 final int ttsSkipSeconds;
42 final bool smartReplyEnabled;
43 + final bool aiUserResponseEnabled;
44 final String? localeCode;
45 final String? huggingFaceToken;
46 final bool unloadModelsBeforeLoad;
@@ -74,6 +75,7 @@ class AppSettings {
75 this.ttsProcessMarkdown = true,
76 this.ttsSkipSeconds = 10,
77 this.smartReplyEnabled = true,
78 + this.aiUserResponseEnabled = false,
79 this.localeCode,
80 this.huggingFaceToken,
81 this.unloadModelsBeforeLoad = false,
@@ -109,6 +111,7 @@ class AppSettings {
111 bool? ttsProcessMarkdown,
112 int? ttsSkipSeconds,
113 bool? smartReplyEnabled,
114 + bool? aiUserResponseEnabled,
115 Object? localeCode = _unset,
116 Object? huggingFaceToken = _unset,
117 bool? unloadModelsBeforeLoad,
@@ -149,6 +152,8 @@ class AppSettings {
152 ttsProcessMarkdown: ttsProcessMarkdown ?? this.ttsProcessMarkdown,
153 ttsSkipSeconds: ttsSkipSeconds ?? this.ttsSkipSeconds,
154 smartReplyEnabled: smartReplyEnabled ?? this.smartReplyEnabled,
155 + aiUserResponseEnabled:
156 + aiUserResponseEnabled ?? this.aiUserResponseEnabled,
157 localeCode: identical(localeCode, _unset)
158 ? this.localeCode
159 : localeCode as String?,
@@ -191,6 +196,7 @@ class AppSettings {
196 'ttsProcessMarkdown': ttsProcessMarkdown,
197 'ttsSkipSeconds': ttsSkipSeconds,
198 'smartReplyEnabled': smartReplyEnabled,
199 + 'aiUserResponseEnabled': aiUserResponseEnabled,
200 'localeCode': localeCode,
201 'huggingFaceToken': huggingFaceToken,
202 'unloadModelsBeforeLoad': unloadModelsBeforeLoad,
@@ -228,6 +234,7 @@ class AppSettings {
234 ttsProcessMarkdown: map['ttsProcessMarkdown'] ?? true,
235 ttsSkipSeconds: _parseTtsSkipSeconds(map['ttsSkipSeconds']),
236 smartReplyEnabled: map['smartReplyEnabled'] ?? true,
237 + aiUserResponseEnabled: map['aiUserResponseEnabled'] ?? false,
238 localeCode: map['localeCode'] as String?,
239 huggingFaceToken: map['huggingFaceToken'] as String?,
240 unloadModelsBeforeLoad: map['unloadModelsBeforeLoad'] ?? false,
lib/features/settings/views/settings_screen.dart
+8
@@ -236,6 +236,14 @@ class SettingsViews extends ConsumerWidget {
236 .read(settingsProvider.notifier)
237 .setSmartReplyEnabled(value),
238 ),
239 + _ToggleSetting(
240 + label: l10n.ai_user_response_enabled,
241 + value: settings.aiUserResponseEnabled,
242 + badges: [_FeatureBadge(label: l10n.experimental_label)],
243 + onChanged: (value) => ref
244 + .read(settingsProvider.notifier)
245 + .setAiUserResponseEnabled(value),
246 + ),
247 _HuggingFaceTokenSetting(
248 currentToken: settings.huggingFaceToken,
249 onSave: (value) {
lib/features/tts/providers/tts_providers.dart
+3
@@ -855,7 +855,9 @@ class TtsNotifier extends Notifier<TtsState> {
855 return;
856 }
857
858 + _isStopping = true;
859 await _flutterTts!.stop();
860 + _isStopping = false;
861 _systemSkippedOffset = Duration(
862 milliseconds: (start / cps * 1000).round(),
863 );
@@ -864,6 +866,7 @@ class TtsNotifier extends Notifier<TtsState> {
866 await _flutterTts!.setSpeechRate(
867 (settings.ttsSpeed * 0.5).clamp(0.0, 1.0),
868 );
869 + state = state.copyWith(isSpeaking: true, canSeek: true);
870 await _flutterTts!.speak(remainder);
871 _updatePlaybackProgress();
872 }
lib/features/tts/views/components/tts_player_bar.dart
+5 -1
@@ -94,7 +94,11 @@ class TtsPlayerBar extends ConsumerWidget {
94 }
95 });
96
97 - if ((!ttsState.isSpeaking && !ttsState.isInitializing) || ttsState.isPreview) {
97 + final isActivePlayback =
98 + ttsState.isSpeaking ||
99 + ttsState.isInitializing ||
100 + (ttsState.isPaused && ttsState.playingContent != null);
101 + if (!isActivePlayback || ttsState.isPreview) {
102 return const SizedBox.shrink();
103 }
104
lib/l10n/app_en.arb
+8
@@ -660,6 +660,14 @@
660 "@enable_smart_reply": {
661 "description": "Toggle for on-device smart reply suggestions"
662 },
663 + "ai_user_response_enabled": "AI user message button",
664 + "@ai_user_response_enabled": {
665 + "description": "Settings toggle for AI-generated user message button in chat input"
666 + },
667 + "ai_user_response_tooltip": "Generate user message with AI",
668 + "@ai_user_response_tooltip": {
669 + "description": "Tooltip for the AI user message button next to send"
670 + },
671 "streaming_responses": "Streaming Responses",
672 "@streaming_responses": {
673 "description": "Toggle for streaming responses"
lib/l10n/app_localizations.dart
+12
@@ -977,6 +977,18 @@ abstract class AppLocalizations {
977 /// **'On-Device Smart Replies'**
978 String get enable_smart_reply;
979
980 + /// Settings toggle for AI-generated user message button in chat input
981 + ///
982 + /// In en, this message translates to:
983 + /// **'AI user message button'**
984 + String get ai_user_response_enabled;
985 +
986 + /// Tooltip for the AI user message button next to send
987 + ///
988 + /// In en, this message translates to:
989 + /// **'Generate user message with AI'**
990 + String get ai_user_response_tooltip;
991 +
992 /// Toggle for streaming responses
993 ///
994 /// In en, this message translates to:
lib/l10n/app_localizations_ar.dart
+6
@@ -467,6 +467,12 @@ class AppLocalizationsAr extends AppLocalizations {
467 @override
468 String get enable_smart_reply => 'الردود الذكية المحلية';
469
470 + @override
471 + String get ai_user_response_enabled => 'AI user message button';
472 +
473 + @override
474 + String get ai_user_response_tooltip => 'Generate user message with AI';
475 +
476 @override
477 String get streaming_responses => 'الردود المتدفقة';
478
lib/l10n/app_localizations_bn.dart
+6
@@ -470,6 +470,12 @@ class AppLocalizationsBn extends AppLocalizations {
470 @override
471 String get enable_smart_reply => 'অন-ডিভাইস স্মার্ট রিপ্লাই';
472
473 + @override
474 + String get ai_user_response_enabled => 'AI user message button';
475 +
476 + @override
477 + String get ai_user_response_tooltip => 'Generate user message with AI';
478 +
479 @override
480 String get streaming_responses => 'স্ট্রিমিং প্রতিক্রিয়া';
481
lib/l10n/app_localizations_en.dart
+6
@@ -468,6 +468,12 @@ class AppLocalizationsEn extends AppLocalizations {
468 @override
469 String get enable_smart_reply => 'On-Device Smart Replies';
470
471 + @override
472 + String get ai_user_response_enabled => 'AI user message button';
473 +
474 + @override
475 + String get ai_user_response_tooltip => 'Generate user message with AI';
476 +
477 @override
478 String get streaming_responses => 'Streaming Responses';
479
lib/l10n/app_localizations_es.dart
+6
@@ -469,6 +469,12 @@ class AppLocalizationsEs extends AppLocalizations {
469 @override
470 String get enable_smart_reply => 'Respuestas inteligentes locales';
471
472 + @override
473 + String get ai_user_response_enabled => 'AI user message button';
474 +
475 + @override
476 + String get ai_user_response_tooltip => 'Generate user message with AI';
477 +
478 @override
479 String get streaming_responses => 'Respuestas en tiempo real (streaming)';
480
lib/l10n/app_localizations_hi.dart
+6
@@ -470,6 +470,12 @@ class AppLocalizationsHi extends AppLocalizations {
470 @override
471 String get enable_smart_reply => 'ऑन-डिवाइस स्मार्ट रिप्लाइ';
472
473 + @override
474 + String get ai_user_response_enabled => 'AI user message button';
475 +
476 + @override
477 + String get ai_user_response_tooltip => 'Generate user message with AI';
478 +
479 @override
480 String get streaming_responses => 'प्रतिक्रियाएं स्ट्रीम करें';
481
lib/l10n/app_localizations_it.dart
+6
@@ -470,6 +470,12 @@ class AppLocalizationsIt extends AppLocalizations {
470 @override
471 String get enable_smart_reply => 'Risposte intelligenti sul dispositivo';
472
473 + @override
474 + String get ai_user_response_enabled => 'AI user message button';
475 +
476 + @override
477 + String get ai_user_response_tooltip => 'Generate user message with AI';
478 +
479 @override
480 String get streaming_responses => 'Risposte in streaming';
481
lib/l10n/app_localizations_ja.dart
+6
@@ -466,6 +466,12 @@ class AppLocalizationsJa extends AppLocalizations {
466 @override
467 String get enable_smart_reply => 'スマートリプライ';
468
469 + @override
470 + String get ai_user_response_enabled => 'AI user message button';
471 +
472 + @override
473 + String get ai_user_response_tooltip => 'Generate user message with AI';
474 +
475 @override
476 String get streaming_responses => 'ストリーミング応答';
477
lib/l10n/app_localizations_ru.dart
+6
@@ -470,6 +470,12 @@ class AppLocalizationsRu extends AppLocalizations {
470 @override
471 String get enable_smart_reply => 'Умные ответы на устройстве';
472
473 + @override
474 + String get ai_user_response_enabled => 'AI user message button';
475 +
476 + @override
477 + String get ai_user_response_tooltip => 'Generate user message with AI';
478 +
479 @override
480 String get streaming_responses => 'Потоковая передача ответов';
481
lib/l10n/app_localizations_zh.dart
+6
@@ -464,6 +464,12 @@ class AppLocalizationsZh extends AppLocalizations {
464 @override
465 String get enable_smart_reply => '设备端智能回复';
466
467 + @override
468 + String get ai_user_response_enabled => 'AI user message button';
469 +
470 + @override
471 + String get ai_user_response_tooltip => 'Generate user message with AI';
472 +
473 @override
474 String get streaming_responses => '流式响应';
475