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