@tduncan / Cortana / commits / ef044d7

Merge pull request #32 from onlypuppy7/main

Model browser, fixes and improvements

onlypuppy7 committed Jul 5, 2026 at 05:21 UTC ef044d72f27ee82b4e4737b9556b690db3eb4195
89 files changed +11226 -1539
lib/app.dart
+23 -1
@@ -30,6 +30,7 @@ import 'features/servers/views/server_list_screen.dart';
30 import 'features/tts/views/tts_model_manager_screen.dart';
31 import 'features/saved_messages/views/saved_messages_screen.dart';
32 import 'features/settings/views/settings_screen.dart';
33 +import 'features/lm_studio_catalog/views/lm_studio_model_browser_screen.dart';
34 import 'features/sidebar/sidebar_drawer.dart';
35 import 'features/sidebar/sidebar_widget.dart';
36
@@ -162,6 +163,18 @@ final routerProvider = Provider<GoRouter>((ref) {
163 pageBuilder: (context, state) =>
164 const NoTransitionPage(child: SavedMessagesScreen()),
165 ),
166 + GoRoute(
167 + path: AppRoutes.lmStudioModelBrowser,
168 + pageBuilder: (context, state) {
169 + final server = state.extra as Server?;
170 + if (server == null) {
171 + return const MaterialPage(child: SizedBox.shrink());
172 + }
173 + return MaterialPage(
174 + child: LmStudioModelBrowserScreen(server: server),
175 + );
176 + },
177 + ),
178 ],
179 ),
180 ],
@@ -278,7 +291,16 @@ class AppShell extends ConsumerWidget {
291 onPopInvokedWithResult: (didPop, result) {
292 if (didPop) return;
293 if (isHome && hasActiveChat) {
281 - ref.read(chatProvider.notifier).startNewConversation();
294 + final origin = ref.read(chatOriginProvider);
295 + ref.read(chatOriginProvider.notifier).clear();
296 + switch (origin) {
297 + case ChatOrigin.history:
298 + context.go(AppRoutes.chatHistory);
299 + case ChatOrigin.savedMessages:
300 + context.go(AppRoutes.savedMessages);
301 + case ChatOrigin.none:
302 + ref.read(chatProvider.notifier).startNewConversation();
303 + }
304 return;
305 }
306 if (!isHome) {
lib/core/components/list_filter_button.dart
+6 -2
@@ -19,15 +19,19 @@ class ListFilterButton<T> extends StatelessWidget {
19 required this.selected,
20 required this.onChanged,
21 this.tooltip,
22 + this.icon = Icons.filter_list_rounded,
23 + this.showBadgeWhenNotDefault = true,
24 });
25
26 final List<ListFilterOption<T>> options;
27 final T selected;
28 final ValueChanged<T> onChanged;
29 final String? tooltip;
30 + final IconData icon;
31 + final bool showBadgeWhenNotDefault;
32
33 bool get _hasActiveFilter {
30 - if (options.isEmpty) return false;
34 + if (!showBadgeWhenNotDefault || options.isEmpty) return false;
35 return selected != options.first.value;
36 }
37
@@ -42,7 +46,7 @@ class ListFilterButton<T> extends StatelessWidget {
46 isLabelVisible: _hasActiveFilter,
47 smallSize: 8,
48 child: Icon(
45 - Icons.filter_list_rounded,
49 + icon,
50 color: _hasActiveFilter
51 ? theme.colorScheme.primary
52 : theme.iconTheme.color,
lib/core/providers/app_providers.dart
+17
@@ -2,6 +2,7 @@ import 'package:flutter_gemma/flutter_gemma.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:package_info_plus/package_info_plus.dart';
4
5 +import '../../features/chat/utils/image_upload_utils.dart';
6 import '../../features/settings/data/models/app_settings.dart';
7 import '../models/enums.dart';
8 import '../../features/tts/data/kitten_tts_model.dart';
@@ -112,6 +113,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
113 _update(state.copyWith(huggingFaceToken: value));
114 void setUnloadModelsBeforeLoad(bool value) =>
115 _update(state.copyWith(unloadModelsBeforeLoad: value));
116 + void setTempChatKeyboardIncognito(bool value) =>
117 + _update(state.copyWith(tempChatKeyboardIncognito: value));
118 + void setResumeLastChat(bool value) =>
119 + _update(state.copyWith(resumeLastChat: value));
120 + void setImageCompressionEnabled(bool value) =>
121 + _update(state.copyWith(imageCompressionEnabled: value));
122 + void setImageCompressionLevel(ImageCompressionLevel value) =>
123 + _update(state.copyWith(imageCompressionLevel: value));
124 + void setSmartRepliesUsePersona(bool value) =>
125 + _update(state.copyWith(smartRepliesUsePersona: value));
126 + void setKeepPersonaOnNewChat(bool value) =>
127 + _update(state.copyWith(keepPersonaOnNewChat: value));
128 + void setRoleSwapButtonEnabled(bool value) =>
129 + _update(state.copyWith(roleSwapButtonEnabled: value));
130 + void setShowSystemMessagesInChat(bool value) =>
131 + _update(state.copyWith(showSystemMessagesInChat: value));
132
133 Future<void> _update(AppSettings updated) async {
134 state = updated;
lib/core/providers/service_providers.dart
+17 -1
@@ -66,6 +66,7 @@ class RedirectInterceptor extends Interceptor {
66
67 Future<Response<dynamic>> _performRedirect(RequestOptions requestOptions, String location) async {
68 var redirectUrl = location;
69 + final originalUri = requestOptions.uri;
70 if (!redirectUrl.startsWith('http://') && !redirectUrl.startsWith('https://')) {
71 final baseUri = Uri.parse(requestOptions.path);
72 redirectUrl = baseUri.resolve(redirectUrl).toString();
@@ -82,13 +83,28 @@ class RedirectInterceptor extends Interceptor {
83 }
84 extra['redirectCount'] = redirectCount;
85
86 + // Never replay credentials (API keys, auth tokens) to a different
87 + // origin than the one the caller actually configured — a redirect
88 + // response (e.g. from a compromised or misconfigured server) must not
89 + // be able to exfiltrate them to an arbitrary host.
90 + final redirectUri = Uri.parse(redirectUrl);
91 + final sameOrigin = redirectUri.scheme == originalUri.scheme &&
92 + redirectUri.host == originalUri.host &&
93 + redirectUri.port == originalUri.port;
94 + final headers = Map<String, dynamic>.from(requestOptions.headers);
95 + if (!sameOrigin) {
96 + headers.removeWhere(
97 + (key, _) => key.toLowerCase() == 'authorization',
98 + );
99 + }
100 +
101 return _dio.request(
102 redirectUrl,
103 data: requestOptions.data,
104 queryParameters: requestOptions.queryParameters,
105 options: Options(
106 method: requestOptions.method,
91 - headers: requestOptions.headers,
107 + headers: headers,
108 contentType: requestOptions.contentType,
109 responseType: requestOptions.responseType,
110 extra: extra,
lib/core/routes/app_routes.dart
+1
@@ -17,4 +17,5 @@ class AppRoutes {
17 static const String onDeviceModels = '/on-device-models';
18 static const String ttsModels = '/tts-models';
19 static const String savedMessages = '/saved-messages';
20 + static const String lmStudioModelBrowser = '/lm-studio-models';
21 }
lib/core/services/data_backup_service.dart
+77 -10
@@ -1,6 +1,8 @@
1 import 'dart:convert';
2
3 import 'package:archive/archive.dart';
4 +import 'package:path/path.dart' as p;
5 +import 'package:path_provider/path_provider.dart';
6 import 'package:shared_preferences/shared_preferences.dart';
7
8 import '../models/enums.dart';
@@ -205,6 +207,48 @@ class DataBackupService {
207 };
208 }
209
210 + Map<String, dynamic> exportConversationsForIds(
211 + Store store,
212 + Set<String> conversationIds,
213 + ) {
214 + final all = exportAll(store);
215 + final conversations = (all['conversations'] as List)
216 + .where((c) => conversationIds.contains((c as Map)['id']))
217 + .toList();
218 + final messages = (all['messages'] as List)
219 + .where((m) => conversationIds.contains((m as Map)['conversationId']))
220 + .toList();
221 + final savedMessages = (all['savedMessages'] as List)
222 + .where(
223 + (m) => conversationIds.contains((m as Map)['conversationId']),
224 + )
225 + .toList();
226 + final folderIds = savedMessages
227 + .map((m) => (m as Map)['folderId'] as String?)
228 + .whereType<String>()
229 + .toSet();
230 + final savedMessageFolders = (all['savedMessageFolders'] as List)
231 + .where((f) => folderIds.contains((f as Map)['id']))
232 + .toList();
233 +
234 + return {
235 + 'version': 3,
236 + 'type': 'conversations',
237 + 'exportedAt': all['exportedAt'],
238 + 'conversations': conversations,
239 + 'messages': messages,
240 + 'savedMessages': savedMessages,
241 + 'savedMessageFolders': savedMessageFolders,
242 + };
243 + }
244 +
245 + String exportConversationsForIdsAsJson(
246 + Store store,
247 + Set<String> conversationIds,
248 + ) =>
249 + const JsonEncoder.withIndent(' ')
250 + .convert(exportConversationsForIds(store, conversationIds));
251 +
252 Map<String, dynamic> exportPersonas(Store store) {
253 final all = exportAll(store);
254 return {
@@ -295,7 +339,16 @@ class DataBackupService {
339 throw FormatException('Invalid backup format');
340 }
341
342 + // Imported attachment paths are restored verbatim onto Message records
343 + // and later opened directly as files (image/text preview), so a
344 + // crafted backup could otherwise point them at arbitrary files already
345 + // readable by this app. Only accept paths that resolve inside our own
346 + // attachments directory.
347 + final appDir = await getApplicationDocumentsDirectory();
348 + final attachmentsDir = p.normalize(p.join(appDir.path, 'attachments'));
349 +
350 await store.runInTransactionAsync(TxMode.write, (store, data) {
351 + final (decoded, attachmentsDir) = data;
352 final convBox = store.box<ConversationEntity>();
353 final messageBox = store.box<MessageEntity>();
354 final personaBox = store.box<PersonaEntity>();
@@ -303,7 +356,7 @@ class DataBackupService {
356 final savedBox = store.box<SavedMessageEntity>();
357 final savedFolderBox = store.box<SavedMessageFolderEntity>();
358
306 - final personas = data['personas'];
359 + final personas = decoded['personas'];
360 if (personas is List) {
361 for (final item in personas) {
362 if (item is! Map) continue;
@@ -338,7 +391,7 @@ class DataBackupService {
391 }
392 }
393
341 - final servers = data['servers'];
394 + final servers = decoded['servers'];
395 if (servers is List) {
396 for (final item in servers) {
397 if (item is! Map) continue;
@@ -377,7 +430,7 @@ class DataBackupService {
430 }
431 }
432
380 - final savedFolders = data['savedMessageFolders'];
433 + final savedFolders = decoded['savedMessageFolders'];
434 if (savedFolders is List) {
435 for (final item in savedFolders) {
436 if (item is! Map) continue;
@@ -401,7 +454,7 @@ class DataBackupService {
454 }
455 }
456
404 - final savedMessages = data['savedMessages'];
457 + final savedMessages = decoded['savedMessages'];
458 if (savedMessages is List) {
459 for (final item in savedMessages) {
460 if (item is! Map) continue;
@@ -435,7 +488,7 @@ class DataBackupService {
488 }
489 }
490
438 - final conversations = data['conversations'];
491 + final conversations = decoded['conversations'];
492 if (conversations is List) {
493 for (final item in conversations) {
494 if (item is! Map) continue;
@@ -477,7 +530,7 @@ class DataBackupService {
530 }
531 }
532
480 - final messages = data['messages'];
533 + final messages = decoded['messages'];
534 if (messages is List) {
535 for (final item in messages) {
536 if (item is! Map) continue;
@@ -506,9 +559,10 @@ class DataBackupService {
559 modelId: item['modelId'] as String?,
560 tokenCount: item['tokenCount'] as int?,
561 errorMessage: item['errorMessage'] as String?,
509 - attachmentPaths: item['attachmentPaths'] is List
510 - ? List<String>.from(item['attachmentPaths'] as List)
511 - : null,
562 + attachmentPaths: _sanitizedAttachmentPaths(
563 + item['attachmentPaths'],
564 + attachmentsDir,
565 + ),
566 generationTimeMs: item['generationTimeMs'] as int?,
567 reasoningContent: item['reasoningContent'] as String?,
568 toolCallId: item['toolCallId'] as String?,
@@ -530,7 +584,20 @@ class DataBackupService {
584 messageBox.put(entity);
585 }
586 }
533 - }, decoded);
587 + }, (decoded, attachmentsDir));
588 + }
589 +
590 + List<String>? _sanitizedAttachmentPaths(dynamic raw, String attachmentsDir) {
591 + if (raw is! List) return null;
592 + final sanitized = <String>[];
593 + for (final entry in raw) {
594 + if (entry is! String || entry.isEmpty) continue;
595 + final normalized = p.normalize(entry);
596 + if (normalized == attachmentsDir || p.isWithin(attachmentsDir, normalized)) {
597 + sanitized.add(entry);
598 + }
599 + }
600 + return sanitized.isEmpty ? null : sanitized;
601 }
602
603 Future<void> importZip(Store store, List<int> bytes) async {
lib/core/services/export_choice_dialog.dart new
+52
@@ -0,0 +1,52 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter/services.dart';
3 +
4 +import '../../l10n/app_localizations.dart';
5 +import 'share_service.dart';
6 +
7 +enum _ExportChoice { clipboard, share }
8 +
9 +/// Prompts the user to choose between copying [content] to the clipboard
10 +/// or sharing it to another app, instead of committing to one path.
11 +Future<void> showExportChoiceDialog(
12 + BuildContext context, {
13 + required String content,
14 + String? subject,
15 +}) async {
16 + final l10n = AppLocalizations.of(context)!;
17 +
18 + final choice = await showDialog<_ExportChoice>(
19 + context: context,
20 + builder: (ctx) => AlertDialog(
21 + title: Text(l10n.export_choice_title),
22 + content: Text(l10n.export_choice_body),
23 + actions: [
24 + TextButton(
25 + onPressed: () => Navigator.pop(ctx),
26 + child: Text(l10n.cancel),
27 + ),
28 + TextButton(
29 + onPressed: () => Navigator.pop(ctx, _ExportChoice.clipboard),
30 + child: Text(l10n.copy_to_clipboard),
31 + ),
32 + FilledButton(
33 + onPressed: () => Navigator.pop(ctx, _ExportChoice.share),
34 + child: Text(l10n.share),
35 + ),
36 + ],
37 + ),
38 + );
39 +
40 + if (choice == null) return;
41 +
42 + if (choice == _ExportChoice.clipboard) {
43 + await Clipboard.setData(ClipboardData(text: content));
44 + if (context.mounted) {
45 + ScaffoldMessenger.of(context).showSnackBar(
46 + SnackBar(content: Text(l10n.copied_to_clipboard)),
47 + );
48 + }
49 + } else {
50 + await ShareService.shareText(content, subject: subject);
51 + }
52 +}
lib/core/storage/entities.dart
+19
@@ -35,6 +35,8 @@ class ServerEntity {
35
36 String? iconName;
37 String? pathPrefix;
38 + int? availableRamGb;
39 + int? availableVramGb;
40
41 ServerEntity({
42 this.internalId = 0,
@@ -50,6 +52,8 @@ class ServerEntity {
52 required this.statusIndex,
53 this.iconName,
54 this.pathPrefix,
55 + this.availableRamGb,
56 + this.availableVramGb,
57 });
58
59 factory ServerEntity.fromDomain(Server server) {
@@ -66,6 +70,8 @@ class ServerEntity {
70 statusIndex: server.status.index,
71 iconName: server.iconName,
72 pathPrefix: server.pathPrefix,
73 + availableRamGb: server.availableRamGb,
74 + availableVramGb: server.availableVramGb,
75 );
76 }
77
@@ -87,6 +93,8 @@ class ServerEntity {
93 : ConnectionStatus.values.first,
94 iconName: iconName,
95 pathPrefix: pathPrefix,
96 + availableRamGb: availableRamGb,
97 + availableVramGb: availableVramGb,
98 );
99 }
100 }
@@ -205,6 +213,7 @@ class ConversationEntity {
213 bool isTemporary;
214 bool isArchived;
215 int characterCount;
216 + int? totalTokenCount;
217
218 @Backlink()
219 final messages = ToMany<MessageEntity>();
@@ -233,6 +242,7 @@ class ConversationEntity {
242 this.isTemporary = false,
243 this.isArchived = false,
244 this.characterCount = 0,
245 + this.totalTokenCount,
246 });
247
248 factory ConversationEntity.fromDomain(Conversation conversation) {
@@ -261,6 +271,7 @@ class ConversationEntity {
271 isTemporary: conversation.isTemporary,
272 isArchived: conversation.isArchived,
273 characterCount: conversation.characterCount,
274 + totalTokenCount: conversation.totalTokenCount,
275 );
276 }
277
@@ -300,6 +311,7 @@ class ConversationEntity {
311 isTemporary: isTemporary,
312 isArchived: isArchived,
313 characterCount: characterCount,
314 + totalTokenCount: totalTokenCount,
315 );
316 }
317
@@ -375,6 +387,8 @@ class SavedMessageEntity {
387 @Property(type: PropertyType.date)
388 DateTime savedAt;
389
390 + bool isArchived;
391 +
392 SavedMessageEntity({
393 this.internalId = 0,
394 required this.id,
@@ -386,6 +400,7 @@ class SavedMessageEntity {
400 this.modelId,
401 this.folderId,
402 required this.savedAt,
403 + this.isArchived = false,
404 });
405 }
406
@@ -433,6 +448,7 @@ class MessageEntity {
448 int threadOrder;
449 bool isActiveVariant;
450 String? parentMessageId;
451 + int? contentTokenCount;
452
453 MessageEntity({
454 this.internalId = 0,
@@ -462,6 +478,7 @@ class MessageEntity {
478 this.threadOrder = 0,
479 this.isActiveVariant = true,
480 this.parentMessageId,
481 + this.contentTokenCount,
482 });
483
484 factory MessageEntity.fromDomain(Message message) {
@@ -498,6 +515,7 @@ class MessageEntity {
515 threadOrder: message.threadOrder,
516 isActiveVariant: message.isActiveVariant,
517 parentMessageId: message.parentMessageId,
518 + contentTokenCount: message.contentTokenCount,
519 );
520 }
521
@@ -571,6 +589,7 @@ class MessageEntity {
589 threadOrder: threadOrder,
590 isActiveVariant: isActiveVariant,
591 parentMessageId: parentMessageId,
592 + contentTokenCount: contentTokenCount,
593 );
594 }
595 }
lib/features/chat/data/chat_api_error.dart new
+85
@@ -0,0 +1,85 @@
1 +import 'dart:convert';
2 +
3 +/// Structured API error details from LM Studio, OpenAI-compatible, etc.
4 +class ChatApiError {
5 + const ChatApiError({
6 + required this.message,
7 + this.type,
8 + this.code,
9 + this.param,
10 + });
11 +
12 + final String message;
13 + final String? type;
14 + final String? code;
15 + final String? param;
16 +
17 + bool get hasMetadata =>
18 + (type?.isNotEmpty ?? false) ||
19 + (code?.isNotEmpty ?? false) ||
20 + (param?.isNotEmpty ?? false);
21 +
22 + String encode() => jsonEncode({
23 + 'message': message,
24 + if (type != null && type!.isNotEmpty) 'type': type,
25 + if (code != null && code!.isNotEmpty) 'code': code,
26 + if (param != null && param!.isNotEmpty) 'param': param,
27 + });
28 +
29 + static ChatApiError? tryParse(String? raw) {
30 + if (raw == null || raw.isEmpty) return null;
31 + final trimmed = raw.trim();
32 + if (!trimmed.startsWith('{')) return null;
33 + try {
34 + final map = jsonDecode(trimmed) as Map<String, dynamic>;
35 + final message = map['message']?.toString();
36 + if (message == null || message.isEmpty) return null;
37 + return ChatApiError(
38 + message: message,
39 + type: map['type']?.toString(),
40 + code: map['code']?.toString(),
41 + param: map['param']?.toString(),
42 + );
43 + } catch (_) {
44 + return null;
45 + }
46 + }
47 +
48 + static ChatApiError? fromErrorMap(Map<String, dynamic>? error) {
49 + if (error == null) return null;
50 + final message = error['message']?.toString();
51 + if (message == null || message.isEmpty) return null;
52 + return ChatApiError(
53 + message: message,
54 + type: error['type']?.toString(),
55 + code: error['code']?.toString(),
56 + param: error['param']?.toString(),
57 + );
58 + }
59 +
60 + static ChatApiError? fromResponseBody(dynamic data) {
61 + if (data == null) return null;
62 + Map<String, dynamic>? map;
63 + if (data is Map<String, dynamic>) {
64 + map = data;
65 + } else if (data is String && data.trim().isNotEmpty) {
66 + try {
67 + map = jsonDecode(data) as Map<String, dynamic>;
68 + } catch (_) {
69 + return null;
70 + }
71 + }
72 + if (map == null) return null;
73 +
74 + final nested = map['error'];
75 + if (nested is Map<String, dynamic>) {
76 + return fromErrorMap(nested);
77 + }
78 + return fromErrorMap(map);
79 + }
80 +}
81 +
82 +String? encodeChatErrorMessage(ChatApiError? error) {
83 + if (error == null) return null;
84 + return error.encode();
85 +}
lib/features/chat/data/chat_service.dart
+252 -279
@@ -14,9 +14,11 @@ import '../../on_device/data/on_device_gemma_service.dart';
14 import '../../on_device/data/on_device_chat_service.dart';
15 import 'tools/adapters/tool_transport_adapter.dart';
16 import '../utils/attachment_helpers.dart';
17 +import '../utils/image_upload_utils.dart';
18 import 'tools/adapters/openai_tool_adapter.dart';
19 import 'tools/adapters/openrouter_tool_adapter.dart';
20 import 'tools/adapters/ollama_tool_adapter.dart';
21 +import 'chat_api_error.dart';
22
23 abstract class ChatService {
24 Stream<ChatResponse> sendMessage({
@@ -36,10 +38,16 @@ abstract class ChatService {
38 ServerType type,
39 Dio dio, {
40 OnDeviceGemmaService? onDeviceGemma,
41 + bool imageCompressionEnabled = true,
42 + ImageCompressionLevel imageCompressionLevel = ImageCompressionLevel.medium,
43 }) {
44 switch (type) {
45 case ServerType.lmStudio:
42 - return LMStudioChatService(dio);
46 + return LMStudioChatService(
47 + dio,
48 + imageCompressionEnabled: imageCompressionEnabled,
49 + imageCompressionLevel: imageCompressionLevel,
50 + );
51 case ServerType.openAICompatible:
52 return OpenAICompatibleChatService(dio);
53 case ServerType.ollama:
@@ -144,9 +152,15 @@ class ChatStats {
152
153 class LMStudioChatService implements ChatService {
154 final Dio _dio;
155 + final bool imageCompressionEnabled;
156 + final ImageCompressionLevel imageCompressionLevel;
157 CancelToken? _cancelToken;
158
149 - LMStudioChatService(this._dio);
159 + LMStudioChatService(
160 + this._dio, {
161 + this.imageCompressionEnabled = true,
162 + this.imageCompressionLevel = ImageCompressionLevel.medium,
163 + });
164
165 @override
166 Stream<ChatResponse> sendMessage({
@@ -160,56 +174,58 @@ class LMStudioChatService implements ChatService {
174 bool continueGeneration = false,
175 }) async* {
176 _cancelToken = CancelToken();
177 + final toolAdapter = OpenAiToolAdapter();
178 +
179 + final apiMessages = <Map<String, dynamic>>[];
180 + for (final m in messages) {
181 + apiMessages.add(await _messageToApiMapWithImages(m));
182 + }
183
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 - });
184 + // Strip trailing empty assistant messages to avoid "prefill incompatible
185 + // with enable_thinking" errors from servers running thinking models.
186 + if (!continueGeneration) {
187 + while (apiMessages.isNotEmpty &&
188 + apiMessages.last['role'] == 'assistant' &&
189 + (apiMessages.last['content'] == null ||
190 + (apiMessages.last['content'] is String &&
191 + (apiMessages.last['content'] as String).isEmpty))) {
192 + apiMessages.removeLast();
193 }
183 - } else {
184 - formattedInput = await _formatInputWithImages(messages);
194 + } else if (apiMessages.isNotEmpty &&
195 + apiMessages.last['role'] == 'assistant') {
196 + // Keep the assistant prefill for continuation; ensure content is a
197 + // string (the model continues from exactly where this cuts off, and
198 + // the response contains only the newly generated continuation text).
199 + final last = apiMessages.last;
200 + last['content'] = (last['content'] as String?) ?? '';
201 }
202
203 final body = <String, dynamic>{
204 'model': modelId,
189 - 'input': formattedInput,
205 + 'messages': apiMessages,
206 'temperature': params.temperature,
207 'top_p': params.topP,
192 - 'max_output_tokens': params.maxTokens,
208 + 'max_tokens': params.maxTokens,
209 'stream': true,
194 - 'store': true,
210 + // Requests a final chunk with an empty `choices` array and a
211 + // `usage` object (prompt_tokens/completion_tokens) so tokens/sec and
212 + // token counts can still be computed after switching off the native
213 + // Responses-style endpoint's richer `chat.end` stats.
214 + 'stream_options': {'include_usage': true},
215 };
216
197 - if (params.systemPrompt != null && params.systemPrompt!.isNotEmpty) {
198 - body['system_prompt'] = params.systemPrompt;
199 - }
217 if (params.topK != null) body['top_k'] = params.topK;
218 if (params.minP != null) body['min_p'] = params.minP;
219 if (params.repeatPenalty != null) {
220 body['repeat_penalty'] = params.repeatPenalty;
221 }
205 - if (params.reasoningLevel != null) {
206 - body['reasoning'] = params.reasoningLevel;
207 - }
208 - if (integrations != null && integrations.isNotEmpty) {
209 - body['integrations'] = integrations.map((i) => i.toJson()).toList();
210 - }
211 - if (previousResponseId != null) {
212 - body['previous_response_id'] = previousResponseId;
222 + _applyReasoningControl(body, params);
223 +
224 + if (tools != null && tools.isNotEmpty) {
225 + final toolsPayload = toolAdapter.buildToolDefinitionPayload(tools);
226 + if (toolsPayload.containsKey('tools')) {
227 + body['tools'] = toolsPayload['tools'];
228 + }
229 }
230
231 try {
@@ -230,7 +246,6 @@ class LMStudioChatService implements ChatService {
246 utf8.decoder,
247 );
248 String buffer = '';
233 - String currentEventType = '';
249
250 await for (final chunk in stream) {
251 buffer += chunk;
@@ -238,30 +253,83 @@ class LMStudioChatService implements ChatService {
253 buffer = lines.removeLast();
254
255 for (final line in lines) {
241 - final trimmedLine = line.trim();
242 - if (trimmedLine.isEmpty) continue;
256 + if (!line.startsWith('data: ')) continue;
257 + final data = line.substring(6);
258 + if (data == '[DONE]') {
259 + for (final call in toolAdapter.takeCompletedCalls()) {
260 + yield ChatResponse(
261 + type: ChatResponseType.toolCall,
262 + toolCall: ToolCallData(
263 + tool: call.name,
264 + arguments: call.arguments,
265 + ),
266 + );
267 + }
268 + yield const ChatResponse(type: ChatResponseType.done);
269 + return;
270 + }
271 + if (data.isEmpty) continue;
272 +
273 + try {
274 + final json = jsonDecode(data) as Map<String, dynamic>;
275 + toolAdapter.consumeDynamicChunk(json);
276 +
277 + if (json['error'] != null) {
278 + yield ChatResponse(
279 + type: ChatResponseType.error,
280 + content: _formatApiErrorContent(json['error']),
281 + );
282 + return;
283 + }
284
244 - if (trimmedLine.startsWith('event: ')) {
245 - currentEventType = trimmedLine.substring(7);
246 - } else if (trimmedLine.startsWith('data: ')) {
247 - final data = trimmedLine.substring(6);
248 - if (data.isEmpty) continue;
285 + final usage = json['usage'] as Map<String, dynamic>?;
286 + if (usage != null) {
287 + yield ChatResponse(
288 + type: ChatResponseType.done,
289 + stats: ChatStats(
290 + inputTokens: usage['prompt_tokens'] as int? ?? 0,
291 + totalOutputTokens: usage['completion_tokens'] as int? ?? 0,
292 + ),
293 + );
294 + }
295
250 - try {
251 - final json = jsonDecode(data) as Map<String, dynamic>;
252 - await for (final response in _handleSseEvent(
253 - currentEventType,
254 - json,
255 - )) {
256 - yield response;
257 - }
258 - currentEventType = '';
259 - } catch (e) {
260 - currentEventType = '';
296 + final choices = json['choices'] as List<dynamic>?;
297 + if (choices == null || choices.isEmpty) continue;
298 +
299 + final firstChoice = choices[0] as Map<String, dynamic>?;
300 + if (firstChoice == null) continue;
301 +
302 + final delta = firstChoice['delta'];
303 + if (delta == null || delta is! Map<String, dynamic>) continue;
304 +
305 + final content = (delta['content'] ?? delta['text']) as String?;
306 + final reasoning =
307 + (delta['reasoning'] ?? delta['reasoning_content']) as String?;
308 +
309 + if (content != null && content.isNotEmpty) {
310 + yield ChatResponse(type: ChatResponseType.message, content: content);
311 + }
312 + if (reasoning != null && reasoning.isNotEmpty) {
313 + yield ChatResponse(
314 + type: ChatResponseType.reasoning,
315 + reasoningContent: reasoning,
316 + );
317 }
318 + } catch (e) {
319 + Log.error('LMStudio chat parsing error: $e');
320 }
321 }
322 }
323 +
324 + for (final call in toolAdapter.takeCompletedCalls()) {
325 + yield ChatResponse(
326 + type: ChatResponseType.toolCall,
327 + toolCall: ToolCallData(
328 + tool: call.name,
329 + arguments: call.arguments,
330 + ),
331 + );
332 + }
333 } catch (e) {
334 Log.error('LMStudio connection error: $e');
335 yield ChatResponse(
@@ -273,219 +341,90 @@ class LMStudioChatService implements ChatService {
341 yield const ChatResponse(type: ChatResponseType.done);
342 }
343
276 - Stream<ChatResponse> _handleSseEvent(
277 - String eventType,
278 - Map<String, dynamic> json,
279 - ) async* {
280 - switch (eventType) {
281 - case 'message.delta':
282 - final content = json['content'] as String?;
283 - if (content != null && content.isNotEmpty) {
284 - yield ChatResponse(type: ChatResponseType.message, content: content);
285 - }
286 - break;
287 -
288 - case 'reasoning.delta':
289 - final content = json['content'] as String?;
290 - if (content != null && content.isNotEmpty) {
291 - yield ChatResponse(
292 - type: ChatResponseType.reasoning,
293 - reasoningContent: content,
294 - );
295 - }
296 - break;
297 -
298 - case 'tool_call.start':
299 - final tool = json['tool'] as String?;
300 - final providerInfo = json['provider_info'] as Map<String, dynamic>?;
301 - if (tool != null) {
302 - yield ChatResponse(
303 - type: ChatResponseType.toolCall,
304 - toolCall: ToolCallData(
305 - tool: tool,
306 - arguments: {},
307 - providerInfo: providerInfo != null
308 - ? ToolProviderInfo(
309 - type: providerInfo['type'] as String? ?? '',
310 - pluginId: providerInfo['plugin_id'] as String?,
311 - serverLabel: providerInfo['server_label'] as String?,
312 - )
313 - : null,
314 - ),
315 - );
316 - }
317 - break;
318 -
319 - case 'tool_call.arguments':
320 - final tool = json['tool'] as String?;
321 - final arguments = json['arguments'] as Map<String, dynamic>?;
322 - final providerInfo = json['provider_info'] as Map<String, dynamic>?;
323 - if (tool != null && arguments != null) {
324 - yield ChatResponse(
325 - type: ChatResponseType.toolCall,
326 - toolCall: ToolCallData(
327 - tool: tool,
328 - arguments: arguments,
329 - providerInfo: providerInfo != null
330 - ? ToolProviderInfo(
331 - type: providerInfo['type'] as String? ?? '',
332 - pluginId: providerInfo['plugin_id'] as String?,
333 - serverLabel: providerInfo['server_label'] as String?,
334 - )
335 - : null,
336 - ),
337 - );
338 - }
339 - break;
340 -
341 - case 'tool_call.success':
342 - final tool = json['tool'] as String?;
343 - final arguments = json['arguments'] as Map<String, dynamic>?;
344 - final output = json['output'] as String?;
345 - final providerInfo = json['provider_info'] as Map<String, dynamic>?;
346 - if (tool != null) {
347 - yield ChatResponse(
348 - type: ChatResponseType.toolCall,
349 - toolCall: ToolCallData(
350 - tool: tool,
351 - arguments: arguments ?? {},
352 - output: output,
353 - providerInfo: providerInfo != null
354 - ? ToolProviderInfo(
355 - type: providerInfo['type'] as String? ?? '',
356 - pluginId: providerInfo['plugin_id'] as String?,
357 - serverLabel: providerInfo['server_label'] as String?,
358 - )
359 - : null,
360 - ),
361 - );
362 - }
363 - break;
364 -
365 - case 'tool_call.failure':
366 - final reason = json['reason'] as String?;
367 - final metadata = json['metadata'] as Map<String, dynamic>?;
368 - if (reason != null) {
369 - final providerInfo =
370 - metadata?['provider_info'] as Map<String, dynamic>?;
371 - yield ChatResponse(
372 - type: ChatResponseType.invalidToolCall,
373 - invalidToolCall: InvalidToolCallData(
374 - reason: reason,
375 - metadataType: metadata?['type'] as String?,
376 - toolName: metadata?['tool_name'] as String?,
377 - arguments: metadata?['arguments'] as Map<String, dynamic>?,
378 - providerInfo: providerInfo != null
379 - ? ToolProviderInfo(
380 - type: providerInfo['type'] as String? ?? '',
381 - pluginId: providerInfo['plugin_id'] as String?,
382 - serverLabel: providerInfo['server_label'] as String?,
383 - )
384 - : null,
385 - ),
386 - );
387 - }
388 - break;
389 -
390 - case 'chat.end':
391 - final result = json['result'] as Map<String, dynamic>?;
392 - if (result != null) {
393 - final stats = result['stats'] as Map<String, dynamic>?;
394 - yield ChatResponse(
395 - type: ChatResponseType.done,
396 - stats: stats != null
397 - ? ChatStats(
398 - inputTokens: stats['input_tokens'] as int? ?? 0,
399 - totalOutputTokens:
400 - stats['total_output_tokens'] as int? ?? 0,
401 - reasoningOutputTokens:
402 - stats['reasoning_output_tokens'] as int?,
403 - tokensPerSecond: (stats['tokens_per_second'] as num?)
404 - ?.toDouble(),
405 - timeToFirstTokenSeconds:
406 - (stats['time_to_first_token_seconds'] as num?)
407 - ?.toDouble(),
408 - modelLoadTimeSeconds:
409 - (stats['model_load_time_seconds'] as num?)?.toDouble(),
410 - )
411 - : null,
412 - );
413 - }
414 - break;
415 -
416 - case 'error':
417 - final error = json['error'] as Map<String, dynamic>?;
418 - if (error != null) {
419 - final type = error['type']?.toString();
420 - final message = error['message']?.toString() ?? 'Unknown error';
421 - yield ChatResponse(
422 - type: ChatResponseType.error,
423 - content: type != null ? '$type: $message' : message,
344 + Future<Map<String, dynamic>> _messageToApiMapWithImages(Message m) async {
345 + var textContent = m.content;
346 + final hasAttachments =
347 + m.attachmentPaths != null && m.attachmentPaths!.isNotEmpty;
348 +
349 + if (hasAttachments) {
350 + for (final path in m.attachmentPaths!) {
351 + if (!AttachmentHelpers.isTextPath(path)) continue;
352 + final text = await AttachmentHelpers.readTextFile(path);
353 + if (text != null) {
354 + textContent = AttachmentHelpers.appendTextAttachment(
355 + textContent,
356 + AttachmentHelpers.fileNameOf(path),
357 + text,
358 );
359 }
426 - break;
427 - }
428 - }
429 -
430 - Future<dynamic> _formatInputWithImages(List<Message> messages) async {
431 - final formattedInputs = <Map<String, dynamic>>[];
432 - for (final m in messages) {
433 - if (m.role == MessageRole.system) continue;
434 -
435 - var textContent = m.content;
436 - final hasAttachments =
437 - m.attachmentPaths != null && m.attachmentPaths!.isNotEmpty;
438 -
439 - if (hasAttachments) {
440 - for (final path in m.attachmentPaths!) {
441 - if (!AttachmentHelpers.isTextPath(path)) continue;
442 - final text = await AttachmentHelpers.readTextFile(path);
443 - if (text != null) {
444 - textContent = AttachmentHelpers.appendTextAttachment(
445 - textContent,
446 - AttachmentHelpers.fileNameOf(path),
447 - text,
448 - );
449 - }
450 - }
360 }
361 + }
362
453 - final isEmptyAssistant =
454 - m.role == MessageRole.assistant && textContent.trim().isEmpty;
455 -
456 - if (textContent.trim().isNotEmpty && !isEmptyAssistant) {
457 - formattedInputs.add({'type': 'text', 'content': textContent});
458 - }
363 + final imageParts = <Map<String, dynamic>>[];
364 + if (hasAttachments) {
365 + for (final path in m.attachmentPaths!) {
366 + if (!AttachmentHelpers.isImagePath(path)) continue;
367 + try {
368 + final file = File(path);
369 + if (!await file.exists()) continue;
370 + final fileBytes = await ImageUploadUtils.prepareImageBytes(
371 + file,
372 + enabled: imageCompressionEnabled,
373 + level: imageCompressionLevel,
374 + );
375 + final base64Image = await Isolate.run(() {
376 + try {
377 + return base64Encode(fileBytes);
378 + } catch (_) {
379 + return null;
380 + }
381 + });
382
460 - if (hasAttachments) {
461 - for (final path in m.attachmentPaths!) {
462 - if (!AttachmentHelpers.isImagePath(path)) continue;
463 - try {
464 - final file = File(path);
465 - if (!await file.exists()) continue;
466 - final fileBytes = await file.readAsBytes();
467 - final base64Image = await Isolate.run(() {
468 - try {
469 - return base64Encode(fileBytes);
470 - } catch (_) {
471 - return null;
472 - }
383 + if (base64Image != null) {
384 + imageParts.add({
385 + 'type': 'image_url',
386 + 'image_url': {'url': 'data:image/png;base64,$base64Image'},
387 });
474 -
475 - if (base64Image != null) {
476 - final mimeType = AttachmentHelpers.mimeTypeForImage(path);
477 - formattedInputs.add({
478 - 'type': 'image',
479 - 'data_url': 'data:$mimeType;base64,$base64Image',
480 - });
481 - }
482 - } catch (e) {
483 - Log.error('Failed to read attachment for LMStudio format: $e');
388 }
389 + } catch (e) {
390 + Log.error('Failed to read attachment for LMStudio format: $e');
391 }
392 }
393 }
488 - return formattedInputs;
394 +
395 + dynamic content;
396 + if (imageParts.isEmpty) {
397 + content = textContent;
398 + } else {
399 + content = <Map<String, dynamic>>[
400 + if (textContent.trim().isNotEmpty)
401 + {'type': 'text', 'text': textContent},
402 + ...imageParts,
403 + ];
404 + }
405 +
406 + final map = <String, dynamic>{
407 + 'role': _roleToString(m.role),
408 + 'content': content,
409 + };
410 + if (m.role == MessageRole.tool && m.toolCallId != null) {
411 + map['tool_call_id'] = m.toolCallId;
412 + }
413 + if (m.role == MessageRole.assistant &&
414 + m.toolCalls != null &&
415 + m.toolCalls!.isNotEmpty) {
416 + map['tool_calls'] = m.toolCalls!.map((tc) {
417 + return {
418 + 'id': tc.id,
419 + 'type': 'function',
420 + 'function': {
421 + 'name': tc.toolName,
422 + 'arguments': jsonEncode(tc.arguments),
423 + },
424 + };
425 + }).toList();
426 + }
427 + return map;
428 }
429
430 @override
@@ -533,7 +472,7 @@ class OpenAICompatibleChatService implements ChatService {
472 last['content'] = (last['content'] as String?) ?? '';
473 }
474
536 - final body = {
475 + final body = <String, dynamic>{
476 'model': modelId,
477 'messages': apiMessages,
478 'temperature': params.temperature,
@@ -541,6 +480,7 @@ class OpenAICompatibleChatService implements ChatService {
480 'max_tokens': params.maxTokens,
481 'stream': true,
482 };
483 + _applyReasoningControl(body, params);
484
485 if (tools != null && tools.isNotEmpty) {
486 final toolsPayload = toolAdapter.buildToolDefinitionPayload(tools);
@@ -629,17 +569,11 @@ class OpenAICompatibleChatService implements ChatService {
569 // Check for errors in the response
570 if (json['error'] != null) {
571 _timeoutTimer?.cancel();
632 - final error = json['error'];
633 - String errorMsg;
634 - if (error is Map) {
635 - errorMsg = (error['message'] ?? 'Unknown API error') as String;
636 - } else {
637 - errorMsg = error.toString();
638 - }
639 - Log.error('OpenAICompatible mid-stream error: $errorMsg');
572 + final errorContent = _formatApiErrorContent(json['error']);
573 + Log.error('OpenAICompatible mid-stream error: $errorContent');
574 yield ChatResponse(
575 type: ChatResponseType.error,
642 - content: 'API Error: $errorMsg',
576 + content: errorContent,
577 );
578 return;
579 }
@@ -757,7 +691,7 @@ class OllamaChatService implements ChatService {
691 _cancelToken = CancelToken();
692 final toolAdapter = OllamaToolAdapter();
693
760 - final body = {
694 + final body = <String, dynamic>{
695 'model': modelId,
696 'messages': messages.map(_messageToApiMap).toList(),
697 'stream': true,
@@ -767,6 +701,7 @@ class OllamaChatService implements ChatService {
701 'num_predict': params.maxTokens,
702 },
703 };
704 + _applyReasoningControl(body, params);
705
706 if (tools != null && tools.isNotEmpty) {
707 final toolsPayload = toolAdapter.buildToolDefinitionPayload(tools);
@@ -879,7 +814,7 @@ class OpenRouterChatService implements ChatService {
814 _cancelToken = CancelToken();
815 final toolAdapter = OpenRouterToolAdapter();
816
882 - final body = {
817 + final body = <String, dynamic>{
818 'model': modelId,
819 'messages': messages.map(_messageToApiMap).toList(),
820 'temperature': params.temperature,
@@ -887,6 +822,7 @@ class OpenRouterChatService implements ChatService {
822 'max_tokens': params.maxTokens,
823 'stream': true,
824 };
825 + _applyReasoningControl(body, params);
826
827 if (tools != null && tools.isNotEmpty) {
828 final toolsPayload = toolAdapter.buildToolDefinitionPayload(tools);
@@ -977,17 +913,11 @@ class OpenRouterChatService implements ChatService {
913 // Check for errors in the response
914 if (json['error'] != null) {
915 _timeoutTimer?.cancel();
980 - final error = json['error'];
981 - String errorMsg;
982 - if (error is Map) {
983 - errorMsg = (error['message'] ?? 'Unknown API error') as String;
984 - } else {
985 - errorMsg = error.toString();
986 - }
987 - Log.error('OpenRouter mid-stream error: $errorMsg');
916 + final errorContent = _formatApiErrorContent(json['error']);
917 + Log.error('OpenRouter mid-stream error: $errorContent');
918 yield ChatResponse(
919 type: ChatResponseType.error,
990 - content: 'API Error: $errorMsg',
920 + content: errorContent,
921 );
922 return;
923 }
@@ -1090,9 +1020,49 @@ class OpenRouterChatService implements ChatService {
1020 }
1021 }
1022
1023 +/// Applies the Think toggle to a request body. Different local/hosted
1024 +/// backends expose "disable reasoning for this hybrid model" a handful of
1025 +/// different ways (a `reasoning` object, a top-level `reasoning_effort`,
1026 +/// llama.cpp's `enable_thinking`, Ollama's `think`) — send all of them so
1027 +/// whichever one the connected server actually understands takes effect.
1028 +/// No-op when [ChatParameters.reasoningEnabled] is null, i.e. the active
1029 +/// model doesn't support reasoning.
1030 +void _applyReasoningControl(Map<String, dynamic> body, ChatParameters params) {
1031 + if (params.reasoningEnabled == false) {
1032 + body['reasoning'] = {
1033 + 'enabled': false,
1034 + 'type': 'disabled',
1035 + 'effort': 'none',
1036 + };
1037 + body['reasoning_effort'] = 'none';
1038 + body['think'] = false;
1039 + body['enable_thinking'] = false;
1040 + } else if (params.reasoningEnabled == true) {
1041 + final effort = params.reasoningEffort.apiValue;
1042 + body['reasoning'] = {'effort': effort};
1043 + body['reasoning_effort'] = effort;
1044 + }
1045 +}
1046 +
1047 +String _formatApiErrorContent(dynamic error) {
1048 + if (error is Map<String, dynamic>) {
1049 + final parsed = ChatApiError.fromErrorMap(error);
1050 + if (parsed != null) return parsed.encode();
1051 + } else if (error is Map) {
1052 + final parsed = ChatApiError.fromErrorMap(
1053 + Map<String, dynamic>.from(error),
1054 + );
1055 + if (parsed != null) return parsed.encode();
1056 + }
1057 + return error.toString();
1058 +}
1059 +
1060 String _handleChatError(dynamic e) {
1061 if (e is DioException) {
1062 if (e.response != null) {
1063 + final parsed = ChatApiError.fromResponseBody(e.response!.data);
1064 + if (parsed != null) return parsed.encode();
1065 +
1066 final status = e.response!.statusCode;
1067
1068 switch (status) {
@@ -1106,6 +1076,9 @@ String _handleChatError(dynamic e) {
1076 return 'Model not found or API endpoint is incorrect.';
1077 case 408:
1078 return 'Request timeout. The server took too long to respond.';
1079 + case 413:
1080 + return 'Image too large for the server to accept. Try enabling '
1081 + 'image compression in Settings.';
1082 case 429:
1083 return 'Rate limit exceeded. Please wait a moment before trying again.';
1084 case 500:
lib/features/chat/data/models/chat_parameters.dart
+11 -4
@@ -1,4 +1,5 @@
1 import '../../../../core/constants/app_constants.dart';
2 +import '../../providers/chat_reasoning_providers.dart';
3
4 class ChatParameters {
5 final double temperature;
@@ -9,7 +10,10 @@ class ChatParameters {
10 final int? topK;
11 final double? minP;
12 final double? repeatPenalty;
12 - final String? reasoningLevel;
13 + /// Null when the active model doesn't support reasoning (no reasoning
14 + /// control fields should be sent); otherwise reflects the Think toggle.
15 + final bool? reasoningEnabled;
16 + final ReasoningEffort reasoningEffort;
17
18 const ChatParameters({
19 required this.temperature,
@@ -20,7 +24,8 @@ class ChatParameters {
24 this.topK,
25 this.minP,
26 this.repeatPenalty,
23 - this.reasoningLevel,
27 + this.reasoningEnabled,
28 + this.reasoningEffort = ReasoningEffort.low,
29 });
30
31 factory ChatParameters.defaults() => const ChatParameters(
@@ -39,7 +44,8 @@ class ChatParameters {
44 int? topK,
45 double? minP,
46 double? repeatPenalty,
42 - String? reasoningLevel,
47 + bool? reasoningEnabled,
48 + ReasoningEffort? reasoningEffort,
49 }) {
50 return ChatParameters(
51 temperature: temperature ?? this.temperature,
@@ -50,7 +56,8 @@ class ChatParameters {
56 topK: topK ?? this.topK,
57 minP: minP ?? this.minP,
58 repeatPenalty: repeatPenalty ?? this.repeatPenalty,
53 - reasoningLevel: reasoningLevel ?? this.reasoningLevel,
59 + reasoningEnabled: reasoningEnabled ?? this.reasoningEnabled,
60 + reasoningEffort: reasoningEffort ?? this.reasoningEffort,
61 );
62 }
63 }
lib/features/chat/data/models/message.dart
+4
@@ -30,6 +30,7 @@ class Message {
30 final int threadOrder;
31 final bool isActiveVariant;
32 final String? parentMessageId;
33 + final int? contentTokenCount;
34
35 Message({
36 required this.id,
@@ -58,6 +59,7 @@ class Message {
59 this.threadOrder = 0,
60 this.isActiveVariant = true,
61 this.parentMessageId,
62 + this.contentTokenCount,
63 });
64
65 Message copyWith({
@@ -87,6 +89,7 @@ class Message {
89 int? threadOrder,
90 bool? isActiveVariant,
91 String? parentMessageId,
92 + int? contentTokenCount,
93 }) {
94 return Message(
95 id: id ?? this.id,
@@ -115,6 +118,7 @@ class Message {
118 threadOrder: threadOrder ?? this.threadOrder,
119 isActiveVariant: isActiveVariant ?? this.isActiveVariant,
120 parentMessageId: parentMessageId ?? this.parentMessageId,
121 + contentTokenCount: contentTokenCount ?? this.contentTokenCount,
122 );
123 }
124 }
lib/features/chat/data/smart_reply_service.dart
+34 -7
@@ -15,6 +15,7 @@ class SmartReplyService {
15 required String modelId,
16 required List<Message> messages,
17 required ChatParameters params,
18 + String? personaSystemPrompt,
19 }) async {
20 if (messages.isEmpty) return [];
21
@@ -36,11 +37,16 @@ class SmartReplyService {
37 promptMessage,
38 ];
39
40 + const baseInstruction =
41 + 'You are a smart reply assistant. Your job is to output exactly a JSON array containing 3 suggested short replies for the user. Do not output anything other than the JSON array.';
42 +
43 // Use low temperature and small tokens for fast, cheap, and deterministic suggestions
44 final suggestionParams = params.copyWith(
45 temperature: 0.2,
46 maxTokens: 128,
43 - systemPrompt: 'You are a smart reply assistant. Your job is to output exactly a JSON array containing 3 suggested short replies for the user. Do not output anything other than the JSON array.',
47 + systemPrompt: personaSystemPrompt != null && personaSystemPrompt.trim().isNotEmpty
48 + ? '$personaSystemPrompt\n\n$baseInstruction'
49 + : baseInstruction,
50 );
51
52 try {
@@ -111,6 +117,22 @@ class SmartReplyService {
117 }) async {
118 if (messages.isEmpty) return null;
119
120 + return _generateUserMessageViaChat(
121 + chatService: chatService,
122 + server: server,
123 + modelId: modelId,
124 + messages: messages,
125 + params: params,
126 + );
127 + }
128 +
129 + Future<String?> _generateUserMessageViaChat({
130 + required ChatService chatService,
131 + required Server server,
132 + required String modelId,
133 + required List<Message> messages,
134 + required ChatParameters params,
135 + }) async {
136 final promptMessage = Message(
137 id: 'ai-user-response-prompt',
138 conversationId: messages.last.conversationId,
@@ -176,18 +198,23 @@ class SmartReplyService {
198 },
199 );
200
179 - final cleaned = rawResponse.trim();
180 - if (cleaned.isEmpty) return null;
181 - if (cleaned.startsWith('"') && cleaned.endsWith('"')) {
182 - return cleaned.substring(1, cleaned.length - 1).trim();
183 - }
184 - return cleaned;
201 + return _cleanGeneratedUserMessage(rawResponse);
202 } catch (e) {
203 Log.warning('AI user message generation failed: $e');
204 return null;
205 }
206 }
207
208 + String? _cleanGeneratedUserMessage(String? rawResponse) {
209 + if (rawResponse == null) return null;
210 + final cleaned = rawResponse.trim();
211 + if (cleaned.isEmpty) return null;
212 + if (cleaned.startsWith('"') && cleaned.endsWith('"')) {
213 + return cleaned.substring(1, cleaned.length - 1).trim();
214 + }
215 + return cleaned;
216 + }
217 +
218 List<String> _parseSuggestions(String text) {
219 final cleaned = text.trim();
220
lib/features/chat/providers/chat_notifier.dart
+418 -119
@@ -18,6 +18,7 @@ import 'package:localmind/features/conversations/providers/conversation_provider
18 import 'package:localmind/features/models/data/models/model_info.dart';
19 import 'package:localmind/features/on_device/providers/on_device_providers.dart';
20 import 'package:localmind/features/personas/providers/personas_providers.dart';
21 +import 'package:localmind/features/personas/utils/persona_prompt_utils.dart';
22 import 'package:localmind/features/servers/data/models/server.dart';
23 import 'package:localmind/features/servers/providers/server_providers.dart';
24 import 'package:localmind/objectbox.g.dart';
@@ -27,9 +28,13 @@ import '../data/title_generation_service.dart';
28 import '../data/tools/tool_definition.dart';
29 import '../data/tools/tool_execution_loop.dart';
30 import '../data/tools/adapters/tool_transport_adapter.dart' show ParsedToolCall;
31 +import '../utils/attachment_helpers.dart';
32 import 'chat_mcp_providers.dart';
33 +import 'chat_origin_provider.dart';
34 import 'chat_params_providers.dart';
35 +import 'chat_reasoning_providers.dart';
36 import 'chat_service_providers.dart';
37 +import 'message_selection_provider.dart';
38 import 'model_selection_providers.dart';
39 import 'tooling_providers.dart';
40 import '../utils/message_variants.dart';
@@ -113,9 +118,13 @@ class ChatNotifier extends Notifier<ChatState> {
118 MessageSaveService? _saveService;
119 bool _pendingTemporaryChat = false;
120 String? _ephemeralConversationId;
121 + bool _attemptedResume = false;
122 + static const _lastActiveConversationKey = 'lastActiveConversationId';
123 ChatStats? _streamStats;
124 DateTime? _streamStartTime;
125 DateTime? _firstTokenTime;
126 + bool _useFreshConversationSystemPrompt = false;
127 + String? _freshConversationSystemPrompt;
128
129 String? get _activeConversationId =>
130 _currentConversationId ?? _ephemeralConversationId;
@@ -166,6 +175,87 @@ class ChatNotifier extends Notifier<ChatState> {
175 );
176 }
177
178 + /// Updates the conversation's total token count from the most recent
179 + /// assistant message's own end-of-stream stats (`inputTokenCount` +
180 + /// `tokenCount`, populated from the server's real usage/stats payload) —
181 + /// this is exactly the size of the context window the model saw on its
182 + /// last turn, so it doubles as an accurate "context used" figure without
183 + /// ever issuing an extra request just to count tokens.
184 + void _recomputeConversationTotal(
185 + String conversationId, {
186 + List<Message>? messages,
187 + }) {
188 + final timeline = messages ?? state.messages;
189 + final lastWithStats = timeline.reversed
190 + .where(
191 + (m) =>
192 + m.role == MessageRole.assistant &&
193 + (m.inputTokenCount != null || m.tokenCount != null),
194 + )
195 + .firstOrNull;
196 + final total = lastWithStats == null
197 + ? 0
198 + : (lastWithStats.inputTokenCount ?? 0) + (lastWithStats.tokenCount ?? 0);
199 + unawaited(
200 + ref
201 + .read(conv.conversationsProvider.notifier)
202 + .updateTokenCount(conversationId, total),
203 + );
204 + }
205 +
206 + /// Syncs the history-list preview/message-count/char-count/token-total
207 + /// for [conversationId] after a generation finishes. Generation runs in
208 + /// the background even after the user switches to a different
209 + /// conversation, so this must never assume `state.messages` belongs to
210 + /// [conversationId] — when it isn't the conversation currently on screen,
211 + /// the active timeline is re-read from the database instead. Without
212 + /// this, a background reply could overwrite the *foreground* conversation's
213 + /// history preview with its own content.
214 + Future<void> _syncConversationStatsAfterGeneration(
215 + String conversationId,
216 + Message finalMessage, {
217 + required bool isCurrentContext,
218 + }) async {
219 + final timeline = isCurrentContext
220 + ? state.messages
221 + : MessageVariants.resolveActiveTimeline(
222 + await ref
223 + .read(databaseProvider)
224 + .store
225 + .runInTransactionAsync(
226 + TxMode.read,
227 + _loadMessagesInBackground,
228 + conversationId,
229 + ),
230 + );
231 +
232 + final preview = finalMessage.content.length > 100
233 + ? '${finalMessage.content.substring(0, 100)}...'
234 + : finalMessage.content;
235 + final totalChars = timeline.fold<int>(
236 + 0,
237 + (sum, message) => sum + message.content.length,
238 + );
239 +
240 + await ref
241 + .read(conv.conversationsProvider.notifier)
242 + .syncConversationStats(
243 + conversationId,
244 + messageCount: timeline.length,
245 + characterCount: totalChars,
246 + preview: preview,
247 + );
248 + _recomputeConversationTotal(conversationId, messages: timeline);
249 +
250 + if (isCurrentContext) {
251 + final lastUserMessage =
252 + timeline.where((m) => m.role == MessageRole.user).firstOrNull;
253 + if (lastUserMessage != null) {
254 + _maybeAutoGenerateTitleAfterFirstReply();
255 + }
256 + }
257 + }
258 +
259 void approveTool(bool approved) {
260 final pending = state.pendingToolApproval;
261 if (pending != null && !pending.completer.isCompleted) {
@@ -199,9 +289,46 @@ class ChatNotifier extends Notifier<ChatState> {
289 pending.completer.complete(false);
290 }
291 });
292 + if (!_attemptedResume) {
293 + _attemptedResume = true;
294 + Future.microtask(_tryResumeLastChat);
295 + }
296 return const ChatState();
297 }
298
299 + Future<void> _tryResumeLastChat() async {
300 + final settings = ref.read(settingsProvider);
301 + if (!settings.resumeLastChat) return;
302 + if (state.messages.isNotEmpty || _currentConversationId != null) return;
303 +
304 + final prefs = ref.read(sharedPreferencesProvider);
305 + final lastId = prefs.getString(_lastActiveConversationKey);
306 + if (lastId == null || lastId.isEmpty) return;
307 +
308 + try {
309 + final conversations = await ref.read(conv.conversationsProvider.future);
310 + Conversation? target;
311 + for (final conversation in conversations) {
312 + if (conversation.id == lastId && !conversation.isTemporary) {
313 + target = conversation;
314 + break;
315 + }
316 + }
317 + if (target != null) {
318 + await loadConversation(target);
319 + }
320 + } catch (e, st) {
321 + Log.error('Failed to resume last chat: $e\n$st');
322 + }
323 + }
324 +
325 + void _persistLastActiveConversation(String conversationId) {
326 + if (!ref.read(settingsProvider).resumeLastChat) return;
327 + ref
328 + .read(sharedPreferencesProvider)
329 + .setString(_lastActiveConversationKey, conversationId);
330 + }
331 +
332 Future<void> loadConversation(Conversation conversation) async {
333 await cancelStream();
334 _currentConversationId = conversation.id;
@@ -231,6 +358,10 @@ class ChatNotifier extends Notifier<ChatState> {
358 .read(conv.activeConversationProvider.notifier)
359 .setActiveConversation(conversation);
360
361 + if (!conversation.isTemporary) {
362 + _persistLastActiveConversation(conversation.id);
363 + }
364 +
365 ref
366 .read(chatMcpConfigProvider.notifier)
367 .setEnabled(conversation.mcpEnabled ?? true);
@@ -347,6 +478,8 @@ class ChatNotifier extends Notifier<ChatState> {
478 ref
479 .read(conv.activeConversationProvider.notifier)
480 .setActiveConversation(null);
481 + ref.read(chatOriginProvider.notifier).clear();
482 + ref.read(messageSelectionModeProvider.notifier).disable();
483
484 final settings = ref.read(settingsProvider);
485 ref
@@ -481,12 +614,77 @@ class ChatNotifier extends Notifier<ChatState> {
614 _replaceMessageInAll(message, clearStreaming: clearStreaming);
615 }
616
617 + /// Creates a new conversation (or ephemeral in-memory chat) if one isn't
618 + /// already active, mirroring the same persona/title/MCP setup [sendMessage]
619 + /// has always done on the first message of a chat. Shared with
620 + /// [insertMessageWithoutGenerating] so manually-inserted messages can also
621 + /// start a brand new conversation.
622 + Future<void> _ensureConversationExists(String titleSource) async {
623 + if (_currentConversationId != null || _ephemeralConversationId != null) {
624 + return;
625 + }
626 +
627 + final server = ref.read(activeServerProvider);
628 + final selectedModel = ref.read(selectedModelProvider);
629 + final settings = ref.read(settingsProvider);
630 + if (server == null) return;
631 +
632 + final isTemp = state.isTemporary || _pendingTemporaryChat;
633 + if (isTemp) {
634 + _ephemeralConversationId = generateUuid();
635 + _pendingTemporaryChat = false;
636 + state = state.copyWith(isTemporary: true);
637 + return;
638 + }
639 +
640 + final titleService = ref.read(titleGenerationServiceProvider);
641 + final initialTitle = settings.autoGenerateTitle
642 + ? 'New Chat'
643 + : titleService.truncateFirstMessageTitle(titleSource);
644 + final preselected = ref.read(selectedPersonasProvider);
645 + final newConversationSystemPrompt = preselected.isEmpty
646 + ? null
647 + : PersonaPromptUtils.combineSystemPrompts(preselected);
648 + final conversation = await ref
649 + .read(conv.conversationsProvider.notifier)
650 + .createConversation(
651 + title: initialTitle,
652 + serverId: server.id,
653 + modelId: selectedModel?.id,
654 + personaId: preselected.isEmpty
655 + ? null
656 + : PersonaPromptUtils.joinPersonaIds(
657 + preselected.map((p) => p.id).toList(),
658 + ),
659 + systemPrompt: newConversationSystemPrompt,
660 + mcpEnabled: settings.newChatMcpEnabled,
661 + isTemporary: false,
662 + folderId: ref.read(pendingNewChatFolderIdProvider.notifier).consume(),
663 + );
664 + _currentConversationId = conversation.id;
665 + ref
666 + .read(conv.activeConversationProvider.notifier)
667 + .setActiveConversation(conversation);
668 + _persistLastActiveConversation(conversation.id);
669 + // A conversationsProvider refresh triggered later in this same send
670 + // (e.g. syncConversationStats) rebuilds activeConversationProvider
671 + // from the reloaded list; use this captured value for this send so
672 + // the persona system prompt is never missed on the very first
673 + // message of a brand new conversation.
674 + _useFreshConversationSystemPrompt = true;
675 + _freshConversationSystemPrompt = newConversationSystemPrompt;
676 +
677 + ref.read(chatMcpConfigProvider.notifier).setEnabled(settings.newChatMcpEnabled);
678 +
679 + if (!settings.keepPersonaOnNewChat) {
680 + ref.read(selectedPersonasProvider.notifier).clear();
681 + }
682 + }
683 +
684 Future<void> sendMessage(String content, {List<File>? attachments}) async {
685 final server = ref.read(activeServerProvider);
686 final selectedModel = ref.read(selectedModelProvider);
487 - final chatParams = ref.read(chatParamsProvider);
687 final chatService = ref.read(chatServiceProvider);
489 - final settings = ref.read(settingsProvider);
688
689 if (server == null) {
690 state = state.copyWith(errorMessage: 'No server connected');
@@ -500,48 +698,19 @@ class ChatNotifier extends Notifier<ChatState> {
698
699 final trimmedContent = content.trim();
700
503 - if (_currentConversationId == null && _ephemeralConversationId == null) {
504 - final isTemp = state.isTemporary || _pendingTemporaryChat;
505 - if (isTemp) {
506 - _ephemeralConversationId = generateUuid();
507 - _pendingTemporaryChat = false;
508 - state = state.copyWith(isTemporary: true);
509 - } else {
510 - final titleService = ref.read(titleGenerationServiceProvider);
511 - final String initialTitle;
512 - if (settings.autoGenerateTitle) {
513 - initialTitle = 'New Chat';
514 - } else {
515 - final titleSource = trimmedContent.isNotEmpty
516 - ? trimmedContent
517 - : (attachments?.isNotEmpty == true
518 - ? attachments!.first.path.split(Platform.pathSeparator).last
519 - : 'New Chat');
520 - initialTitle = titleService.truncateFirstMessageTitle(titleSource);
521 - }
522 - final conversation = await ref
523 - .read(conv.conversationsProvider.notifier)
524 - .createConversation(
525 - title: initialTitle,
526 - serverId: server.id,
527 - modelId: selectedModel?.id,
528 - personaId: ref.read(selectedPersonaProvider)?.id,
529 - systemPrompt: ref.read(selectedPersonaProvider)?.systemPrompt,
530 - mcpEnabled: settings.newChatMcpEnabled,
531 - isTemporary: false,
532 - );
533 - _currentConversationId = conversation.id;
534 - ref
535 - .read(conv.activeConversationProvider.notifier)
536 - .setActiveConversation(conversation);
537 -
538 - ref
539 - .read(chatMcpConfigProvider.notifier)
540 - .setEnabled(settings.newChatMcpEnabled);
541 -
542 - ref.read(selectedPersonaProvider.notifier).clear();
543 - }
544 - }
701 + final titleSource = trimmedContent.isNotEmpty
702 + ? trimmedContent
703 + : (attachments?.isNotEmpty == true
704 + ? attachments!.first.path.split(Platform.pathSeparator).last
705 + : 'New Chat');
706 + await _ensureConversationExists(titleSource);
707 +
708 + // Read after the conversation (and its persona system prompt) is
709 + // created above — chatParamsProvider derives systemPrompt from
710 + // activeConversationProvider, which doesn't have it yet if read at the
711 + // top of this function, causing the very first message's request to
712 + // go out with no persona system prompt.
713 + final chatParams = ref.read(chatParamsProvider);
714
715 final convId = _activeConversationId;
716 if (convId == null) return;
@@ -555,11 +724,9 @@ class ChatNotifier extends Notifier<ChatState> {
724 }
725
726 for (final file in attachments) {
558 - final fileName = file.path.split('/').last;
559 - final timestamp = DateTime.now().millisecondsSinceEpoch;
560 - final newPath = '${attachmentsDir.path}/${timestamp}_$fileName';
561 - await file.copy(newPath);
562 - savedPaths.add(newPath);
727 + final savedPath =
728 + await AttachmentHelpers.saveAttachment(file, attachmentsDir);
729 + if (savedPath != null) savedPaths.add(savedPath);
730 }
731 }
732
@@ -613,6 +780,24 @@ class ChatNotifier extends Notifier<ChatState> {
780 await _saveMessage(userMessage);
781 await _saveMessage(assistantMessage);
782
783 + if (!_isInMemoryChat && _currentConversationId != null) {
784 + final preview = trimmedContent.isNotEmpty
785 + ? (trimmedContent.length > 100
786 + ? '${trimmedContent.substring(0, 100)}...'
787 + : trimmedContent)
788 + : (attachments?.isNotEmpty == true ? '[attachment]' : 'New message');
789 + final timeline = [...state.messages, userMessage];
790 + await ref.read(conv.conversationsProvider.notifier).syncConversationStats(
791 + _currentConversationId!,
792 + messageCount: timeline.length,
793 + characterCount: timeline.fold<int>(
794 + 0,
795 + (sum, message) => sum + message.content.length,
796 + ),
797 + preview: preview,
798 + );
799 + }
800 +
801 ref.read(chatBackgroundServiceProvider).start();
802
803 _resetStreamMetrics();
@@ -888,30 +1073,12 @@ class ChatNotifier extends Notifier<ChatState> {
1073 }
1074 ref.read(isStreamingProvider.notifier).setStreaming(false);
1075 _latestStreamingMessage = null;
891 - if (_currentConversationId != null) {
892 - final preview = finalMessage.content.length > 100
893 - ? '${finalMessage.content.substring(0, 100)}...'
894 - : finalMessage.content;
895 - final totalChars = state.messages.fold<int>(
896 - 0,
897 - (sum, message) => sum + message.content.length,
898 - );
899 - await ref
900 - .read(conv.conversationsProvider.notifier)
901 - .updatePreview(
902 - _currentConversationId!,
903 - preview,
904 - DateTime.now(),
905 - characterCount: totalChars,
906 - );
1076
908 - final userMessage = state.messages
909 - .where((m) => m.role == MessageRole.user)
910 - .firstOrNull;
911 - if (userMessage != null) {
912 - _maybeAutoGenerateTitleAfterFirstReply();
913 - }
914 - }
1077 + await _syncConversationStatsAfterGeneration(
1078 + streamConvId,
1079 + finalMessage,
1080 + isCurrentContext: isCurrentContext,
1081 + );
1082
1083 _maybeRequestReviewAfterSuccessfulCompletion(
1084 finalMessage: finalMessage,
@@ -979,30 +1146,129 @@ class ChatNotifier extends Notifier<ChatState> {
1146 }
1147 }
1148
1149 + /// Inserts [content] as a message with the given [role] without calling
1150 + /// the chat service or starting generation. Used by the send button's
1151 + /// long-press ("insert without generating") and the role-swap button
1152 + /// (which lets the user manually author an assistant-role turn).
1153 + Future<void> insertMessageWithoutGenerating(
1154 + String content, {
1155 + required MessageRole role,
1156 + List<File>? attachments,
1157 + }) async {
1158 + final server = ref.read(activeServerProvider);
1159 + if (server == null) {
1160 + state = state.copyWith(errorMessage: 'No server connected');
1161 + return;
1162 + }
1163 +
1164 + final trimmedContent = content.trim();
1165 + if (trimmedContent.isEmpty && (attachments == null || attachments.isEmpty)) {
1166 + return;
1167 + }
1168 +
1169 + final titleSource = trimmedContent.isNotEmpty
1170 + ? trimmedContent
1171 + : (attachments?.isNotEmpty == true
1172 + ? attachments!.first.path.split(Platform.pathSeparator).last
1173 + : 'New Chat');
1174 + await _ensureConversationExists(titleSource);
1175 +
1176 + final convId = _activeConversationId;
1177 + if (convId == null) return;
1178 +
1179 + final List<String> savedPaths = [];
1180 + if (attachments != null && attachments.isNotEmpty) {
1181 + final appDir = await ref.read(storageDirectoryProvider.future);
1182 + final attachmentsDir = Directory('${appDir.path}/attachments');
1183 + if (!await attachmentsDir.exists()) {
1184 + await attachmentsDir.create(recursive: true);
1185 + }
1186 +
1187 + for (final file in attachments) {
1188 + final savedPath =
1189 + await AttachmentHelpers.saveAttachment(file, attachmentsDir);
1190 + if (savedPath != null) savedPaths.add(savedPath);
1191 + }
1192 + }
1193 +
1194 + final threadOrder = MessageVariants.nextThreadOrder(state.messages);
1195 + final groupId = generateUuid();
1196 + final lastInTimeline = state.messages.isNotEmpty ? state.messages.last : null;
1197 +
1198 + final message = Message(
1199 + id: generateUuid(),
1200 + conversationId: convId,
1201 + role: role,
1202 + content: trimmedContent,
1203 + createdAt: DateTime.now(),
1204 + status: MessageStatus.complete,
1205 + attachmentPaths: savedPaths.isNotEmpty ? savedPaths : null,
1206 + variantGroupId: groupId,
1207 + variantIndex: 0,
1208 + threadOrder: threadOrder,
1209 + isActiveVariant: true,
1210 + parentMessageId: lastInTimeline?.id,
1211 + );
1212 +
1213 + final updatedAll = [...state.allMessages, message];
1214 + state = state.copyWith(
1215 + allMessages: updatedAll,
1216 + messages: MessageVariants.resolveActiveTimeline(updatedAll),
1217 + clearError: true,
1218 + );
1219 +
1220 + await _saveMessage(message);
1221 +
1222 + if (!_isInMemoryChat && _currentConversationId != null) {
1223 + final preview = trimmedContent.isNotEmpty
1224 + ? (trimmedContent.length > 100
1225 + ? '${trimmedContent.substring(0, 100)}...'
1226 + : trimmedContent)
1227 + : (attachments?.isNotEmpty == true ? '[attachment]' : 'New message');
1228 + final timeline = state.messages;
1229 + await ref.read(conv.conversationsProvider.notifier).syncConversationStats(
1230 + _currentConversationId!,
1231 + messageCount: timeline.length,
1232 + characterCount: timeline.fold<int>(
1233 + 0,
1234 + (sum, message) => sum + message.content.length,
1235 + ),
1236 + preview: preview,
1237 + );
1238 + }
1239 + }
1240 +
1241 List<Message> _buildMessagesForApi(ModelInfo? selectedModel) {
1242 final settings = ref.read(settingsProvider);
1243 final messages = <Message>[];
1244
1245 + final reasoningConfig = ref.read(chatReasoningConfigProvider);
1246 + final shouldDisableThinking =
1247 + (selectedModel?.supportsReasoning ?? false) && !reasoningConfig.enabled;
1248 +
1249 final personaPrompt = _getPersonaSystemPrompt();
987 - if (personaPrompt != null) {
1250 + var systemContent = personaPrompt ??
1251 + (settings.showSystemMessages
1252 + ? 'You are LocalMind, a helpful AI assistant. Provide clear, accurate, and concise responses.'
1253 + : null);
1254 +
1255 + if (shouldDisableThinking) {
1256 + // Hybrid reasoning models (Qwen3 and similar) key off this literal
1257 + // token in the prompt to skip their <think> block — send it whenever
1258 + // the model supports reasoning and the user has switched Think off,
1259 + // alongside the request-level reasoning-disable fields.
1260 + systemContent = (systemContent == null || systemContent.trim().isEmpty)
1261 + ? '/no_think'
1262 + : '$systemContent\n/no_think';
1263 + }
1264 +
1265 + if (systemContent != null) {
1266 messages.add(
1267 Message(
1268 id: 'system-$_currentConversationId',
1269 conversationId: _currentConversationId ?? '',
1270 role: MessageRole.system,
993 - content: personaPrompt,
994 - createdAt: DateTime.now(),
995 - status: MessageStatus.complete,
996 - ),
997 - );
998 - } else if (settings.showSystemMessages) {
999 - messages.add(
1000 - Message(
1001 - id: 'system-default-$_currentConversationId',
1002 - conversationId: _currentConversationId ?? '',
1003 - role: MessageRole.system,
1004 - content:
1005 - 'You are LocalMind, a helpful AI assistant. Provide clear, accurate, and concise responses.',
1271 + content: systemContent,
1272 createdAt: DateTime.now(),
1273 status: MessageStatus.complete,
1274 ),
@@ -1035,25 +1301,28 @@ class ChatNotifier extends Notifier<ChatState> {
1301 }
1302
1303 String? _getPersonaSystemPrompt() {
1038 - final conversation = ref.read(conv.activeConversationProvider);
1039 - final personaId = conversation?.personaId;
1040 - if (personaId == null) return null;
1304 + if (_useFreshConversationSystemPrompt) {
1305 + _useFreshConversationSystemPrompt = false;
1306 + final override = _freshConversationSystemPrompt;
1307 + _freshConversationSystemPrompt = null;
1308 + if (override != null && override.trim().isNotEmpty) return override;
1309 + }
1310
1311 + final conversation = ref.read(conv.activeConversationProvider);
1312 if (conversation?.systemPrompt != null &&
1043 - conversation!.systemPrompt!.isNotEmpty) {
1313 + conversation!.systemPrompt!.trim().isNotEmpty) {
1314 return conversation.systemPrompt;
1315 }
1316
1317 + final personaIds = conversation?.personaId;
1318 + if (personaIds == null || personaIds.isEmpty) return null;
1319 +
1320 try {
1048 - final personasAsync = ref.read(personasNotifierProvider);
1049 - final personas = personasAsync.value ?? [];
1050 - final persona = personas.firstWhere(
1051 - (p) => p.id == personaId,
1052 - orElse: () => throw Exception('Persona not found'),
1053 - );
1054 - if (persona.systemPrompt.isNotEmpty) {
1055 - return persona.systemPrompt;
1056 - }
1321 + final personas = ref.read(personasNotifierProvider).value ?? [];
1322 + final selected =
1323 + PersonaPromptUtils.resolvePersonas(personaIds, personas);
1324 + if (selected.isEmpty) return null;
1325 + return PersonaPromptUtils.combineSystemPrompts(selected);
1326 } catch (_) {}
1327
1328 return null;
@@ -1440,11 +1709,11 @@ class ChatNotifier extends Notifier<ChatState> {
1709 }) async {
1710 final selectedModel = ref.read(selectedModelProvider);
1711 final server = ref.read(activeServerProvider);
1443 - if (server == null || _currentConversationId == null) return;
1712 + if (server == null || _activeConversationId == null) return;
1713
1714 final assistantMessage = Message(
1715 id: generateUuid(),
1447 - conversationId: _currentConversationId!,
1716 + conversationId: _activeConversationId!,
1717 role: MessageRole.assistant,
1718 content: '',
1719 createdAt: DateTime.now(),
@@ -1492,6 +1761,7 @@ class ChatNotifier extends Notifier<ChatState> {
1761 try {
1762 await _detachStreamSubscription();
1763 _uiUpdateTimer?.cancel();
1764 + _resetStreamMetrics();
1765
1766 String reasoningContent = '';
1767 var streamingAssistantMessage = assistantMessage;
@@ -1537,9 +1807,8 @@ class ChatNotifier extends Notifier<ChatState> {
1807 .listen(
1808 (response) async {
1809 if (streamGeneration != _streamGeneration) return;
1540 - if (_activeConversationId != assistantMessage.conversationId) {
1541 - return;
1542 - }
1810 + final streamConvId = assistantMessage.conversationId;
1811 + final isCurrentContext = _activeConversationId == streamConvId;
1812
1813 switch (response.type) {
1814 case ChatResponseType.message:
@@ -1612,11 +1881,16 @@ class ChatNotifier extends Notifier<ChatState> {
1881 stopReason: 'error',
1882 );
1883 await _saveMessage(streamingAssistantMessage);
1615 - _replaceMessageInAll(
1616 - streamingAssistantMessage,
1617 - clearStreaming: true,
1618 - );
1619 - state = state.copyWith(isStreaming: false, clearStreaming: true);
1884 + if (isCurrentContext) {
1885 + _replaceMessageInAll(
1886 + streamingAssistantMessage,
1887 + clearStreaming: true,
1888 + );
1889 + state = state.copyWith(
1890 + isStreaming: false,
1891 + clearStreaming: true,
1892 + );
1893 + }
1894 ref.read(isStreamingProvider.notifier).setStreaming(false);
1895 ref.read(chatBackgroundServiceProvider).stop();
1896 break;
@@ -1632,6 +1906,9 @@ class ChatNotifier extends Notifier<ChatState> {
1906 onDone: () async {
1907 _uiUpdateTimer?.cancel();
1908 _uiUpdateTimer = null;
1909 + final streamConvId = assistantMessage.conversationId;
1910 + final isCurrentContext = _activeConversationId == streamConvId;
1911 +
1912 final finalMessage = _finalizeStreamMessage(
1913 streamingAssistantMessage.copyWith(
1914 status: MessageStatus.complete,
@@ -1640,10 +1917,17 @@ class ChatNotifier extends Notifier<ChatState> {
1917 stopReason: 'complete',
1918 );
1919 await _saveMessage(finalMessage);
1643 - _replaceMessageInAll(finalMessage, clearStreaming: true);
1644 - state = state.copyWith(isStreaming: false, clearStreaming: true);
1920 + if (isCurrentContext) {
1921 + _replaceMessageInAll(finalMessage, clearStreaming: true);
1922 + state = state.copyWith(isStreaming: false, clearStreaming: true);
1923 + }
1924 ref.read(isStreamingProvider.notifier).setStreaming(false);
1925 ref.read(chatBackgroundServiceProvider).stop();
1926 + await _syncConversationStatsAfterGeneration(
1927 + streamConvId,
1928 + finalMessage,
1929 + isCurrentContext: isCurrentContext,
1930 + );
1931 _maybeRequestReviewAfterSuccessfulCompletion(
1932 finalMessage: finalMessage,
1933 server: server,
@@ -1654,18 +1938,22 @@ class ChatNotifier extends Notifier<ChatState> {
1938 Log.error('Stream error: $error');
1939 _uiUpdateTimer?.cancel();
1940 _uiUpdateTimer = null;
1941 + final isCurrentContext =
1942 + _activeConversationId == assistantMessage.conversationId;
1943 final errorMessage = streamingAssistantMessage.copyWith(
1944 status: MessageStatus.error,
1945 errorMessage: error.toString(),
1946 isProcessing: false,
1947 );
1948 await _saveMessage(errorMessage);
1663 - _replaceMessageInAll(errorMessage, clearStreaming: true);
1664 - state = state.copyWith(
1665 - isStreaming: false,
1666 - clearStreaming: true,
1667 - errorMessage: error.toString(),
1668 - );
1949 + if (isCurrentContext) {
1950 + _replaceMessageInAll(errorMessage, clearStreaming: true);
1951 + state = state.copyWith(
1952 + isStreaming: false,
1953 + clearStreaming: true,
1954 + errorMessage: error.toString(),
1955 + );
1956 + }
1957 ref.read(isStreamingProvider.notifier).setStreaming(false);
1958 ref.read(chatBackgroundServiceProvider).stop();
1959 },
@@ -1794,6 +2082,10 @@ class ChatNotifier extends Notifier<ChatState> {
2082 allMessages: updatedAll,
2083 messages: MessageVariants.resolveActiveTimeline(updatedAll),
2084 );
2085 +
2086 + if (_currentConversationId != null) {
2087 + _recomputeConversationTotal(_currentConversationId!);
2088 + }
2089 }
2090
2091 Future<void> editMessageSaveOnly(String messageId, String newContent) async {
@@ -1865,14 +2157,21 @@ class ChatNotifier extends Notifier<ChatState> {
2157 firstUser.isNotEmpty ? firstUser : 'New Chat',
2158 );
2159
2160 + final preselected = ref.read(selectedPersonasProvider);
2161 final conversation = await ref
2162 .read(conv.conversationsProvider.notifier)
2163 .createConversation(
2164 title: title,
2165 serverId: server.id,
2166 modelId: ref.read(selectedModelProvider)?.id,
1874 - personaId: ref.read(selectedPersonaProvider)?.id,
1875 - systemPrompt: ref.read(selectedPersonaProvider)?.systemPrompt,
2167 + personaId: preselected.isEmpty
2168 + ? null
2169 + : PersonaPromptUtils.joinPersonaIds(
2170 + preselected.map((p) => p.id).toList(),
2171 + ),
2172 + systemPrompt: preselected.isEmpty
2173 + ? null
2174 + : PersonaPromptUtils.combineSystemPrompts(preselected),
2175 mcpEnabled: settings.newChatMcpEnabled,
2176 isTemporary: false,
2177 );
lib/features/chat/providers/chat_origin_provider.dart new
+38
@@ -0,0 +1,38 @@
1 +import 'package:flutter_riverpod/flutter_riverpod.dart';
2 +
3 +/// Where the currently active chat was opened from, so the root back
4 +/// handler can return there instead of always starting a new chat.
5 +enum ChatOrigin { none, history, savedMessages }
6 +
7 +final chatOriginProvider =
8 + NotifierProvider<ChatOriginNotifier, ChatOrigin>(ChatOriginNotifier.new);
9 +
10 +class ChatOriginNotifier extends Notifier<ChatOrigin> {
11 + @override
12 + ChatOrigin build() => ChatOrigin.none;
13 +
14 + void set(ChatOrigin origin) => state = origin;
15 +
16 + void clear() => state = ChatOrigin.none;
17 +}
18 +
19 +/// Folder to auto-assign to the next conversation created by sendMessage,
20 +/// set by the history screen's "new chat in this folder" FAB and consumed
21 +/// once when that conversation is actually created.
22 +final pendingNewChatFolderIdProvider =
23 + NotifierProvider<PendingNewChatFolderIdNotifier, String?>(
24 + PendingNewChatFolderIdNotifier.new,
25 + );
26 +
27 +class PendingNewChatFolderIdNotifier extends Notifier<String?> {
28 + @override
29 + String? build() => null;
30 +
31 + void set(String? folderId) => state = folderId;
32 +
33 + String? consume() {
34 + final value = state;
35 + state = null;
36 + return value;
37 + }
38 +}
lib/features/chat/providers/chat_params_providers.dart
+23 -11
@@ -3,11 +3,18 @@ import 'package:localmind/core/providers/app_providers.dart';
3 import 'package:localmind/features/conversations/providers/conversation_providers.dart'
4 as conv;
5 import 'package:localmind/features/personas/providers/personas_providers.dart';
6 +import 'package:localmind/features/personas/utils/persona_prompt_utils.dart';
7 import '../data/models/chat_parameters.dart';
8 +import 'chat_reasoning_providers.dart';
9 +import 'model_selection_providers.dart';
10
11 final chatParamsProvider = Provider<ChatParameters>((ref) {
12 final settings = ref.watch(settingsProvider);
13 final activeConv = ref.watch(conv.activeConversationProvider);
14 + final selectedModel = ref.watch(selectedModelProvider);
15 + final reasoningConfig = ref.watch(chatReasoningConfigProvider);
16 + final reasoningEnabled =
17 + (selectedModel?.supportsReasoning ?? false) ? reasoningConfig.enabled : null;
18
19 double temperature = settings.temperature;
20 double topP = settings.topP;
@@ -28,16 +35,19 @@ final chatParamsProvider = Provider<ChatParameters>((ref) {
35 }
36
37 String? systemPrompt;
31 - if (activeConv?.personaId != null) {
32 - final personaId = activeConv!.personaId;
33 - try {
34 - final personasAsync = ref.read(personasNotifierProvider);
35 - final personas = personasAsync.value ?? [];
36 - final persona = personas.firstWhere(
37 - (p) => p.id == personaId,
38 - orElse: () => throw Exception('Persona not found'),
39 - );
40 - systemPrompt = persona.systemPrompt;
38 + if (activeConv?.systemPrompt != null &&
39 + activeConv!.systemPrompt!.trim().isNotEmpty) {
40 + systemPrompt = activeConv.systemPrompt;
41 + } else if (activeConv?.personaId != null) {
42 + final personasAsync = ref.read(personasNotifierProvider);
43 + final personas = personasAsync.value ?? [];
44 + final selected = PersonaPromptUtils.resolvePersonas(
45 + activeConv!.personaId,
46 + personas,
47 + );
48 + if (selected.isNotEmpty) {
49 + systemPrompt = PersonaPromptUtils.combineSystemPrompts(selected);
50 + final persona = selected.first;
51 if (persona.preferredParams != null) {
52 final params = persona.preferredParams as Map<String, dynamic>;
53 if (params['temperature'] != null) {
@@ -48,7 +58,7 @@ final chatParamsProvider = Provider<ChatParameters>((ref) {
58 maxTokens = (params['maxTokens'] as num).toInt();
59 }
60 }
51 - } catch (_) {}
61 + }
62 }
63
64 return ChatParameters(
@@ -57,5 +67,7 @@ final chatParamsProvider = Provider<ChatParameters>((ref) {
67 maxTokens: maxTokens,
68 contextLength: contextLength,
69 systemPrompt: systemPrompt,
70 + reasoningEnabled: reasoningEnabled,
71 + reasoningEffort: reasoningConfig.effort,
72 );
73 });
lib/features/chat/providers/chat_providers.dart
+3
@@ -1,6 +1,9 @@
1 export 'chat_notifier.dart';
2 +export 'chat_origin_provider.dart';
3 export 'chat_params_providers.dart';
4 +export 'chat_reasoning_providers.dart';
5 export 'chat_service_providers.dart';
6 +export 'message_selection_provider.dart';
7 export 'model_loading_providers.dart';
8 export 'model_selection_providers.dart';
9 export 'smart_reply_providers.dart';
lib/features/chat/providers/chat_reasoning_providers.dart new
+43
@@ -0,0 +1,43 @@
1 +import 'package:flutter_riverpod/flutter_riverpod.dart';
2 +
3 +/// How hard a reasoning-capable model should think, sent as the server's
4 +/// `reasoning.effort` / `reasoning_effort` fields when thinking is enabled.
5 +enum ReasoningEffort {
6 + low,
7 + medium,
8 + high;
9 +
10 + String get apiValue => name;
11 +}
12 +
13 +class ChatReasoningConfig {
14 + const ChatReasoningConfig({
15 + this.enabled = true,
16 + this.effort = ReasoningEffort.low,
17 + });
18 +
19 + final bool enabled;
20 + final ReasoningEffort effort;
21 +
22 + ChatReasoningConfig copyWith({bool? enabled, ReasoningEffort? effort}) {
23 + return ChatReasoningConfig(
24 + enabled: enabled ?? this.enabled,
25 + effort: effort ?? this.effort,
26 + );
27 + }
28 +}
29 +
30 +class ChatReasoningConfigNotifier extends Notifier<ChatReasoningConfig> {
31 + @override
32 + ChatReasoningConfig build() => const ChatReasoningConfig();
33 +
34 + void setEnabled(bool enabled) => state = state.copyWith(enabled: enabled);
35 +
36 + void setEffort(ReasoningEffort effort) =>
37 + state = state.copyWith(effort: effort);
38 +}
39 +
40 +final chatReasoningConfigProvider =
41 + NotifierProvider<ChatReasoningConfigNotifier, ChatReasoningConfig>(() {
42 + return ChatReasoningConfigNotifier();
43 + });
lib/features/chat/providers/chat_service_providers.dart
+17 -1
@@ -1,7 +1,9 @@
1 import 'package:dio/dio.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:localmind/core/models/enums.dart';
4 +import 'package:localmind/core/providers/app_providers.dart';
5 import 'package:localmind/core/providers/service_providers.dart';
6 +import 'package:localmind/features/chat/utils/image_upload_utils.dart';
7 import 'package:localmind/features/servers/data/models/server.dart';
8 import 'package:localmind/features/servers/providers/server_providers.dart';
9 import 'package:localmind/features/on_device/providers/on_device_providers.dart';
@@ -21,12 +23,19 @@ final chatServiceProvider = Provider<ChatService?>((ref) {
23 final loadedRuntime = ref.watch(
24 onDeviceEngineProvider.select((s) => s.loadedRuntime),
25 );
26 + final settings = ref.watch(
27 + settingsProvider.select(
28 + (s) => (s.imageCompressionEnabled, s.imageCompressionLevel),
29 + ),
30 + );
31 final service = createChatServiceForServer(
32 server: server,
33 dio: ref.read(dioProvider),
34 onDeviceGemmaService: ref.read(onDeviceGemmaServiceProvider),
35 onDeviceLlamaService: ref.read(onDeviceLlamaServiceProvider),
36 loadedOnDeviceRuntime: loadedRuntime,
37 + imageCompressionEnabled: settings.$1,
38 + imageCompressionLevel: settings.$2,
39 );
40
41 ref.onDispose(() {
@@ -45,6 +54,8 @@ ChatService createChatServiceForServer({
54 required OnDeviceGemmaService onDeviceGemmaService,
55 required OnDeviceLlamaService onDeviceLlamaService,
56 OnDeviceModelRuntime? loadedOnDeviceRuntime,
57 + bool imageCompressionEnabled = true,
58 + ImageCompressionLevel imageCompressionLevel = ImageCompressionLevel.medium,
59 }) {
60 if (server.type == ServerType.onDevice) {
61 if (loadedOnDeviceRuntime == OnDeviceModelRuntime.llamaCpp) {
@@ -58,7 +69,12 @@ ChatService createChatServiceForServer({
69 );
70 }
71
61 - return ChatService.forServer(server.type, dio);
72 + return ChatService.forServer(
73 + server.type,
74 + dio,
75 + imageCompressionEnabled: imageCompressionEnabled,
76 + imageCompressionLevel: imageCompressionLevel,
77 + );
78 }
79
80 final smartReplyServiceProvider = Provider<SmartReplyService>((ref) {
lib/features/chat/providers/message_selection_provider.dart new
+36
@@ -0,0 +1,36 @@
1 +import 'package:flutter_riverpod/flutter_riverpod.dart';
2 +
3 +final messageSelectionModeProvider =
4 + NotifierProvider<MessageSelectionModeNotifier, bool>(
5 + MessageSelectionModeNotifier.new,
6 + );
7 +
8 +class MessageSelectionModeNotifier extends Notifier<bool> {
9 + @override
10 + bool build() => false;
11 +
12 + void enable() => state = true;
13 +
14 + void disable() {
15 + state = false;
16 + ref.read(selectedMessageIdsProvider.notifier).clear();
17 + }
18 +}
19 +
20 +final selectedMessageIdsProvider =
21 + NotifierProvider<SelectedMessageIdsNotifier, Set<String>>(
22 + SelectedMessageIdsNotifier.new,
23 + );
24 +
25 +class SelectedMessageIdsNotifier extends Notifier<Set<String>> {
26 + @override
27 + Set<String> build() => const {};
28 +
29 + void toggle(String id) {
30 + final updated = {...state};
31 + if (!updated.remove(id)) updated.add(id);
32 + state = updated;
33 + }
34 +
35 + void clear() => state = const {};
36 +}
lib/features/chat/providers/model_selection_providers.dart
+13
@@ -84,6 +84,19 @@ final autoSelectFirstLoadedModelProvider = FutureProvider<void>((ref) async {
84 }
85 });
86
87 +/// The actual context length the active model was loaded with, fetched
88 +/// live from the server (currently only LM Studio reports this — see
89 +/// [ServerApiService.fetchLoadedContextLength]). Re-fetches whenever the
90 +/// active server or selected model changes.
91 +final activeModelContextLengthProvider = FutureProvider<int?>((ref) async {
92 + final server = ref.watch(activeServerProvider);
93 + final model = ref.watch(selectedModelProvider);
94 + if (server == null || model == null) return null;
95 +
96 + final apiService = ref.read(serverApiServiceProvider);
97 + return apiService.fetchLoadedContextLength(server, model.id);
98 +});
99 +
100 final isStreamingProvider = NotifierProvider<IsStreamingNotifier, bool>(() {
101 return IsStreamingNotifier();
102 });
lib/features/chat/providers/smart_reply_providers.dart
+2
@@ -50,6 +50,8 @@ final smartRepliesProvider = FutureProvider<List<String>>((ref) async {
50 modelId: selectedModel.id,
51 messages: messages,
52 params: chatParams,
53 + personaSystemPrompt:
54 + settings.smartRepliesUsePersona ? chatParams.systemPrompt : null,
55 );
56 if (!ref.mounted) return [];
57 }
lib/features/chat/utils/attachment_helpers.dart
+17
@@ -58,4 +58,21 @@ class AttachmentHelpers {
58 if (content.trim().isEmpty) return block;
59 return '$content\n\n$block';
60 }
61 +
62 + /// Copies [file] into [directory] under a timestamp-prefixed unique name
63 + /// and returns the new path, or null if the copy fails (e.g. a locked
64 + /// file or a permissions error) so callers can skip that one attachment
65 + /// instead of aborting the whole send.
66 + static Future<String?> saveAttachment(File file, Directory directory) async {
67 + try {
68 + final fileName = fileNameOf(file.path);
69 + final timestamp = DateTime.now().millisecondsSinceEpoch;
70 + final newPath =
71 + '${directory.path}${Platform.pathSeparator}${timestamp}_$fileName';
72 + await file.copy(newPath);
73 + return newPath;
74 + } catch (_) {
75 + return null;
76 + }
77 + }
78 }
lib/features/chat/utils/image_upload_utils.dart new
+84
@@ -0,0 +1,84 @@
1 +import 'dart:io';
2 +import 'dart:typed_data';
3 +import 'dart:ui' as ui;
4 +
5 +enum ImageCompressionLevel { low, medium, high }
6 +
7 +class _CompressionConfig {
8 + const _CompressionConfig(this.maxPayloadBytes, this.dimensions);
9 +
10 + final int maxPayloadBytes;
11 + final List<int> dimensions;
12 +}
13 +
14 +/// Resize/compress images so LM Studio requests stay under payload limits.
15 +class ImageUploadUtils {
16 + ImageUploadUtils._();
17 +
18 + /// Kept for callers that don't care about the configured level.
19 + static const maxPayloadBytes = 750000;
20 + static const maxBase64Chars = 1000000;
21 +
22 + static const Map<ImageCompressionLevel, _CompressionConfig> _configs = {
23 + ImageCompressionLevel.low:
24 + _CompressionConfig(1500000, [1920, 1536, 1280, 1024]),
25 + ImageCompressionLevel.medium:
26 + _CompressionConfig(750000, [1536, 1280, 1024, 768, 512]),
27 + ImageCompressionLevel.high:
28 + _CompressionConfig(400000, [1024, 768, 512, 384, 256]),
29 + };
30 +
31 + static Future<Uint8List> prepareImageBytes(
32 + File file, {
33 + bool enabled = true,
34 + ImageCompressionLevel level = ImageCompressionLevel.medium,
35 + }) async {
36 + var bytes = await file.readAsBytes();
37 + if (!enabled) return bytes;
38 +
39 + final config = _configs[level]!;
40 + if (bytes.length <= config.maxPayloadBytes) return bytes;
41 +
42 + for (final dimension in config.dimensions) {
43 + final resized = await _resizeToPng(bytes, dimension);
44 + if (resized.length <= config.maxPayloadBytes) return resized;
45 + bytes = resized;
46 + }
47 + return bytes;
48 + }
49 +
50 + static Future<File> prepareImageFile(
51 + File source, {
52 + bool enabled = true,
53 + ImageCompressionLevel level = ImageCompressionLevel.medium,
54 + }) async {
55 + final bytes = await prepareImageBytes(
56 + source,
57 + enabled: enabled,
58 + level: level,
59 + );
60 + final original = source.path.split(Platform.pathSeparator).last;
61 + final dot = original.lastIndexOf('.');
62 + final base = dot > 0 ? original.substring(0, dot) : original;
63 + final outPath =
64 + '${source.parent.path}${Platform.pathSeparator}upload_${DateTime.now().millisecondsSinceEpoch}_$base.png';
65 + final out = File(outPath);
66 + await out.writeAsBytes(bytes);
67 + return out;
68 + }
69 +
70 + static Future<Uint8List> _resizeToPng(
71 + Uint8List bytes,
72 + int maxDimension,
73 + ) async {
74 + final codec = await ui.instantiateImageCodec(
75 + bytes,
76 + targetWidth: maxDimension,
77 + targetHeight: maxDimension,
78 + );
79 + final frame = await codec.getNextFrame();
80 + final data = await frame.image.toByteData(format: ui.ImageByteFormat.png);
81 + frame.image.dispose();
82 + return data?.buffer.asUint8List() ?? bytes;
83 + }
84 +}
lib/features/chat/views/chat_screen.dart
+297 -82
@@ -1,7 +1,5 @@
1 import 'dart:convert';
2 -import 'dart:typed_data';
2
4 -import 'package:file_picker/file_picker.dart';
3 import 'package:flutter/material.dart';
4 import 'package:flutter_riverpod/flutter_riverpod.dart';
5 import 'package:go_router/go_router.dart';
@@ -10,14 +8,17 @@ import 'package:shadcn_ui/shadcn_ui.dart';
8 import 'package:localmind/core/models/enums.dart';
9 import 'package:localmind/core/providers/app_providers.dart';
10 import 'package:localmind/core/routes/app_routes.dart';
11 +import 'package:localmind/core/theme/colors.dart';
12 import 'package:localmind/core/utils/system_insets.dart';
13 import 'package:localmind/l10n/app_localizations.dart';
14 import 'package:localmind/core/providers/storage_providers.dart';
15 import 'package:localmind/core/services/data_backup_service.dart';
16 +import 'package:localmind/core/services/export_choice_dialog.dart';
17 import 'package:localmind/core/services/share_service.dart';
18 import 'package:localmind/features/conversations/data/models/conversation.dart';
19 import 'package:localmind/features/conversations/providers/conversation_providers.dart'
20 as conv;
21 +import 'package:localmind/features/conversations/views/components/conversation_list.dart';
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';
@@ -43,6 +44,11 @@ import 'components/top_bar/persona_indicator.dart';
44 import 'components/top_bar/smart_reply_chips.dart';
45 import 'package:localmind/features/personas/views/components/persona_picker_sheet.dart';
46
47 +/// Height the always-on token usage row adds below the input box (its own
48 +/// content height plus the padding around it) — added to the message list's
49 +/// bottom padding so streamed content never ends up hidden behind it.
50 +const double _tokenIndicatorRowHeight = 22;
51 +
52 class ChatScreen extends ConsumerStatefulWidget {
53 const ChatScreen({super.key});
54
@@ -125,17 +131,20 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
131 _shareConversation(context);
132 case 'persona':
133 showPersonaPickerSheet(context);
128 - case 'remove_persona':
134 + case 'rename':
135 final activeConv = ref.read(conv.activeConversationProvider);
136 if (activeConv != null) {
131 - ref
132 - .read(conv.conversationsProvider.notifier)
133 - .updatePersona(activeConv.id, null, null);
137 + _showRenameDialog(context, activeConv);
138 }
135 - case 'rename':
139 + case 'move_to_folder':
140 final activeConv = ref.read(conv.activeConversationProvider);
141 if (activeConv != null) {
138 - _showRenameDialog(context, activeConv);
142 + showMoveToFolderSheet(
143 + context,
144 + ref,
145 + AppLocalizations.of(context)!,
146 + activeConv,
147 + );
148 }
149 case 'export_chat':
150 _exportConversation(context);
@@ -179,53 +188,24 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
188 }
189
190 Future<void> _exportConversation(BuildContext context) async {
182 - final l10n = AppLocalizations.of(context)!;
191 final messages = ref.read(chatProvider).messages;
192 if (messages.isEmpty) return;
193
194 final activeConv = ref.read(conv.activeConversationProvider);
195 final isTemporary = ref.read(chatProvider.select((s) => s.isTemporary));
196 final title = isTemporary
189 - ? l10n.temporary_chat
197 + ? AppLocalizations.of(context)!.temporary_chat
198 : activeConv?.title;
199
192 - if (activeConv != null && !isTemporary) {
193 - final db = ref.read(databaseProvider);
194 - final json = DataBackupService()
195 - .exportConversationAsJson(db.store, activeConv.id);
196 - final saved = await FilePicker.saveFile(
197 - dialogTitle: l10n.export_conversation,
198 - fileName:
199 - 'localmind_${activeConv.title.replaceAll(RegExp(r'[^\w\-]+'), '_')}_${DateTime.now().millisecondsSinceEpoch}.json',
200 - type: FileType.custom,
201 - allowedExtensions: const ['json'],
202 - bytes: Uint8List.fromList(utf8.encode(json)),
203 - );
204 - if (saved != null && context.mounted) {
205 - ScaffoldMessenger.of(context).showSnackBar(
206 - SnackBar(content: Text(l10n.export_data_success)),
207 - );
208 - }
209 - return;
210 - }
200 + final content = activeConv != null && !isTemporary
201 + ? DataBackupService().exportConversationAsJson(
202 + ref.read(databaseProvider).store,
203 + activeConv.id,
204 + )
205 + : await ExportService.exportAsMarkdown(messages, title: title);
206
212 - final markdown = await ExportService.exportAsMarkdown(
213 - messages,
214 - title: title,
215 - );
216 - final saved = await FilePicker.saveFile(
217 - dialogTitle: l10n.export_conversation,
218 - fileName:
219 - 'localmind_${(title ?? 'chat').replaceAll(RegExp(r'[^\w\-]+'), '_')}_${DateTime.now().millisecondsSinceEpoch}.md',
220 - type: FileType.custom,
221 - allowedExtensions: const ['md'],
222 - bytes: Uint8List.fromList(utf8.encode(markdown)),
223 - );
224 - if (saved != null && context.mounted) {
225 - ScaffoldMessenger.of(context).showSnackBar(
226 - SnackBar(content: Text(l10n.export_data_success)),
227 - );
228 - }
207 + if (!context.mounted) return;
208 + await showExportChoiceDialog(context, content: content, subject: title);
209 }
210
211 void _showRenameDialog(BuildContext context, Conversation conversation) {
@@ -317,15 +297,18 @@ class _ChatBody extends ConsumerWidget {
297 final connectionStatus = ref.watch(connectionStatusProvider);
298 final activeConversation = ref.watch(conv.activeConversationProvider);
299 final isTemporary = ref.watch(chatProvider.select((s) => s.isTemporary));
320 - final personaId = activeConversation?.personaId;
321 - final persona = personaId != null
322 - ? ref.watch(personaByIdProvider(personaId))
323 - : null;
300 + final personas = ref.watch(
301 + personasForConversationProvider(activeConversation?.personaId),
302 + );
303 + final hasPersonas = personas.isNotEmpty;
304 final keyboardBottomInset = bottomKeyboardInset(context);
305 final systemBottomInset = bottomSystemInset(context);
306 + // The token usage row (below the input box) is hidden while the
307 + // keyboard is open, same as the smart-reply chips, so only reserve
308 + // extra scroll space for it when it's actually visible.
309 final effectiveBottomInset = keyboardBottomInset > 0
310 ? 0.0
328 - : systemBottomInset;
311 + : systemBottomInset + _tokenIndicatorRowHeight;
312
313 final needsScroll = autoScroll.checkAndUpdate(
314 messageCount: messages.length,
@@ -345,7 +328,7 @@ class _ChatBody extends ConsumerWidget {
328 _ScreenAppBar(
329 activeConversation: activeConversation,
330 isDark: isDark,
348 - persona: persona,
331 + hasPersonas: hasPersonas,
332 isTemporary: isTemporary,
333 hasMessages: messages.isNotEmpty,
334 onMenuAction: onMenuAction,
@@ -357,23 +340,15 @@ class _ChatBody extends ConsumerWidget {
340 isTemporary: isTemporary,
341 ),
342 ),
343 + PersonaIndicator(
344 + personas: personas,
345 + onTap: () => showPersonaPickerSheet(context),
346 + onClear: () => _clearPersonas(ref),
347 + ),
348 const NotificationPermissionBanner(),
349 if (connectionStatus == ConnectionStatus.disconnected ||
350 connectionStatus == ConnectionStatus.error)
351 ConnectionBanner(status: connectionStatus),
364 - if (persona != null)
365 - PersonaIndicator(
366 - persona: persona,
367 - onTap: () => showPersonaPickerSheet(context),
368 - onRemove: () {
369 - final activeConv = ref.read(conv.activeConversationProvider);
370 - if (activeConv != null) {
371 - ref
372 - .read(conv.conversationsProvider.notifier)
373 - .updatePersona(activeConv.id, null, null);
374 - }
375 - },
376 - ),
352 const TtsPlayerBar(),
353 Expanded(
354 child: DecoratedBox(
@@ -474,7 +449,7 @@ class _MessageArea extends ConsumerWidget {
449 onSeeAll: () => context.push(AppRoutes.chatHistory),
450 selectedModel: selectedModel,
451 onModelTap: onModelPicker,
477 - selectedPersona: ref.watch(selectedPersonaProvider),
452 + selectedPersonas: ref.watch(selectedPersonasProvider),
453 onPersonaTap: () => showPersonaPickerSheet(
454 context,
455 mode: PersonaPickerMode.preselection,
@@ -548,6 +523,18 @@ class _MessageArea extends ConsumerWidget {
523 }
524 }
525
526 +void _clearPersonas(WidgetRef ref) {
527 + final activeConv = ref.read(conv.activeConversationProvider);
528 + if (activeConv != null) {
529 + ref.read(conv.conversationsProvider.notifier).updatePersonas(
530 + activeConv.id,
531 + const [],
532 + );
533 + } else {
534 + ref.read(selectedPersonasProvider.notifier).clear();
535 + }
536 +}
537 +
538 void _handleChatModeAction(
539 BuildContext context,
540 WidgetRef ref, {
@@ -575,6 +562,7 @@ void _handleChatModeAction(
562 TextButton(
563 onPressed: () {
564 Navigator.pop(ctx);
565 + FocusManager.instance.primaryFocus?.unfocus();
566 ref.read(chatProvider.notifier).startNewConversation();
567 },
568 child: Text(l10n.nav_new_chat),
@@ -585,6 +573,7 @@ void _handleChatModeAction(
573 return;
574 }
575
576 + FocusManager.instance.primaryFocus?.unfocus();
577 ref.read(chatProvider.notifier).startNewConversation();
578 }
579
@@ -592,7 +581,7 @@ class _ScreenAppBar extends ConsumerWidget {
581 const _ScreenAppBar({
582 required this.activeConversation,
583 required this.isDark,
595 - required this.persona,
584 + required this.hasPersonas,
585 required this.isTemporary,
586 required this.hasMessages,
587 required this.onMenuAction,
@@ -602,7 +591,7 @@ class _ScreenAppBar extends ConsumerWidget {
591
592 final Conversation? activeConversation;
593 final bool isDark;
605 - final dynamic persona;
594 + final bool hasPersonas;
595 final bool isTemporary;
596 final bool hasMessages;
597 final void Function(String) onMenuAction;
@@ -615,6 +604,44 @@ class _ScreenAppBar extends ConsumerWidget {
604 final settings = ref.watch(settingsProvider);
605 final mcpConfig = ref.watch(chatMcpConfigProvider);
606 final isMcpEnabled = settings.mcpEnabled && mcpConfig.enabled;
607 + final messageSelectionMode = ref.watch(messageSelectionModeProvider);
608 + final selectedMessageIds = ref.watch(selectedMessageIdsProvider);
609 +
610 + if (messageSelectionMode) {
611 + return Container(
612 + height: 56,
613 + padding: const EdgeInsets.symmetric(horizontal: 4),
614 + child: Row(
615 + children: [
616 + IconButton(
617 + icon: const Icon(Icons.close),
618 + onPressed: () =>
619 + ref.read(messageSelectionModeProvider.notifier).disable(),
620 + ),
621 + Expanded(
622 + child: Text(
623 + l10n.selected_count(selectedMessageIds.length),
624 + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
625 + ),
626 + ),
627 + IconButton(
628 + icon: const Icon(Icons.ios_share),
629 + tooltip: l10n.export_conversation,
630 + onPressed: selectedMessageIds.isEmpty
631 + ? null
632 + : () => _shareSelectedMessages(context, ref, selectedMessageIds),
633 + ),
634 + IconButton(
635 + icon: const Icon(Icons.delete_outline, color: Colors.red),
636 + tooltip: l10n.delete,
637 + onPressed: selectedMessageIds.isEmpty
638 + ? null
639 + : () => _deleteSelectedMessages(context, ref, selectedMessageIds),
640 + ),
641 + ],
642 + ),
643 + );
644 + }
645
646 return Container(
647 height: 56,
@@ -691,7 +718,7 @@ class _ScreenAppBar extends ConsumerWidget {
718 contentPadding: EdgeInsets.zero,
719 ),
720 ),
694 - if (activeConversation != null && !isTemporary)
721 + if (activeConversation != null && !isTemporary) ...[
722 PopupMenuItem(
723 value: 'rename',
724 child: ListTile(
@@ -700,6 +727,15 @@ class _ScreenAppBar extends ConsumerWidget {
727 contentPadding: EdgeInsets.zero,
728 ),
729 ),
730 + PopupMenuItem(
731 + value: 'move_to_folder',
732 + child: ListTile(
733 + leading: const Icon(Icons.folder_outlined),
734 + title: Text(l10n.move_to_folder),
735 + contentPadding: EdgeInsets.zero,
736 + ),
737 + ),
738 + ],
739 if (hasMessages) ...[
740 PopupMenuItem(
741 value: 'export_chat',
@@ -722,25 +758,14 @@ class _ScreenAppBar extends ConsumerWidget {
758 value: 'persona',
759 child: ListTile(
760 leading: Icon(
725 - persona != null
726 - ? Icons.swap_horiz
727 - : Icons.smart_toy_outlined,
761 + hasPersonas ? Icons.swap_horiz : Icons.smart_toy_outlined,
762 ),
763 title: Text(
730 - persona != null ? l10n.change_persona : l10n.set_persona,
764 + hasPersonas ? l10n.change_persona : l10n.set_persona,
765 ),
766 contentPadding: EdgeInsets.zero,
767 ),
768 ),
735 - if (persona != null)
736 - PopupMenuItem(
737 - value: 'remove_persona',
738 - child: ListTile(
739 - leading: const Icon(Icons.person_remove_outlined),
740 - title: Text(l10n.remove_persona),
741 - contentPadding: EdgeInsets.zero,
742 - ),
743 - ),
769 PopupMenuItem(
770 value: 'clear',
771 child: ListTile(
@@ -763,6 +788,57 @@ class _ScreenAppBar extends ConsumerWidget {
788 }
789 return l10n.nav_new_chat;
790 }
791 +
792 + Future<void> _shareSelectedMessages(
793 + BuildContext context,
794 + WidgetRef ref,
795 + Set<String> selectedIds,
796 + ) async {
797 + final messages = ref
798 + .read(chatProvider)
799 + .messages
800 + .where((m) => selectedIds.contains(m.id))
801 + .toList();
802 + if (messages.isEmpty) return;
803 + final text = ExportService.exportAsText(messages);
804 + ref.read(messageSelectionModeProvider.notifier).disable();
805 + if (!context.mounted) return;
806 + await showExportChoiceDialog(context, content: text);
807 + }
808 +
809 + void _deleteSelectedMessages(
810 + BuildContext context,
811 + WidgetRef ref,
812 + Set<String> selectedIds,
813 + ) {
814 + final l10n = AppLocalizations.of(context)!;
815 + showDialog<void>(
816 + context: context,
817 + builder: (dialogContext) {
818 + return AlertDialog(
819 + title: Text(l10n.delete_message_title),
820 + content: Text(l10n.selected_count(selectedIds.length)),
821 + actions: [
822 + TextButton(
823 + onPressed: () => Navigator.pop(dialogContext),
824 + child: Text(l10n.cancel),
825 + ),
826 + TextButton(
827 + onPressed: () async {
828 + Navigator.pop(dialogContext);
829 + for (final id in selectedIds) {
830 + await ref.read(chatProvider.notifier).deleteMessage(id);
831 + }
832 + ref.read(messageSelectionModeProvider.notifier).disable();
833 + },
834 + style: TextButton.styleFrom(foregroundColor: Colors.red),
835 + child: Text(l10n.delete),
836 + ),
837 + ],
838 + );
839 + },
840 + );
841 + }
842 }
843
844 class _ChatModeIconButton extends StatelessWidget {
@@ -841,6 +917,13 @@ class _ChatBottomBar extends ConsumerWidget {
917
918 @override
919 Widget build(BuildContext context, WidgetRef ref) {
920 + final isTemporary = ref.watch(chatProvider.select((s) => s.isTemporary));
921 + final keyboardIncognito = isTemporary &&
922 + ref.watch(settingsProvider.select((s) => s.tempChatKeyboardIncognito));
923 + final totalTokenCount = ref.watch(
924 + conv.activeConversationProvider.select((c) => c?.totalTokenCount),
925 + );
926 +
927 return Positioned(
928 bottom: 0,
929 left: 0,
@@ -868,6 +951,7 @@ class _ChatBottomBar extends ConsumerWidget {
951 ChatInputBar(
952 focusNode: inputFocusNode,
953 isStreaming: isStreaming,
954 + keyboardIncognito: keyboardIncognito,
955 onSend: (message, {attachments}) {
956 ref
957 .read(chatProvider.notifier)
@@ -875,11 +959,142 @@ class _ChatBottomBar extends ConsumerWidget {
959 },
960 onStop: () => ref.read(chatProvider.notifier).cancelStream(),
961 ),
962 + if (keyboardBottomInset == 0)
963 + Padding(
964 + padding: const EdgeInsetsDirectional.only(bottom: 6, end: 16),
965 + child: Align(
966 + alignment: AlignmentDirectional.centerEnd,
967 + child: _TokenUsageIndicator(
968 + totalTokenCount: totalTokenCount ?? 0,
969 + ),
970 + ),
971 + ),
972 + ],
973 + ),
974 + ),
975 + );
976 + }
977 +}
978 +
979 +class _TokenUsageIndicator extends ConsumerWidget {
980 + const _TokenUsageIndicator({required this.totalTokenCount});
981 +
982 + final int totalTokenCount;
983 +
984 + @override
985 + Widget build(BuildContext context, WidgetRef ref) {
986 + final theme = Theme.of(context);
987 + final isDark = theme.brightness == Brightness.dark;
988 + final liveContextLength =
989 + ref.watch(activeModelContextLengthProvider).value;
990 + final fallbackContextLength =
991 + ref.watch(settingsProvider.select((s) => s.contextLength));
992 + final int contextLength = liveContextLength ?? fallbackContextLength;
993 +
994 + // totalTokenCount only updates once a response finishes (it's the real
995 + // server-reported count), so while one is streaming in, grow the ring
996 + // with a rough chars-per-token estimate of the in-progress reply —
997 + // corrected back to the exact figure the moment the stream ends.
998 + final isStreaming = ref.watch(chatProvider.select((s) => s.isStreaming));
999 + final streamingLength = ref.watch(
1000 + chatProvider.select((s) => s.streamingMessage?.content.length ?? 0),
1001 + );
1002 + final estimatedTokenCount = isStreaming
1003 + ? totalTokenCount + (streamingLength / 4).round()
1004 + : totalTokenCount;
1005 +
1006 + final ratio = contextLength > 0
1007 + ? (estimatedTokenCount / contextLength).clamp(0.0, 1.0)
1008 + : 0.0;
1009 + final ringColor = ratio >= 0.9 ? Colors.red : theme.colorScheme.primary;
1010 +
1011 + return GestureDetector(
1012 + onTap: () => _showTokenUsageSheet(context, contextLength, ratio),
1013 + child: SizedBox(
1014 + height: 16,
1015 + child: Row(
1016 + mainAxisSize: MainAxisSize.min,
1017 + children: [
1018 + SizedBox(
1019 + width: 12,
1020 + height: 12,
1021 + child: CircularProgressIndicator(
1022 + value: ratio,
1023 + strokeWidth: 2,
1024 + backgroundColor: (isDark ? Colors.white : Colors.black)
1025 + .withValues(alpha: 0.12),
1026 + valueColor: AlwaysStoppedAnimation<Color>(ringColor),
1027 + ),
1028 + ),
1029 + const SizedBox(width: 4),
1030 + Text(
1031 + '${(ratio * 100).round()}%',
1032 + style: TextStyle(
1033 + fontSize: 10,
1034 + color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
1035 + ),
1036 + ),
1037 ],
1038 ),
1039 ),
1040 );
1041 }
1042 +
1043 + void _showTokenUsageSheet(
1044 + BuildContext context,
1045 + int contextLength,
1046 + double ratio,
1047 + ) {
1048 + final l10n = AppLocalizations.of(context)!;
1049 + showModalBottomSheet(
1050 + context: context,
1051 + builder: (ctx) {
1052 + final sheetTheme = Theme.of(ctx);
1053 + final isDark = sheetTheme.brightness == Brightness.dark;
1054 + final muted =
1055 + isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
1056 + return SafeArea(
1057 + child: Padding(
1058 + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
1059 + child: Column(
1060 + mainAxisSize: MainAxisSize.min,
1061 + crossAxisAlignment: CrossAxisAlignment.start,
1062 + children: [
1063 + Text(
1064 + l10n.token_usage_title,
1065 + style: sheetTheme.textTheme.titleMedium,
1066 + ),
1067 + const SizedBox(height: 12),
1068 + _usageRow(l10n.total_tokens_label, '$totalTokenCount', muted),
1069 + _usageRow(l10n.context_length, '$contextLength', muted),
1070 + _usageRow(
1071 + l10n.usage_percent_label,
1072 + '${(ratio * 100).toStringAsFixed(1)}%',
1073 + muted,
1074 + ),
1075 + ],
1076 + ),
1077 + ),
1078 + );
1079 + },
1080 + );
1081 + }
1082 +
1083 + Widget _usageRow(String label, String value, Color muted) {
1084 + return Padding(
1085 + padding: const EdgeInsets.symmetric(vertical: 4),
1086 + child: Row(
1087 + mainAxisAlignment: MainAxisAlignment.spaceBetween,
1088 + children: [
1089 + Text(label, style: TextStyle(fontSize: 13, color: muted)),
1090 + Text(
1091 + value,
1092 + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
1093 + ),
1094 + ],
1095 + ),
1096 + );
1097 + }
1098 }
1099
1100 class _SmartReplyChipsWrapper extends ConsumerWidget {
lib/features/chat/views/components/chat_bubble/assistant_bubble.dart
+3 -13
@@ -11,6 +11,7 @@ import 'package:localmind/features/chat/views/components/message_action_bar.dart
11 import 'package:localmind/features/chat/views/components/message_variant_navigator.dart';
12 import 'markdown/themed_gpt_markdown.dart';
13 import 'tool_bubble/tool_timeline.dart';
14 +import 'chat_error_display.dart';
15
16 class AssistantBubble extends StatelessWidget {
17 const AssistantBubble({
@@ -112,14 +113,7 @@ class AssistantBubble extends StatelessWidget {
113 borderRadius: BorderRadius.circular(8),
114 border: Border.all(color: Colors.red.withValues(alpha: 0.2)),
115 ),
115 - child: Text(
116 - message.errorMessage!,
117 - style: TextStyle(
118 - color: Colors.red[400],
119 - fontSize: 12,
120 - fontWeight: FontWeight.w500,
121 - ),
122 - ),
116 + child: ChatErrorDisplay(errorMessage: message.errorMessage!),
117 ),
118 ),
119 if (message.toolEvents != null && message.toolEvents!.isNotEmpty)
@@ -150,11 +144,7 @@ class AssistantBubble extends StatelessWidget {
144 Icon(Icons.error_outline, size: 14, color: Colors.red[400]),
145 ],
146 const Spacer(),
153 - if (!isStreaming &&
154 - (message.status == MessageStatus.complete ||
155 - message.status == MessageStatus.error ||
156 - message.status == MessageStatus.cancelled ||
157 - message.content.isEmpty))
147 + if (!isStreaming)
148 MessageActionBar(
149 content: message.content.isEmpty
150 ? AppLocalizations.of(context)!.no_response
lib/features/chat/views/components/chat_bubble/attachment_list.dart
+2 -32
@@ -1,6 +1,7 @@
1 import 'dart:io';
2 import 'package:flutter/material.dart';
3 import 'package:localmind/core/theme/colors.dart';
4 +import 'package:localmind/features/chat/views/components/image_preview_dialog.dart';
5 import 'package:localmind/features/chat/utils/attachment_helpers.dart';
6 import 'package:localmind/features/chat/views/components/audio_player_widget.dart';
7
@@ -26,11 +27,7 @@ class _AttachmentItem extends StatelessWidget {
27 final String path;
28
29 void _viewImage(BuildContext context) {
29 - showDialog(
30 - context: context,
31 - useSafeArea: false,
32 - builder: (context) => _ImageViewer(path: path),
33 - );
30 + showImagePreview(context, path);
31 }
32
33 void _viewText(BuildContext context) async {
@@ -264,30 +261,3 @@ class _FilePlaceholder extends StatelessWidget {
261 );
262 }
263 }
267 -
268 -class _ImageViewer extends StatelessWidget {
269 - const _ImageViewer({required this.path});
270 - final String path;
271 -
272 - @override
273 - Widget build(BuildContext context) {
274 - return Scaffold(
275 - backgroundColor: Colors.black,
276 - appBar: AppBar(
277 - backgroundColor: Colors.transparent,
278 - elevation: 0,
279 - leading: IconButton(
280 - icon: const Icon(Icons.close, color: Colors.white),
281 - onPressed: () => Navigator.pop(context),
282 - ),
283 - ),
284 - body: Center(
285 - child: InteractiveViewer(
286 - minScale: 0.5,
287 - maxScale: 4.0,
288 - child: Image.file(File(path)),
289 - ),
290 - ),
291 - );
292 - }
293 -}
lib/features/chat/views/components/chat_bubble/chat_error_display.dart new
+63
@@ -0,0 +1,63 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:localmind/features/chat/data/chat_api_error.dart';
3 +
4 +class ChatErrorDisplay extends StatelessWidget {
5 + const ChatErrorDisplay({
6 + super.key,
7 + required this.errorMessage,
8 + });
9 +
10 + final String errorMessage;
11 +
12 + @override
13 + Widget build(BuildContext context) {
14 + final parsed = ChatApiError.tryParse(errorMessage);
15 + if (parsed == null) {
16 + return Text(
17 + errorMessage,
18 + style: TextStyle(
19 + color: Colors.red[400],
20 + fontSize: 12,
21 + fontWeight: FontWeight.w500,
22 + ),
23 + );
24 + }
25 +
26 + final metaParts = <String>[];
27 + if (parsed.type != null && parsed.type!.isNotEmpty) {
28 + metaParts.add('type: ${parsed.type}');
29 + }
30 + if (parsed.code != null && parsed.code!.isNotEmpty) {
31 + metaParts.add('code: ${parsed.code}');
32 + }
33 + if (parsed.param != null && parsed.param!.isNotEmpty) {
34 + metaParts.add('param: ${parsed.param}');
35 + }
36 +
37 + return Column(
38 + crossAxisAlignment: CrossAxisAlignment.start,
39 + children: [
40 + Text(
41 + parsed.message,
42 + style: TextStyle(
43 + color: Colors.red[400],
44 + fontSize: 12,
45 + fontWeight: FontWeight.w500,
46 + ),
47 + ),
48 + if (metaParts.isNotEmpty)
49 + Padding(
50 + padding: const EdgeInsets.only(top: 4),
51 + child: Text(
52 + metaParts.join(' · '),
53 + style: TextStyle(
54 + color: Colors.red[300]?.withValues(alpha: 0.75),
55 + fontSize: 10,
56 + fontWeight: FontWeight.w400,
57 + ),
58 + ),
59 + ),
60 + ],
61 + );
62 + }
63 +}
lib/features/chat/views/components/chat_bubble/markdown/themed_gpt_markdown.dart
+29 -1
@@ -4,6 +4,34 @@ import 'package:localmind/core/theme/colors.dart';
4 import 'package:localmind/features/chat/views/components/audio_player_widget.dart';
5 import 'deferred_markdown.dart';
6
7 +final _blockDollarLatex = RegExp(r'\$\$([\s\S]+?)\$\$');
8 +final _inlineDollarLatex = RegExp(r'\$([^\$\n]+?)\$');
9 +final _currencyLike = RegExp(r'^\s*\d[\d,]*(\.\d+)?\s*$');
10 +
11 +/// gpt_markdown only recognizes `\(...\)` / `\[...\]` for LaTeX, not the
12 +/// `$...$` / `$$...$$` delimiters models most commonly output. Convert the
13 +/// dollar forms to the ones gpt_markdown understands, skipping anything
14 +/// that looks like plain currency (e.g. "$5" or "$10.99") so ordinary
15 +/// prices aren't mistaken for math.
16 +String normalizeDollarLatex(String input) {
17 + if (!input.contains(r'$')) return input;
18 +
19 + var result = input.replaceAllMapped(
20 + _blockDollarLatex,
21 + (m) => '\\[${m[1]!.trim()}\\]',
22 + );
23 +
24 + result = result.replaceAllMapped(_inlineDollarLatex, (m) {
25 + final inner = m[1]!;
26 + if (inner.trim().isEmpty || _currencyLike.hasMatch(inner)) {
27 + return m[0]!;
28 + }
29 + return '\\(${inner.trim()}\\)';
30 + });
31 +
32 + return result;
33 +}
34 +
35 class ThemedGptMarkdown extends StatelessWidget {
36 const ThemedGptMarkdown({
37 super.key,
@@ -48,7 +76,7 @@ class ThemedGptMarkdown extends StatelessWidget {
76 return GptMarkdownTheme(
77 gptThemeData: gptTheme,
78 child: GptMarkdown(
51 - content,
79 + normalizeDollarLatex(content),
80 style: style,
81 followLinkColor: true,
82 imageBuilder: _buildImageOrAudio,
lib/features/chat/views/components/chat_input_bar.dart
+457 -210
@@ -29,6 +29,9 @@ import '../../../servers/providers/server_providers.dart';
29
30 import '../../../stt/providers/stt_providers.dart';
31 import '../../providers/chat_providers.dart';
32 +import '../../utils/attachment_helpers.dart';
33 +import '../../utils/image_upload_utils.dart';
34 +import 'image_preview_dialog.dart';
35 import '../../../saved_messages/views/components/saved_message_picker_sheet.dart';
36
37
@@ -51,6 +54,8 @@ class ChatInputBar extends ConsumerStatefulWidget {
54
55 this.focusNode,
56
57 + this.keyboardIncognito = false,
58 +
59 });
60
61
@@ -67,6 +72,8 @@ class ChatInputBar extends ConsumerStatefulWidget {
72
73 final FocusNode? focusNode;
74
75 + final bool keyboardIncognito;
76 +
77
78
79 @override
@@ -81,13 +88,22 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
88
89 with TickerProviderStateMixin {
90
84 - final _controller = TextEditingController();
85 -
91 + final _normalController = TextEditingController();
92 + final _incognitoController = TextEditingController();
93 late final FocusNode _focusNode;
94 + late final FocusNode _incognitoFocus;
95 +
96 + TextEditingController get _controller =>
97 + widget.keyboardIncognito ? _incognitoController : _normalController;
98 +
99 + FocusNode get _activeFocus =>
100 + widget.keyboardIncognito ? _incognitoFocus : _focusNode;
101
102 final List<File> _attachedFiles = [];
103
104 bool _isGeneratingAiUser = false;
105 + bool _sendAsAssistant = false;
106 + bool _holdTriggered = false;
107
108 late AnimationController _sendButtonAnimController;
109
@@ -95,17 +111,21 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
111
112 late AnimationController _micAnimController;
113
114 + /// Drives the clockwise ring drawn around the send button while it's held
115 + /// down. Reaching the end (3s) triggers AI-generated-user-message instead
116 + /// of the normal tap/short-hold actions.
117 + late AnimationController _holdProgressController;
118 +
119 String _preSpeechText = '';
120
121
122
123 @override
103 -
124 void initState() {
105 -
125 super.initState();
126
127 _focusNode = widget.focusNode ?? FocusNode();
128 + _incognitoFocus = FocusNode();
129
130 _sendButtonAnimController = AnimationController(
131
@@ -129,6 +149,40 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
149
150 );
151
152 + _holdProgressController = AnimationController(
153 + duration: _holdDuration,
154 + vsync: this,
155 + )..addStatusListener((status) {
156 + if (status == AnimationStatus.completed) {
157 + _holdTriggered = true;
158 + _holdProgressController.value = 0;
159 + _handleGenerateAiUser();
160 + }
161 + });
162 +
163 + }
164 +
165 +
166 +
167 + @override
168 + void didUpdateWidget(ChatInputBar oldWidget) {
169 + super.didUpdateWidget(oldWidget);
170 + if (oldWidget.keyboardIncognito == widget.keyboardIncognito) return;
171 +
172 + final text = _normalController.text;
173 + final selection = _normalController.selection;
174 +
175 + if (widget.keyboardIncognito) {
176 + _incognitoController.value = TextEditingValue(text: text, selection: selection);
177 + if (_focusNode.hasFocus) {
178 + _incognitoFocus.requestFocus();
179 + }
180 + } else {
181 + _normalController.value = TextEditingValue(text: text, selection: selection);
182 + if (_incognitoFocus.hasFocus) {
183 + _focusNode.requestFocus();
184 + }
185 + }
186 }
187
188
@@ -137,18 +191,20 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
191
192 void dispose() {
193
140 - _controller.dispose();
194 + _normalController.dispose();
195 + _incognitoController.dispose();
196
197 if (widget.focusNode == null) {
143 -
198 _focusNode.dispose();
145 -
199 }
200 + _incognitoFocus.dispose();
201
202 _sendButtonAnimController.dispose();
203
204 _micAnimController.dispose();
205
206 + _holdProgressController.dispose();
207 +
208 super.dispose();
209
210 }
@@ -175,7 +231,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
231
232 setState(() {});
233
178 - _focusNode.requestFocus();
234 + _activeFocus.requestFocus();
235
236 _controller.selection = TextSelection.fromPosition(
237
@@ -249,12 +305,20 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
305
306 if (images.isEmpty) return;
307
252 -
308 + final settings = ref.read(settingsProvider);
309 + final compressed = <File>[];
310 + for (final image in images) {
311 + compressed.add(
312 + await ImageUploadUtils.prepareImageFile(
313 + File(image.path),
314 + enabled: settings.imageCompressionEnabled,
315 + level: settings.imageCompressionLevel,
316 + ),
317 + );
318 + }
319
320 setState(() {
255 -
256 - _attachedFiles.addAll(images.map((x) => File(x.path)));
257 -
321 + _attachedFiles.addAll(compressed);
322 });
323
324 widget.onAttach?.call(_attachedFiles);
@@ -326,35 +390,20 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
390 children: [
391
392 ListTile(
329 -
330 - leading: const Icon(Icons.image_outlined),
331 -
332 - title: Text(l10n.attach_image),
333 -
393 + leading: const Icon(Icons.description_outlined),
394 + title: Text(l10n.attach_text_document),
395 onTap: () {
335 -
396 Navigator.pop(ctx);
337 -
338 - _pickImages();
339 -
397 + _pickTextDocument();
398 },
341 -
399 ),
343 -
400 ListTile(
345 -
346 - leading: const Icon(Icons.description_outlined),
347 -
348 - title: Text(l10n.attach_text_document),
349 -
401 + leading: const Icon(Icons.image_outlined),
402 + title: Text(l10n.attach_image),
403 onTap: () {
351 -
404 Navigator.pop(ctx);
353 -
354 - _pickTextDocument();
355 -
405 + _pickImages();
406 },
357 -
407 ),
408
409 ListTile(
@@ -551,18 +600,45 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
600
601 Haptics.vibrate(HapticsType.medium);
602
554 - widget.onSend(text, attachments: List.from(_attachedFiles));
603 + if (_sendAsAssistant) {
604 + ref.read(chatProvider.notifier).insertMessageWithoutGenerating(
605 + text,
606 + role: MessageRole.assistant,
607 + attachments: List.from(_attachedFiles),
608 + );
609 + } else {
610 + widget.onSend(text, attachments: List.from(_attachedFiles));
611 + }
612
613 _controller.clear();
614
615 setState(() {
616
617 _attachedFiles.clear();
618 + _sendAsAssistant = false;
619
620 });
621
622 }
623
624 + void _handleInsertWithoutGenerating() {
625 + final text = _controller.text.trim();
626 + if (text.isEmpty && _attachedFiles.isEmpty) return;
627 +
628 + Haptics.vibrate(HapticsType.medium);
629 + ref.read(chatProvider.notifier).insertMessageWithoutGenerating(
630 + text,
631 + role: _sendAsAssistant ? MessageRole.assistant : MessageRole.user,
632 + attachments: List.from(_attachedFiles),
633 + );
634 +
635 + _controller.clear();
636 + setState(() {
637 + _attachedFiles.clear();
638 + _sendAsAssistant = false;
639 + });
640 + }
641 +
642
643
644 void _handleStop() {
@@ -585,33 +661,134 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
661 }
662 }
663
588 - Widget _buildAiUserButton(bool isConnected, ThemeData theme) {
664 + Widget _buildRoleSwapButton(ThemeData theme) {
665 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,
666 +
667 + return SizedBox(
668 + width: 36,
669 + height: 36,
670 + child: IconButton(
671 + padding: EdgeInsets.zero,
672 + tooltip: _sendAsAssistant
673 + ? l10n.send_as_assistant_tooltip
674 + : l10n.send_as_user_tooltip,
675 + onPressed: widget.enabled
676 + ? () {
677 + Haptics.vibrate(HapticsType.light);
678 + setState(() => _sendAsAssistant = !_sendAsAssistant);
679 + }
680 + : null,
681 + icon: Icon(
682 + Icons.swap_horiz_rounded,
683 + size: 20,
684 + color: _sendAsAssistant
685 + ? theme.colorScheme.primary
686 + : theme.colorScheme.onSurface.withValues(alpha: 0.7),
687 + ),
688 + ),
689 + );
690 + }
691 +
692 + String _effortLabel(ReasoningEffort effort) {
693 + final l10n = AppLocalizations.of(context)!;
694 + return switch (effort) {
695 + ReasoningEffort.low => l10n.reasoning_effort_low,
696 + ReasoningEffort.medium => l10n.reasoning_effort_medium,
697 + ReasoningEffort.high => l10n.reasoning_effort_high,
698 + };
699 + }
700 +
701 + Widget _buildThinkButton(ThemeData theme) {
702 + final selectedModel = ref.watch(selectedModelProvider);
703 + if (selectedModel?.supportsReasoning != true) {
704 + return const SizedBox.shrink();
705 + }
706 +
707 + final l10n = AppLocalizations.of(context)!;
708 + final reasoningConfig = ref.watch(chatReasoningConfigProvider);
709 + final enabled = reasoningConfig.enabled;
710 + final activeColor = theme.colorScheme.primary;
711 + final fgColor = enabled
712 + ? activeColor
713 + : theme.colorScheme.onSurface.withValues(alpha: 0.6);
714 +
715 + return Padding(
716 + padding: const EdgeInsets.only(left: 6),
717 + child: Container(
718 + height: 32,
719 + decoration: BoxDecoration(
720 + color: enabled
721 + ? activeColor.withValues(alpha: 0.15)
722 + : theme.colorScheme.onSurface.withValues(alpha: 0.06),
723 + borderRadius: BorderRadius.circular(16),
724 + ),
725 + child: Row(
726 + mainAxisSize: MainAxisSize.min,
727 + children: [
728 + InkWell(
729 + borderRadius: BorderRadius.circular(16),
730 + onTap: () {
731 + Haptics.vibrate(HapticsType.light);
732 + ref
733 + .read(chatReasoningConfigProvider.notifier)
734 + .setEnabled(!enabled);
735 + },
736 + child: Padding(
737 + padding: const EdgeInsetsDirectional.only(
738 + start: 10,
739 + end: 4,
740 + top: 6,
741 + bottom: 6,
742 + ),
743 + child: Icon(
744 + enabled ? Icons.check_box : Icons.check_box_outline_blank,
745 + size: 18,
746 + color: fgColor,
747 + ),
748 + ),
749 + ),
750 + PopupMenuButton<ReasoningEffort>(
751 + tooltip: '',
752 + padding: EdgeInsets.zero,
753 + onSelected: (effort) {
754 + Haptics.vibrate(HapticsType.light);
755 + ref
756 + .read(chatReasoningConfigProvider.notifier)
757 + .setEffort(effort);
758 + },
759 + itemBuilder: (context) => ReasoningEffort.values
760 + .map(
761 + (effort) => PopupMenuItem(
762 + value: effort,
763 + child: Text(_effortLabel(effort)),
764 + ),
765 + )
766 + .toList(),
767 + child: Padding(
768 + padding: const EdgeInsetsDirectional.only(
769 + end: 12,
770 + top: 6,
771 + bottom: 6,
772 + ),
773 + child: Text(
774 + '${l10n.think_button_label} (${_effortLabel(reasoningConfig.effort)})',
775 + style: TextStyle(
776 + fontSize: 13,
777 + fontWeight: FontWeight.w600,
778 + color: fgColor,
779 + ),
780 + ),
781 ),
606 - )
607 - : Icon(
608 - Icons.person_outline,
609 - size: 22,
610 - color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
782 ),
783 + ],
784 + ),
785 + ),
786 );
787 }
788
789 + static const _holdDuration = Duration(milliseconds: 3000);
790 + static const _insertHoldThreshold = 500 / 3000;
791 +
792 Widget _buildActionButton(bool canSend, ThemeData theme) {
793
794 final l10n = AppLocalizations.of(context)!;
@@ -628,100 +805,141 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
805
806 : theme.colorScheme.onPrimary;
807
808 + final connectionStatus = ref.watch(connectionStatusProvider);
809 + final isConnected = connectionStatus == ConnectionStatus.connected;
810 + final aiUserHoldEnabled =
811 + ref.watch(settingsProvider.select((s) => s.aiUserResponseEnabled)) &&
812 + isConnected &&
813 + widget.enabled;
814 +
815 + void handleTapDown() {
816 + _holdTriggered = false;
817 + if (canSend) _sendButtonAnimController.forward();
818 + if (!widget.isStreaming && !_isGeneratingAiUser && aiUserHoldEnabled) {
819 + _holdProgressController.forward(from: 0);
820 + }
821 + }
822
823 + void resetHold() {
824 + _holdProgressController.stop();
825 + _holdProgressController.value = 0;
826 + }
827
633 - return GestureDetector(
634 -
635 - onTapDown: canSend ? (_) => _sendButtonAnimController.forward() : null,
636 -
637 - onTapUp: canSend ? (_) => _sendButtonAnimController.reverse() : null,
638 -
639 - onTapCancel: canSend ? () => _sendButtonAnimController.reverse() : null,
640 -
641 - child: ScaleTransition(
642 -
643 - scale: _sendButtonScale,
644 -
645 - child: AnimatedContainer(
646 -
647 - duration: const Duration(milliseconds: 200),
648 -
649 - curve: Curves.easeOut,
650 -
651 - width: 36,
652 -
653 - height: 36,
654 -
655 - decoration: BoxDecoration(
656 -
657 - color: backgroundColor.withValues(
658 -
659 - alpha: (canSend || widget.isStreaming) ? 1.0 : 0.2,
660 -
661 - ),
662 -
663 - shape: BoxShape.circle,
664 -
665 - ),
666 -
667 - child: IconButton(
668 -
669 - padding: EdgeInsets.zero,
670 -
671 - icon: AnimatedSwitcher(
672 -
673 - duration: const Duration(milliseconds: 150),
674 -
675 - transitionBuilder: (child, animation) {
676 -
677 - return ScaleTransition(scale: animation, child: child);
678 -
679 - },
680 -
681 - child: widget.isStreaming
682 -
683 - ? HugeIcon(
684 -
685 - icon: HugeIcons.strokeRoundedStop,
686 -
687 - key: const ValueKey('stop'),
828 + void handleTapUp() {
829 + if (canSend) _sendButtonAnimController.reverse();
830 + final holdFraction = _holdProgressController.value;
831 + final wasTriggered = _holdTriggered;
832 + resetHold();
833 + _holdTriggered = false;
834
689 - color: iconColor,
835 + if (wasTriggered || _isGeneratingAiUser) return;
836
691 - size: 18,
837 + if (widget.isStreaming) {
838 + _handleStop();
839 + return;
840 + }
841
693 - )
842 + if (holdFraction >= _insertHoldThreshold) {
843 + _handleInsertWithoutGenerating();
844 + } else if (canSend) {
845 + _handleSubmit();
846 + }
847 + }
848
695 - : HugeIcon(
849 + void handleTapCancel() {
850 + if (canSend) _sendButtonAnimController.reverse();
851 + resetHold();
852 + _holdTriggered = false;
853 + }
854
697 - icon: HugeIcons.strokeRoundedArrowUp01,
855 + // Uses raw pointer callbacks instead of GestureDetector's onTapDown/
856 + // onTapUp: those depend on winning the gesture arena, but a nested
857 + // interactive descendant (the button's own tap target) can win instead,
858 + // silently swallowing onTapUp and breaking the hold-timing logic below.
859 + // Listener always fires regardless of who else claims the gesture.
860 + return Listener(
861
699 - key: ValueKey(canSend),
862 + onPointerDown: (_) => handleTapDown(),
863
701 - color: iconColor,
864 + onPointerUp: (_) => handleTapUp(),
865
703 - size: 18,
866 + onPointerCancel: (_) => handleTapCancel(),
867
868 + child: Tooltip(
869 + message: widget.isStreaming
870 + ? l10n.stop_generation_tooltip
871 + : l10n.send_message_tooltip,
872 + child: Stack(
873 + alignment: Alignment.center,
874 + children: [
875 + AnimatedBuilder(
876 + animation: _holdProgressController,
877 + builder: (context, child) {
878 + if (_holdProgressController.value <= 0) {
879 + return const SizedBox(width: 44, height: 44);
880 + }
881 + return SizedBox(
882 + width: 44,
883 + height: 44,
884 + child: CircularProgressIndicator(
885 + value: _holdProgressController.value,
886 + strokeWidth: 2.5,
887 + backgroundColor: Colors.transparent,
888 + valueColor: AlwaysStoppedAnimation<Color>(
889 + theme.colorScheme.primary,
890 ),
706 -
891 + ),
892 + );
893 + },
894 ),
708 -
709 - onPressed: widget.isStreaming
710 -
711 - ? _handleStop
712 -
713 - : (canSend ? _handleSubmit : null),
714 -
715 - tooltip: widget.isStreaming
716 -
717 - ? l10n.stop_generation_tooltip
718 -
719 - : l10n.send_message_tooltip,
720 -
721 - ),
722 -
895 + ScaleTransition(
896 + scale: _sendButtonScale,
897 + child: AnimatedContainer(
898 + duration: const Duration(milliseconds: 200),
899 + curve: Curves.easeOut,
900 + width: 36,
901 + height: 36,
902 + decoration: BoxDecoration(
903 + color: backgroundColor.withValues(
904 + alpha: (canSend || widget.isStreaming) ? 1.0 : 0.2,
905 + ),
906 + shape: BoxShape.circle,
907 + ),
908 + child: Center(
909 + child: AnimatedSwitcher(
910 + duration: const Duration(milliseconds: 150),
911 + transitionBuilder: (child, animation) {
912 + return ScaleTransition(scale: animation, child: child);
913 + },
914 + child: _isGeneratingAiUser
915 + ? SizedBox(
916 + key: const ValueKey('ai-user-generating'),
917 + width: 16,
918 + height: 16,
919 + child: CircularProgressIndicator(
920 + strokeWidth: 2,
921 + color: iconColor,
922 + ),
923 + )
924 + : widget.isStreaming
925 + ? HugeIcon(
926 + icon: HugeIcons.strokeRoundedStop,
927 + key: const ValueKey('stop'),
928 + color: iconColor,
929 + size: 18,
930 + )
931 + : HugeIcon(
932 + icon: HugeIcons.strokeRoundedArrowUp01,
933 + key: ValueKey(canSend),
934 + color: iconColor,
935 + size: 18,
936 + ),
937 + ),
938 + ),
939 + ),
940 + ),
941 + ],
942 ),
724 -
943 ),
944
945 );
@@ -793,7 +1011,8 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
1011 final connectionStatus = ref.watch(connectionStatusProvider);
1012
1013 final isConnected = connectionStatus == ConnectionStatus.connected;
796 - final showAiUserButton = ref.watch(settingsProvider).aiUserResponseEnabled;
1014 + final showRoleSwapButton =
1015 + ref.watch(settingsProvider).roleSwapButtonEnabled;
1016
1017
1018
@@ -869,7 +1088,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
1088
1089 child: Container(
1090
872 - margin: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 12),
1091 + margin: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 6),
1092
1093 padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
1094
@@ -912,10 +1131,7 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
1131 itemBuilder: (context, index) {
1132
1133 final file = _attachedFiles[index];
915 -
916 - final ext = file.path.split('.').last.toLowerCase();
917 -
918 - final isImage = !['txt', 'md'].contains(ext);
1134 + final isImage = AttachmentHelpers.isImagePath(file.path);
1135
1136 return Stack(
1137
@@ -946,17 +1162,15 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
1162 borderRadius: BorderRadius.circular(9),
1163
1164 child: isImage
949 -
950 - ? Image.file(
951 -
952 - file,
953 -
954 - width: 48,
955 -
956 - height: 48,
957 -
958 - fit: BoxFit.cover,
959 -
1165 + ? GestureDetector(
1166 + onTap: () =>
1167 + showImagePreview(context, file.path),
1168 + child: Image.file(
1169 + file,
1170 + width: 48,
1171 + height: 48,
1172 + fit: BoxFit.cover,
1173 + ),
1174 )
1175
1176 : Container(
@@ -1037,60 +1251,91 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
1251
1252 ),
1253
1040 - TextField(
1041 -
1042 - controller: _controller,
1043 -
1044 - focusNode: _focusNode,
1045 -
1046 - enabled: widget.enabled,
1047 -
1048 - maxLines: 6,
1049 -
1050 - minLines: 1,
1051 -
1052 - textInputAction: TextInputAction.newline,
1053 -
1054 - keyboardType: TextInputType.multiline,
1055 -
1056 - onChanged: (_) => setState(() {}),
1057 -
1058 - style: TextStyle(
1059 -
1060 - fontSize: 15,
1061 -
1062 - color: theme.colorScheme.onSurface,
1063 -
1064 - ),
1065 -
1066 - decoration: InputDecoration(
1067 -
1068 - filled: false,
1069 -
1070 - border: InputBorder.none,
1071 -
1072 - enabledBorder: InputBorder.none,
1073 -
1074 - focusedBorder: InputBorder.none,
1075 -
1076 - disabledBorder: InputBorder.none,
1077 -
1078 - hintText: l10n.chat_input_hint,
1079 -
1080 - hintStyle: TextStyle(
1081 -
1082 - color: theme.colorScheme.onSurface.withValues(alpha: 0.38),
1083 -
1084 - fontSize: 15,
1085 -
1254 + Stack(
1255 + children: [
1256 + IgnorePointer(
1257 + ignoring: widget.keyboardIncognito,
1258 + child: Opacity(
1259 + opacity: widget.keyboardIncognito ? 0 : 1,
1260 + child: TextField(
1261 + controller: _normalController,
1262 + focusNode: _focusNode,
1263 + enabled: widget.enabled,
1264 + maxLines: 6,
1265 + minLines: 1,
1266 + textInputAction: TextInputAction.newline,
1267 + keyboardType: TextInputType.multiline,
1268 + enableSuggestions: true,
1269 + autocorrect: true,
1270 + enableIMEPersonalizedLearning: true,
1271 + onChanged: (_) => setState(() {}),
1272 + style: TextStyle(
1273 + fontSize: 15,
1274 + color: theme.colorScheme.onSurface,
1275 + ),
1276 + decoration: InputDecoration(
1277 + filled: false,
1278 + border: InputBorder.none,
1279 + enabledBorder: InputBorder.none,
1280 + focusedBorder: InputBorder.none,
1281 + disabledBorder: InputBorder.none,
1282 + hintText: l10n.chat_input_hint,
1283 + hintStyle: TextStyle(
1284 + color: theme.colorScheme.onSurface
1285 + .withValues(alpha: 0.38),
1286 + fontSize: 15,
1287 + ),
1288 + isDense: true,
1289 + contentPadding:
1290 + const EdgeInsets.symmetric(vertical: 4),
1291 + ),
1292 + ),
1293 + ),
1294 ),
1087 -
1088 - isDense: true,
1089 -
1090 - contentPadding: const EdgeInsets.symmetric(vertical: 4),
1091 -
1092 - ),
1093 -
1295 + IgnorePointer(
1296 + ignoring: !widget.keyboardIncognito,
1297 + child: Opacity(
1298 + opacity: widget.keyboardIncognito ? 1 : 0,
1299 + child: TextField(
1300 + controller: _incognitoController,
1301 + focusNode: _incognitoFocus,
1302 + enabled: widget.enabled,
1303 + maxLines: 6,
1304 + minLines: 1,
1305 + textInputAction: TextInputAction.newline,
1306 + keyboardType: TextInputType.multiline,
1307 + enableSuggestions: false,
1308 + autocorrect: false,
1309 + enableIMEPersonalizedLearning: false,
1310 + spellCheckConfiguration:
1311 + const SpellCheckConfiguration.disabled(),
1312 + smartDashesType: SmartDashesType.disabled,
1313 + smartQuotesType: SmartQuotesType.disabled,
1314 + onChanged: (_) => setState(() {}),
1315 + style: TextStyle(
1316 + fontSize: 15,
1317 + color: theme.colorScheme.onSurface,
1318 + ),
1319 + decoration: InputDecoration(
1320 + filled: false,
1321 + border: InputBorder.none,
1322 + enabledBorder: InputBorder.none,
1323 + focusedBorder: InputBorder.none,
1324 + disabledBorder: InputBorder.none,
1325 + hintText: l10n.chat_input_hint,
1326 + hintStyle: TextStyle(
1327 + color: theme.colorScheme.onSurface
1328 + .withValues(alpha: 0.38),
1329 + fontSize: 15,
1330 + ),
1331 + isDense: true,
1332 + contentPadding:
1333 + const EdgeInsets.symmetric(vertical: 4),
1334 + ),
1335 + ),
1336 + ),
1337 + ),
1338 + ],
1339 ),
1340
1341 const SizedBox(height: 6),
@@ -1123,17 +1368,19 @@ class ChatInputBarState extends ConsumerState<ChatInputBar>
1368
1369 ),
1370
1371 + _buildThinkButton(theme),
1372 +
1373 const Spacer(),
1374
1375 + if (showRoleSwapButton) ...[
1376 + _buildRoleSwapButton(theme),
1377 + const SizedBox(width: 6),
1378 + ],
1379 +
1380 _buildMicButton(isListening, theme),
1381
1382 const SizedBox(width: 6),
1383
1132 - if (showAiUserButton) ...[
1133 - _buildAiUserButton(isConnected, theme),
1134 - const SizedBox(width: 6),
1135 - ],
1136 -
1384 _buildActionButton(canSend, theme),
1385
1386 ],
lib/features/chat/views/components/image_preview_dialog.dart new
+39
@@ -0,0 +1,39 @@
1 +import 'dart:io';
2 +
3 +import 'package:flutter/material.dart';
4 +
5 +void showImagePreview(BuildContext context, String path) {
6 + showDialog<void>(
7 + context: context,
8 + useSafeArea: false,
9 + builder: (context) => _ImagePreviewDialog(path: path),
10 + );
11 +}
12 +
13 +class _ImagePreviewDialog extends StatelessWidget {
14 + const _ImagePreviewDialog({required this.path});
15 +
16 + final String path;
17 +
18 + @override
19 + Widget build(BuildContext context) {
20 + return Scaffold(
21 + backgroundColor: Colors.black,
22 + appBar: AppBar(
23 + backgroundColor: Colors.transparent,
24 + elevation: 0,
25 + leading: IconButton(
26 + icon: const Icon(Icons.close, color: Colors.white),
27 + onPressed: () => Navigator.pop(context),
28 + ),
29 + ),
30 + body: Center(
31 + child: InteractiveViewer(
32 + minScale: 0.5,
33 + maxScale: 4,
34 + child: Image.file(File(path)),
35 + ),
36 + ),
37 + );
38 + }
39 +}
lib/features/chat/views/components/message_action_bar.dart
+13
@@ -6,6 +6,7 @@ import 'package:localmind/l10n/app_localizations.dart';
6 import 'package:localmind/core/theme/colors.dart';
7 import '../../../saved_messages/providers/saved_message_providers.dart';
8 import '../../../tts/providers/tts_providers.dart' as tts;
9 +import '../../providers/message_selection_provider.dart';
10
11 class MessageActionBar extends ConsumerStatefulWidget {
12 const MessageActionBar({
@@ -289,6 +290,18 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
290 );
291 },
292 ),
293 + if (widget.messageId != null)
294 + _CompactOptionTile(
295 + icon: Icons.checklist_outlined,
296 + label: sheetL10n.select,
297 + onTap: () {
298 + Navigator.pop(ctx);
299 + ref.read(messageSelectionModeProvider.notifier).enable();
300 + ref
301 + .read(selectedMessageIdsProvider.notifier)
302 + .toggle(widget.messageId!);
303 + },
304 + ),
305 _CompactOptionTile(
306 icon: isThisActive
307 ? (isThisPlaying
lib/features/chat/views/components/message_list/empty_state.dart
+21 -17
@@ -1,7 +1,6 @@
1 import 'dart:async';
2
3 import 'package:flutter/material.dart';
4 -import 'package:shadcn_ui/shadcn_ui.dart';
4 import 'package:localmind/core/theme/colors.dart';
5 import 'package:localmind/l10n/app_localizations.dart';
6 import 'recent_conversation_item.dart';
@@ -15,7 +14,7 @@ class EmptyState extends StatefulWidget {
14 required this.onSeeAll,
15 required this.selectedModel,
16 required this.onModelTap,
18 - this.selectedPersona,
17 + this.selectedPersonas = const [],
18 required this.onPersonaTap,
19 });
20
@@ -25,7 +24,7 @@ class EmptyState extends StatefulWidget {
24 final VoidCallback onSeeAll;
25 final dynamic selectedModel;
26 final VoidCallback onModelTap;
28 - final dynamic selectedPersona;
27 + final List<dynamic> selectedPersonas;
28 final VoidCallback onPersonaTap;
29
30 @override
@@ -201,22 +200,27 @@ class _EmptyStateState extends State<EmptyState>
200 isDark: isDark,
201 theme: theme,
202 child: Row(
204 - mainAxisSize: MainAxisSize.min,
203 + mainAxisSize: widget.selectedPersonas.isEmpty
204 + ? MainAxisSize.min
205 + : MainAxisSize.max,
206 children: [
206 - if (widget.selectedPersona != null) ...[
207 - Text(widget.selectedPersona!.emoji,
208 - style: const TextStyle(fontSize: 18)),
209 - const SizedBox(width: 8),
207 + if (widget.selectedPersonas.isNotEmpty) ...[
208 Flexible(
211 - child: Text(
212 - widget.selectedPersona!.name,
213 - style: TextStyle(
214 - fontSize: 15,
215 - fontWeight: FontWeight.w600,
216 - color: isDark ? Colors.white : Colors.black,
217 - ),
218 - maxLines: 2,
219 - overflow: TextOverflow.ellipsis,
209 + child: Wrap(
210 + spacing: 4,
211 + runSpacing: 4,
212 + children: widget.selectedPersonas.map((persona) {
213 + return Text(
214 + '${persona.emoji} ${persona.name}',
215 + style: TextStyle(
216 + fontSize: 14,
217 + fontWeight: FontWeight.w600,
218 + color: isDark ? Colors.white : Colors.black,
219 + ),
220 + maxLines: 1,
221 + overflow: TextOverflow.ellipsis,
222 + );
223 + }).toList(),
224 ),
225 ),
226 ] else ...[
lib/features/chat/views/components/message_list/message_list.dart
+71 -37
@@ -1,6 +1,7 @@
1 import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:localmind/core/models/enums.dart';
4 +import 'package:localmind/core/providers/app_providers.dart';
5 import 'package:localmind/features/chat/data/models/message.dart';
6 import 'package:localmind/features/chat/providers/chat_providers.dart';
7 import 'package:localmind/features/conversations/providers/conversation_providers.dart';
@@ -189,7 +190,7 @@ class _MessageListConsumerState extends ConsumerState<_MessageListConsumer> {
190 }
191 }
192
192 -class _MessageList extends StatelessWidget {
193 +class _MessageList extends ConsumerWidget {
194 const _MessageList({
195 required this.scrollController,
196 required this.messages,
@@ -235,11 +236,26 @@ class _MessageList extends StatelessWidget {
236 final double bottomInset;
237
238 @override
238 - Widget build(BuildContext context) {
239 + Widget build(BuildContext context, WidgetRef ref) {
240 + final selectionMode = ref.watch(messageSelectionModeProvider);
241 + final selectedIds = ref.watch(selectedMessageIdsProvider);
242 + final showSystemMessages =
243 + ref.watch(settingsProvider.select((s) => s.showSystemMessagesInChat));
244 final visibleMessages = <Message>[];
245
246 + // The streaming message only belongs at the end of the currently
247 + // displayed branch if that branch's active timeline actually resolves
248 + // to it — otherwise the user has cycled to a different variant while
249 + // a sibling variant is still generating in the background, and the
250 + // live bubble must not be appended to whatever branch is now on screen.
251 + final streamingBelongsToActiveTimeline = streamingMessage != null &&
252 + messages.any((m) => m.id == streamingMessage!.id);
253 +
254 for (final message in messages) {
242 - if (streamingMessage != null &&
255 + if (!showSystemMessages && message.role == MessageRole.system) {
256 + continue;
257 + }
258 + if (streamingBelongsToActiveTimeline &&
259 message.id == streamingMessage!.id &&
260 isStreaming) {
261 continue;
@@ -247,6 +263,9 @@ class _MessageList extends StatelessWidget {
263 visibleMessages.add(message);
264 }
265
266 + final showTrailingStreamingBubble =
267 + streamingBelongsToActiveTimeline && isStreaming;
268 +
269 return ListView.builder(
270 controller: scrollController,
271 padding: EdgeInsets.only(
@@ -255,11 +274,9 @@ class _MessageList extends StatelessWidget {
274 ),
275 itemCount:
276 visibleMessages.length +
258 - (streamingMessage != null && isStreaming ? 1 : 0),
277 + (showTrailingStreamingBubble ? 1 : 0),
278 itemBuilder: (context, index) {
260 - if (streamingMessage != null &&
261 - isStreaming &&
262 - index == visibleMessages.length) {
279 + if (showTrailingStreamingBubble && index == visibleMessages.length) {
280 return ChatBubble(
281 key: ValueKey(streamingMessage!.id),
282 message: streamingMessage!,
@@ -274,39 +291,56 @@ class _MessageList extends StatelessWidget {
291 final itemKey =
292 messageKeys.putIfAbsent(message.id, GlobalKey.new);
293
294 + final bubble = ChatBubble(
295 + key: itemKey,
296 + message: message,
297 + allMessages: this.allMessages,
298 + isStreaming: isLast &&
299 + showTrailingStreamingBubble &&
300 + message.id == streamingMessage?.id,
301 + onRetry: () => onRetry(message.id),
302 + onDelete: () => onDelete(message.id),
303 + onEdit: message.role == MessageRole.user
304 + ? () => onEdit(message.id, message.content)
305 + : message.role == MessageRole.assistant
306 + ? () => onEditAssistant(message.id, message.content)
307 + : null,
308 + onBranch: () => onBranch(message.id),
309 + onContinue: message.role == MessageRole.assistant && isLast
310 + ? () => onContinue(message.id)
311 + : null,
312 + onCycleVariant: (direction) =>
313 + onCycleVariant(message.id, direction),
314 + onModelTap: onModelPicker,
315 + onModelLongPress: message.modelId != null &&
316 + message.role == MessageRole.assistant &&
317 + onModelLongPress != null
318 + ? () => onModelLongPress!(message.modelId!)
319 + : null,
320 + onSave: onSave,
321 + onShare: onShare == null
322 + ? null
323 + : () => onShare!(message),
324 + );
325 +
326 return Column(
327 crossAxisAlignment: CrossAxisAlignment.stretch,
328 children: [
280 - ChatBubble(
281 - key: itemKey,
282 - message: message,
283 - allMessages: this.allMessages,
284 - isStreaming:
285 - isLast && isStreaming && message.id == streamingMessage?.id,
286 - onRetry: () => onRetry(message.id),
287 - onDelete: () => onDelete(message.id),
288 - onEdit: message.role == MessageRole.user
289 - ? () => onEdit(message.id, message.content)
290 - : message.role == MessageRole.assistant
291 - ? () => onEditAssistant(message.id, message.content)
292 - : null,
293 - onBranch: () => onBranch(message.id),
294 - onContinue: message.role == MessageRole.assistant && isLast
295 - ? () => onContinue(message.id)
296 - : null,
297 - onCycleVariant: (direction) =>
298 - onCycleVariant(message.id, direction),
299 - onModelTap: onModelPicker,
300 - onModelLongPress: message.modelId != null &&
301 - message.role == MessageRole.assistant &&
302 - onModelLongPress != null
303 - ? () => onModelLongPress!(message.modelId!)
304 - : null,
305 - onSave: onSave,
306 - onShare: onShare == null
307 - ? null
308 - : () => onShare!(message),
309 - ),
329 + if (selectionMode)
330 + Row(
331 + crossAxisAlignment: CrossAxisAlignment.start,
332 + children: [
333 + Checkbox(
334 + value: selectedIds.contains(message.id),
335 + onChanged: (_) => ref
336 + .read(selectedMessageIdsProvider.notifier)
337 + .toggle(message.id),
338 + ),
339 + Expanded(child: bubble),
340 + ],
341 + )
342 + else
343 + bubble,
344 if (!isStreaming &&
345 isLast &&
346 message.role == MessageRole.user &&
lib/features/chat/views/components/top_bar/persona_indicator.dart
+46 -34
@@ -1,18 +1,19 @@
1 import 'package:flutter/material.dart';
2 import 'package:localmind/core/theme/colors.dart';
3 +import 'package:localmind/features/personas/data/models/persona.dart';
4 import 'package:localmind/l10n/app_localizations.dart';
5
6 class PersonaIndicator extends StatelessWidget {
7 const PersonaIndicator({
8 super.key,
8 - required this.persona,
9 + required this.personas,
10 required this.onTap,
10 - required this.onRemove,
11 + required this.onClear,
12 });
13
13 - final dynamic persona;
14 + final List<Persona> personas;
15 final VoidCallback onTap;
15 - final VoidCallback onRemove;
16 + final VoidCallback onClear;
17
18 @override
19 Widget build(BuildContext context) {
@@ -20,51 +21,62 @@ class PersonaIndicator extends StatelessWidget {
21 final theme = Theme.of(context);
22 final isDark = theme.brightness == Brightness.dark;
23
24 + if (personas.isEmpty) return const SizedBox.shrink();
25 +
26 return Container(
27 width: double.infinity,
28 color: isDark ? AppColors.darkBackground : AppColors.lightSurface,
29 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
30 child: Row(
31 children: [
29 - GestureDetector(
30 - onTap: onTap,
31 - child: Container(
32 - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
33 - decoration: BoxDecoration(
34 - color: theme.colorScheme.primary.withValues(alpha: 0.1),
35 - borderRadius: BorderRadius.circular(16),
36 - border: Border.all(
37 - color: theme.colorScheme.primary.withValues(alpha: 0.2),
38 - ),
39 - ),
40 - child: Row(
41 - mainAxisSize: MainAxisSize.min,
42 - children: [
43 - Text(persona.emoji, style: const TextStyle(fontSize: 16)),
44 - const SizedBox(width: 6),
45 - Flexible(
46 - child: Text(
47 - persona.name,
48 - style: TextStyle(
49 - fontSize: 13,
50 - fontWeight: FontWeight.w600,
51 - color: theme.colorScheme.primary,
32 + Expanded(
33 + child: GestureDetector(
34 + onTap: onTap,
35 + child: Wrap(
36 + spacing: 6,
37 + runSpacing: 6,
38 + crossAxisAlignment: WrapCrossAlignment.center,
39 + children: personas.map((persona) {
40 + return Container(
41 + padding: const EdgeInsets.symmetric(
42 + horizontal: 10,
43 + vertical: 6,
44 + ),
45 + decoration: BoxDecoration(
46 + color: theme.colorScheme.primary.withValues(alpha: 0.1),
47 + borderRadius: BorderRadius.circular(16),
48 + border: Border.all(
49 + color:
50 + theme.colorScheme.primary.withValues(alpha: 0.2),
51 ),
53 - maxLines: 2,
54 - overflow: TextOverflow.ellipsis,
52 ),
56 - ),
57 - ],
53 + child: Row(
54 + mainAxisSize: MainAxisSize.min,
55 + children: [
56 + Text(persona.emoji, style: const TextStyle(fontSize: 16)),
57 + const SizedBox(width: 6),
58 + Text(
59 + persona.name,
60 + style: TextStyle(
61 + fontSize: 13,
62 + fontWeight: FontWeight.w600,
63 + color: theme.colorScheme.primary,
64 + ),
65 + ),
66 + ],
67 + ),
68 + );
69 + }).toList(),
70 ),
71 ),
72 ),
61 - const Spacer(),
73 + const SizedBox(width: 8),
74 IconButton(
75 icon: const Icon(Icons.close, size: 16),
64 - onPressed: onRemove,
76 + onPressed: onClear,
77 padding: EdgeInsets.zero,
78 constraints: const BoxConstraints(),
67 - tooltip: l10n.remove_persona,
79 + tooltip: l10n.clear_personas,
80 color: isDark ? Colors.white38 : Colors.black38,
81 ),
82 ],
lib/features/conversations/data/models/conversation.dart
+4
@@ -21,6 +21,7 @@ class Conversation {
21 final bool isTemporary;
22 final bool isArchived;
23 final int characterCount;
24 + final int? totalTokenCount;
25
26 Conversation({
27 required this.id,
@@ -45,6 +46,7 @@ class Conversation {
46 this.isTemporary = false,
47 this.isArchived = false,
48 this.characterCount = 0,
49 + this.totalTokenCount,
50 });
51
52 Conversation copyWith({
@@ -80,6 +82,7 @@ class Conversation {
82 bool? isTemporary,
83 bool? isArchived,
84 int? characterCount,
85 + int? totalTokenCount,
86 }) {
87 return Conversation(
88 id: id ?? this.id,
@@ -108,6 +111,7 @@ class Conversation {
111 isTemporary: isTemporary ?? this.isTemporary,
112 isArchived: isArchived ?? this.isArchived,
113 characterCount: characterCount ?? this.characterCount,
114 + totalTokenCount: totalTokenCount ?? this.totalTokenCount,
115 );
116 }
117 }
lib/features/conversations/providers/conversation_providers.dart
+182 -15
@@ -5,10 +5,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
5 import '../../../core/providers/storage_providers.dart';
6 import '../../../core/storage/entities.dart';
7 import '../../../objectbox.g.dart';
8 +import '../../chat/utils/message_variants.dart';
9 import '../data/message_search_service.dart';
10 import '../data/models/message_search_hit.dart';
11 import '../data/models/conversation.dart';
12 import '../data/models/conversation_folder.dart';
13 +import '../../personas/data/models/persona.dart';
14 +import '../../personas/utils/persona_prompt_utils.dart';
15
16 final conversationsProvider =
17 AsyncNotifierProvider<ConversationsNotifier, List<Conversation>>(() {
@@ -32,8 +35,35 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
35
36 static List<Conversation> _loadConversationsInBackground(Store store, _) {
37 final convBox = store.box<ConversationEntity>();
38 + final msgBox = store.box<MessageEntity>();
39 final entities = convBox.getAll();
36 - final conversations = entities.map((e) => e.toDomain()).toList();
40 + final conversations = <Conversation>[];
41 +
42 + for (final entity in entities) {
43 + final domain = entity.toDomain();
44 + final query = msgBox
45 + .query(MessageEntity_.conversationUid.equals(domain.id))
46 + .build();
47 + final messages = query.find();
48 + query.close();
49 +
50 + // A flat `isActiveVariant` filter counts every message ever marked
51 + // active within its own variant group, including messages hanging
52 + // off a branch whose ancestor is no longer the active variant.
53 + // Resolve the real connected timeline from the root instead.
54 + final activeMessages = MessageVariants.resolveActiveTimeline(
55 + messages.map((e) => e.toDomain()).toList(),
56 + );
57 + final count = activeMessages.length;
58 + final chars = activeMessages.fold<int>(
59 + 0,
60 + (sum, message) => sum + message.content.length,
61 + );
62 +
63 + conversations.add(
64 + domain.copyWith(messageCount: count, characterCount: chars),
65 + );
66 + }
67
68 conversations.sort((a, b) {
69 if (a.isPinned != b.isPinned) {
@@ -53,6 +83,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
83 String? modelId,
84 bool? mcpEnabled,
85 bool isTemporary = false,
86 + String? folderId,
87 }) async {
88 final db = ref.read(databaseProvider);
89 final now = DateTime.now();
@@ -72,6 +103,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
103 systemPrompt: systemPrompt,
104 mcpEnabled: mcpEnabled,
105 isTemporary: isTemporary,
106 + folderId: folderId,
107 );
108
109 db.conversationBox.put(ConversationEntity.fromDomain(conversation));
@@ -114,10 +146,9 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
146 orElse: () => throw Exception('Conversation not found in state'),
147 );
148
117 - final updated = existing.copyWith(
118 - title: newTitle,
119 - updatedAt: DateTime.now(),
120 - );
149 + // Renaming is a metadata edit, not conversation activity — don't bump
150 + // updatedAt, since that's used as the "last modified" sort/section date.
151 + final updated = existing.copyWith(title: newTitle);
152
153 final query = db.conversationBox
154 .query(ConversationEntity_.id.equals(id))
@@ -276,7 +307,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
307 final updated = existing.copyWith(
308 lastMessagePreview: preview,
309 updatedAt: updatedAt,
279 - messageCount: messageCount ?? existing.messageCount + 1,
310 + messageCount: messageCount ?? existing.messageCount,
311 characterCount: characterCount ??
312 (characterCountDelta != null
313 ? existing.characterCount + characterCountDelta
@@ -299,6 +330,76 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
330 }
331 }
332
333 + Future<void> syncConversationStats(
334 + String id, {
335 + required int messageCount,
336 + required int characterCount,
337 + String? preview,
338 + }) async {
339 + final db = ref.read(databaseProvider);
340 + final conversations = state.value ?? [];
341 + final existingIndex = conversations.indexWhere((c) => c.id == id);
342 + if (existingIndex == -1) return;
343 +
344 + final existing = conversations[existingIndex];
345 + final updated = existing.copyWith(
346 + messageCount: messageCount,
347 + characterCount: characterCount,
348 + lastMessagePreview: preview ?? existing.lastMessagePreview,
349 + updatedAt: DateTime.now(),
350 + );
351 +
352 + final query = db.conversationBox
353 + .query(ConversationEntity_.id.equals(id))
354 + .build();
355 + final existingEntity = query.findFirst();
356 + query.close();
357 +
358 + final entity = ConversationEntity.fromDomain(updated);
359 + if (existingEntity != null) {
360 + entity.internalId = existingEntity.internalId;
361 + }
362 + db.conversationBox.put(entity);
363 + state = AsyncData(await _loadAll());
364 + }
365 +
366 + /// Updates just the embeddings-derived total token count without
367 + /// touching `updatedAt` — this runs as a background recount after every
368 + /// send/edit/regenerate and shouldn't bump the conversation's sort order
369 + /// or last-modified time on its own.
370 + Future<void> updateTokenCount(String id, int totalTokenCount) async {
371 + final db = ref.read(databaseProvider);
372 + final conversations = state.value ?? [];
373 + final existingIndex = conversations.indexWhere((c) => c.id == id);
374 + if (existingIndex == -1) return;
375 +
376 + final existing = conversations[existingIndex];
377 + final updated = existing.copyWith(totalTokenCount: totalTokenCount);
378 +
379 + final query = db.conversationBox
380 + .query(ConversationEntity_.id.equals(id))
381 + .build();
382 + final existingEntity = query.findFirst();
383 + query.close();
384 +
385 + final entity = ConversationEntity.fromDomain(updated);
386 + if (existingEntity != null) {
387 + entity.internalId = existingEntity.internalId;
388 + }
389 + db.conversationBox.put(entity);
390 + state = AsyncData(await _loadAll());
391 + }
392 +
393 + Future<void> updatePersonas(String id, List<Persona> personas) async {
394 + final personaId = personas.isEmpty
395 + ? null
396 + : PersonaPromptUtils.joinPersonaIds(personas.map((p) => p.id).toList());
397 + final systemPrompt = personas.isEmpty
398 + ? null
399 + : PersonaPromptUtils.combineSystemPrompts(personas);
400 + await updatePersona(id, personaId, systemPrompt);
401 + }
402 +
403 Future<void> updatePersona(
404 String id,
405 String? personaId,
@@ -464,10 +565,12 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
565 final db = ref.read(databaseProvider);
566 final conversations = state.value ?? [];
567 final existing = conversations.firstWhere((c) => c.id == id);
568 + // Moving folders is a metadata edit, not conversation activity — don't
569 + // bump updatedAt, since that's used as the "last modified" sort/section
570 + // date.
571 final updated = existing.copyWith(
572 folderId: folderId,
573 clearFolderId: folderId == null,
470 - updatedAt: DateTime.now(),
574 );
575
576 final query = db.conversationBox.query(ConversationEntity_.id.equals(id)).build();
@@ -534,6 +637,23 @@ class ConversationSearchNotifier extends Notifier<String> {
637 }
638 }
639
640 +enum HistorySortOption { modified, created }
641 +
642 +final historySortOptionProvider =
643 + NotifierProvider<HistorySortOptionNotifier, HistorySortOption>(() {
644 + return HistorySortOptionNotifier();
645 + });
646 +
647 +class HistorySortOptionNotifier extends Notifier<HistorySortOption> {
648 + @override
649 + HistorySortOption build() => HistorySortOption.modified;
650 +
651 + void setOption(HistorySortOption option) => state = option;
652 +}
653 +
654 +DateTime historySortDate(Conversation c, HistorySortOption option) =>
655 + option == HistorySortOption.created ? c.createdAt : c.updatedAt;
656 +
657 final filteredConversationsProvider = Provider<AsyncValue<List<Conversation>>>((
658 ref,
659 ) {
@@ -541,6 +661,7 @@ final filteredConversationsProvider = Provider<AsyncValue<List<Conversation>>>((
661 final query = ref.watch(conversationSearchProvider).toLowerCase();
662 final folderFilter = ref.watch(historyFolderFilterProvider);
663 final listFilter = ref.watch(historyListFilterProvider);
664 + final sortOption = ref.watch(historySortOptionProvider);
665
666 return conversationsAsync.whenData((conversations) {
667 var filtered = conversations.where((c) => !c.isTemporary).toList();
@@ -563,11 +684,20 @@ final filteredConversationsProvider = Provider<AsyncValue<List<Conversation>>>((
684 filtered = filtered.where((c) => c.folderId == folderFilter).toList();
685 }
686 }
566 - if (query.isEmpty) return filtered;
567 - return filtered.where((c) {
568 - return c.title.toLowerCase().contains(query) ||
569 - (c.lastMessagePreview?.toLowerCase().contains(query) ?? false);
570 - }).toList();
687 + if (query.isNotEmpty) {
688 + filtered = filtered.where((c) {
689 + return c.title.toLowerCase().contains(query) ||
690 + (c.lastMessagePreview?.toLowerCase().contains(query) ?? false);
691 + }).toList();
692 + }
693 +
694 + filtered.sort((a, b) {
695 + if (a.isPinned != b.isPinned) return a.isPinned ? -1 : 1;
696 + return historySortDate(b, sortOption).compareTo(
697 + historySortDate(a, sortOption),
698 + );
699 + });
700 + return filtered;
701 });
702 });
703
@@ -580,6 +710,7 @@ final recentConversationsProvider = Provider<List<Conversation>>((ref) {
710 final groupedConversationsProvider =
711 Provider<AsyncValue<Map<String, List<Conversation>>>>((ref) {
712 final filteredAsync = ref.watch(filteredConversationsProvider);
713 + final sortOption = ref.watch(historySortOptionProvider);
714
715 return filteredAsync.whenData((conversations) {
716 final now = DateTime.now();
@@ -591,10 +722,11 @@ final groupedConversationsProvider =
722 final grouped = <String, List<Conversation>>{};
723
724 for (final conversation in conversations) {
725 + final sectionDate = historySortDate(conversation, sortOption);
726 final convDate = DateTime(
595 - conversation.updatedAt.year,
596 - conversation.updatedAt.month,
597 - conversation.updatedAt.day,
727 + sectionDate.year,
728 + sectionDate.month,
729 + sectionDate.day,
730 );
731
732 String section;
@@ -645,6 +777,41 @@ class HistoryFolderFilterNotifier extends Notifier<String?> {
777 void setFilter(String? folderId) => state = folderId;
778 }
779
780 +final historySelectionModeProvider =
781 + NotifierProvider<HistorySelectionModeNotifier, bool>(
782 + HistorySelectionModeNotifier.new,
783 + );
784 +
785 +class HistorySelectionModeNotifier extends Notifier<bool> {
786 + @override
787 + bool build() => false;
788 +
789 + void enable() => state = true;
790 +
791 + void disable() {
792 + state = false;
793 + ref.read(historySelectedIdsProvider.notifier).clear();
794 + }
795 +}
796 +
797 +final historySelectedIdsProvider =
798 + NotifierProvider<HistorySelectedIdsNotifier, Set<String>>(
799 + HistorySelectedIdsNotifier.new,
800 + );
801 +
802 +class HistorySelectedIdsNotifier extends Notifier<Set<String>> {
803 + @override
804 + Set<String> build() => const {};
805 +
806 + void toggle(String id) {
807 + final updated = {...state};
808 + if (!updated.remove(id)) updated.add(id);
809 + state = updated;
810 + }
811 +
812 + void clear() => state = const {};
813 +}
814 +
815 final conversationFoldersProvider =
816 AsyncNotifierProvider<ConversationFoldersNotifier, List<ConversationFolder>>(
817 () => ConversationFoldersNotifier(),
lib/features/conversations/views/chat_history_screen.dart
+191 -84
@@ -1,6 +1,9 @@
1 import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:go_router/go_router.dart';
4 import 'package:localmind/core/components/list_filter_button.dart';
5 +import 'package:localmind/core/routes/app_routes.dart';
6 +import 'package:localmind/features/chat/providers/chat_providers.dart';
7 import 'package:localmind/l10n/app_localizations.dart';
8 import '../providers/conversation_providers.dart';
9 import 'components/conversation_folder_bar.dart';
@@ -23,103 +26,207 @@ class ChatHistoryScreen extends ConsumerWidget {
26 final searchQuery = ref.watch(conversationSearchProvider);
27 final messageSearchHits = ref.watch(messageSearchResultsProvider);
28 final topPadding = MediaQuery.of(context).padding.top;
29 + final selectionMode = ref.watch(historySelectionModeProvider);
30 + final selectedIds = ref.watch(historySelectedIdsProvider);
31 + final currentFolder = ref.watch(historyFolderFilterProvider);
32
27 - return Column(
33 + return Stack(
34 children: [
29 - Container(
30 - padding: EdgeInsets.only(
31 - left: 16,
32 - right: 16,
33 - top: topPadding + 8,
34 - bottom: 16,
35 - ),
36 - decoration: BoxDecoration(
37 - color: isDark ? const Color(0xFF0A0A0A) : const Color(0xFFFAFAFA),
38 - border: Border(
39 - bottom: BorderSide(
40 - color: isDark
41 - ? const Color(0xFF2A2A2A)
42 - : const Color(0xFFE5E5E5),
43 - ),
35 + Column(
36 + children: [
37 + Container(
38 + padding: EdgeInsets.only(
39 + left: 16,
40 + right: 16,
41 + top: topPadding + 8,
42 + bottom: 16,
43 ),
45 - ),
46 - child: Row(
47 - children: [
48 - Builder(
49 - builder: (context) => IconButton(
50 - icon: const Icon(Icons.menu),
51 - onPressed: () => Scaffold.of(context).openDrawer(),
52 - ),
53 - ),
54 - const SizedBox(width: 8),
55 - Text(
56 - l10n.chat_history_title,
57 - style: TextStyle(
58 - fontSize: 20,
59 - fontWeight: FontWeight.bold,
60 - color: isDark ? Colors.white : Colors.black,
44 + decoration: BoxDecoration(
45 + color: isDark ? const Color(0xFF0A0A0A) : const Color(0xFFFAFAFA),
46 + border: Border(
47 + bottom: BorderSide(
48 + color: isDark
49 + ? const Color(0xFF2A2A2A)
50 + : const Color(0xFFE5E5E5),
51 ),
52 ),
63 - const Spacer(),
64 - ListFilterButton<HistoryListFilter>(
65 - tooltip: l10n.filter_title,
66 - selected: ref.watch(historyListFilterProvider),
67 - onChanged: (filter) => ref
68 - .read(historyListFilterProvider.notifier)
69 - .setFilter(filter),
70 - options: [
71 - ListFilterOption(
72 - value: HistoryListFilter.all,
73 - label: l10n.all_chats,
74 - icon: Icons.view_list_rounded,
75 - ),
76 - ListFilterOption(
77 - value: HistoryListFilter.pinned,
78 - label: l10n.filter_pinned,
79 - icon: Icons.push_pin_outlined,
80 - ),
81 - ListFilterOption(
82 - value: HistoryListFilter.archived,
83 - label: l10n.filter_archived,
84 - icon: Icons.archive_outlined,
53 + ),
54 + child: selectionMode
55 + ? Row(
56 + children: [
57 + IconButton(
58 + icon: const Icon(Icons.close),
59 + onPressed: () => ref
60 + .read(historySelectionModeProvider.notifier)
61 + .disable(),
62 + ),
63 + Expanded(
64 + child: Text(
65 + l10n.selected_count(selectedIds.length),
66 + style: TextStyle(
67 + fontSize: 16,
68 + fontWeight: FontWeight.w600,
69 + color: isDark ? Colors.white : Colors.black,
70 + ),
71 + ),
72 + ),
73 + IconButton(
74 + icon: const Icon(Icons.folder_outlined),
75 + tooltip: l10n.move_to_folder,
76 + onPressed: selectedIds.isEmpty
77 + ? null
78 + : () => showBulkMoveToFolderSheet(
79 + context, ref, selectedIds),
80 + ),
81 + IconButton(
82 + icon: const Icon(Icons.ios_share),
83 + tooltip: l10n.export_conversation,
84 + onPressed: selectedIds.isEmpty
85 + ? null
86 + : () => runBulkExportConversations(
87 + context, ref, selectedIds),
88 + ),
89 + IconButton(
90 + icon: const Icon(Icons.auto_awesome_outlined),
91 + tooltip: l10n.ai_rename_tooltip,
92 + onPressed: selectedIds.isEmpty
93 + ? null
94 + : () async {
95 + final ids = selectedIds.toList();
96 + ref
97 + .read(historySelectionModeProvider.notifier)
98 + .disable();
99 + await runBulkAiRename(context, ref, ids);
100 + },
101 + ),
102 + ],
103 + )
104 + : Row(
105 + children: [
106 + Builder(
107 + builder: (context) => IconButton(
108 + icon: const Icon(Icons.menu),
109 + onPressed: () => Scaffold.of(context).openDrawer(),
110 + ),
111 + ),
112 + const SizedBox(width: 8),
113 + Text(
114 + l10n.chat_history_title,
115 + style: TextStyle(
116 + fontSize: 20,
117 + fontWeight: FontWeight.bold,
118 + color: isDark ? Colors.white : Colors.black,
119 + ),
120 + ),
121 + const Spacer(),
122 + IconButton(
123 + icon: const Icon(Icons.checklist_outlined),
124 + tooltip: l10n.select,
125 + onPressed: () => ref
126 + .read(historySelectionModeProvider.notifier)
127 + .enable(),
128 + ),
129 + ListFilterButton<HistorySortOption>(
130 + tooltip: l10n.sort_title,
131 + icon: Icons.sort,
132 + showBadgeWhenNotDefault: false,
133 + selected: ref.watch(historySortOptionProvider),
134 + onChanged: (option) => ref
135 + .read(historySortOptionProvider.notifier)
136 + .setOption(option),
137 + options: [
138 + ListFilterOption(
139 + value: HistorySortOption.modified,
140 + label: l10n.sort_by_modified_date,
141 + icon: Icons.edit_calendar_outlined,
142 + ),
143 + ListFilterOption(
144 + value: HistorySortOption.created,
145 + label: l10n.sort_by_created_date,
146 + icon: Icons.calendar_today_outlined,
147 + ),
148 + ],
149 + ),
150 + ListFilterButton<HistoryListFilter>(
151 + tooltip: l10n.filter_title,
152 + selected: ref.watch(historyListFilterProvider),
153 + onChanged: (filter) => ref
154 + .read(historyListFilterProvider.notifier)
155 + .setFilter(filter),
156 + options: [
157 + ListFilterOption(
158 + value: HistoryListFilter.all,
159 + label: l10n.all_chats,
160 + icon: Icons.view_list_rounded,
161 + ),
162 + ListFilterOption(
163 + value: HistoryListFilter.pinned,
164 + label: l10n.filter_pinned,
165 + icon: Icons.push_pin_outlined,
166 + ),
167 + ListFilterOption(
168 + value: HistoryListFilter.archived,
169 + label: l10n.filter_archived,
170 + icon: Icons.archive_outlined,
171 + ),
172 + ],
173 + ),
174 + ],
175 ),
86 - ],
87 - ),
88 - ],
176 ),
90 - ),
177
92 - const ConversationSearchBar(),
93 - const ConversationFolderBar(),
94 - if (messageSearchHits.isNotEmpty)
95 - ConstrainedBox(
96 - constraints: BoxConstraints(
97 - maxHeight: (MediaQuery.sizeOf(context).height * 0.35)
98 - .clamp(120.0, 280.0),
99 - ),
100 - child: const SingleChildScrollView(
101 - child: MessageSearchResultsList(),
178 + const ConversationSearchBar(),
179 + const ConversationFolderBar(),
180 + if (messageSearchHits.isNotEmpty)
181 + ConstrainedBox(
182 + constraints: BoxConstraints(
183 + maxHeight: (MediaQuery.sizeOf(context).height * 0.35)
184 + .clamp(120.0, 280.0),
185 + ),
186 + child: const SingleChildScrollView(
187 + child: MessageSearchResultsList(),
188 + ),
189 ),
103 - ),
190
105 - Expanded(
106 - child: groupedConversations.when(
107 - data: (grouped) => grouped.isEmpty
108 - ? ConversationEmptyState(isSearching: searchQuery.isNotEmpty)
109 - : ConversationList(
110 - groupedConversations: grouped,
111 - activeConversation: activeConversation,
112 - ),
113 - loading: () => const Center(
114 - child: CircularProgressIndicator(strokeWidth: 2),
115 - ),
116 - error: (err, stack) => Center(
117 - child: Text(
118 - l10n.error_with_message(err.toString()),
119 - style: TextStyle(color: theme.colorScheme.error),
191 + Expanded(
192 + child: groupedConversations.when(
193 + data: (grouped) => grouped.isEmpty
194 + ? ConversationEmptyState(isSearching: searchQuery.isNotEmpty)
195 + : ConversationList(
196 + groupedConversations: grouped,
197 + activeConversation: activeConversation,
198 + ),
199 + loading: () => const Center(
200 + child: CircularProgressIndicator(strokeWidth: 2),
201 + ),
202 + error: (err, stack) => Center(
203 + child: Text(
204 + l10n.error_with_message(err.toString()),
205 + style: TextStyle(color: theme.colorScheme.error),
206 + ),
207 ),
208 ),
209 ),
210 + ],
211 + ),
212 + if (!selectionMode)
213 + PositionedDirectional(
214 + bottom: 24,
215 + end: 24,
216 + child: FloatingActionButton(
217 + tooltip: l10n.new_chat_in_folder_tooltip,
218 + onPressed: () {
219 + final folderId = (currentFolder != null && currentFolder.isNotEmpty)
220 + ? currentFolder
221 + : null;
222 + ref
223 + .read(pendingNewChatFolderIdProvider.notifier)
224 + .set(folderId);
225 + ref.read(chatProvider.notifier).startNewConversation();
226 + context.go(AppRoutes.home);
227 + },
228 + child: const Icon(Icons.add),
229 + ),
230 ),
231 ],
232 );
lib/features/conversations/views/components/conversation_list.dart
+198 -55
@@ -1,13 +1,10 @@
1 -import 'dart:convert';
2 -import 'dart:typed_data';
3 -
4 -import 'package:file_picker/file_picker.dart';
1 import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 import 'package:go_router/go_router.dart';
4 import 'package:localmind/l10n/app_localizations.dart';
5 import 'package:localmind/core/providers/storage_providers.dart';
6 import 'package:localmind/core/services/data_backup_service.dart';
7 +import 'package:localmind/core/services/export_choice_dialog.dart';
8 import '../../../../core/routes/app_routes.dart';
9 import '../../../chat/providers/chat_providers.dart';
10 import '../../data/models/conversation.dart';
@@ -29,6 +26,8 @@ class ConversationList extends ConsumerWidget {
26 @override
27 Widget build(BuildContext context, WidgetRef ref) {
28 final l10n = AppLocalizations.of(context)!;
29 + final selectionMode = ref.watch(historySelectionModeProvider);
30 + final selectedIds = ref.watch(historySelectedIdsProvider);
31 final sectionOrder = [
32 l10n.pinned_section,
33 l10n.today_section,
@@ -63,10 +62,27 @@ class ConversationList extends ConsumerWidget {
62 return ConversationTile(
63 conversation: conversation,
64 isActive: activeConversation?.id == conversation.id,
65 + selectionMode: selectionMode,
66 + isSelected: selectedIds.contains(conversation.id),
67 + onEnterSelectionMode: () {
68 + ref.read(historySelectionModeProvider.notifier).enable();
69 + ref
70 + .read(historySelectedIdsProvider.notifier)
71 + .toggle(conversation.id);
72 + },
73 onTap: () {
74 + if (selectionMode) {
75 + ref
76 + .read(historySelectedIdsProvider.notifier)
77 + .toggle(conversation.id);
78 + return;
79 + }
80 ref
81 .read(chatProvider.notifier)
82 .loadConversation(conversation);
83 + ref
84 + .read(chatOriginProvider.notifier)
85 + .set(ChatOrigin.history);
86 if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
87 Navigator.pop(context);
88 }
@@ -92,7 +108,7 @@ class ConversationList extends ConsumerWidget {
108 );
109 },
110 onMoveToFolder: () {
95 - _showMoveToFolderSheet(context, ref, l10n, conversation);
111 + showMoveToFolderSheet(context, ref, l10n, conversation);
112 },
113 onExport: () {
114 _exportConversation(context, ref, l10n, conversation);
@@ -153,71 +169,198 @@ class ConversationList extends ConsumerWidget {
169 );
170 }
171
156 - void _showMoveToFolderSheet(
172 + Future<void> _exportConversation(
173 BuildContext context,
174 WidgetRef ref,
175 AppLocalizations l10n,
176 Conversation conversation,
161 - ) {
162 - final folders = ref.read(conversationFoldersProvider).value ?? [];
177 + ) async {
178 + final db = ref.read(databaseProvider);
179 + final json = DataBackupService()
180 + .exportConversationAsJson(db.store, conversation.id);
181 + if (!context.mounted) return;
182 + await showExportChoiceDialog(
183 + context,
184 + content: json,
185 + subject: conversation.title,
186 + );
187 + }
188 +}
189
164 - showModalBottomSheet<void>(
165 - context: context,
166 - builder: (ctx) {
167 - return SafeArea(
168 - child: Column(
169 - mainAxisSize: MainAxisSize.min,
170 - children: [
171 - ListTile(
172 - leading: const Icon(Icons.folder_off_outlined),
173 - title: Text(l10n.remove_from_folder),
190 +/// Shown from a single conversation's overflow menu (in history, or from
191 +/// the chat screen itself) to move it into a folder or remove it from one.
192 +void showMoveToFolderSheet(
193 + BuildContext context,
194 + WidgetRef ref,
195 + AppLocalizations l10n,
196 + Conversation conversation,
197 +) {
198 + final folders = ref.read(conversationFoldersProvider).value ?? [];
199 +
200 + showModalBottomSheet<void>(
201 + context: context,
202 + builder: (ctx) {
203 + return SafeArea(
204 + child: Column(
205 + mainAxisSize: MainAxisSize.min,
206 + children: [
207 + ListTile(
208 + leading: const Icon(Icons.folder_off_outlined),
209 + title: Text(l10n.remove_from_folder),
210 + onTap: () async {
211 + Navigator.pop(ctx);
212 + await ref
213 + .read(conversationsProvider.notifier)
214 + .moveConversationToFolder(conversation.id, null);
215 + },
216 + ),
217 + ...folders.map(
218 + (folder) => ListTile(
219 + leading: const Icon(Icons.folder_outlined),
220 + title: Text(folder.name),
221 onTap: () async {
222 Navigator.pop(ctx);
223 await ref
224 .read(conversationsProvider.notifier)
178 - .moveConversationToFolder(conversation.id, null);
225 + .moveConversationToFolder(conversation.id, folder.id);
226 },
227 ),
181 - ...folders.map(
182 - (folder) => ListTile(
183 - leading: const Icon(Icons.folder_outlined),
184 - title: Text(folder.name),
185 - onTap: () async {
186 - Navigator.pop(ctx);
228 + ),
229 + ],
230 + ),
231 + );
232 + },
233 + );
234 +}
235 +
236 +Future<void> showBulkMoveToFolderSheet(
237 + BuildContext context,
238 + WidgetRef ref,
239 + Set<String> conversationIds,
240 +) async {
241 + final l10n = AppLocalizations.of(context)!;
242 + final folders = ref.read(conversationFoldersProvider).value ?? [];
243 +
244 + await showModalBottomSheet<void>(
245 + context: context,
246 + builder: (ctx) {
247 + return SafeArea(
248 + child: Column(
249 + mainAxisSize: MainAxisSize.min,
250 + children: [
251 + ListTile(
252 + leading: const Icon(Icons.folder_off_outlined),
253 + title: Text(l10n.remove_from_folder),
254 + onTap: () async {
255 + Navigator.pop(ctx);
256 + for (final id in conversationIds) {
257 + await ref
258 + .read(conversationsProvider.notifier)
259 + .moveConversationToFolder(id, null);
260 + }
261 + },
262 + ),
263 + ...folders.map(
264 + (folder) => ListTile(
265 + leading: const Icon(Icons.folder_outlined),
266 + title: Text(folder.name),
267 + onTap: () async {
268 + Navigator.pop(ctx);
269 + for (final id in conversationIds) {
270 await ref
271 .read(conversationsProvider.notifier)
189 - .moveConversationToFolder(conversation.id, folder.id);
190 - },
191 - ),
272 + .moveConversationToFolder(id, folder.id);
273 + }
274 + },
275 ),
193 - ],
194 - ),
195 - );
196 - },
197 - );
198 - }
199 -
200 - Future<void> _exportConversation(
201 - BuildContext context,
202 - WidgetRef ref,
203 - AppLocalizations l10n,
204 - Conversation conversation,
205 - ) async {
206 - final db = ref.read(databaseProvider);
207 - final json = DataBackupService()
208 - .exportConversationAsJson(db.store, conversation.id);
209 - final saved = await FilePicker.saveFile(
210 - dialogTitle: l10n.export_conversation,
211 - fileName:
212 - 'localmind_${conversation.title.replaceAll(RegExp(r'[^\w\-]+'), '_')}_${DateTime.now().millisecondsSinceEpoch}.json',
213 - type: FileType.custom,
214 - allowedExtensions: const ['json'],
215 - bytes: Uint8List.fromList(utf8.encode(json)),
216 - );
217 - if (saved != null && context.mounted) {
218 - ScaffoldMessenger.of(context).showSnackBar(
219 - SnackBar(content: Text(l10n.export_data_success)),
276 + ),
277 + ],
278 + ),
279 );
280 + },
281 + );
282 +}
283 +
284 +Future<void> runBulkExportConversations(
285 + BuildContext context,
286 + WidgetRef ref,
287 + Set<String> conversationIds,
288 +) async {
289 + final l10n = AppLocalizations.of(context)!;
290 + final db = ref.read(databaseProvider);
291 + final json = DataBackupService()
292 + .exportConversationsForIdsAsJson(db.store, conversationIds);
293 + if (!context.mounted) return;
294 + await showExportChoiceDialog(
295 + context,
296 + content: json,
297 + subject: l10n.bulk_export_conversations_success(conversationIds.length),
298 + );
299 +}
300 +
301 +Future<void> runBulkAiRename(
302 + BuildContext context,
303 + WidgetRef ref,
304 + List<String> conversationIds,
305 +) async {
306 + final l10n = AppLocalizations.of(context)!;
307 +
308 + final confirmed = await showDialog<bool>(
309 + context: context,
310 + builder: (ctx) => AlertDialog(
311 + title: Text(l10n.bulk_ai_rename_confirm_title),
312 + content: Text(
313 + l10n.bulk_ai_rename_confirm_body(conversationIds.length),
314 + ),
315 + actions: [
316 + TextButton(
317 + onPressed: () => Navigator.pop(ctx, false),
318 + child: Text(l10n.cancel),
319 + ),
320 + FilledButton(
321 + onPressed: () => Navigator.pop(ctx, true),
322 + child: Text(l10n.confirm),
323 + ),
324 + ],
325 + ),
326 + );
327 + if (confirmed != true) return;
328 + if (!context.mounted) return;
329 +
330 + final progress = ValueNotifier<int>(0);
331 +
332 + showDialog<void>(
333 + context: context,
334 + barrierDismissible: false,
335 + builder: (ctx) => AlertDialog(
336 + content: ValueListenableBuilder<int>(
337 + valueListenable: progress,
338 + builder: (context, done, _) => Row(
339 + children: [
340 + const SizedBox(
341 + width: 20,
342 + height: 20,
343 + child: CircularProgressIndicator(strokeWidth: 2),
344 + ),
345 + const SizedBox(width: 16),
346 + Expanded(
347 + child: Text(
348 + l10n.bulk_ai_rename_progress(done, conversationIds.length),
349 + ),
350 + ),
351 + ],
352 + ),
353 + ),
354 + ),
355 + );
356 +
357 + for (final id in conversationIds) {
358 + final title = await ref.read(chatProvider.notifier).generateTitleWithAi(id);
359 + if (title != null && title.isNotEmpty) {
360 + await ref.read(conversationsProvider.notifier).renameConversation(id, title);
361 }
362 + progress.value += 1;
363 }
364 +
365 + if (context.mounted) Navigator.of(context, rootNavigator: true).pop();
366 }
lib/features/conversations/views/components/conversation_tile.dart
+47 -9
@@ -17,6 +17,9 @@ class ConversationTile extends StatelessWidget {
17 required this.onMoveToFolder,
18 required this.onExport,
19 required this.onArchive,
20 + this.selectionMode = false,
21 + this.isSelected = false,
22 + this.onEnterSelectionMode,
23 });
24
25 final Conversation conversation;
@@ -29,6 +32,9 @@ class ConversationTile extends StatelessWidget {
32 final VoidCallback onMoveToFolder;
33 final VoidCallback onExport;
34 final VoidCallback onArchive;
35 + final bool selectionMode;
36 + final bool isSelected;
37 + final VoidCallback? onEnterSelectionMode;
38
39 String _formatTimestamp(AppLocalizations l10n, DateTime dateTime) {
40 final now = DateTime.now();
@@ -57,15 +63,30 @@ class ConversationTile extends StatelessWidget {
63
64 return Dismissible(
65 key: Key(conversation.id),
60 - direction: DismissDirection.endToStart,
66 + direction: DismissDirection.horizontal,
67 background: Container(
68 + alignment: AlignmentDirectional.centerStart,
69 + padding: const EdgeInsetsDirectional.only(start: 16),
70 + color: Colors.blue,
71 + child: Icon(
72 + conversation.isArchived
73 + ? Icons.unarchive_outlined
74 + : Icons.archive_outlined,
75 + color: Colors.white,
76 + ),
77 + ),
78 + secondaryBackground: Container(
79 alignment: AlignmentDirectional.centerEnd,
80 padding: const EdgeInsetsDirectional.only(end: 16),
81 color: Colors.red,
82 child: const Icon(Icons.delete, color: Colors.white),
83 ),
84 confirmDismiss: (direction) async {
68 - onDelete();
85 + if (direction == DismissDirection.startToEnd) {
86 + onArchive();
87 + } else {
88 + onDelete();
89 + }
90 return false;
91 },
92 child: Material(
@@ -81,11 +102,14 @@ class ConversationTile extends StatelessWidget {
102 padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
103 child: Row(
104 children: [
84 - Icon(
85 - conversation.isPinned
86 - ? Icons.push_pin
87 - : Icons.chat_bubble_outline,
88 - size: 20,
105 + if (selectionMode)
106 + Checkbox(value: isSelected, onChanged: (_) => onTap())
107 + else
108 + Icon(
109 + conversation.isPinned
110 + ? Icons.push_pin
111 + : Icons.chat_bubble_outline,
112 + size: 20,
113 color: isActive
114 ? (isDark
115 ? AppColors.darkAccent
@@ -93,7 +117,7 @@ class ConversationTile extends StatelessWidget {
117 : (isDark
118 ? AppColors.darkMutedText
119 : AppColors.lightMutedText),
96 - ),
120 + ),
121 const SizedBox(width: 12),
122 Expanded(
123 child: Column(
@@ -127,7 +151,12 @@ class ConversationTile extends StatelessWidget {
151 ],
152 const SizedBox(height: 2),
153 Text(
130 - '${l10n.conversation_message_count(conversation.messageCount)} · ${l10n.conversation_character_count(conversation.characterCount)}',
154 + [
155 + l10n.conversation_message_count(conversation.messageCount),
156 + l10n.conversation_character_count(conversation.characterCount),
157 + if (conversation.totalTokenCount != null)
158 + l10n.total_tokens_count(conversation.totalTokenCount!),
159 + ].join(' · '),
160 style: TextStyle(
161 fontSize: 11,
162 color: isDark
@@ -179,6 +208,15 @@ class ConversationTile extends StatelessWidget {
208 child: Column(
209 mainAxisSize: MainAxisSize.min,
210 children: [
211 + if (onEnterSelectionMode != null)
212 + ListTile(
213 + leading: const Icon(Icons.checklist_outlined),
214 + title: Text(l10n.select),
215 + onTap: () {
216 + Navigator.pop(ctx);
217 + onEnterSelectionMode!();
218 + },
219 + ),
220 ListTile(
221 leading: Icon(
222 conversation.isPinned
lib/features/lm_studio_catalog/data/catalog_models.dart new
+490
@@ -0,0 +1,490 @@
1 +class LmCatalogModel {
2 + const LmCatalogModel({
3 + required this.id,
4 + required this.owner,
5 + required this.name,
6 + this.description,
7 + this.revisionNumber = 1,
8 + this.downloads = 0,
9 + this.likes = 0,
10 + this.updatedAt,
11 + this.isStaffPick = false,
12 + this.isVerified = false,
13 + this.metadata = const LmCatalogMetadata(),
14 + this.url,
15 + this.source = LmCatalogSource.lmStudio,
16 + this.hfRepoId,
17 + });
18 +
19 + final String id;
20 + final String owner;
21 + final String name;
22 + final String? description;
23 + final int revisionNumber;
24 + final int downloads;
25 + final int likes;
26 + final DateTime? updatedAt;
27 + final bool isStaffPick;
28 + final bool isVerified;
29 + final LmCatalogMetadata metadata;
30 + final String? url;
31 + final LmCatalogSource source;
32 + /// Hugging Face repo used for quant listing / HF downloads.
33 + final String? hfRepoId;
34 +
35 + String get catalogId => '$owner/$name';
36 +
37 + String get displayLabel => name;
38 +
39 + String get thumbnailUrl =>
40 + 'https://lmstudio.ai/api/v1/artifacts/$owner/$name/revision/$revisionNumber?action=thumbnail';
41 +
42 + String get manifestUrl =>
43 + 'https://lmstudio.ai/api/v1/artifacts/$owner/$name/revision/-1?manifest=true';
44 +
45 + String get readmeUrl =>
46 + 'https://lmstudio.ai/api/v1/artifacts/$owner/$name/revision/$revisionNumber?action=readme';
47 +
48 + String? get hfDownloadBaseUrl {
49 + final repo = hfRepoId ?? (source == LmCatalogSource.huggingFace ? id : null);
50 + if (repo == null || repo.isEmpty) return null;
51 + return 'https://huggingface.co/$repo';
52 + }
53 +
54 + factory LmCatalogModel.fromStaffPickJson(Map<String, dynamic> json) {
55 + final owner = json['owner']?.toString() ?? '';
56 + final name = json['name']?.toString() ?? '';
57 + final metadataJson = json['metadata'];
58 + final metadata = metadataJson is Map<String, dynamic>
59 + ? LmCatalogMetadata.fromJson(metadataJson)
60 + : const LmCatalogMetadata();
61 +
62 + return LmCatalogModel(
63 + id: '$owner/$name',
64 + owner: owner,
65 + name: name,
66 + description: json['description']?.toString(),
67 + revisionNumber: (json['revisionNumber'] as num?)?.toInt() ?? 1,
68 + downloads: (json['downloads'] as num?)?.toInt() ?? 0,
69 + likes: (json['likeCount'] as num?)?.toInt() ?? 0,
70 + updatedAt: _parseEpochMs(json['updatedAt']),
71 + isStaffPick: json['staffPickedAt'] != null,
72 + isVerified: true,
73 + metadata: metadata,
74 + url: json['url']?.toString(),
75 + source: LmCatalogSource.lmStudio,
76 + );
77 + }
78 +
79 + factory LmCatalogModel.fromHuggingFaceJson(Map<String, dynamic> json) {
80 + final modelId = json['modelId']?.toString() ?? json['id']?.toString() ?? '';
81 + final parts = modelId.split('/');
82 + final owner = parts.isNotEmpty ? parts.first : '';
83 + final name = parts.length > 1 ? parts.sublist(1).join('/') : modelId;
84 + final tags = (json['tags'] as List<dynamic>?)
85 + ?.map((e) => e.toString().toLowerCase())
86 + .toList() ??
87 + const [];
88 +
89 + return LmCatalogModel(
90 + id: modelId,
91 + owner: owner,
92 + name: name,
93 + description: null,
94 + revisionNumber: 1,
95 + downloads: (json['downloads'] as num?)?.toInt() ?? 0,
96 + likes: (json['likes'] as num?)?.toInt() ?? 0,
97 + updatedAt: json['lastModified'] != null
98 + ? DateTime.tryParse(json['lastModified'].toString())
99 + : (json['createdAt'] != null
100 + ? DateTime.tryParse(json['createdAt'].toString())
101 + : null),
102 + isStaffPick: false,
103 + isVerified: false,
104 + metadata: LmCatalogMetadata(
105 + compatibilityTypes: tags.contains('gguf') ? const ['gguf'] : const [],
106 + vision: tags.contains('vision') || tags.contains('multimodal'),
107 + reasoning: tags.contains('reasoning') || tags.contains('thinking'),
108 + trainedForToolUse:
109 + tags.contains('tool-use') || tags.contains('function-calling'),
110 + ),
111 + url: 'https://huggingface.co/$modelId',
112 + source: LmCatalogSource.huggingFace,
113 + hfRepoId: modelId,
114 + );
115 + }
116 +
117 + LmCatalogModel copyWith({
118 + String? hfRepoId,
119 + int? downloads,
120 + int? likes,
121 + LmCatalogMetadata? metadata,
122 + }) {
123 + return LmCatalogModel(
124 + id: id,
125 + owner: owner,
126 + name: name,
127 + description: description,
128 + revisionNumber: revisionNumber,
129 + downloads: downloads ?? this.downloads,
130 + likes: likes ?? this.likes,
131 + updatedAt: updatedAt,
132 + isStaffPick: isStaffPick,
133 + isVerified: isVerified,
134 + metadata: metadata ?? this.metadata,
135 + url: url,
136 + source: source,
137 + hfRepoId: hfRepoId ?? this.hfRepoId,
138 + );
139 + }
140 +
141 + bool matchesQuery(String query) {
142 + final q = query.toLowerCase().trim();
143 + if (q.isEmpty) return true;
144 + return id.toLowerCase().contains(q) ||
145 + name.toLowerCase().contains(q) ||
146 + owner.toLowerCase().contains(q) ||
147 + (description?.toLowerCase().contains(q) ?? false);
148 + }
149 +}
150 +
151 +class LmCatalogMetadata {
152 + const LmCatalogMetadata({
153 + this.type = 'llm',
154 + this.architectures = const [],
155 + this.compatibilityTypes = const [],
156 + this.paramsStrings = const [],
157 + this.minMemoryUsageBytes,
158 + this.trainedForToolUse = false,
159 + this.vision = false,
160 + this.reasoning = false,
161 + this.contextLengths = const [],
162 + });
163 +
164 + final String type;
165 + final List<String> architectures;
166 + final List<String> compatibilityTypes;
167 + final List<String> paramsStrings;
168 + final int? minMemoryUsageBytes;
169 + final bool trainedForToolUse;
170 + final bool vision;
171 + final bool reasoning;
172 + final List<int> contextLengths;
173 +
174 + factory LmCatalogMetadata.fromJson(Map<String, dynamic> json) {
175 + return LmCatalogMetadata(
176 + type: json['type']?.toString() ?? 'llm',
177 + architectures: _stringList(json['architectures']),
178 + compatibilityTypes: _stringList(json['compatibilityTypes']),
179 + paramsStrings: _stringList(json['paramsStrings']),
180 + minMemoryUsageBytes: (json['minMemoryUsageBytes'] as num?)?.toInt(),
181 + trainedForToolUse: json['trainedForToolUse'] == true,
182 + vision: json['vision'] == true,
183 + reasoning: json['reasoning'] == true,
184 + contextLengths: (json['contextLengths'] as List<dynamic>?)
185 + ?.map((e) => (e as num).toInt())
186 + .toList() ??
187 + const [],
188 + );
189 + }
190 +}
191 +
192 +class LmArtifactManifest {
193 + const LmArtifactManifest({
194 + this.hfRepoId,
195 + this.downloadCount,
196 + this.readme,
197 + });
198 +
199 + final String? hfRepoId;
200 + final int? downloadCount;
201 + final String? readme;
202 +
203 + factory LmArtifactManifest.fromJson(Map<String, dynamic> json) {
204 + String? hfRepo;
205 + final manifest = json['manifest'];
206 + if (manifest is Map<String, dynamic>) {
207 + final deps = manifest['dependencies'] as List<dynamic>?;
208 + if (deps != null) {
209 + for (final dep in deps) {
210 + if (dep is! Map<String, dynamic>) continue;
211 + final sources = dep['sources'] as List<dynamic>?;
212 + if (sources == null) continue;
213 + for (final source in sources) {
214 + if (source is! Map<String, dynamic>) continue;
215 + if (source['type']?.toString() != 'huggingface') continue;
216 + final user = source['user']?.toString();
217 + final repo = source['repo']?.toString();
218 + if (user != null && repo != null) {
219 + hfRepo = '$user/$repo';
220 + break;
221 + }
222 + }
223 + if (hfRepo != null) break;
224 + }
225 + }
226 + }
227 +
228 + return LmArtifactManifest(
229 + hfRepoId: hfRepo,
230 + downloadCount: (json['downloadCount'] as num?)?.toInt(),
231 + );
232 + }
233 +}
234 +
235 +class LmModelQuantOption {
236 + const LmModelQuantOption({
237 + required this.fileName,
238 + required this.quantization,
239 + required this.sizeBytes,
240 + });
241 +
242 + final String fileName;
243 + final String quantization;
244 + final int sizeBytes;
245 +
246 + static List<LmModelQuantOption> fromHfTree(List<dynamic> tree) {
247 + final options = <LmModelQuantOption>[];
248 + for (final entry in tree) {
249 + if (entry is! Map<String, dynamic>) continue;
250 + if (entry['type']?.toString() != 'file') continue;
251 + final path = entry['path']?.toString() ?? '';
252 + if (!path.toLowerCase().endsWith('.gguf')) continue;
253 + // mmproj files are multimodal projector weights, not standalone
254 + // model quants — they can't be selected/downloaded on their own.
255 + if (path.toLowerCase().contains('mmproj')) continue;
256 + final quant = extractQuantization(path);
257 + if (quant == null) continue;
258 + final size = _fileSize(entry);
259 + if (size <= 0) continue;
260 + options.add(
261 + LmModelQuantOption(
262 + fileName: path,
263 + quantization: quant,
264 + sizeBytes: size,
265 + ),
266 + );
267 + }
268 + options.sort((a, b) => a.sizeBytes.compareTo(b.sizeBytes));
269 + return options;
270 + }
271 +
272 + /// Preferred quant order used to pick a sensible default/recommended
273 + /// option when the user hasn't picked one explicitly.
274 + static const preferredQuantOrder = ['Q4_K_M', 'Q4_K_S', 'Q4_0', 'Q5_K_M'];
275 +
276 + static LmModelQuantOption? recommended(List<LmModelQuantOption> quants) {
277 + if (quants.isEmpty) return null;
278 + for (final preferred in preferredQuantOrder) {
279 + final match =
280 + quants.where((q) => q.quantization == preferred).firstOrNull;
281 + if (match != null) return match;
282 + }
283 + return quants.first;
284 + }
285 +
286 + static int _fileSize(Map<String, dynamic> entry) {
287 + final lfs = entry['lfs'];
288 + if (lfs is Map && lfs['size'] is num) {
289 + return (lfs['size'] as num).toInt();
290 + }
291 + if (entry['size'] is num) {
292 + return (entry['size'] as num).toInt();
293 + }
294 + return 0;
295 + }
296 +
297 + static String? extractQuantization(String fileName) {
298 + final base = fileName.split('/').last;
299 + final patterns = [
300 + RegExp(r'-((?:iQ|IQ|Q|F|BF)\d[\w_]*)\.gguf$', caseSensitive: false),
301 + RegExp(r'\.((?:iQ|IQ|Q|F|BF)\d[\w_]*)\.gguf$', caseSensitive: false),
302 + ];
303 + for (final pattern in patterns) {
304 + final match = pattern.firstMatch(base);
305 + if (match != null) {
306 + return match.group(1)?.toUpperCase();
307 + }
308 + }
309 + return null;
310 + }
311 +}
312 +
313 +class LmModelDetail {
314 + const LmModelDetail({
315 + required this.model,
316 + this.readme,
317 + this.quants = const [],
318 + this.hfRepoId,
319 + });
320 +
321 + final LmCatalogModel model;
322 + final String? readme;
323 + final List<LmModelQuantOption> quants;
324 + final String? hfRepoId;
325 +}
326 +
327 +class LmDownloadRequest {
328 + const LmDownloadRequest({
329 + required this.model,
330 + this.quantization,
331 + this.displayName,
332 + });
333 +
334 + /// Catalog id (`owner/name`) or full Hugging Face model URL.
335 + final String model;
336 + final String? quantization;
337 + final String? displayName;
338 +}
339 +
340 +enum LmCatalogSource { lmStudio, huggingFace }
341 +
342 +enum MemoryCompatibility {
343 + fullGpuOffload,
344 + partialGpuOffload,
345 + likelyTooLarge,
346 + unknown,
347 +}
348 +
349 +class LmDownloadJob {
350 + const LmDownloadJob({
351 + required this.jobId,
352 + required this.modelId,
353 + required this.displayName,
354 + required this.status,
355 + this.totalSizeBytes,
356 + this.downloadedBytes,
357 + this.bytesPerSecond,
358 + this.startedAt,
359 + this.completedAt,
360 + this.estimatedCompletion,
361 + this.errorMessage,
362 + });
363 +
364 + final String jobId;
365 + final String modelId;
366 + final String displayName;
367 + final LmDownloadStatus status;
368 + final int? totalSizeBytes;
369 + final int? downloadedBytes;
370 + final int? bytesPerSecond;
371 + final DateTime? startedAt;
372 + final DateTime? completedAt;
373 + final DateTime? estimatedCompletion;
374 + final String? errorMessage;
375 +
376 + double? get progressFraction {
377 + if (totalSizeBytes == null ||
378 + totalSizeBytes! <= 0 ||
379 + downloadedBytes == null) {
380 + return null;
381 + }
382 + return (downloadedBytes! / totalSizeBytes!).clamp(0.0, 1.0);
383 + }
384 +
385 + LmDownloadJob copyWith({
386 + String? jobId,
387 + String? modelId,
388 + String? displayName,
389 + LmDownloadStatus? status,
390 + int? totalSizeBytes,
391 + int? downloadedBytes,
392 + int? bytesPerSecond,
393 + DateTime? startedAt,
394 + DateTime? completedAt,
395 + DateTime? estimatedCompletion,
396 + String? errorMessage,
397 + }) {
398 + return LmDownloadJob(
399 + jobId: jobId ?? this.jobId,
400 + modelId: modelId ?? this.modelId,
401 + displayName: displayName ?? this.displayName,
402 + status: status ?? this.status,
403 + totalSizeBytes: totalSizeBytes ?? this.totalSizeBytes,
404 + downloadedBytes: downloadedBytes ?? this.downloadedBytes,
405 + bytesPerSecond: bytesPerSecond ?? this.bytesPerSecond,
406 + startedAt: startedAt ?? this.startedAt,
407 + completedAt: completedAt ?? this.completedAt,
408 + estimatedCompletion: estimatedCompletion ?? this.estimatedCompletion,
409 + errorMessage: errorMessage ?? this.errorMessage,
410 + );
411 + }
412 +
413 + factory LmDownloadJob.fromJson(
414 + Map<String, dynamic> json, {
415 + required String modelId,
416 + required String displayName,
417 + }) {
418 + return LmDownloadJob(
419 + jobId: json['job_id']?.toString() ?? '',
420 + modelId: modelId,
421 + displayName: displayName,
422 + status: LmDownloadStatus.fromApi(json['status']?.toString()),
423 + totalSizeBytes: (json['total_size_bytes'] as num?)?.toInt(),
424 + downloadedBytes: (json['downloaded_bytes'] as num?)?.toInt(),
425 + bytesPerSecond: (json['bytes_per_second'] as num?)?.toInt(),
426 + startedAt: _parseIso(json['started_at']),
427 + completedAt: _parseIso(json['completed_at']),
428 + estimatedCompletion: _parseIso(json['estimated_completion']),
429 + errorMessage: json['error'] is Map
430 + ? (json['error'] as Map)['message']?.toString()
431 + : null,
432 + );
433 + }
434 +}
435 +
436 +enum LmDownloadStatus {
437 + downloading,
438 + paused,
439 + completed,
440 + failed,
441 + alreadyDownloaded;
442 +
443 + static LmDownloadStatus fromApi(String? value) {
444 + switch (value) {
445 + case 'downloading':
446 + return LmDownloadStatus.downloading;
447 + case 'paused':
448 + return LmDownloadStatus.paused;
449 + case 'completed':
450 + return LmDownloadStatus.completed;
451 + case 'failed':
452 + return LmDownloadStatus.failed;
453 + case 'already_downloaded':
454 + return LmDownloadStatus.alreadyDownloaded;
455 + default:
456 + return LmDownloadStatus.downloading;
457 + }
458 + }
459 +
460 + bool get isActive =>
461 + this == LmDownloadStatus.downloading || this == LmDownloadStatus.paused;
462 +}
463 +
464 +class HfSearchPage {
465 + const HfSearchPage({
466 + required this.models,
467 + this.nextUrl,
468 + });
469 +
470 + final List<LmCatalogModel> models;
471 + final String? nextUrl;
472 +}
473 +
474 +List<String> _stringList(dynamic value) {
475 + if (value is! List) return const [];
476 + return value.map((e) => e.toString()).toList();
477 +}
478 +
479 +DateTime? _parseEpochMs(dynamic value) {
480 + if (value == null) return null;
481 + if (value is num) {
482 + return DateTime.fromMillisecondsSinceEpoch(value.toInt());
483 + }
484 + return DateTime.tryParse(value.toString());
485 +}
486 +
487 +DateTime? _parseIso(dynamic value) {
488 + if (value == null) return null;
489 + return DateTime.tryParse(value.toString());
490 +}
lib/features/lm_studio_catalog/data/lm_studio_catalog_service.dart new
+220
@@ -0,0 +1,220 @@
1 +import 'package:dio/dio.dart';
2 +
3 +import '../../../core/logger/app_logger.dart';
4 +import 'catalog_models.dart';
5 +
6 +class LmStudioCatalogService {
7 + LmStudioCatalogService(this._dio);
8 +
9 + final Dio _dio;
10 +
11 + static const _staffPicksUrl =
12 + 'https://lmstudio.ai/api/v1/models?action=staff-picks';
13 +
14 + static const _hfModelsBase = 'https://huggingface.co/api/models';
15 +
16 + Future<List<LmCatalogModel>> fetchStaffPicks() async {
17 + try {
18 + final response = await _dio.get<List<dynamic>>(_staffPicksUrl);
19 + final data = response.data;
20 + if (data == null) return [];
21 + return data
22 + .whereType<Map<String, dynamic>>()
23 + .map(LmCatalogModel.fromStaffPickJson)
24 + .toList();
25 + } catch (e) {
26 + Log.warning('Failed to fetch LM Studio staff picks: $e');
27 + rethrow;
28 + }
29 + }
30 +
31 + Future<HfSearchPage> searchHuggingFace({
32 + String query = '',
33 + int limit = 50,
34 + String? nextUrl,
35 + }) async {
36 + final trimmed = query.trim();
37 + if (trimmed.isEmpty && nextUrl == null) {
38 + return const HfSearchPage(models: []);
39 + }
40 +
41 + try {
42 + final Response<List<dynamic>> response;
43 + if (nextUrl != null) {
44 + response = await _dio.get<List<dynamic>>(nextUrl);
45 + } else {
46 + response = await _dio.get<List<dynamic>>(
47 + _hfModelsBase,
48 + queryParameters: {
49 + 'search': trimmed,
50 + 'filter': 'gguf',
51 + 'sort': 'likes',
52 + 'direction': '-1',
53 + 'limit': limit,
54 + },
55 + );
56 + }
57 +
58 + final data = response.data ?? [];
59 + final models = data
60 + .whereType<Map<String, dynamic>>()
61 + .map(LmCatalogModel.fromHuggingFaceJson)
62 + .toList();
63 +
64 + final next = _parseNextLink(response.headers.value('link'));
65 + return HfSearchPage(models: models, nextUrl: next);
66 + } catch (e) {
67 + Log.warning('Failed to search Hugging Face GGUF models: $e');
68 + rethrow;
69 + }
70 + }
71 +
72 + String? _parseNextLink(String? linkHeader) {
73 + if (linkHeader == null || linkHeader.isEmpty) return null;
74 + for (final part in linkHeader.split(',')) {
75 + final trimmed = part.trim();
76 + if (!trimmed.contains('rel="next"')) continue;
77 + final match = RegExp(r'<([^>]+)>').firstMatch(trimmed);
78 + return match?.group(1);
79 + }
80 + return null;
81 + }
82 +
83 + Future<LmArtifactManifest> fetchArtifactManifest(LmCatalogModel model) async {
84 + try {
85 + final response = await _dio.get<Map<String, dynamic>>(model.manifestUrl);
86 + return LmArtifactManifest.fromJson(response.data ?? {});
87 + } catch (e) {
88 + Log.debug('Failed to fetch manifest for ${model.id}: $e');
89 + return const LmArtifactManifest();
90 + }
91 + }
92 +
93 + Future<String?> fetchLmStudioReadme(LmCatalogModel model) async {
94 + try {
95 + final response = await _dio.get<dynamic>(model.readmeUrl);
96 + final data = response.data;
97 + if (data is String) {
98 + final trimmed = data.trim();
99 + if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
100 + return _decodeJsonString(trimmed);
101 + }
102 + return trimmed;
103 + }
104 + if (data is Map && data['readme'] is String) {
105 + return data['readme'] as String;
106 + }
107 + return data?.toString();
108 + } catch (e) {
109 + Log.debug('Failed to fetch LM Studio readme for ${model.id}: $e');
110 + return null;
111 + }
112 + }
113 +
114 + String _decodeJsonString(String raw) {
115 + try {
116 + if (raw.startsWith('"') && raw.endsWith('"')) {
117 + return raw
118 + .substring(1, raw.length - 1)
119 + .replaceAll(r'\n', '\n')
120 + .replaceAll(r'\"', '"')
121 + .replaceAll(r'\\', r'\');
122 + }
123 + } catch (_) {}
124 + return raw;
125 + }
126 +
127 + Future<Map<String, dynamic>?> fetchHfModelInfo(String repoId) async {
128 + try {
129 + // HF expects owner/repo in the path — encoding the slash returns 400.
130 + final response = await _dio.get<Map<String, dynamic>>(
131 + '$_hfModelsBase/$repoId',
132 + );
133 + return response.data;
134 + } catch (e) {
135 + Log.debug('Failed to fetch HF model info for $repoId: $e');
136 + return null;
137 + }
138 + }
139 +
140 + Future<List<LmModelQuantOption>> fetchHfQuants(String repoId) async {
141 + try {
142 + final response = await _dio.get<List<dynamic>>(
143 + 'https://huggingface.co/api/models/$repoId/tree/main',
144 + );
145 + final quants = LmModelQuantOption.fromHfTree(response.data ?? []);
146 + Log.debug('Fetched ${quants.length} quants for $repoId');
147 + return quants;
148 + } catch (e) {
149 + Log.debug('Failed to fetch HF quants for $repoId: $e');
150 + return const [];
151 + }
152 + }
153 +
154 + Future<String?> fetchHfReadme(String repoId) async {
155 + try {
156 + final response = await _dio.get<String>(
157 + 'https://huggingface.co/$repoId/raw/main/README.md',
158 + options: Options(responseType: ResponseType.plain),
159 + );
160 + return response.data;
161 + } catch (e) {
162 + Log.debug('Failed to fetch HF readme for $repoId: $e');
163 + return null;
164 + }
165 + }
166 +
167 + Future<LmModelDetail> fetchModelDetail(LmCatalogModel model) async {
168 + String? hfRepo = model.hfRepoId;
169 + String? readme;
170 + List<LmModelQuantOption> quants = const [];
171 +
172 + if (model.source == LmCatalogSource.lmStudio) {
173 + final manifest = await fetchArtifactManifest(model);
174 + hfRepo ??= manifest.hfRepoId;
175 + readme = await fetchLmStudioReadme(model);
176 + } else {
177 + hfRepo ??= model.id;
178 + final info = await fetchHfModelInfo(hfRepo);
179 + readme = await fetchHfReadme(hfRepo);
180 + if (info != null) {
181 + readme ??= info['cardData'] is Map
182 + ? (info['cardData'] as Map)['description']?.toString()
183 + : null;
184 + }
185 + }
186 +
187 + if (hfRepo != null && hfRepo.isNotEmpty) {
188 + quants = await fetchHfQuants(hfRepo);
189 + }
190 +
191 + return LmModelDetail(
192 + model: model.copyWith(hfRepoId: hfRepo),
193 + readme: readme,
194 + quants: quants,
195 + hfRepoId: hfRepo,
196 + );
197 + }
198 +
199 + Future<List<LmCatalogModel>> searchCatalog({
200 + required String query,
201 + required List<LmCatalogModel> staffPicks,
202 + }) async {
203 + final trimmed = query.trim().toLowerCase();
204 + if (trimmed.isEmpty) return staffPicks;
205 +
206 + final staffMatches =
207 + staffPicks.where((model) => model.matchesQuery(trimmed)).toList();
208 +
209 + List<LmCatalogModel> community = [];
210 + try {
211 + final page = await searchHuggingFace(query: trimmed);
212 + community = page.models;
213 + } catch (_) {}
214 +
215 + final staffIds = staffMatches.map((m) => m.id).toSet();
216 + community = community.where((m) => !staffIds.contains(m.id)).toList();
217 +
218 + return [...staffMatches, ...community];
219 + }
220 +}
lib/features/lm_studio_catalog/data/lm_studio_download_service.dart new
+120
@@ -0,0 +1,120 @@
1 +import 'package:dio/dio.dart';
2 +
3 +import '../../servers/data/models/server.dart';
4 +import 'catalog_models.dart';
5 +
6 +class LmDownloadJobNotFoundException implements Exception {
7 + const LmDownloadJobNotFoundException(this.jobId);
8 + final String jobId;
9 +}
10 +
11 +class LmStudioDownloadService {
12 + LmStudioDownloadService(this._dio);
13 +
14 + final Dio _dio;
15 +
16 + LmDownloadRequest buildDownloadRequest({
17 + required LmCatalogModel model,
18 + required LmModelDetail detail,
19 + LmModelQuantOption? quant,
20 + }) {
21 + final hfUrl = detail.hfRepoId != null
22 + ? 'https://huggingface.co/${detail.hfRepoId}'
23 + : model.hfDownloadBaseUrl;
24 +
25 + if (hfUrl != null && quant != null) {
26 + return LmDownloadRequest(
27 + model: hfUrl,
28 + quantization: quant.quantization,
29 + displayName: '${model.displayLabel} ${quant.quantization}',
30 + );
31 + }
32 +
33 + if (hfUrl != null) {
34 + return LmDownloadRequest(
35 + model: hfUrl,
36 + displayName: model.displayLabel,
37 + );
38 + }
39 +
40 + if (model.source == LmCatalogSource.lmStudio) {
41 + return LmDownloadRequest(
42 + model: model.catalogId,
43 + displayName: model.displayLabel,
44 + );
45 + }
46 +
47 + return LmDownloadRequest(
48 + model: 'https://huggingface.co/${model.id}',
49 + quantization: quant?.quantization,
50 + displayName: model.displayLabel,
51 + );
52 + }
53 +
54 + Future<LmDownloadJob> startDownload({
55 + required Server server,
56 + required LmDownloadRequest request,
57 + }) async {
58 + final body = <String, dynamic>{
59 + 'model': request.model,
60 + if (request.quantization != null && request.quantization!.isNotEmpty)
61 + 'quantization': request.quantization,
62 + };
63 +
64 + final response = await _dio.post<Map<String, dynamic>>(
65 + '${server.baseUrl}/api/v1/models/download',
66 + data: body,
67 + options: Options(
68 + headers: buildServerAuthHeaders(server),
69 + validateStatus: (status) => status != null && status < 500,
70 + ),
71 + );
72 +
73 + final data = response.data ?? {};
74 + if (response.statusCode != 200 || data['error'] != null) {
75 + final error = data['error'];
76 + final message = error is Map
77 + ? error['message']?.toString()
78 + : error?.toString();
79 + throw Exception(message ?? 'Download failed');
80 + }
81 +
82 + return LmDownloadJob.fromJson(
83 + data,
84 + modelId: request.model,
85 + displayName: request.displayName ?? request.model,
86 + );
87 + }
88 +
89 + Future<LmDownloadJob> fetchStatus({
90 + required Server server,
91 + required LmDownloadJob job,
92 + }) async {
93 + final response = await _dio.get<Map<String, dynamic>>(
94 + '${server.baseUrl}/api/v1/models/download/status/${Uri.encodeComponent(job.jobId)}',
95 + options: Options(
96 + headers: buildServerAuthHeaders(server),
97 + validateStatus: (status) => status != null && status < 500,
98 + ),
99 + );
100 +
101 + final data = response.data ?? {};
102 + if (response.statusCode != 200 || data['error'] != null) {
103 + final error = data['error'];
104 + final type = error is Map ? error['type']?.toString() : null;
105 + if (type == 'job_not_found') {
106 + throw LmDownloadJobNotFoundException(job.jobId);
107 + }
108 + final message = error is Map
109 + ? error['message']?.toString()
110 + : error?.toString();
111 + throw Exception(message ?? 'Failed to fetch download status');
112 + }
113 +
114 + return LmDownloadJob.fromJson(
115 + data,
116 + modelId: job.modelId,
117 + displayName: job.displayName,
118 + );
119 + }
120 +}
lib/features/lm_studio_catalog/providers/lm_studio_catalog_providers.dart new
+451
@@ -0,0 +1,451 @@
1 +import 'dart:async';
2 +import 'dart:convert';
3 +
4 +import 'package:flutter_local_notifications/flutter_local_notifications.dart';
5 +import 'package:flutter_riverpod/flutter_riverpod.dart';
6 +
7 +import '../../../core/providers/service_providers.dart';
8 +import '../../../core/providers/storage_providers.dart';
9 +import '../../servers/data/models/server.dart';
10 +import '../../servers/providers/server_providers.dart';
11 +import '../data/catalog_models.dart';
12 +import '../data/lm_studio_catalog_service.dart';
13 +import '../data/lm_studio_download_service.dart';
14 +
15 +final lmStudioCatalogServiceProvider = Provider<LmStudioCatalogService>((ref) {
16 + return LmStudioCatalogService(ref.read(dioProvider));
17 +});
18 +
19 +final lmStudioDownloadServiceProvider = Provider<LmStudioDownloadService>((ref) {
20 + return LmStudioDownloadService(ref.read(dioProvider));
21 +});
22 +
23 +final lmStudioStaffPicksProvider =
24 + FutureProvider.autoDispose<List<LmCatalogModel>>((ref) async {
25 + final service = ref.read(lmStudioCatalogServiceProvider);
26 + return service.fetchStaffPicks();
27 +});
28 +
29 +class LmCatalogSearchState {
30 + const LmCatalogSearchState({
31 + this.staffMatches = const [],
32 + this.communityModels = const [],
33 + this.isLoading = false,
34 + this.isLoadingMore = false,
35 + this.error,
36 + this.nextUrl,
37 + this.hasMore = false,
38 + });
39 +
40 + final List<LmCatalogModel> staffMatches;
41 + final List<LmCatalogModel> communityModels;
42 + final bool isLoading;
43 + final bool isLoadingMore;
44 + final String? error;
45 + final String? nextUrl;
46 + final bool hasMore;
47 +
48 + List<LmCatalogModel> get allModels => [...staffMatches, ...communityModels];
49 +
50 + LmCatalogSearchState copyWith({
51 + List<LmCatalogModel>? staffMatches,
52 + List<LmCatalogModel>? communityModels,
53 + bool? isLoading,
54 + bool? isLoadingMore,
55 + String? error,
56 + String? nextUrl,
57 + bool? hasMore,
58 + bool clearError = false,
59 + }) {
60 + return LmCatalogSearchState(
61 + staffMatches: staffMatches ?? this.staffMatches,
62 + communityModels: communityModels ?? this.communityModels,
63 + isLoading: isLoading ?? this.isLoading,
64 + isLoadingMore: isLoadingMore ?? this.isLoadingMore,
65 + error: clearError ? null : (error ?? this.error),
66 + nextUrl: nextUrl ?? this.nextUrl,
67 + hasMore: hasMore ?? this.hasMore,
68 + );
69 + }
70 +}
71 +
72 +class LmCatalogSearchNotifier extends Notifier<LmCatalogSearchState> {
73 + String _query = '';
74 +
75 + @override
76 + LmCatalogSearchState build() => const LmCatalogSearchState();
77 +
78 + Future<void> search(String query) async {
79 + final trimmed = query.trim();
80 + if (trimmed == _query && state.allModels.isNotEmpty && !state.isLoading) {
81 + return;
82 + }
83 + _query = trimmed;
84 +
85 + if (trimmed.isEmpty) {
86 + state = const LmCatalogSearchState();
87 + return;
88 + }
89 +
90 + state = state.copyWith(isLoading: true, clearError: true);
91 + final service = ref.read(lmStudioCatalogServiceProvider);
92 +
93 + try {
94 + final staffPicks = await ref.read(lmStudioStaffPicksProvider.future);
95 + final staffMatches =
96 + staffPicks.where((m) => m.matchesQuery(trimmed)).toList();
97 + final page = await service.searchHuggingFace(query: trimmed);
98 + final staffIds = staffMatches.map((m) => m.id).toSet();
99 + final community =
100 + page.models.where((m) => !staffIds.contains(m.id)).toList();
101 +
102 + state = LmCatalogSearchState(
103 + staffMatches: staffMatches,
104 + communityModels: community,
105 + nextUrl: page.nextUrl,
106 + hasMore: page.nextUrl != null,
107 + isLoading: false,
108 + );
109 + } catch (e) {
110 + state = state.copyWith(isLoading: false, error: e.toString());
111 + }
112 + }
113 +
114 + Future<void> loadMore() async {
115 + if (state.isLoadingMore || !state.hasMore || state.nextUrl == null) return;
116 +
117 + state = state.copyWith(isLoadingMore: true);
118 + final service = ref.read(lmStudioCatalogServiceProvider);
119 +
120 + try {
121 + final page = await service.searchHuggingFace(nextUrl: state.nextUrl);
122 + final existingIds = state.allModels.map((m) => m.id).toSet();
123 + final more =
124 + page.models.where((m) => !existingIds.contains(m.id)).toList();
125 +
126 + state = state.copyWith(
127 + communityModels: [...state.communityModels, ...more],
128 + nextUrl: page.nextUrl,
129 + hasMore: page.nextUrl != null,
130 + isLoadingMore: false,
131 + );
132 + } catch (e) {
133 + state = state.copyWith(isLoadingMore: false, error: e.toString());
134 + }
135 + }
136 +}
137 +
138 +final lmCatalogSearchProvider =
139 + NotifierProvider<LmCatalogSearchNotifier, LmCatalogSearchState>(
140 + LmCatalogSearchNotifier.new,
141 +);
142 +
143 +final lmModelDetailProvider =
144 + FutureProvider.autoDispose.family<LmModelDetail, LmCatalogModel>(
145 + (ref, model) async {
146 + final service = ref.read(lmStudioCatalogServiceProvider);
147 + return service.fetchModelDetail(model);
148 +});
149 +
150 +class LmDownloadManagerState {
151 + const LmDownloadManagerState({
152 + this.jobs = const [],
153 + });
154 +
155 + final List<LmDownloadJob> jobs;
156 +
157 + List<LmDownloadJob> get activeJobs =>
158 + jobs.where((job) => job.status.isActive).toList();
159 +
160 + double? get overallProgress {
161 + final active = activeJobs;
162 + if (active.isEmpty) return null;
163 + var total = 0;
164 + var downloaded = 0;
165 + for (final job in active) {
166 + if (job.totalSizeBytes != null && job.downloadedBytes != null) {
167 + total += job.totalSizeBytes!;
168 + downloaded += job.downloadedBytes!;
169 + }
170 + }
171 + if (total <= 0) return null;
172 + return downloaded / total;
173 + }
174 +
175 + LmDownloadManagerState copyWith({List<LmDownloadJob>? jobs}) {
176 + return LmDownloadManagerState(jobs: jobs ?? this.jobs);
177 + }
178 +}
179 +
180 +class LmDownloadManagerNotifier extends Notifier<LmDownloadManagerState> {
181 + static const _storageKey = 'lm_studio_active_downloads';
182 +
183 + final Map<String, Timer> _pollers = {};
184 + final Map<String, String> _jobServerIds = {};
185 + final FlutterLocalNotificationsPlugin _notifications =
186 + FlutterLocalNotificationsPlugin();
187 + bool _notificationsReady = false;
188 + int _notificationId = 9000;
189 +
190 + @override
191 + LmDownloadManagerState build() {
192 + ref.onDispose(_disposePollers);
193 + unawaited(_restoreActiveDownloads());
194 + return const LmDownloadManagerState();
195 + }
196 +
197 + /// Re-attaches to any downloads that were still in progress when the app
198 + /// was last closed, so a restart doesn't silently abandon tracking of a
199 + /// download that's still running on the LM Studio server.
200 + Future<void> _restoreActiveDownloads() async {
201 + final prefs = ref.read(sharedPreferencesProvider);
202 + final raw = prefs.getString(_storageKey);
203 + if (raw == null || raw.isEmpty) return;
204 +
205 + List<dynamic> entries;
206 + try {
207 + entries = jsonDecode(raw) as List<dynamic>;
208 + } catch (_) {
209 + await prefs.remove(_storageKey);
210 + return;
211 + }
212 +
213 + final servers = await ref.read(serversProvider.future);
214 + final service = ref.read(lmStudioDownloadServiceProvider);
215 +
216 + for (final entry in entries) {
217 + if (entry is! Map) continue;
218 + final serverId = entry['serverId'] as String?;
219 + final jobId = entry['jobId'] as String?;
220 + final modelId = entry['modelId'] as String?;
221 + if (serverId == null || jobId == null || modelId == null) continue;
222 + final displayName = entry['displayName'] as String? ?? modelId;
223 +
224 + final server = servers.where((s) => s.id == serverId).firstOrNull;
225 + if (server == null) continue;
226 +
227 + final placeholder = LmDownloadJob(
228 + jobId: jobId,
229 + modelId: modelId,
230 + displayName: displayName,
231 + status: LmDownloadStatus.downloading,
232 + );
233 +
234 + try {
235 + final updated = await service.fetchStatus(
236 + server: server,
237 + job: placeholder,
238 + );
239 + _jobServerIds[jobId] = serverId;
240 + _replaceJob(updated);
241 +
242 + if (updated.status.isActive) {
243 + _startPolling(server: server, job: updated);
244 + } else if (updated.status == LmDownloadStatus.completed ||
245 + updated.status == LmDownloadStatus.alreadyDownloaded) {
246 + await _notifyCompleted(updated);
247 + ref.invalidate(availableModelsProvider(server.id));
248 + } else if (updated.status == LmDownloadStatus.failed) {
249 + await _notifyFailed(updated);
250 + }
251 + } on LmDownloadJobNotFoundException {
252 + // Job no longer exists on the server; drop it.
253 + } catch (_) {
254 + // Couldn't reach the server right now — leave it persisted so the
255 + // next restore attempt (or a manual refresh) can pick it back up.
256 + }
257 + }
258 +
259 + await _persistActiveDownloads();
260 + }
261 +
262 + Future<void> _persistActiveDownloads() async {
263 + final prefs = ref.read(sharedPreferencesProvider);
264 + final active = state.jobs.where((j) => j.status.isActive).toList();
265 + if (active.isEmpty) {
266 + await prefs.remove(_storageKey);
267 + return;
268 + }
269 +
270 + final entries = active
271 + .map(
272 + (j) => {
273 + 'serverId': _jobServerIds[j.jobId],
274 + 'jobId': j.jobId,
275 + 'modelId': j.modelId,
276 + 'displayName': j.displayName,
277 + },
278 + )
279 + .where((e) => e['serverId'] != null)
280 + .toList();
281 + await prefs.setString(_storageKey, jsonEncode(entries));
282 + }
283 +
284 + void _setJobs(List<LmDownloadJob> jobs) {
285 + state = state.copyWith(jobs: jobs);
286 + unawaited(_persistActiveDownloads());
287 + }
288 +
289 + Future<void> _ensureNotifications() async {
290 + if (_notificationsReady) return;
291 + const initSettings = InitializationSettings(
292 + android: AndroidInitializationSettings('@mipmap/ic_launcher'),
293 + iOS: DarwinInitializationSettings(),
294 + );
295 + await _notifications.initialize(settings: initSettings);
296 + _notificationsReady = true;
297 + }
298 +
299 + Future<void> startDownload({
300 + required Server server,
301 + required LmCatalogModel model,
302 + required LmModelDetail detail,
303 + LmModelQuantOption? quant,
304 + }) async {
305 + final downloadService = ref.read(lmStudioDownloadServiceProvider);
306 + final request = downloadService.buildDownloadRequest(
307 + model: model,
308 + detail: detail,
309 + quant: quant,
310 + );
311 + final job = await downloadService.startDownload(
312 + server: server,
313 + request: request,
314 + );
315 +
316 + final updatedJobs = [...state.jobs];
317 + if (job.jobId.isNotEmpty) {
318 + updatedJobs.removeWhere((j) => j.jobId == job.jobId);
319 + }
320 + updatedJobs.insert(0, job);
321 + if (job.jobId.isNotEmpty) {
322 + _jobServerIds[job.jobId] = server.id;
323 + }
324 + _setJobs(updatedJobs);
325 +
326 + if (job.status == LmDownloadStatus.alreadyDownloaded ||
327 + job.status == LmDownloadStatus.completed) {
328 + await _notifyCompleted(job);
329 + ref.invalidate(availableModelsProvider(server.id));
330 + return;
331 + }
332 +
333 + if (job.jobId.isNotEmpty && job.status.isActive) {
334 + _startPolling(server: server, job: job);
335 + }
336 + }
337 +
338 + void _startPolling({required Server server, required LmDownloadJob job}) {
339 + _pollers[job.jobId]?.cancel();
340 + _pollers[job.jobId] = Timer.periodic(const Duration(seconds: 2), (_) async {
341 + await _poll(server: server, jobId: job.jobId);
342 + });
343 + }
344 +
345 + Future<void> _poll({required Server server, required String jobId}) async {
346 + final current = state.jobs.where((j) => j.jobId == jobId).firstOrNull;
347 + if (current == null) return;
348 +
349 + try {
350 + final service = ref.read(lmStudioDownloadServiceProvider);
351 + final updated = await service.fetchStatus(server: server, job: current);
352 + _replaceJob(updated);
353 +
354 + if (updated.status == LmDownloadStatus.completed ||
355 + updated.status == LmDownloadStatus.alreadyDownloaded) {
356 + _pollers[jobId]?.cancel();
357 + _pollers.remove(jobId);
358 + await _notifyCompleted(updated);
359 + ref.invalidate(availableModelsProvider(server.id));
360 + } else if (updated.status == LmDownloadStatus.failed) {
361 + _pollers[jobId]?.cancel();
362 + _pollers.remove(jobId);
363 + await _notifyFailed(updated);
364 + }
365 + } on LmDownloadJobNotFoundException {
366 + _pollers[jobId]?.cancel();
367 + _pollers.remove(jobId);
368 + removeJob(jobId);
369 + } catch (_) {}
370 + }
371 +
372 + void removeJob(String jobId) {
373 + _jobServerIds.remove(jobId);
374 + final jobs = state.jobs.where((j) => j.jobId != jobId).toList();
375 + _setJobs(jobs);
376 + }
377 +
378 + void dismissFinishedJobs() {
379 + final jobs = state.jobs.where((j) => j.status.isActive).toList();
380 + _setJobs(jobs);
381 + }
382 +
383 + void _replaceJob(LmDownloadJob updated) {
384 + final jobs = [...state.jobs];
385 + final index = jobs.indexWhere((j) => j.jobId == updated.jobId);
386 + if (index >= 0) {
387 + jobs[index] = updated;
388 + } else {
389 + jobs.insert(0, updated);
390 + }
391 + _setJobs(jobs);
392 + }
393 +
394 + Future<void> _notifyCompleted(LmDownloadJob job) async {
395 + await _ensureNotifications();
396 + final id = _notificationId++;
397 + await _notifications.show(
398 + id: id,
399 + title: 'Download complete',
400 + body: job.displayName,
401 + notificationDetails: const NotificationDetails(
402 + android: AndroidNotificationDetails(
403 + 'lm_studio_downloads',
404 + 'LM Studio downloads',
405 + importance: Importance.defaultImportance,
406 + priority: Priority.defaultPriority,
407 + ),
408 + iOS: DarwinNotificationDetails(),
409 + ),
410 + );
411 + }
412 +
413 + Future<void> _notifyFailed(LmDownloadJob job) async {
414 + await _ensureNotifications();
415 + final id = _notificationId++;
416 + await _notifications.show(
417 + id: id,
418 + title: 'Download failed',
419 + body: job.errorMessage ?? job.displayName,
420 + notificationDetails: const NotificationDetails(
421 + android: AndroidNotificationDetails(
422 + 'lm_studio_downloads',
423 + 'LM Studio downloads',
424 + importance: Importance.high,
425 + priority: Priority.high,
426 + ),
427 + iOS: DarwinNotificationDetails(),
428 + ),
429 + );
430 + }
431 +
432 + void _disposePollers() {
433 + for (final timer in _pollers.values) {
434 + timer.cancel();
435 + }
436 + _pollers.clear();
437 + }
438 +}
439 +
440 +final lmDownloadManagerProvider =
441 + NotifierProvider<LmDownloadManagerNotifier, LmDownloadManagerState>(
442 + LmDownloadManagerNotifier.new,
443 +);
444 +
445 +final lmActiveDownloadCountProvider = Provider<int>((ref) {
446 + return ref.watch(lmDownloadManagerProvider).activeJobs.length;
447 +});
448 +
449 +final lmOverallDownloadProgressProvider = Provider<double?>((ref) {
450 + return ref.watch(lmDownloadManagerProvider).overallProgress;
451 +});
lib/features/lm_studio_catalog/utils/download_matching.dart new
+37
@@ -0,0 +1,37 @@
1 +import 'package:localmind/features/models/data/models/model_info.dart';
2 +
3 +import '../data/catalog_models.dart';
4 +
5 +String _normalize(String value) =>
6 + value.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
7 +
8 +/// Whether [quant] of [model] is already present on the server, matched by
9 +/// comparing the local model's own `quantization` field (not a loose
10 +/// substring search over its display name/id) plus a normalized identity
11 +/// check against the catalog model's name/owner.
12 +bool isQuantDownloaded({
13 + required LmCatalogModel model,
14 + required LmModelQuantOption quant,
15 + required List<ModelInfo> downloadedModels,
16 +}) {
17 + final targetQuant = _normalize(quant.quantization);
18 + if (targetQuant.isEmpty) return false;
19 +
20 + final modelNameNorm = _normalize(model.name);
21 + final catalogIdNorm = _normalize(model.catalogId);
22 +
23 + for (final local in downloadedModels) {
24 + final localQuant = _normalize(local.quantization ?? '');
25 + if (localQuant.isEmpty || localQuant != targetQuant) continue;
26 +
27 + final idNorm = _normalize(local.id);
28 + final nameNorm = _normalize(local.name);
29 + if (idNorm.contains(modelNameNorm) ||
30 + nameNorm.contains(modelNameNorm) ||
31 + idNorm.contains(catalogIdNorm) ||
32 + (idNorm.isNotEmpty && modelNameNorm.contains(idNorm))) {
33 + return true;
34 + }
35 + }
36 + return false;
37 +}
lib/features/lm_studio_catalog/utils/memory_compatibility.dart new
+79
@@ -0,0 +1,79 @@
1 +import '../data/catalog_models.dart';
2 +
3 +MemoryCompatibility estimateMemoryCompatibility({
4 + required int? modelSizeBytes,
5 + int? availableRamGb,
6 + int? availableVramGb,
7 +}) {
8 + if (modelSizeBytes == null || modelSizeBytes <= 0) {
9 + return MemoryCompatibility.unknown;
10 + }
11 +
12 + final ramBytes = availableRamGb != null && availableRamGb > 0
13 + ? availableRamGb * 1024 * 1024 * 1024
14 + : null;
15 + final vramBytes = availableVramGb != null && availableVramGb > 0
16 + ? availableVramGb * 1024 * 1024 * 1024
17 + : null;
18 +
19 + if (ramBytes == null && vramBytes == null) {
20 + return MemoryCompatibility.unknown;
21 + }
22 +
23 + // Reserve ~15% for runtime overhead (KV cache, context, OS).
24 + const overheadFactor = 0.85;
25 +
26 + if (vramBytes != null &&
27 + modelSizeBytes <= (vramBytes * overheadFactor).round()) {
28 + return MemoryCompatibility.fullGpuOffload;
29 + }
30 +
31 + if (ramBytes != null &&
32 + modelSizeBytes <= (ramBytes * overheadFactor).round()) {
33 + return MemoryCompatibility.partialGpuOffload;
34 + }
35 +
36 + return MemoryCompatibility.likelyTooLarge;
37 +}
38 +
39 +String formatBytes(int bytes) {
40 + if (bytes <= 0) return '0 B';
41 + const units = ['B', 'KB', 'MB', 'GB', 'TB'];
42 + var value = bytes.toDouble();
43 + var unitIndex = 0;
44 + while (value >= 1024 && unitIndex < units.length - 1) {
45 + value /= 1024;
46 + unitIndex++;
47 + }
48 + return '${value.toStringAsFixed(unitIndex == 0 ? 0 : 2)} ${units[unitIndex]}';
49 +}
50 +
51 +String formatSpeed(int? bytesPerSecond) {
52 + if (bytesPerSecond == null || bytesPerSecond <= 0) return '';
53 + final mbPerSecond = bytesPerSecond / (1024 * 1024);
54 + if (mbPerSecond < 0.1) {
55 + return '${(bytesPerSecond / 1024).toStringAsFixed(0)} KB/s';
56 + }
57 + return '${mbPerSecond.toStringAsFixed(1)} MB/s';
58 +}
59 +
60 +String formatRelativeTime(DateTime? dateTime) {
61 + if (dateTime == null) return '';
62 + final diff = DateTime.now().difference(dateTime);
63 + if (diff.inDays >= 365) {
64 + return '${diff.inDays ~/ 365}y ago';
65 + }
66 + if (diff.inDays >= 30) {
67 + return '${diff.inDays ~/ 30}mo ago';
68 + }
69 + if (diff.inDays >= 1) {
70 + return '${diff.inDays}d ago';
71 + }
72 + if (diff.inHours >= 1) {
73 + return '${diff.inHours}h ago';
74 + }
75 + if (diff.inMinutes >= 1) {
76 + return '${diff.inMinutes}m ago';
77 + }
78 + return 'just now';
79 +}
lib/features/lm_studio_catalog/views/lm_studio_download_widgets.dart new
+239
@@ -0,0 +1,239 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +
4 +import '../../../core/theme/colors.dart';
5 +import '../../../l10n/app_localizations.dart';
6 +import '../data/catalog_models.dart';
7 +import '../providers/lm_studio_catalog_providers.dart';
8 +import '../utils/memory_compatibility.dart';
9 +
10 +class LmDownloadIndicatorButton extends ConsumerWidget {
11 + const LmDownloadIndicatorButton({
12 + super.key,
13 + this.compact = false,
14 + this.onTap,
15 + });
16 +
17 + final bool compact;
18 + final VoidCallback? onTap;
19 +
20 + @override
21 + Widget build(BuildContext context, WidgetRef ref) {
22 + final theme = Theme.of(context);
23 + final isDark = theme.brightness == Brightness.dark;
24 + final jobs = ref.watch(lmDownloadManagerProvider).jobs;
25 + final activeCount = ref.watch(lmActiveDownloadCountProvider);
26 + final progress = ref.watch(lmOverallDownloadProgressProvider);
27 +
28 + if (jobs.isEmpty) return const SizedBox.shrink();
29 +
30 + final size = compact ? 28.0 : 36.0;
31 +
32 + return InkWell(
33 + borderRadius: BorderRadius.circular(size / 2),
34 + onTap: onTap ?? () => showLmDownloadsSheet(context),
35 + child: Padding(
36 + padding: EdgeInsets.all(compact ? 2 : 4),
37 + child: Stack(
38 + alignment: Alignment.center,
39 + children: [
40 + SizedBox(
41 + width: size,
42 + height: size,
43 + child: CircularProgressIndicator(
44 + value: activeCount > 0 ? progress : null,
45 + strokeWidth: compact ? 2 : 2.5,
46 + color: theme.colorScheme.primary,
47 + backgroundColor:
48 + isDark ? AppColors.darkBorder : AppColors.lightBorder,
49 + ),
50 + ),
51 + Text(
52 + activeCount > 0 ? '$activeCount' : '${jobs.length}',
53 + style: TextStyle(
54 + fontSize: compact ? 9 : 11,
55 + fontWeight: FontWeight.bold,
56 + ),
57 + ),
58 + ],
59 + ),
60 + ),
61 + );
62 + }
63 +}
64 +
65 +Future<void> showLmDownloadsSheet(BuildContext context) {
66 + return showModalBottomSheet<void>(
67 + context: context,
68 + isScrollControlled: true,
69 + showDragHandle: true,
70 + builder: (context) => const _LmDownloadsSheet(),
71 + );
72 +}
73 +
74 +class _LmDownloadsSheet extends ConsumerWidget {
75 + const _LmDownloadsSheet();
76 +
77 + @override
78 + Widget build(BuildContext context, WidgetRef ref) {
79 + final l10n = AppLocalizations.of(context)!;
80 + final theme = Theme.of(context);
81 + final isDark = theme.brightness == Brightness.dark;
82 + final jobs = ref.watch(lmDownloadManagerProvider).jobs;
83 + final hasFinished = jobs.any((j) => !j.status.isActive);
84 +
85 + return SafeArea(
86 + child: Padding(
87 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
88 + child: Column(
89 + mainAxisSize: MainAxisSize.min,
90 + crossAxisAlignment: CrossAxisAlignment.start,
91 + children: [
92 + Row(
93 + children: [
94 + Expanded(
95 + child: Text(
96 + l10n.lm_studio_downloads_title,
97 + style: theme.textTheme.titleMedium?.copyWith(
98 + fontWeight: FontWeight.w700,
99 + ),
100 + ),
101 + ),
102 + if (hasFinished)
103 + TextButton(
104 + onPressed: () => ref
105 + .read(lmDownloadManagerProvider.notifier)
106 + .dismissFinishedJobs(),
107 + child: Text(l10n.lm_studio_clear_downloads),
108 + ),
109 + ],
110 + ),
111 + const SizedBox(height: 4),
112 + Text(
113 + l10n.lm_studio_downloads_disclaimer,
114 + style: theme.textTheme.bodySmall?.copyWith(
115 + color: isDark
116 + ? AppColors.darkMutedText
117 + : AppColors.lightMutedText,
118 + ),
119 + ),
120 + const SizedBox(height: 8),
121 + if (jobs.isEmpty)
122 + Padding(
123 + padding: const EdgeInsets.symmetric(vertical: 24),
124 + child: Center(
125 + child: Text(
126 + l10n.lm_studio_no_downloads,
127 + style: theme.textTheme.bodyMedium?.copyWith(
128 + color: isDark
129 + ? AppColors.darkMutedText
130 + : AppColors.lightMutedText,
131 + ),
132 + ),
133 + ),
134 + )
135 + else
136 + Flexible(
137 + child: ListView.separated(
138 + shrinkWrap: true,
139 + itemCount: jobs.length,
140 + separatorBuilder: (_, __) => const Divider(height: 1),
141 + itemBuilder: (context, index) {
142 + final job = jobs[index];
143 + return _DownloadJobTile(job: job);
144 + },
145 + ),
146 + ),
147 + ],
148 + ),
149 + ),
150 + );
151 + }
152 +}
153 +
154 +class _DownloadJobTile extends ConsumerWidget {
155 + const _DownloadJobTile({required this.job});
156 +
157 + final LmDownloadJob job;
158 +
159 + @override
160 + Widget build(BuildContext context, WidgetRef ref) {
161 + final l10n = AppLocalizations.of(context)!;
162 + final theme = Theme.of(context);
163 + final isDark = theme.brightness == Brightness.dark;
164 +
165 + late final String statusLabel;
166 + late final Color statusColor;
167 + switch (job.status) {
168 + case LmDownloadStatus.downloading:
169 + statusLabel = l10n.lm_studio_downloading_percent(
170 + ((job.progressFraction ?? 0) * 100).round(),
171 + );
172 + statusColor = theme.colorScheme.primary;
173 + case LmDownloadStatus.paused:
174 + statusLabel = l10n.pause;
175 + statusColor = Colors.orange;
176 + case LmDownloadStatus.completed:
177 + case LmDownloadStatus.alreadyDownloaded:
178 + statusLabel = l10n.downloaded;
179 + statusColor = Colors.green;
180 + case LmDownloadStatus.failed:
181 + statusLabel = l10n.download_failed;
182 + statusColor = Colors.red;
183 + }
184 +
185 + return ListTile(
186 + contentPadding: EdgeInsets.zero,
187 + title: Text(
188 + job.displayName,
189 + maxLines: 2,
190 + overflow: TextOverflow.ellipsis,
191 + style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
192 + ),
193 + subtitle: Column(
194 + crossAxisAlignment: CrossAxisAlignment.start,
195 + children: [
196 + const SizedBox(height: 4),
197 + Text(statusLabel, style: TextStyle(color: statusColor, fontSize: 12)),
198 + if (job.errorMessage != null && job.status == LmDownloadStatus.failed)
199 + Text(
200 + job.errorMessage!,
201 + style: theme.textTheme.bodySmall?.copyWith(color: Colors.red),
202 + maxLines: 2,
203 + overflow: TextOverflow.ellipsis,
204 + ),
205 + if (job.status.isActive && job.totalSizeBytes != null) ...[
206 + const SizedBox(height: 6),
207 + ClipRRect(
208 + borderRadius: BorderRadius.circular(4),
209 + child: LinearProgressIndicator(
210 + value: job.progressFraction,
211 + minHeight: 6,
212 + color: theme.colorScheme.primary,
213 + backgroundColor:
214 + isDark ? AppColors.darkBorder : AppColors.lightBorder,
215 + ),
216 + ),
217 + const SizedBox(height: 4),
218 + Text(
219 + [
220 + '${formatBytes(job.downloadedBytes ?? 0)} / ${formatBytes(job.totalSizeBytes!)}',
221 + if (formatSpeed(job.bytesPerSecond).isNotEmpty)
222 + formatSpeed(job.bytesPerSecond),
223 + ].join(' · '),
224 + style: theme.textTheme.labelSmall,
225 + ),
226 + ],
227 + ],
228 + ),
229 + trailing: job.status.isActive
230 + ? null
231 + : IconButton(
232 + icon: const Icon(Icons.close, size: 18),
233 + onPressed: () => ref
234 + .read(lmDownloadManagerProvider.notifier)
235 + .removeJob(job.jobId),
236 + ),
237 + );
238 + }
239 +}
lib/features/lm_studio_catalog/views/lm_studio_model_browser_screen.dart new
+1030
@@ -0,0 +1,1030 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:gpt_markdown/gpt_markdown.dart';
4 +import 'package:localmind/core/theme/colors.dart';
5 +import 'package:localmind/features/models/data/models/model_info.dart';
6 +import 'package:localmind/features/servers/data/models/server.dart';
7 +import 'package:localmind/features/servers/providers/server_providers.dart';
8 +import 'package:localmind/l10n/app_localizations.dart';
9 +
10 +import '../data/catalog_models.dart';
11 +import '../providers/lm_studio_catalog_providers.dart';
12 +import '../utils/download_matching.dart';
13 +import '../utils/memory_compatibility.dart';
14 +import 'lm_studio_download_widgets.dart';
15 +import 'lm_studio_quant_selector.dart';
16 +
17 +class LmStudioModelBrowserScreen extends ConsumerStatefulWidget {
18 + const LmStudioModelBrowserScreen({super.key, required this.server});
19 +
20 + final Server server;
21 +
22 + @override
23 + ConsumerState<LmStudioModelBrowserScreen> createState() =>
24 + _LmStudioModelBrowserScreenState();
25 +}
26 +
27 +class _LmStudioModelBrowserScreenState
28 + extends ConsumerState<LmStudioModelBrowserScreen>
29 + with SingleTickerProviderStateMixin {
30 + final _searchController = TextEditingController();
31 + final _listScrollController = ScrollController();
32 + String _searchQuery = '';
33 + LmCatalogModel? _selectedModel;
34 + late AnimationController _panelController;
35 + late Animation<Offset> _panelSlide;
36 +
37 + @override
38 + void initState() {
39 + super.initState();
40 + _panelController = AnimationController(
41 + vsync: this,
42 + duration: const Duration(milliseconds: 280),
43 + );
44 + _panelSlide = Tween<Offset>(
45 + begin: const Offset(1, 0),
46 + end: Offset.zero,
47 + ).animate(CurvedAnimation(
48 + parent: _panelController,
49 + curve: Curves.easeOutCubic,
50 + reverseCurve: Curves.easeInCubic,
51 + ));
52 + _listScrollController.addListener(_onListScroll);
53 + }
54 +
55 + @override
56 + void dispose() {
57 + _searchController.dispose();
58 + _listScrollController.dispose();
59 + _panelController.dispose();
60 + super.dispose();
61 + }
62 +
63 + void _onListScroll() {
64 + if (_searchQuery.trim().isEmpty) return;
65 + if (!_listScrollController.hasClients) return;
66 + final pos = _listScrollController.position;
67 + if (pos.pixels >= pos.maxScrollExtent - 240) {
68 + ref.read(lmCatalogSearchProvider.notifier).loadMore();
69 + }
70 + }
71 +
72 + void _onSearchChanged(String value) {
73 + setState(() {
74 + _searchQuery = value;
75 + if (value.trim().isEmpty) {
76 + _closeDetails();
77 + }
78 + });
79 + ref.read(lmCatalogSearchProvider.notifier).search(value);
80 + }
81 +
82 + Future<void> _selectModel(LmCatalogModel model) async {
83 + setState(() => _selectedModel = model);
84 + await _panelController.forward();
85 + }
86 +
87 + Future<void> _closeDetails() async {
88 + if (_selectedModel == null) return;
89 + await _panelController.reverse();
90 + if (mounted) setState(() => _selectedModel = null);
91 + }
92 +
93 + @override
94 + Widget build(BuildContext context) {
95 + final l10n = AppLocalizations.of(context)!;
96 + final theme = Theme.of(context);
97 + final isDark = theme.brightness == Brightness.dark;
98 + final trimmedQuery = _searchQuery.trim();
99 + final isSearching = trimmedQuery.isNotEmpty;
100 +
101 + final staffPicksAsync = ref.watch(lmStudioStaffPicksProvider);
102 + final searchState = isSearching
103 + ? ref.watch(lmCatalogSearchProvider)
104 + : null;
105 +
106 + return PopScope(
107 + canPop: _selectedModel == null,
108 + onPopInvokedWithResult: (didPop, result) {
109 + if (didPop) return;
110 + _closeDetails();
111 + },
112 + child: Scaffold(
113 + backgroundColor:
114 + isDark ? AppColors.darkBackground : AppColors.lightBackground,
115 + appBar: AppBar(
116 + title: Text(l10n.lm_studio_model_browser_title),
117 + actions: const [
118 + LmDownloadIndicatorButton(),
119 + ],
120 + ),
121 + body: Stack(
122 + children: [
123 + Column(
124 + children: [
125 + Padding(
126 + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
127 + child: TextField(
128 + controller: _searchController,
129 + onChanged: _onSearchChanged,
130 + decoration: InputDecoration(
131 + hintText: l10n.lm_studio_model_search_hint,
132 + prefixIcon: const Icon(Icons.search),
133 + suffixIcon: _searchQuery.isNotEmpty
134 + ? IconButton(
135 + icon: const Icon(Icons.close),
136 + onPressed: () {
137 + _searchController.clear();
138 + _onSearchChanged('');
139 + },
140 + )
141 + : null,
142 + filled: true,
143 + fillColor:
144 + isDark ? AppColors.darkSurface : AppColors.lightSurface,
145 + border: OutlineInputBorder(
146 + borderRadius: BorderRadius.circular(12),
147 + borderSide: BorderSide.none,
148 + ),
149 + ),
150 + ),
151 + ),
152 + Expanded(
153 + child: isSearching
154 + ? _buildSearchList(searchState, l10n, theme)
155 + : staffPicksAsync.when(
156 + loading: () =>
157 + const Center(child: CircularProgressIndicator()),
158 + error: (error, _) => Center(
159 + child:
160 + Text(l10n.error_with_message(error.toString())),
161 + ),
162 + data: (models) =>
163 + _buildModelList(models, l10n, theme, null),
164 + ),
165 + ),
166 + ],
167 + ),
168 + if (_selectedModel != null) ...[
169 + GestureDetector(
170 + onTap: _closeDetails,
171 + child: AnimatedBuilder(
172 + animation: _panelController,
173 + builder: (context, _) => Container(
174 + color: Colors.black.withValues(
175 + alpha: 0.45 * _panelController.value,
176 + ),
177 + ),
178 + ),
179 + ),
180 + SlideTransition(
181 + position: _panelSlide,
182 + child: Align(
183 + alignment: Alignment.centerRight,
184 + child: SizedBox(
185 + width: MediaQuery.sizeOf(context).width * 0.85,
186 + child: _ModelDetailPanel(
187 + server: widget.server,
188 + model: _selectedModel!,
189 + onClose: _closeDetails,
190 + onSwipeDismiss: _closeDetails,
191 + ),
192 + ),
193 + ),
194 + ),
195 + ],
196 + ],
197 + ),
198 + ),
199 + );
200 + }
201 +
202 + Widget _buildSearchList(
203 + LmCatalogSearchState? state,
204 + AppLocalizations l10n,
205 + ThemeData theme,
206 + ) {
207 + if (state == null || state.isLoading) {
208 + return const Center(child: CircularProgressIndicator());
209 + }
210 + if (state.error != null && state.allModels.isEmpty) {
211 + return Center(child: Text(l10n.error_with_message(state.error!)));
212 + }
213 + if (state.allModels.isEmpty) {
214 + return Center(child: Text(l10n.lm_studio_no_models));
215 + }
216 + return _buildModelList(
217 + state.allModels,
218 + l10n,
219 + theme,
220 + state,
221 + staffMatches: state.staffMatches,
222 + communityModels: state.communityModels,
223 + );
224 + }
225 +
226 + Widget _buildModelList(
227 + List<LmCatalogModel> models,
228 + AppLocalizations l10n,
229 + ThemeData theme,
230 + LmCatalogSearchState? searchState, {
231 + List<LmCatalogModel>? staffMatches,
232 + List<LmCatalogModel>? communityModels,
233 + }) {
234 + final isSearching = _searchQuery.trim().isNotEmpty;
235 + final staff = isSearching ? (staffMatches ?? []) : models;
236 + final community = isSearching ? (communityModels ?? []) : const <LmCatalogModel>[];
237 +
238 + return ListView(
239 + controller: _listScrollController,
240 + padding: const EdgeInsets.symmetric(horizontal: 8),
241 + children: [
242 + if (_searchQuery.trim().isEmpty)
243 + Padding(
244 + padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
245 + child: Row(
246 + children: [
247 + Text(
248 + l10n.lm_studio_staff_picks,
249 + style: theme.textTheme.titleSmall?.copyWith(
250 + fontWeight: FontWeight.w600,
251 + ),
252 + ),
253 + const Spacer(),
254 + Text(
255 + l10n.lm_studio_models_count(models.length),
256 + style: theme.textTheme.bodySmall,
257 + ),
258 + ],
259 + ),
260 + ),
261 + if (staff.isNotEmpty && isSearching)
262 + Padding(
263 + padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
264 + child: Text(
265 + l10n.lm_studio_staff_picks,
266 + style: theme.textTheme.titleSmall?.copyWith(
267 + fontWeight: FontWeight.w600,
268 + ),
269 + ),
270 + ),
271 + ...staff.map(
272 + (model) => _ModelListTile(
273 + model: model,
274 + selected: _selectedModel?.id == model.id,
275 + onTap: () => _selectModel(model),
276 + ),
277 + ),
278 + if (community.isNotEmpty) ...[
279 + Padding(
280 + padding: const EdgeInsets.fromLTRB(12, 16, 12, 8),
281 + child: Text(
282 + l10n.lm_studio_community_models,
283 + style: theme.textTheme.titleSmall?.copyWith(
284 + fontWeight: FontWeight.w600,
285 + ),
286 + ),
287 + ),
288 + ...community.map(
289 + (model) => _ModelListTile(
290 + model: model,
291 + selected: _selectedModel?.id == model.id,
292 + onTap: () => _selectModel(model),
293 + ),
294 + ),
295 + ],
296 + if (searchState?.isLoadingMore == true)
297 + const Padding(
298 + padding: EdgeInsets.all(16),
299 + child: Center(child: CircularProgressIndicator()),
300 + ),
301 + ],
302 + );
303 + }
304 +}
305 +
306 +class _ModelListTile extends StatelessWidget {
307 + const _ModelListTile({
308 + required this.model,
309 + required this.selected,
310 + required this.onTap,
311 + });
312 +
313 + final LmCatalogModel model;
314 + final bool selected;
315 + final VoidCallback onTap;
316 +
317 + @override
318 + Widget build(BuildContext context) {
319 + final theme = Theme.of(context);
320 + final isDark = theme.brightness == Brightness.dark;
321 +
322 + return Material(
323 + color: selected
324 + ? (isDark ? AppColors.darkAccent : AppColors.lightAccent)
325 + .withValues(alpha: 0.18)
326 + : Colors.transparent,
327 + borderRadius: BorderRadius.circular(12),
328 + child: InkWell(
329 + onTap: onTap,
330 + borderRadius: BorderRadius.circular(12),
331 + child: Padding(
332 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
333 + child: Row(
334 + crossAxisAlignment: CrossAxisAlignment.start,
335 + children: [
336 + ClipRRect(
337 + borderRadius: BorderRadius.circular(8),
338 + child: model.source == LmCatalogSource.lmStudio
339 + ? Image.network(
340 + model.thumbnailUrl,
341 + width: 40,
342 + height: 40,
343 + fit: BoxFit.cover,
344 + errorBuilder: (_, __, ___) =>
345 + _FallbackIcon(model: model),
346 + )
347 + : _FallbackIcon(model: model),
348 + ),
349 + const SizedBox(width: 12),
350 + Expanded(
351 + child: Column(
352 + crossAxisAlignment: CrossAxisAlignment.start,
353 + children: [
354 + Row(
355 + children: [
356 + Expanded(
357 + child: Text(
358 + model.displayLabel,
359 + maxLines: 1,
360 + overflow: TextOverflow.ellipsis,
361 + style: theme.textTheme.titleSmall?.copyWith(
362 + fontWeight: FontWeight.w600,
363 + ),
364 + ),
365 + ),
366 + if (model.isVerified)
367 + Icon(
368 + Icons.verified,
369 + size: 16,
370 + color: isDark
371 + ? AppColors.darkAccent
372 + : AppColors.lightAccent,
373 + ),
374 + if (model.isStaffPick) ...[
375 + const SizedBox(width: 4),
376 + Icon(
377 + Icons.auto_awesome,
378 + size: 14,
379 + color: Colors.purple.shade300,
380 + ),
381 + ],
382 + ],
383 + ),
384 + const SizedBox(height: 2),
385 + Text(
386 + model.owner,
387 + style: theme.textTheme.bodySmall?.copyWith(
388 + color: isDark
389 + ? AppColors.darkMutedText
390 + : AppColors.lightMutedText,
391 + ),
392 + ),
393 + if (model.description != null) ...[
394 + const SizedBox(height: 4),
395 + Text(
396 + model.description!,
397 + maxLines: 2,
398 + overflow: TextOverflow.ellipsis,
399 + style: theme.textTheme.bodySmall,
400 + ),
401 + ],
402 + const SizedBox(height: 6),
403 + Row(
404 + children: [
405 + _CapabilityIcons(model: model),
406 + const Spacer(),
407 + if (model.likes > 0) ...[
408 + Icon(Icons.favorite_border,
409 + size: 14, color: theme.hintColor),
410 + const SizedBox(width: 2),
411 + Text(
412 + _formatCount(model.likes),
413 + style: theme.textTheme.labelSmall,
414 + ),
415 + const SizedBox(width: 8),
416 + ],
417 + if (model.downloads > 0) ...[
418 + Icon(Icons.download_outlined,
419 + size: 14, color: theme.hintColor),
420 + const SizedBox(width: 2),
421 + Text(
422 + _formatCount(model.downloads),
423 + style: theme.textTheme.labelSmall,
424 + ),
425 + const SizedBox(width: 8),
426 + ],
427 + if (model.updatedAt != null)
428 + Text(
429 + formatRelativeTime(model.updatedAt),
430 + style: theme.textTheme.labelSmall?.copyWith(
431 + color: theme.hintColor,
432 + ),
433 + ),
434 + ],
435 + ),
436 + ],
437 + ),
438 + ),
439 + ],
440 + ),
441 + ),
442 + ),
443 + );
444 + }
445 +
446 + String _formatCount(int value) {
447 + if (value >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M';
448 + if (value >= 1000) return '${(value / 1000).toStringAsFixed(1)}K';
449 + return value.toString();
450 + }
451 +}
452 +
453 +class _FallbackIcon extends StatelessWidget {
454 + const _FallbackIcon({required this.model});
455 +
456 + final LmCatalogModel model;
457 +
458 + @override
459 + Widget build(BuildContext context) {
460 + return Container(
461 + width: 40,
462 + height: 40,
463 + color: Colors.grey.shade800,
464 + child: Center(
465 + child: Text(
466 + model.owner.isNotEmpty ? model.owner[0].toUpperCase() : '?',
467 + style: const TextStyle(fontWeight: FontWeight.bold),
468 + ),
469 + ),
470 + );
471 + }
472 +}
473 +
474 +class _CapabilityIcons extends StatelessWidget {
475 + const _CapabilityIcons({required this.model});
476 +
477 + final LmCatalogModel model;
478 +
479 + @override
480 + Widget build(BuildContext context) {
481 + final icons = <Widget>[];
482 + if (model.metadata.vision) {
483 + icons.add(_capIcon(Icons.visibility_outlined, Colors.amber));
484 + }
485 + if (model.metadata.trainedForToolUse) {
486 + icons.add(_capIcon(Icons.build_outlined, Colors.blue));
487 + }
488 + if (model.metadata.reasoning) {
489 + icons.add(_capIcon(Icons.psychology_outlined, Colors.green));
490 + }
491 + return Row(children: icons);
492 + }
493 +
494 + Widget _capIcon(IconData icon, Color color) {
495 + return Padding(
496 + padding: const EdgeInsets.only(right: 4),
497 + child: Icon(icon, size: 16, color: color),
498 + );
499 + }
500 +}
501 +
502 +class _ModelDetailPanel extends ConsumerStatefulWidget {
503 + const _ModelDetailPanel({
504 + required this.server,
505 + required this.model,
506 + required this.onClose,
507 + required this.onSwipeDismiss,
508 + });
509 +
510 + final Server server;
511 + final LmCatalogModel model;
512 + final VoidCallback onClose;
513 + final VoidCallback onSwipeDismiss;
514 +
515 + @override
516 + ConsumerState<_ModelDetailPanel> createState() => _ModelDetailPanelState();
517 +}
518 +
519 +class _ModelDetailPanelState extends ConsumerState<_ModelDetailPanel> {
520 + LmModelQuantOption? _selectedQuant;
521 +
522 + @override
523 + void didUpdateWidget(covariant _ModelDetailPanel oldWidget) {
524 + super.didUpdateWidget(oldWidget);
525 + if (oldWidget.model.id != widget.model.id) {
526 + _selectedQuant = null;
527 + }
528 + }
529 +
530 + LmModelQuantOption? _effectiveQuant(List<LmModelQuantOption> quants) {
531 + if (quants.isEmpty) return null;
532 + if (_selectedQuant != null &&
533 + quants.any((q) => q.fileName == _selectedQuant!.fileName)) {
534 + return _selectedQuant;
535 + }
536 + return LmModelQuantOption.recommended(quants);
537 + }
538 +
539 + LmDownloadJob? _activeJobFor(LmModelDetail detail) {
540 + final jobs = ref.watch(lmDownloadManagerProvider).jobs;
541 + final hfRepo = detail.hfRepoId;
542 + return jobs.where((job) {
543 + if (!job.status.isActive) return false;
544 + if (job.modelId == widget.model.catalogId) return true;
545 + if (hfRepo != null && job.modelId.contains(hfRepo)) return true;
546 + if (_selectedQuant != null &&
547 + job.displayName.contains(_selectedQuant!.quantization)) {
548 + return job.displayName.startsWith(widget.model.displayLabel);
549 + }
550 + return job.displayName.startsWith(widget.model.displayLabel);
551 + }).firstOrNull;
552 + }
553 +
554 + Future<void> _startDownload(LmModelDetail detail) async {
555 + final l10n = AppLocalizations.of(context)!;
556 + final quant = _effectiveQuant(detail.quants);
557 + if (detail.quants.isNotEmpty && quant == null) {
558 + ScaffoldMessenger.of(context).showSnackBar(
559 + SnackBar(content: Text(l10n.lm_studio_download_options)),
560 + );
561 + return;
562 + }
563 +
564 + try {
565 + await ref.read(lmDownloadManagerProvider.notifier).startDownload(
566 + server: widget.server,
567 + model: widget.model,
568 + detail: detail,
569 + quant: quant,
570 + );
571 + } catch (e) {
572 + if (!mounted) return;
573 + ScaffoldMessenger.of(context).showSnackBar(
574 + SnackBar(
575 + content: Text(l10n.error_with_message(e.toString())),
576 + backgroundColor: Colors.red,
577 + ),
578 + );
579 + }
580 + }
581 +
582 + @override
583 + Widget build(BuildContext context) {
584 + final l10n = AppLocalizations.of(context)!;
585 + final theme = Theme.of(context);
586 + final isDark = theme.brightness == Brightness.dark;
587 + final detailAsync = ref.watch(lmModelDetailProvider(widget.model));
588 +
589 + return GestureDetector(
590 + onHorizontalDragEnd: (details) {
591 + if ((details.primaryVelocity ?? 0) > 250) {
592 + widget.onSwipeDismiss();
593 + }
594 + },
595 + child: Material(
596 + color: isDark ? AppColors.darkSurface : AppColors.lightSurface,
597 + elevation: 8,
598 + child: SafeArea(
599 + child: detailAsync.when(
600 + loading: () => const Center(child: CircularProgressIndicator()),
601 + error: (error, _) => Center(
602 + child: Padding(
603 + padding: const EdgeInsets.all(16),
604 + child: Text(l10n.error_with_message(error.toString())),
605 + ),
606 + ),
607 + data: (detail) {
608 + final selectedQuant = _effectiveQuant(detail.quants);
609 + final activeJob = _activeJobFor(detail);
610 + final selectedSize = selectedQuant?.sizeBytes ??
611 + detail.model.metadata.minMemoryUsageBytes;
612 + final compatibility = estimateMemoryCompatibility(
613 + modelSizeBytes: selectedSize,
614 + availableRamGb: widget.server.availableRamGb,
615 + availableVramGb: widget.server.availableVramGb,
616 + );
617 + final serverModels =
618 + ref.watch(availableModelsProvider(widget.server.id));
619 + final downloadedModels = serverModels.maybeWhen(
620 + data: downloadedModelsList,
621 + orElse: () => const <ModelInfo>[],
622 + );
623 + final selectedDownloaded = selectedQuant != null &&
624 + isQuantDownloaded(
625 + model: widget.model,
626 + quant: selectedQuant,
627 + downloadedModels: downloadedModels,
628 + );
629 +
630 + return Column(
631 + crossAxisAlignment: CrossAxisAlignment.start,
632 + children: [
633 + Padding(
634 + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
635 + child: Row(
636 + children: [
637 + IconButton(
638 + icon: const Icon(Icons.arrow_back),
639 + onPressed: widget.onClose,
640 + ),
641 + Expanded(
642 + child: Text(
643 + widget.model.catalogId,
644 + maxLines: 1,
645 + overflow: TextOverflow.ellipsis,
646 + style: theme.textTheme.titleMedium?.copyWith(
647 + fontWeight: FontWeight.w600,
648 + ),
649 + ),
650 + ),
651 + IconButton(
652 + icon: const Icon(Icons.close),
653 + onPressed: widget.onClose,
654 + ),
655 + ],
656 + ),
657 + ),
658 + Expanded(
659 + child: ListView(
660 + padding: const EdgeInsets.all(16),
661 + children: [
662 + if (widget.model.isStaffPick)
663 + _BadgeChip(
664 + label: l10n.lm_studio_staff_pick,
665 + color: Colors.purple,
666 + ),
667 + const SizedBox(height: 12),
668 + Text(
669 + widget.model.displayLabel,
670 + style: theme.textTheme.titleLarge?.copyWith(
671 + fontWeight: FontWeight.w700,
672 + ),
673 + ),
674 + const SizedBox(height: 4),
675 + Text(
676 + widget.model.owner,
677 + style: theme.textTheme.bodySmall?.copyWith(
678 + color: isDark
679 + ? AppColors.darkMutedText
680 + : AppColors.lightMutedText,
681 + ),
682 + ),
683 + if (widget.model.likes > 0 ||
684 + widget.model.downloads > 0) ...[
685 + const SizedBox(height: 8),
686 + Row(
687 + children: [
688 + if (widget.model.likes > 0) ...[
689 + Icon(Icons.favorite_border,
690 + size: 16, color: theme.hintColor),
691 + const SizedBox(width: 4),
692 + Text('${_formatCount(widget.model.likes)}'),
693 + const SizedBox(width: 16),
694 + ],
695 + if (widget.model.downloads > 0) ...[
696 + Icon(Icons.download_outlined,
697 + size: 16, color: theme.hintColor),
698 + const SizedBox(width: 4),
699 + Text('${_formatCount(widget.model.downloads)}'),
700 + ],
701 + ],
702 + ),
703 + ],
704 + if (widget.model.description != null) ...[
705 + const SizedBox(height: 12),
706 + Text(
707 + widget.model.description!,
708 + style: theme.textTheme.bodyLarge?.copyWith(
709 + color: isDark
710 + ? AppColors.darkAccent
711 + : AppColors.lightAccent,
712 + ),
713 + ),
714 + ],
715 + const SizedBox(height: 16),
716 + Wrap(
717 + spacing: 8,
718 + runSpacing: 8,
719 + children: [
720 + if (widget.model.metadata.paramsStrings.isNotEmpty)
721 + _MetaChip(
722 + label: l10n.lm_studio_params,
723 + value: widget.model.metadata.paramsStrings
724 + .join(', '),
725 + ),
726 + if (widget.model.metadata.architectures.isNotEmpty)
727 + _MetaChip(
728 + label: l10n.lm_studio_arch,
729 + value: widget.model.metadata.architectures
730 + .join(', '),
731 + ),
732 + _MetaChip(
733 + label: l10n.lm_studio_domain,
734 + value: widget.model.metadata.type.toUpperCase(),
735 + ),
736 + if (widget
737 + .model.metadata.compatibilityTypes.isNotEmpty)
738 + _MetaChip(
739 + label: l10n.lm_studio_format,
740 + value: widget.model.metadata.compatibilityTypes
741 + .join(', ')
742 + .toUpperCase(),
743 + highlighted: true,
744 + ),
745 + ],
746 + ),
747 + const SizedBox(height: 12),
748 + _CapabilityRow(model: widget.model),
749 + const SizedBox(height: 20),
750 + Text(
751 + l10n.lm_studio_download_options,
752 + style: theme.textTheme.titleSmall?.copyWith(
753 + fontWeight: FontWeight.w600,
754 + ),
755 + ),
756 + const SizedBox(height: 8),
757 + if (detail.quants.isEmpty)
758 + Container(
759 + width: double.infinity,
760 + padding: const EdgeInsets.all(12),
761 + decoration: BoxDecoration(
762 + color: isDark
763 + ? AppColors.darkBackground
764 + : AppColors.lightBackground,
765 + borderRadius: BorderRadius.circular(12),
766 + border: Border.all(
767 + color: isDark
768 + ? AppColors.darkBorder
769 + : AppColors.lightBorder,
770 + ),
771 + ),
772 + child: Text(
773 + widget.model.displayLabel,
774 + style: theme.textTheme.titleSmall,
775 + ),
776 + )
777 + else
778 + LmStudioQuantSelector(
779 + quants: detail.quants,
780 + selected: selectedQuant,
781 + onSelected: (quant) =>
782 + setState(() => _selectedQuant = quant),
783 + serverRamGb: widget.server.availableRamGb,
784 + serverVramGb: widget.server.availableVramGb,
785 + downloadedModels: downloadedModels,
786 + modelCapabilities: widget.model,
787 + ),
788 + if (detail.quants.isEmpty &&
789 + compatibility != MemoryCompatibility.unknown) ...[
790 + const SizedBox(height: 8),
791 + _CompatibilityBadge(compatibility: compatibility),
792 + ],
793 + const SizedBox(height: 16),
794 + Align(
795 + alignment: Alignment.centerRight,
796 + child: FilledButton.icon(
797 + onPressed: activeJob != null || selectedDownloaded
798 + ? null
799 + : () => _startDownload(detail),
800 + icon: activeJob != null
801 + ? SizedBox(
802 + width: 18,
803 + height: 18,
804 + child: CircularProgressIndicator(
805 + strokeWidth: 2,
806 + value: activeJob.progressFraction,
807 + ),
808 + )
809 + : Icon(
810 + selectedDownloaded
811 + ? Icons.check
812 + : Icons.download,
813 + ),
814 + label: Text(
815 + activeJob != null
816 + ? l10n.lm_studio_downloading_percent(
817 + ((activeJob.progressFraction ?? 0) * 100)
818 + .round(),
819 + )
820 + : selectedDownloaded
821 + ? l10n.downloaded
822 + : selectedSize != null
823 + ? l10n.lm_studio_download_size(
824 + formatBytes(selectedSize),
825 + )
826 + : l10n.lm_studio_download,
827 + ),
828 + ),
829 + ),
830 + const SizedBox(height: 24),
831 + Text(
832 + 'README',
833 + style: theme.textTheme.titleSmall?.copyWith(
834 + fontWeight: FontWeight.w600,
835 + ),
836 + ),
837 + const SizedBox(height: 8),
838 + if (detail.readme == null ||
839 + detail.readme!.trim().isEmpty)
840 + Text(l10n.lm_studio_readme_unavailable)
841 + else
842 + GptMarkdown(detail.readme!),
843 + ],
844 + ),
845 + ),
846 + ],
847 + );
848 + },
849 + ),
850 + ),
851 + ),
852 + );
853 + }
854 +
855 + String _formatCount(int value) {
856 + if (value >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M';
857 + if (value >= 1000) return '${(value / 1000).toStringAsFixed(1)}K';
858 + return value.toString();
859 + }
860 +}
861 +
862 +class _MetaChip extends StatelessWidget {
863 + const _MetaChip({
864 + required this.label,
865 + required this.value,
866 + this.highlighted = false,
867 + });
868 +
869 + final String label;
870 + final String value;
871 + final bool highlighted;
872 +
873 + @override
874 + Widget build(BuildContext context) {
875 + final theme = Theme.of(context);
876 + final isDark = theme.brightness == Brightness.dark;
877 + return Container(
878 + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
879 + decoration: BoxDecoration(
880 + borderRadius: BorderRadius.circular(8),
881 + color: isDark ? AppColors.darkBackground : AppColors.lightBackground,
882 + border: Border.all(
883 + color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
884 + ),
885 + ),
886 + child: RichText(
887 + text: TextSpan(
888 + style: theme.textTheme.labelSmall,
889 + children: [
890 + TextSpan(
891 + text: '$label ',
892 + style: TextStyle(
893 + color: isDark
894 + ? AppColors.darkMutedText
895 + : AppColors.lightMutedText,
896 + ),
897 + ),
898 + TextSpan(
899 + text: value,
900 + style: TextStyle(
901 + fontWeight: FontWeight.w600,
902 + color: highlighted
903 + ? (isDark ? AppColors.darkAccent : AppColors.lightAccent)
904 + : null,
905 + ),
906 + ),
907 + ],
908 + ),
909 + ),
910 + );
911 + }
912 +}
913 +
914 +class _BadgeChip extends StatelessWidget {
915 + const _BadgeChip({required this.label, required this.color});
916 +
917 + final String label;
918 + final Color color;
919 +
920 + @override
921 + Widget build(BuildContext context) {
922 + return Container(
923 + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
924 + decoration: BoxDecoration(
925 + color: color.withValues(alpha: 0.15),
926 + borderRadius: BorderRadius.circular(999),
927 + border: Border.all(color: color.withValues(alpha: 0.4)),
928 + ),
929 + child: Text(
930 + label,
931 + style: TextStyle(
932 + color: color,
933 + fontWeight: FontWeight.w600,
934 + fontSize: 12,
935 + ),
936 + ),
937 + );
938 + }
939 +}
940 +
941 +class _CapabilityRow extends StatelessWidget {
942 + const _CapabilityRow({required this.model});
943 +
944 + final LmCatalogModel model;
945 +
946 + @override
947 + Widget build(BuildContext context) {
948 + final l10n = AppLocalizations.of(context)!;
949 + final caps = <Widget>[];
950 + if (model.metadata.vision) {
951 + caps.add(_capLabel(Icons.visibility_outlined, l10n.lm_studio_vision, Colors.amber));
952 + }
953 + if (model.metadata.trainedForToolUse) {
954 + caps.add(_capLabel(Icons.build_outlined, l10n.lm_studio_tool_use, Colors.blue));
955 + }
956 + if (model.metadata.reasoning) {
957 + caps.add(_capLabel(Icons.psychology_outlined, l10n.lm_studio_reasoning, Colors.green));
958 + }
959 + if (caps.isEmpty) return const SizedBox.shrink();
960 + return Wrap(spacing: 8, runSpacing: 8, children: caps);
961 + }
962 +
963 + Widget _capLabel(IconData icon, String label, Color color) {
964 + return Container(
965 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
966 + decoration: BoxDecoration(
967 + borderRadius: BorderRadius.circular(8),
968 + border: Border.all(color: color.withValues(alpha: 0.4)),
969 + ),
970 + child: Row(
971 + mainAxisSize: MainAxisSize.min,
972 + children: [
973 + Icon(icon, size: 14, color: color),
974 + const SizedBox(width: 4),
975 + Text(label, style: TextStyle(color: color, fontSize: 12)),
976 + ],
977 + ),
978 + );
979 + }
980 +}
981 +
982 +class _CompatibilityBadge extends StatelessWidget {
983 + const _CompatibilityBadge({required this.compatibility});
984 +
985 + final MemoryCompatibility compatibility;
986 +
987 + @override
988 + Widget build(BuildContext context) {
989 + final l10n = AppLocalizations.of(context)!;
990 + late final String label;
991 + late final Color color;
992 + late final IconData icon;
993 +
994 + switch (compatibility) {
995 + case MemoryCompatibility.fullGpuOffload:
996 + label = l10n.lm_studio_full_gpu_offload;
997 + color = Colors.green;
998 + icon = Icons.rocket_launch_outlined;
999 + case MemoryCompatibility.partialGpuOffload:
1000 + label = l10n.lm_studio_partial_gpu_offload;
1001 + color = Colors.blue;
1002 + icon = Icons.memory_outlined;
1003 + case MemoryCompatibility.likelyTooLarge:
1004 + label = l10n.lm_studio_likely_too_large;
1005 + color = Colors.red;
1006 + icon = Icons.cancel_outlined;
1007 + case MemoryCompatibility.unknown:
1008 + return const SizedBox.shrink();
1009 + }
1010 +
1011 + return Container(
1012 + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
1013 + decoration: BoxDecoration(
1014 + borderRadius: BorderRadius.circular(999),
1015 + border: Border.all(color: color.withValues(alpha: 0.5)),
1016 + ),
1017 + child: Row(
1018 + mainAxisSize: MainAxisSize.min,
1019 + children: [
1020 + Icon(icon, size: 14, color: color),
1021 + const SizedBox(width: 6),
1022 + Text(
1023 + label,
1024 + style: TextStyle(color: color, fontWeight: FontWeight.w600, fontSize: 12),
1025 + ),
1026 + ],
1027 + ),
1028 + );
1029 + }
1030 +}
lib/features/lm_studio_catalog/views/lm_studio_quant_selector.dart new
+375
@@ -0,0 +1,375 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:localmind/core/theme/colors.dart';
3 +import 'package:localmind/features/models/data/models/model_info.dart';
4 +import 'package:localmind/l10n/app_localizations.dart';
5 +
6 +import '../data/catalog_models.dart';
7 +import '../utils/download_matching.dart';
8 +import '../utils/memory_compatibility.dart';
9 +
10 +class LmStudioQuantSelector extends StatefulWidget {
11 + const LmStudioQuantSelector({
12 + super.key,
13 + required this.quants,
14 + required this.selected,
15 + required this.onSelected,
16 + required this.serverRamGb,
17 + required this.serverVramGb,
18 + required this.downloadedModels,
19 + required this.modelCapabilities,
20 + });
21 +
22 + final List<LmModelQuantOption> quants;
23 + final LmModelQuantOption? selected;
24 + final ValueChanged<LmModelQuantOption> onSelected;
25 + final int? serverRamGb;
26 + final int? serverVramGb;
27 + final List<ModelInfo> downloadedModels;
28 + final LmCatalogModel modelCapabilities;
29 +
30 + @override
31 + State<LmStudioQuantSelector> createState() => _LmStudioQuantSelectorState();
32 +}
33 +
34 +class _LmStudioQuantSelectorState extends State<LmStudioQuantSelector> {
35 + bool _expanded = false;
36 +
37 + bool _isDownloaded(LmModelQuantOption quant) {
38 + return isQuantDownloaded(
39 + model: widget.modelCapabilities,
40 + quant: quant,
41 + downloadedModels: widget.downloadedModels,
42 + );
43 + }
44 +
45 + MemoryCompatibility _compat(LmModelQuantOption quant) {
46 + return estimateMemoryCompatibility(
47 + modelSizeBytes: quant.sizeBytes,
48 + availableRamGb: widget.serverRamGb,
49 + availableVramGb: widget.serverVramGb,
50 + );
51 + }
52 +
53 + Widget _compatBadge(MemoryCompatibility compatibility, AppLocalizations l10n) {
54 + if (compatibility == MemoryCompatibility.unknown) {
55 + return const SizedBox.shrink();
56 + }
57 +
58 + late final String label;
59 + late final Color color;
60 + late final IconData icon;
61 +
62 + switch (compatibility) {
63 + case MemoryCompatibility.fullGpuOffload:
64 + label = l10n.lm_studio_full_gpu_offload;
65 + color = Colors.green;
66 + icon = Icons.rocket_launch_outlined;
67 + case MemoryCompatibility.partialGpuOffload:
68 + label = l10n.lm_studio_partial_gpu_offload;
69 + color = Colors.blue;
70 + icon = Icons.memory_outlined;
71 + case MemoryCompatibility.likelyTooLarge:
72 + label = l10n.lm_studio_likely_too_large;
73 + color = Colors.red;
74 + icon = Icons.cancel_outlined;
75 + case MemoryCompatibility.unknown:
76 + return const SizedBox.shrink();
77 + }
78 +
79 + return Container(
80 + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
81 + decoration: BoxDecoration(
82 + borderRadius: BorderRadius.circular(999),
83 + border: Border.all(color: color.withValues(alpha: 0.5)),
84 + ),
85 + child: Row(
86 + mainAxisSize: MainAxisSize.min,
87 + children: [
88 + Icon(icon, size: 12, color: color),
89 + const SizedBox(width: 4),
90 + Flexible(
91 + child: Text(
92 + label,
93 + style: TextStyle(
94 + color: color,
95 + fontSize: 10,
96 + fontWeight: FontWeight.w600,
97 + ),
98 + maxLines: 1,
99 + overflow: TextOverflow.ellipsis,
100 + ),
101 + ),
102 + ],
103 + ),
104 + );
105 + }
106 +
107 + @override
108 + Widget build(BuildContext context) {
109 + final l10n = AppLocalizations.of(context)!;
110 + final theme = Theme.of(context);
111 + final isDark = theme.brightness == Brightness.dark;
112 + final selected = widget.selected ?? widget.quants.first;
113 + final recommended = LmModelQuantOption.recommended(widget.quants);
114 +
115 + return Column(
116 + crossAxisAlignment: CrossAxisAlignment.stretch,
117 + children: [
118 + Material(
119 + color: isDark ? AppColors.darkBackground : AppColors.lightBackground,
120 + borderRadius: BorderRadius.circular(12),
121 + child: InkWell(
122 + borderRadius: BorderRadius.circular(12),
123 + onTap: () => setState(() => _expanded = !_expanded),
124 + child: Container(
125 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
126 + decoration: BoxDecoration(
127 + borderRadius: BorderRadius.circular(12),
128 + border: Border.all(
129 + color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
130 + ),
131 + ),
132 + child: Row(
133 + children: [
134 + _GgufTag(),
135 + const SizedBox(width: 8),
136 + Expanded(
137 + child: Text(
138 + '${selected.quantization} · ${formatBytes(selected.sizeBytes)}',
139 + style: theme.textTheme.titleSmall?.copyWith(
140 + fontWeight: FontWeight.w600,
141 + ),
142 + maxLines: 1,
143 + overflow: TextOverflow.ellipsis,
144 + ),
145 + ),
146 + Icon(
147 + _expanded ? Icons.expand_less : Icons.expand_more,
148 + color: theme.hintColor,
149 + ),
150 + ],
151 + ),
152 + ),
153 + ),
154 + ),
155 + if (_expanded) ...[
156 + const SizedBox(height: 8),
157 + Container(
158 + decoration: BoxDecoration(
159 + color: isDark ? AppColors.darkBackground : AppColors.lightBackground,
160 + borderRadius: BorderRadius.circular(12),
161 + border: Border.all(
162 + color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
163 + ),
164 + ),
165 + child: Column(
166 + children: [
167 + Padding(
168 + padding: const EdgeInsets.fromLTRB(12, 10, 12, 6),
169 + child: Row(
170 + children: [
171 + Text(
172 + l10n.lm_studio_choose_quant,
173 + style: theme.textTheme.labelMedium?.copyWith(
174 + fontWeight: FontWeight.w600,
175 + ),
176 + ),
177 + const Spacer(),
178 + if (recommended != null)
179 + TextButton(
180 + onPressed: () {
181 + widget.onSelected(recommended);
182 + },
183 + child: Text(l10n.lm_studio_use_default_quant),
184 + ),
185 + ],
186 + ),
187 + ),
188 + ...widget.quants.map((quant) {
189 + final isSelected = quant.fileName == selected.fileName;
190 + final downloaded = _isDownloaded(quant);
191 + final compat = _compat(quant);
192 + final isRecommended =
193 + recommended != null && quant.fileName == recommended.fileName;
194 + return Material(
195 + color: isSelected
196 + ? (isDark ? AppColors.darkAccent : AppColors.lightAccent)
197 + .withValues(alpha: 0.12)
198 + : Colors.transparent,
199 + child: InkWell(
200 + onTap: () {
201 + widget.onSelected(quant);
202 + setState(() => _expanded = false);
203 + },
204 + child: Padding(
205 + padding: const EdgeInsets.symmetric(
206 + horizontal: 12,
207 + vertical: 10,
208 + ),
209 + child: Row(
210 + crossAxisAlignment: CrossAxisAlignment.start,
211 + children: [
212 + Icon(
213 + isSelected
214 + ? Icons.check_circle
215 + : Icons.circle_outlined,
216 + size: 18,
217 + color: isSelected
218 + ? (isDark
219 + ? AppColors.darkAccent
220 + : AppColors.lightAccent)
221 + : theme.hintColor,
222 + ),
223 + const SizedBox(width: 8),
224 + Expanded(
225 + child: Column(
226 + crossAxisAlignment: CrossAxisAlignment.start,
227 + children: [
228 + Wrap(
229 + spacing: 6,
230 + runSpacing: 4,
231 + crossAxisAlignment: WrapCrossAlignment.center,
232 + children: [
233 + _GgufTag(compact: true),
234 + Text(
235 + quant.quantization,
236 + style: theme.textTheme.titleSmall
237 + ?.copyWith(fontWeight: FontWeight.w600),
238 + ),
239 + if (widget.modelCapabilities.metadata.vision)
240 + _CapIcon(Icons.visibility_outlined, Colors.amber),
241 + if (widget.modelCapabilities.metadata.reasoning)
242 + _CapIcon(Icons.psychology_outlined, Colors.green),
243 + if (widget.modelCapabilities.metadata.trainedForToolUse)
244 + _CapIcon(Icons.build_outlined, Colors.blue),
245 + if (isRecommended) _RecommendedTag(l10n: l10n),
246 + ],
247 + ),
248 + const SizedBox(height: 4),
249 + Text(
250 + quant.fileName,
251 + maxLines: 1,
252 + overflow: TextOverflow.ellipsis,
253 + style: theme.textTheme.bodySmall,
254 + ),
255 + const SizedBox(height: 6),
256 + _compatBadge(compat, l10n),
257 + ],
258 + ),
259 + ),
260 + const SizedBox(width: 8),
261 + Column(
262 + crossAxisAlignment: CrossAxisAlignment.end,
263 + children: [
264 + Text(
265 + formatBytes(quant.sizeBytes),
266 + style: theme.textTheme.bodySmall?.copyWith(
267 + fontWeight: FontWeight.w600,
268 + ),
269 + ),
270 + if (downloaded) ...[
271 + const SizedBox(height: 4),
272 + Row(
273 + mainAxisSize: MainAxisSize.min,
274 + children: [
275 + Icon(Icons.check, size: 14, color: Colors.green.shade400),
276 + const SizedBox(width: 2),
277 + Text(
278 + l10n.downloaded,
279 + style: TextStyle(
280 + fontSize: 11,
281 + color: Colors.green.shade400,
282 + fontWeight: FontWeight.w600,
283 + ),
284 + ),
285 + ],
286 + ),
287 + ],
288 + ],
289 + ),
290 + ],
291 + ),
292 + ),
293 + ),
294 + );
295 + }),
296 + ],
297 + ),
298 + ),
299 + ],
300 + ],
301 + );
302 + }
303 +}
304 +
305 +class _GgufTag extends StatelessWidget {
306 + const _GgufTag({this.compact = false});
307 +
308 + final bool compact;
309 +
310 + @override
311 + Widget build(BuildContext context) {
312 + return Container(
313 + padding: EdgeInsets.symmetric(
314 + horizontal: compact ? 5 : 6,
315 + vertical: compact ? 1 : 2,
316 + ),
317 + decoration: BoxDecoration(
318 + color: Colors.blue.withValues(alpha: 0.15),
319 + borderRadius: BorderRadius.circular(4),
320 + border: Border.all(color: Colors.blue.withValues(alpha: 0.35)),
321 + ),
322 + child: Text(
323 + 'GGUF',
324 + style: TextStyle(
325 + color: Colors.blue.shade300,
326 + fontSize: compact ? 9 : 10,
327 + fontWeight: FontWeight.w700,
328 + ),
329 + ),
330 + );
331 + }
332 +}
333 +
334 +class _CapIcon extends StatelessWidget {
335 + const _CapIcon(this.icon, this.color);
336 +
337 + final IconData icon;
338 + final Color color;
339 +
340 + @override
341 + Widget build(BuildContext context) {
342 + return Icon(icon, size: 14, color: color);
343 + }
344 +}
345 +
346 +class _RecommendedTag extends StatelessWidget {
347 + const _RecommendedTag({required this.l10n});
348 +
349 + final AppLocalizations l10n;
350 +
351 + @override
352 + Widget build(BuildContext context) {
353 + return Container(
354 + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
355 + decoration: BoxDecoration(
356 + color: Colors.amber.withValues(alpha: 0.15),
357 + borderRadius: BorderRadius.circular(4),
358 + border: Border.all(color: Colors.amber.withValues(alpha: 0.4)),
359 + ),
360 + child: Text(
361 + l10n.lm_studio_recommended,
362 + style: TextStyle(
363 + color: Colors.amber.shade700,
364 + fontSize: 9,
365 + fontWeight: FontWeight.w700,
366 + ),
367 + ),
368 + );
369 + }
370 +}
371 +
372 +/// Collect downloaded models from server catalog for download detection.
373 +List<ModelInfo> downloadedModelsList(List<dynamic> models) {
374 + return models.whereType<ModelInfo>().toList();
375 +}
lib/features/models/components/model_context_length_section.dart
+17
@@ -20,20 +20,35 @@ class ModelContextLengthSection extends ConsumerStatefulWidget {
20 class _ModelContextLengthSectionState
21 extends ConsumerState<ModelContextLengthSection> {
22 late final TextEditingController _controller;
23 + late final FocusNode _focusNode;
24 int? _lastValue;
25 + String? _activeConversationId;
26
27 @override
28 void initState() {
29 super.initState();
30 _controller = TextEditingController();
31 + _focusNode = FocusNode();
32 + _focusNode.addListener(_onFocusChange);
33 }
34
35 @override
36 void dispose() {
37 + _focusNode.removeListener(_onFocusChange);
38 + _focusNode.dispose();
39 _controller.dispose();
40 super.dispose();
41 }
42
43 + void _onFocusChange() {
44 + // Most users dismiss the keyboard by tapping elsewhere rather than
45 + // pressing a keyboard "done" action, which doesn't fire onSubmitted
46 + // or onEditingComplete. Save on focus loss too so typed values apply.
47 + if (!_focusNode.hasFocus) {
48 + _saveValue(_controller.text, _activeConversationId);
49 + }
50 + }
51 +
52 void _syncController(int value) {
53 if (_lastValue == value) return;
54 _lastValue = value;
@@ -61,6 +76,7 @@ class _ModelContextLengthSectionState
76 final settings = ref.watch(settingsProvider);
77 final activeConv = ref.watch(conv.activeConversationProvider);
78 final contextLength = activeConv?.contextLength ?? settings.contextLength;
79 + _activeConversationId = activeConv?.id;
80 _syncController(contextLength);
81
82 return Column(
@@ -77,6 +93,7 @@ class _ModelContextLengthSectionState
93 const SizedBox(height: 6),
94 ShadInput(
95 controller: _controller,
96 + focusNode: _focusNode,
97 keyboardType: TextInputType.number,
98 onSubmitted: (value) =>
99 _saveValue(value, activeConv?.id),
lib/features/models/components/model_list.dart
+36 -6
@@ -21,11 +21,13 @@ class ModelList extends ConsumerStatefulWidget {
21 required this.serverId,
22 required this.selectedModelId,
23 required this.isDark,
24 + this.scrollController,
25 });
26
27 final String serverId;
28 final String? selectedModelId;
29 final bool isDark;
30 + final ScrollController? scrollController;
31
32 @override
33 ConsumerState<ModelList> createState() => _ModelListState();
@@ -50,13 +52,38 @@ class _ModelListState extends ConsumerState<ModelList> {
52
53 List<ModelInfo> _sortedModels(List<ModelInfo> models) {
54 final metadata = ref.watch(modelMetadataProvider);
55 + final sortOption = ref.watch(modelSortOptionProvider);
56 final sorted = List<ModelInfo>.from(models);
54 - sorted.sort((a, b) {
55 - final aFavorite = metadata[a.id]?.isFavorite ?? false;
56 - final bFavorite = metadata[b.id]?.isFavorite ?? false;
57 - if (aFavorite != bFavorite) return aFavorite ? -1 : 1;
58 - return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase());
59 - });
57 +
58 + int byName(ModelInfo a, ModelInfo b) =>
59 + a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase());
60 +
61 + switch (sortOption) {
62 + case ModelSortOption.favorites:
63 + sorted.sort((a, b) {
64 + final aFavorite = metadata[a.id]?.isFavorite ?? false;
65 + final bFavorite = metadata[b.id]?.isFavorite ?? false;
66 + if (aFavorite != bFavorite) return aFavorite ? -1 : 1;
67 + return byName(a, b);
68 + });
69 + case ModelSortOption.nameAsc:
70 + sorted.sort(byName);
71 + case ModelSortOption.sizeAsc:
72 + sorted.sort((a, b) {
73 + final size = (a.fileSize ?? 0).compareTo(b.fileSize ?? 0);
74 + return size != 0 ? size : byName(a, b);
75 + });
76 + case ModelSortOption.sizeDesc:
77 + sorted.sort((a, b) {
78 + final size = (b.fileSize ?? 0).compareTo(a.fileSize ?? 0);
79 + return size != 0 ? size : byName(a, b);
80 + });
81 + case ModelSortOption.contextDesc:
82 + sorted.sort((a, b) {
83 + final ctx = (b.contextLength ?? 0).compareTo(a.contextLength ?? 0);
84 + return ctx != 0 ? ctx : byName(a, b);
85 + });
86 + }
87 return sorted;
88 }
89
@@ -204,6 +231,7 @@ class _ModelListState extends ConsumerState<ModelList> {
231 );
232
233 final modelList = models.cast<ModelInfo>();
234 + final modelLoading = ref.watch(modelLoadingProvider);
235 final filtered = searchQuery.isEmpty
236 ? modelList
237 : modelList
@@ -233,6 +261,7 @@ class _ModelListState extends ConsumerState<ModelList> {
261 }
262
263 return ListView.builder(
264 + controller: widget.scrollController,
265 itemCount: sorted.length,
266 itemBuilder: (context, index) {
267 final model = sorted[index];
@@ -245,6 +274,7 @@ class _ModelListState extends ConsumerState<ModelList> {
274 isSelected: isSelected,
275 isLoaded: isLoaded,
276 isDark: widget.isDark,
277 + isLoading: modelLoading.isLoading && modelLoading.modelId == model.id,
278 isFavorite: modelMeta?.isFavorite ?? false,
279 note: modelMeta?.note,
280 onLongPress: () => _showModelOptions(context, model),
lib/features/models/components/model_sort_control.dart new
+78
@@ -0,0 +1,78 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:localmind/l10n/app_localizations.dart';
4 +import '../providers/model_picker_providers.dart';
5 +
6 +class ModelSortControl extends ConsumerWidget {
7 + const ModelSortControl({super.key});
8 +
9 + String _labelFor(AppLocalizations l10n, ModelSortOption option) {
10 + switch (option) {
11 + case ModelSortOption.favorites:
12 + return l10n.sort_by_favorites;
13 + case ModelSortOption.nameAsc:
14 + return l10n.sort_by_name;
15 + case ModelSortOption.sizeAsc:
16 + return l10n.sort_by_size_smallest;
17 + case ModelSortOption.sizeDesc:
18 + return l10n.sort_by_size_largest;
19 + case ModelSortOption.contextDesc:
20 + return l10n.sort_by_context_length;
21 + }
22 + }
23 +
24 + IconData _iconFor(ModelSortOption option) {
25 + switch (option) {
26 + case ModelSortOption.favorites:
27 + return Icons.star_outline;
28 + case ModelSortOption.nameAsc:
29 + return Icons.sort_by_alpha;
30 + case ModelSortOption.sizeAsc:
31 + case ModelSortOption.sizeDesc:
32 + return Icons.sd_storage_outlined;
33 + case ModelSortOption.contextDesc:
34 + return Icons.view_column_outlined;
35 + }
36 + }
37 +
38 + @override
39 + Widget build(BuildContext context, WidgetRef ref) {
40 + final l10n = AppLocalizations.of(context)!;
41 + final current = ref.watch(modelSortOptionProvider);
42 +
43 + return PopupMenuButton<ModelSortOption>(
44 + tooltip: l10n.sort_models_tooltip,
45 + initialValue: current,
46 + onSelected: (option) =>
47 + ref.read(modelSortOptionProvider.notifier).setOption(option),
48 + icon: const Icon(Icons.sort, size: 20),
49 + itemBuilder: (context) => ModelSortOption.values.map((option) {
50 + return PopupMenuItem(
51 + value: option,
52 + child: Row(
53 + children: [
54 + Icon(
55 + _iconFor(option),
56 + size: 18,
57 + color: option == current
58 + ? Theme.of(context).colorScheme.primary
59 + : null,
60 + ),
61 + const SizedBox(width: 10),
62 + Text(
63 + _labelFor(l10n, option),
64 + style: TextStyle(
65 + fontWeight:
66 + option == current ? FontWeight.w600 : FontWeight.normal,
67 + color: option == current
68 + ? Theme.of(context).colorScheme.primary
69 + : null,
70 + ),
71 + ),
72 + ],
73 + ),
74 + );
75 + }).toList(),
76 + );
77 + }
78 +}
lib/features/models/components/model_tile.dart
+14 -3
@@ -18,6 +18,7 @@ class ModelTile extends StatelessWidget {
18 this.onUnload,
19 this.isFavorite = false,
20 this.note,
21 + this.isLoading = false,
22 });
23
24 final ModelInfo model;
@@ -29,6 +30,7 @@ class ModelTile extends StatelessWidget {
30 final Future<void> Function()? onUnload;
31 final bool isFavorite;
32 final String? note;
33 + final bool isLoading;
34
35 @override
36 Widget build(BuildContext context) {
@@ -36,8 +38,8 @@ class ModelTile extends StatelessWidget {
38 final accent = isDark ? AppColors.darkAccent : AppColors.lightAccent;
39
40 return InkWell(
39 - onTap: onTap,
40 - onLongPress: onLongPress,
41 + onTap: isLoading ? null : onTap,
42 + onLongPress: isLoading ? null : onLongPress,
43 borderRadius: BorderRadius.circular(8),
44 child: Container(
45 padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
@@ -150,7 +152,16 @@ class ModelTile extends StatelessWidget {
152 ),
153 const SizedBox(width: 8),
154 ],
153 - if (isSelected)
155 + if (isLoading)
156 + SizedBox(
157 + width: 22,
158 + height: 22,
159 + child: CircularProgressIndicator(
160 + strokeWidth: 2,
161 + color: accent,
162 + ),
163 + )
164 + else if (isSelected)
165 Icon(Icons.check_circle, color: accent, size: 22)
166 else
167 Icon(
lib/features/models/providers/model_picker_providers.dart
+14
@@ -11,3 +11,17 @@ class ModelSearchNotifier extends Notifier<String> {
11 void setQuery(String q) => state = q;
12 void clear() => state = '';
13 }
14 +
15 +enum ModelSortOption { favorites, nameAsc, sizeAsc, sizeDesc, contextDesc }
16 +
17 +final modelSortOptionProvider =
18 + NotifierProvider<ModelSortNotifier, ModelSortOption>(
19 + ModelSortNotifier.new,
20 + );
21 +
22 +class ModelSortNotifier extends Notifier<ModelSortOption> {
23 + @override
24 + ModelSortOption build() => ModelSortOption.favorites;
25 +
26 + void setOption(ModelSortOption option) => state = option;
27 +}
lib/features/models/screens/model_picker_sheet.dart
+144 -132
@@ -1,30 +1,47 @@
1 import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:go_router/go_router.dart';
4 import 'package:localmind/core/models/enums.dart';
5 import 'package:localmind/core/providers/service_providers.dart';
6 +import 'package:localmind/core/routes/app_routes.dart';
7 import 'package:localmind/core/theme/colors.dart';
8 import 'package:localmind/features/chat/providers/chat_providers.dart';
9 import 'package:localmind/features/on_device/components/on_device_picker_section.dart';
10 import 'package:localmind/features/on_device/providers/on_device_providers.dart';
11 import 'package:localmind/features/servers/providers/server_providers.dart';
12 +import 'package:localmind/features/lm_studio_catalog/views/lm_studio_download_widgets.dart';
13 import 'package:localmind/l10n/app_localizations.dart';
14 import '../components/model_context_length_section.dart';
15 import '../components/model_list.dart';
16 import '../components/model_search_field.dart';
17 +import '../components/model_sort_control.dart';
18 import '../components/no_server_state.dart';
19 import '../components/thinking_indicator.dart';
20 +import '../providers/model_picker_providers.dart';
21
17 -class ModelPickerSheet extends ConsumerWidget {
22 +class ModelPickerSheet extends ConsumerStatefulWidget {
23 const ModelPickerSheet({super.key});
24
25 @override
21 - Widget build(BuildContext context, WidgetRef ref) {
26 + ConsumerState<ModelPickerSheet> createState() => _ModelPickerSheetState();
27 +}
28 +
29 +class _ModelPickerSheetState extends ConsumerState<ModelPickerSheet> {
30 + @override
31 + void dispose() {
32 + // Reset search when the sheet closes so the next open starts from a
33 + // clean, matching state instead of an empty box that's still filtering.
34 + ref.read(modelSearchQueryProvider.notifier).clear();
35 + super.dispose();
36 + }
37 +
38 + @override
39 + Widget build(BuildContext context) {
40 final l10n = AppLocalizations.of(context)!;
41 final theme = Theme.of(context);
42 final isDark = theme.brightness == Brightness.dark;
43 final activeServer = ref.watch(activeServerProvider);
44 final selectedModel = ref.watch(selectedModelProvider);
27 - final modelLoading = ref.watch(modelLoadingProvider);
45 final isThinking = ref.watch(modelThinkingProvider);
46
47 ref.listen(onDeviceEngineProvider, (prev, next) {
@@ -58,6 +75,8 @@ class ModelPickerSheet extends ConsumerWidget {
75 servers.where((s) => s.id == activeServer?.id).firstOrNull;
76 final isOnDevice =
77 currentServer != null && currentServer.type == ServerType.onDevice;
78 + final isLmStudio =
79 + currentServer != null && currentServer.type == ServerType.lmStudio;
80
81 final loadedModelsAsync = activeServer != null
82 ? ref.watch(loadedModelsProvider(activeServer))
@@ -67,75 +86,100 @@ class ModelPickerSheet extends ConsumerWidget {
86 orElse: () => 0,
87 );
88
70 - return Container(
71 - height: MediaQuery.of(context).size.height * 0.7,
72 - padding: const EdgeInsets.all(16),
73 - decoration: BoxDecoration(
74 - color: isDark ? AppColors.darkBackground : AppColors.lightSurface,
75 - borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
76 - ),
77 - child: Column(
78 - crossAxisAlignment: CrossAxisAlignment.start,
79 - children: [
80 - Center(
81 - child: Container(
82 - width: 40,
83 - height: 4,
84 - decoration: BoxDecoration(
85 - color: isDark ? Colors.grey[600] : Colors.grey[300],
86 - borderRadius: BorderRadius.circular(2),
87 - ),
88 - ),
89 - ),
90 - const SizedBox(height: 16),
91 - _ModelPickerHeader(
92 - isDark: isDark,
93 - isThinking: isThinking,
94 - modelLoading: modelLoading,
95 - loadedCount: loadedCount,
96 - activeServer: activeServer,
97 - onRefresh: activeServer != null
98 - ? () {
99 - ref.invalidate(availableModelsProvider(activeServer.id));
100 - ref.invalidate(loadedModelsProvider(activeServer));
101 - }
102 - : null,
103 - onUnloadAll: activeServer != null && loadedCount > 0
104 - ? () => _unloadAllModels(context, ref, activeServer)
105 - : null,
89 + return DraggableScrollableSheet(
90 + initialChildSize: 0.7,
91 + minChildSize: 0.4,
92 + maxChildSize: 1.0,
93 + expand: false,
94 + builder: (context, scrollController) {
95 + return Container(
96 + padding: const EdgeInsets.all(16),
97 + decoration: BoxDecoration(
98 + color: isDark ? AppColors.darkBackground : AppColors.lightSurface,
99 + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
100 ),
107 - if (activeServer != null)
108 - Padding(
109 - padding: const EdgeInsets.only(top: 4),
110 - child: Text(
111 - activeServer.name,
112 - style: TextStyle(
113 - fontSize: 13,
114 - color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
101 + child: Column(
102 + crossAxisAlignment: CrossAxisAlignment.start,
103 + children: [
104 + Center(
105 + child: Container(
106 + width: 40,
107 + height: 4,
108 + decoration: BoxDecoration(
109 + color: isDark ? Colors.grey[600] : Colors.grey[300],
110 + borderRadius: BorderRadius.circular(2),
111 + ),
112 ),
113 ),
117 - ),
118 - const SizedBox(height: 12),
119 - ModelContextLengthSection(isDark: isDark),
120 - const SizedBox(height: 12),
121 - const ModelSearchField(),
122 - const SizedBox(height: 12),
123 - Expanded(
124 - child: activeServer == null
125 - ? NoServerState(isDark: isDark)
126 - : isOnDevice
127 - ? OnDevicePickerSection(
128 - selectedModelId: selectedModel?.id,
129 - isDark: isDark,
130 - )
131 - : ModelList(
132 - serverId: activeServer.id,
133 - selectedModelId: selectedModel?.id,
134 - isDark: isDark,
135 - ),
114 + const SizedBox(height: 16),
115 + _ModelPickerHeader(
116 + isDark: isDark,
117 + isThinking: isThinking,
118 + loadedCount: loadedCount,
119 + showBrowseButton: isLmStudio,
120 + onBrowseModels: isLmStudio
121 + ? () {
122 + Navigator.of(context).pop();
123 + context.push(
124 + AppRoutes.lmStudioModelBrowser,
125 + extra: currentServer,
126 + );
127 + }
128 + : null,
129 + onRefresh: activeServer != null
130 + ? () {
131 + ref.invalidate(availableModelsProvider(activeServer.id));
132 + ref.invalidate(loadedModelsProvider(activeServer));
133 + }
134 + : null,
135 + onUnloadAll: activeServer != null && loadedCount > 0
136 + ? () => _unloadAllModels(context, ref, activeServer)
137 + : null,
138 + ),
139 + if (activeServer != null)
140 + Padding(
141 + padding: const EdgeInsets.only(top: 4),
142 + child: Text(
143 + activeServer.name,
144 + style: TextStyle(
145 + fontSize: 13,
146 + color: isDark
147 + ? AppColors.darkMutedText
148 + : AppColors.lightMutedText,
149 + ),
150 + ),
151 + ),
152 + const SizedBox(height: 12),
153 + ModelContextLengthSection(isDark: isDark),
154 + const SizedBox(height: 12),
155 + Row(
156 + children: [
157 + const Expanded(child: ModelSearchField()),
158 + const SizedBox(width: 8),
159 + const ModelSortControl(),
160 + ],
161 + ),
162 + const SizedBox(height: 12),
163 + Expanded(
164 + child: activeServer == null
165 + ? NoServerState(isDark: isDark)
166 + : isOnDevice
167 + ? OnDevicePickerSection(
168 + selectedModelId: selectedModel?.id,
169 + isDark: isDark,
170 + scrollController: scrollController,
171 + )
172 + : ModelList(
173 + serverId: activeServer.id,
174 + selectedModelId: selectedModel?.id,
175 + isDark: isDark,
176 + scrollController: scrollController,
177 + ),
178 + ),
179 + ],
180 ),
137 - ],
138 - ),
181 + );
182 + },
183 );
184 }
185
@@ -181,18 +225,18 @@ class _ModelPickerHeader extends StatelessWidget {
225 const _ModelPickerHeader({
226 required this.isDark,
227 required this.isThinking,
184 - required this.modelLoading,
228 required this.loadedCount,
186 - required this.activeServer,
229 + this.showBrowseButton = false,
230 + this.onBrowseModels,
231 this.onRefresh,
232 this.onUnloadAll,
233 });
234
235 final bool isDark;
236 final bool isThinking;
193 - final dynamic modelLoading;
237 final int loadedCount;
195 - final dynamic activeServer;
238 + final bool showBrowseButton;
239 + final VoidCallback? onBrowseModels;
240 final VoidCallback? onRefresh;
241 final VoidCallback? onUnloadAll;
242
@@ -201,6 +245,7 @@ class _ModelPickerHeader extends StatelessWidget {
245 final l10n = AppLocalizations.of(context)!;
246
247 return Row(
248 + crossAxisAlignment: CrossAxisAlignment.start,
249 children: [
250 Expanded(
251 child: Column(
@@ -248,69 +293,36 @@ class _ModelPickerHeader extends StatelessWidget {
293 ],
294 ],
295 ),
251 - if (modelLoading.isLoading)
252 - Padding(
253 - padding: const EdgeInsets.only(top: 8),
254 - child: _ModelLoadingBar(
255 - isDark: isDark,
256 - modelId: modelLoading.modelId,
257 - progress: modelLoading.progress,
258 - ),
259 - ),
296 ],
297 ),
298 ),
263 - if (onUnloadAll != null)
264 - TextButton.icon(
265 - onPressed: onUnloadAll,
266 - icon: const Icon(Icons.power_settings_new_outlined, size: 16),
267 - label: Text(l10n.unload_all_models),
268 - style: TextButton.styleFrom(
269 - foregroundColor: Colors.red[400],
270 - padding: const EdgeInsets.symmetric(horizontal: 8),
271 - ),
272 - ),
273 - if (onRefresh != null)
274 - IconButton(
275 - icon: const Icon(Icons.refresh, size: 20),
276 - onPressed: onRefresh,
277 - tooltip: l10n.refresh_models,
278 - ),
279 - ],
280 - );
281 - }
282 -}
283 -
284 -class _ModelLoadingBar extends StatelessWidget {
285 - const _ModelLoadingBar({
286 - required this.isDark,
287 - required this.modelId,
288 - this.progress,
289 - });
290 -
291 - final bool isDark;
292 - final String? modelId;
293 - final double? progress;
294 -
295 - @override
296 - Widget build(BuildContext context) {
297 - final l10n = AppLocalizations.of(context)!;
298 -
299 - return Column(
300 - crossAxisAlignment: CrossAxisAlignment.start,
301 - children: [
302 - Text(
303 - l10n.loading_model(modelId ?? 'model'),
304 - style: TextStyle(
305 - fontSize: 12,
306 - color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
307 - ),
308 - ),
309 - const SizedBox(height: 4),
310 - LinearProgressIndicator(
311 - value: progress,
312 - backgroundColor:
313 - isDark ? AppColors.darkBorder : AppColors.lightBorder,
299 + Row(
300 + mainAxisSize: MainAxisSize.min,
301 + children: [
302 + if (onUnloadAll != null)
303 + IconButton(
304 + onPressed: onUnloadAll,
305 + tooltip: l10n.unload_all_models,
306 + icon: Icon(
307 + Icons.power_settings_new_outlined,
308 + size: 20,
309 + color: Colors.red[400],
310 + ),
311 + ),
312 + if (showBrowseButton && onBrowseModels != null)
313 + IconButton(
314 + onPressed: onBrowseModels,
315 + tooltip: l10n.lm_studio_browse_models,
316 + icon: const Icon(Icons.explore_outlined, size: 20),
317 + ),
318 + const LmDownloadIndicatorButton(compact: true),
319 + if (onRefresh != null)
320 + IconButton(
321 + icon: const Icon(Icons.refresh, size: 20),
322 + onPressed: onRefresh,
323 + tooltip: l10n.refresh_models,
324 + ),
325 + ],
326 ),
327 ],
328 );
lib/features/on_device/components/on_device_picker_section.dart
+3
@@ -24,10 +24,12 @@ class OnDevicePickerSection extends ConsumerWidget {
24 super.key,
25 required this.selectedModelId,
26 required this.isDark,
27 + this.scrollController,
28 });
29
30 final String? selectedModelId;
31 final bool isDark;
32 + final ScrollController? scrollController;
33
34 @override
35 Widget build(BuildContext context, WidgetRef ref) {
@@ -121,6 +123,7 @@ class OnDevicePickerSection extends ConsumerWidget {
123 }
124
125 return ListView(
126 + controller: scrollController,
127 children: [
128 if (importedModels.isNotEmpty) ...[
129 const SizedBox(height: 4),
lib/features/personas/providers/personas_providers.dart
+56 -27
@@ -4,27 +4,42 @@ import '../../../core/providers/storage_providers.dart';
4 import '../../../core/storage/entities.dart';
5 import '../../../objectbox.g.dart';
6 import '../data/models/persona.dart';
7 +import '../utils/persona_prompt_utils.dart';
8
9 final personaSearchQueryProvider =
10 NotifierProvider<_PersonaSearchNotifier, String>(
11 _PersonaSearchNotifier.new,
12 );
13
13 -final selectedPersonaProvider =
14 - NotifierProvider<SelectedPersonaNotifier, Persona?>(
15 - SelectedPersonaNotifier.new,
14 +final selectedPersonasProvider =
15 + NotifierProvider<SelectedPersonasNotifier, List<Persona>>(
16 + SelectedPersonasNotifier.new,
17 );
18
18 -class SelectedPersonaNotifier extends Notifier<Persona?> {
19 +/// First selected persona, if any (legacy convenience).
20 +final selectedPersonaProvider = Provider<Persona?>((ref) {
21 + final personas = ref.watch(selectedPersonasProvider);
22 + return personas.isEmpty ? null : personas.first;
23 +});
24 +
25 +class SelectedPersonasNotifier extends Notifier<List<Persona>> {
26 @override
20 - Persona? build() => null;
27 + List<Persona> build() => const [];
28
22 - void select(Persona? persona) {
23 - state = persona;
29 + void setPersonas(List<Persona> personas) {
30 + state = List.unmodifiable(personas);
31 + }
32 +
33 + void toggle(Persona persona) {
34 + if (state.any((p) => p.id == persona.id)) {
35 + state = state.where((p) => p.id != persona.id).toList();
36 + } else {
37 + state = [...state, persona];
38 + }
39 }
40
41 void clear() {
27 - state = null;
42 + state = const [];
43 }
44 }
45
@@ -74,21 +89,12 @@ class PersonasNotifier extends AsyncNotifier<List<Persona>> {
89
90 Future<List<Persona>> _loadAndSeed() async {
91 final db = ref.read(databaseProvider);
77 - var entities = db.personaBox.getAll();
78 -
79 - if (entities.isEmpty) {
92 + if (db.personaBox.isEmpty()) {
93 for (final preset in _builtInPersonas) {
94 db.personaBox.put(PersonaEntity.fromDomain(preset));
95 }
83 - entities = db.personaBox.getAll();
96 }
85 -
86 - final personas = entities.map((e) => e.toDomain()).toList();
87 - personas.sort((a, b) {
88 - if (a.isBuiltIn != b.isBuiltIn) return a.isBuiltIn ? -1 : 1;
89 - return a.name.compareTo(b.name);
90 - });
91 - return personas;
97 + return _loadWithoutSeeding();
98 }
99
100 Future<Persona> createPersona({
@@ -138,19 +144,36 @@ class PersonasNotifier extends AsyncNotifier<List<Persona>> {
144 }
145
146 Future<void> deletePersona(String id) async {
141 - final personas = state.value ?? [];
142 - final persona = personas.firstWhere(
143 - (p) => p.id == id,
144 - orElse: () => throw Exception('Persona not found'),
145 - );
146 - if (persona.isBuiltIn) return;
147 -
147 final db = ref.read(databaseProvider);
148 final query = db.personaBox.query(PersonaEntity_.id.equals(id)).build();
149 db.personaBox.removeMany(query.findIds());
150 query.close();
151
153 - state = AsyncData(await _loadAndSeed());
152 + state = AsyncData(await _loadWithoutSeeding());
153 + }
154 +
155 + /// Re-adds any built-in personas that were deleted, without touching
156 + /// custom ones or duplicating built-ins that are still present.
157 + Future<void> restoreBuiltInPersonas() async {
158 + final db = ref.read(databaseProvider);
159 + final existingIds = db.personaBox.getAll().map((e) => e.id).toSet();
160 + for (final preset in _builtInPersonas) {
161 + if (!existingIds.contains(preset.id)) {
162 + db.personaBox.put(PersonaEntity.fromDomain(preset));
163 + }
164 + }
165 + state = AsyncData(await _loadWithoutSeeding());
166 + }
167 +
168 + Future<List<Persona>> _loadWithoutSeeding() async {
169 + final db = ref.read(databaseProvider);
170 + final entities = db.personaBox.getAll();
171 + final personas = entities.map((e) => e.toDomain()).toList();
172 + personas.sort((a, b) {
173 + if (a.isBuiltIn != b.isBuiltIn) return a.isBuiltIn ? 1 : -1;
174 + return a.name.compareTo(b.name);
175 + });
176 + return personas;
177 }
178
179 Future<Persona> clonePersona(String id) async {
@@ -310,3 +333,9 @@ final personaByIdProvider = Provider.family<Persona?, String>((ref, id) {
333 return null;
334 }
335 });
336 +
337 +final personasForConversationProvider =
338 + Provider.family<List<Persona>, String?>((ref, personaIdsRaw) {
339 + final personas = ref.watch(personasNotifierProvider).value ?? [];
340 + return PersonaPromptUtils.resolvePersonas(personaIdsRaw, personas);
341 +});
lib/features/personas/utils/persona_prompt_utils.dart new
+34
@@ -0,0 +1,34 @@
1 +import '../data/models/persona.dart';
2 +
3 +class PersonaPromptUtils {
4 + PersonaPromptUtils._();
5 +
6 + static List<String> parsePersonaIds(String? raw) {
7 + if (raw == null || raw.trim().isEmpty) return const [];
8 + return raw
9 + .split(',')
10 + .map((id) => id.trim())
11 + .where((id) => id.isNotEmpty)
12 + .toList();
13 + }
14 +
15 + static String joinPersonaIds(List<String> ids) => ids.join(',');
16 +
17 + static String combineSystemPrompts(List<Persona> personas) {
18 + if (personas.isEmpty) return '';
19 + if (personas.length == 1) return personas.first.systemPrompt;
20 + return personas
21 + .map((p) => '## ${p.name}\n\n${p.systemPrompt.trim()}')
22 + .join('\n\n---\n\n');
23 + }
24 +
25 + static List<Persona> resolvePersonas(
26 + String? rawIds,
27 + List<Persona> allPersonas,
28 + ) {
29 + final ids = parsePersonaIds(rawIds);
30 + if (ids.isEmpty) return const [];
31 + final byId = {for (final p in allPersonas) p.id: p};
32 + return ids.map((id) => byId[id]).whereType<Persona>().toList();
33 + }
34 +}
lib/features/personas/views/components/persona_picker_sheet.dart
+189 -110
@@ -1,10 +1,13 @@
1 import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:go_router/go_router.dart';
4 +import 'package:localmind/core/routes/app_routes.dart';
5 import 'package:localmind/core/theme/colors.dart';
6 import 'package:localmind/features/conversations/providers/conversation_providers.dart'
7 as conv;
8 import 'package:localmind/features/personas/data/models/persona.dart';
9 import 'package:localmind/features/personas/providers/personas_providers.dart';
10 +import 'package:localmind/features/personas/utils/persona_prompt_utils.dart';
11 import 'package:localmind/l10n/app_localizations.dart';
12
13 enum PersonaPickerMode { conversation, preselection }
@@ -16,53 +19,165 @@ void showPersonaPickerSheet(
19 showModalBottomSheet(
20 context: context,
21 isScrollControlled: true,
22 + showDragHandle: true,
23 builder: (ctx) => PersonaPickerSheet(mode: mode),
24 );
25 }
26
23 -class PersonaPickerSheet extends ConsumerWidget {
27 +class PersonaPickerSheet extends ConsumerStatefulWidget {
28 const PersonaPickerSheet({super.key, required this.mode});
29
30 final PersonaPickerMode mode;
31
32 @override
29 - Widget build(BuildContext context, WidgetRef ref) {
33 + ConsumerState<PersonaPickerSheet> createState() => _PersonaPickerSheetState();
34 +}
35 +
36 +class _PersonaPickerSheetState extends ConsumerState<PersonaPickerSheet> {
37 + late Set<String> _selectedIds;
38 + var _initialized = false;
39 +
40 + @override
41 + void didChangeDependencies() {
42 + super.didChangeDependencies();
43 + if (!_initialized) {
44 + _selectedIds = _initialSelectedIds().toSet();
45 + _initialized = true;
46 + }
47 + }
48 +
49 + List<String> _initialSelectedIds() {
50 + if (widget.mode == PersonaPickerMode.preselection) {
51 + return ref
52 + .read(selectedPersonasProvider)
53 + .map((p) => p.id)
54 + .toList();
55 + }
56 + final activeConv = ref.read(conv.activeConversationProvider);
57 + return PersonaPromptUtils.parsePersonaIds(activeConv?.personaId);
58 + }
59 +
60 + void _toggle(Persona persona, List<Persona> allPersonas) {
61 + setState(() {
62 + if (_selectedIds.contains(persona.id)) {
63 + _selectedIds.remove(persona.id);
64 + } else {
65 + _selectedIds.add(persona.id);
66 + }
67 + });
68 + _apply(allPersonas);
69 + }
70 +
71 + void _clear(List<Persona> allPersonas) {
72 + setState(() => _selectedIds.clear());
73 + _apply(allPersonas);
74 + }
75 +
76 + void _apply(List<Persona> allPersonas) {
77 + final selected = allPersonas
78 + .where((p) => _selectedIds.contains(p.id))
79 + .toList(growable: false);
80 +
81 + if (widget.mode == PersonaPickerMode.preselection) {
82 + ref.read(selectedPersonasProvider.notifier).setPersonas(selected);
83 + } else {
84 + final conversationId = ref.read(conv.activeConversationProvider)?.id;
85 + if (conversationId != null) {
86 + ref
87 + .read(conv.conversationsProvider.notifier)
88 + .updatePersonas(conversationId, selected);
89 + } else {
90 + ref.read(selectedPersonasProvider.notifier).setPersonas(selected);
91 + }
92 + }
93 + }
94 +
95 + @override
96 + Widget build(BuildContext context) {
97 final l10n = AppLocalizations.of(context)!;
31 - final isDark = Theme.of(context).brightness == Brightness.dark;
98 + final theme = Theme.of(context);
99 + final isDark = theme.brightness == Brightness.dark;
100 final personasAsync = ref.watch(personasNotifierProvider);
33 - final activeConv = ref.watch(conv.activeConversationProvider);
34 - final selectedPersona = ref.watch(selectedPersonaProvider);
35 -
36 - final currentPersonaId = mode == PersonaPickerMode.preselection
37 - ? selectedPersona?.id
38 - : activeConv?.personaId;
101 + final selectedCategory = ref.watch(personaCategoryFilterProvider);
102 + final categories = [
103 + l10n.all,
104 + l10n.persona_category_general,
105 + l10n.persona_category_coding,
106 + l10n.persona_category_education,
107 + l10n.persona_category_creative,
108 + ];
109
110 return SafeArea(
41 - child: Container(
42 - padding: const EdgeInsets.symmetric(vertical: 16),
111 + child: Padding(
112 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
113 child: Column(
114 mainAxisSize: MainAxisSize.min,
115 + crossAxisAlignment: CrossAxisAlignment.stretch,
116 children: [
46 - Center(
47 - child: Container(
48 - width: 40,
49 - height: 4,
50 - decoration: BoxDecoration(
51 - color: isDark ? Colors.grey[600] : Colors.grey[300],
52 - borderRadius: BorderRadius.circular(2),
117 + Row(
118 + children: [
119 + Expanded(
120 + child: Text(
121 + l10n.select_persona,
122 + style: theme.textTheme.titleMedium?.copyWith(
123 + fontWeight: FontWeight.w700,
124 + ),
125 + ),
126 ),
54 - ),
127 + if (_selectedIds.isNotEmpty)
128 + TextButton(
129 + onPressed: () =>
130 + _clear(personasAsync.value ?? const []),
131 + child: Text(l10n.clear_personas),
132 + ),
133 + TextButton(
134 + onPressed: () => context.push(AppRoutes.personas),
135 + child: Text(l10n.manage_personas),
136 + ),
137 + ],
138 ),
56 - const SizedBox(height: 16),
57 - Text(
58 - l10n.select_persona,
59 - style: TextStyle(
60 - fontSize: 18,
61 - fontWeight: FontWeight.bold,
62 - color: isDark ? Colors.white : Colors.black,
139 + if (_selectedIds.isNotEmpty)
140 + Padding(
141 + padding: const EdgeInsets.only(bottom: 8),
142 + child: Wrap(
143 + spacing: 6,
144 + runSpacing: 6,
145 + children: _selectedIds.map((id) {
146 + final persona = personasAsync.value
147 + ?.where((p) => p.id == id)
148 + .firstOrNull;
149 + if (persona == null) return const SizedBox.shrink();
150 + return InputChip(
151 + avatar: Text(persona.emoji, style: const TextStyle(fontSize: 14)),
152 + label: Text(persona.name),
153 + onDeleted: () =>
154 + _toggle(persona, personasAsync.value ?? const []),
155 + );
156 + }).toList(),
157 + ),
158 + ),
159 + SizedBox(
160 + height: 40,
161 + child: ListView(
162 + scrollDirection: Axis.horizontal,
163 + children: categories.map((cat) {
164 + final isActive =
165 + selectedCategory == (cat == l10n.all ? null : cat);
166 + return Padding(
167 + padding: const EdgeInsets.only(right: 6),
168 + child: FilterChip(
169 + label: Text(cat, style: const TextStyle(fontSize: 12)),
170 + selected: isActive,
171 + showCheckmark: false,
172 + onSelected: (_) => ref
173 + .read(personaCategoryFilterProvider.notifier)
174 + .setCategory(cat == l10n.all ? null : cat),
175 + ),
176 + );
177 + }).toList(),
178 ),
179 ),
65 - const SizedBox(height: 8),
180 + const SizedBox(height: 4),
181 Flexible(
182 child: personasAsync.when(
183 loading: () => const Padding(
@@ -71,22 +186,20 @@ class PersonaPickerSheet extends ConsumerWidget {
186 ),
187 error: (error, _) => Padding(
188 padding: const EdgeInsets.all(24),
74 - child: Text(
75 - error.toString(),
76 - textAlign: TextAlign.center,
77 - style: TextStyle(
78 - color: isDark
79 - ? AppColors.darkMutedText
80 - : AppColors.lightMutedText,
81 - ),
82 - ),
189 + child: Text(error.toString(), textAlign: TextAlign.center),
190 ),
84 - data: (personas) {
191 + data: (allPersonas) {
192 + final personas = selectedCategory == null
193 + ? allPersonas
194 + : allPersonas
195 + .where((p) => p.category == selectedCategory)
196 + .toList();
197 if (personas.isEmpty) {
198 return Padding(
199 padding: const EdgeInsets.all(24),
200 child: Text(
201 l10n.no_personas_found,
202 + textAlign: TextAlign.center,
203 style: TextStyle(
204 color: isDark
205 ? AppColors.darkMutedText
@@ -96,93 +209,59 @@ class PersonaPickerSheet extends ConsumerWidget {
209 );
210 }
211
99 - return ListView.builder(
212 + return ListView.separated(
213 shrinkWrap: true,
214 itemCount: personas.length,
215 + separatorBuilder: (_, __) => const Divider(height: 1),
216 itemBuilder: (context, index) {
217 final persona = personas[index];
104 - return _PersonaTile(
105 - persona: persona,
106 - isSelected: persona.id == currentPersonaId,
107 - isDark: isDark,
108 - onTap: () => _selectPersona(
109 - context,
110 - ref,
111 - persona,
112 - activeConv?.id,
218 + final selected = _selectedIds.contains(persona.id);
219 + return ListTile(
220 + contentPadding: EdgeInsets.zero,
221 + leading: CircleAvatar(
222 + backgroundColor: selected
223 + ? theme.colorScheme.primary.withValues(alpha: 0.15)
224 + : (isDark
225 + ? AppColors.darkSurface
226 + : AppColors.lightSurface),
227 + child: Text(persona.emoji, style: const TextStyle(fontSize: 20)),
228 + ),
229 + title: Text(
230 + persona.name,
231 + style: TextStyle(
232 + fontWeight:
233 + selected ? FontWeight.w700 : FontWeight.w500,
234 + ),
235 ),
236 + subtitle: persona.description != null
237 + ? Text(
238 + persona.description!,
239 + maxLines: 2,
240 + overflow: TextOverflow.ellipsis,
241 + )
242 + : null,
243 + trailing: Checkbox(
244 + value: selected,
245 + onChanged: (_) => _toggle(persona, allPersonas),
246 + ),
247 + onTap: () => _toggle(persona, allPersonas),
248 );
249 },
250 );
251 },
252 ),
253 ),
254 + const SizedBox(height: 12),
255 + Align(
256 + alignment: Alignment.centerRight,
257 + child: FilledButton(
258 + onPressed: () => Navigator.pop(context),
259 + child: Text(l10n.done),
260 + ),
261 + ),
262 ],
263 ),
264 ),
265 );
266 }
125 -
126 - void _selectPersona(
127 - BuildContext context,
128 - WidgetRef ref,
129 - Persona persona,
130 - String? conversationId,
131 - ) {
132 - if (mode == PersonaPickerMode.preselection || conversationId == null) {
133 - ref.read(selectedPersonaProvider.notifier).select(persona);
134 - } else {
135 - ref.read(conv.conversationsProvider.notifier).updatePersona(
136 - conversationId,
137 - persona.id,
138 - persona.systemPrompt,
139 - );
140 - }
141 - Navigator.pop(context);
142 - }
143 -}
144 -
145 -class _PersonaTile extends StatelessWidget {
146 - const _PersonaTile({
147 - required this.persona,
148 - required this.isSelected,
149 - required this.isDark,
150 - required this.onTap,
151 - });
152 -
153 - final Persona persona;
154 - final bool isSelected;
155 - final bool isDark;
156 - final VoidCallback onTap;
157 -
158 - @override
159 - Widget build(BuildContext context) {
160 - final accent = isDark ? AppColors.darkAccent : AppColors.lightAccent;
161 -
162 - return ListTile(
163 - leading: Text(persona.emoji, style: const TextStyle(fontSize: 22)),
164 - title: Text(
165 - persona.name,
166 - style: TextStyle(
167 - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
168 - color: isDark ? Colors.white : Colors.black,
169 - ),
170 - ),
171 - subtitle: persona.description != null
172 - ? Text(
173 - persona.description!,
174 - style: TextStyle(
175 - fontSize: 12,
176 - color: isDark
177 - ? AppColors.darkMutedText
178 - : AppColors.lightMutedText,
179 - ),
180 - maxLines: 1,
181 - overflow: TextOverflow.ellipsis,
182 - )
183 - : null,
184 - trailing: isSelected ? Icon(Icons.check_circle, color: accent) : null,
185 - onTap: onTap,
186 - );
187 - }
267 }
lib/features/personas/views/persona_list_screen.dart
+67 -52
@@ -77,6 +77,18 @@ class PersonaListScreen extends ConsumerWidget {
77 ],
78 ),
79 ),
80 + Padding(
81 + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
82 + child: Text(
83 + l10n.personas_combine_hint,
84 + style: TextStyle(
85 + fontSize: 13,
86 + color: isDark
87 + ? AppColors.darkMutedText
88 + : AppColors.lightMutedText,
89 + ),
90 + ),
91 + ),
92 SizedBox(
93 height: 44,
94 child: ListView(
@@ -154,40 +166,40 @@ class PersonaListScreen extends ConsumerWidget {
166 vertical: 8,
167 ),
168 children: [
157 - if (builtIn.isNotEmpty) ...[
158 - _SectionLabel(label: l10n.persona_builtin_section, isDark: isDark),
159 - ...builtIn.map(
169 + if (userCreated.isNotEmpty) ...[
170 + _SectionLabel(label: l10n.persona_my_section, isDark: isDark),
171 + ...userCreated.map(
172 (p) => _PersonaCard(
173 persona: p,
174 isDark: isDark,
175 l10n: l10n,
164 - showSystemPrompt: previewSystemPrompts,
176 + onTap: () => context.push(
177 + AppRoutes.createPersona,
178 + extra: p,
179 + ),
180 onLongPress: () => _showActions(
181 context,
182 ref,
183 p,
169 - isBuiltIn: true,
184 + isBuiltIn: false,
185 ),
186 ),
187 ),
188 const SizedBox(height: 12),
189 ],
175 - if (userCreated.isNotEmpty) ...[
176 - _SectionLabel(label: l10n.persona_my_section, isDark: isDark),
177 - ...userCreated.map(
190 + if (builtIn.isNotEmpty) ...[
191 + _SectionLabel(label: l10n.persona_builtin_section, isDark: isDark),
192 + ...builtIn.map(
193 (p) => _PersonaCard(
194 persona: p,
195 isDark: isDark,
196 l10n: l10n,
182 - onTap: () => context.push(
183 - AppRoutes.createPersona,
184 - extra: p,
185 - ),
197 + showSystemPrompt: previewSystemPrompts,
198 onLongPress: () => _showActions(
199 context,
200 ref,
201 p,
190 - isBuiltIn: false,
202 + isBuiltIn: true,
203 ),
204 ),
205 ),
@@ -224,7 +236,7 @@ class PersonaListScreen extends ConsumerWidget {
236 child: Column(
237 mainAxisSize: MainAxisSize.min,
238 children: [
227 - if (!isBuiltIn) ...[
239 + if (!isBuiltIn)
240 ListTile(
241 leading: const Icon(Icons.edit),
242 title: Text(sheetL10n.edit),
@@ -233,45 +245,48 @@ class PersonaListScreen extends ConsumerWidget {
245 context.push(AppRoutes.createPersona, extra: persona);
246 },
247 ),
236 - ListTile(
237 - leading: const Icon(Icons.delete_outline, color: Colors.red),
238 - title: Text(
239 - sheetL10n.delete,
240 - style: const TextStyle(color: Colors.red),
241 - ),
242 - onTap: () {
243 - Navigator.pop(ctx);
244 - showDialog(
245 - context: context,
246 - builder: (dCtx) {
247 - final dlgL10n = AppLocalizations.of(dCtx)!;
248 - return AlertDialog(
249 - title: Text(dlgL10n.delete_persona_title(persona.name)),
250 - content: Text(dlgL10n.delete_persona_body),
251 - actions: [
252 - TextButton(
253 - onPressed: () => Navigator.pop(dCtx),
254 - child: Text(dlgL10n.cancel),
255 - ),
256 - TextButton(
257 - onPressed: () {
258 - ref
259 - .read(personasNotifierProvider.notifier)
260 - .deletePersona(persona.id);
261 - Navigator.pop(dCtx);
262 - },
263 - style: TextButton.styleFrom(
264 - foregroundColor: Colors.red,
265 - ),
266 - child: Text(dlgL10n.delete),
267 - ),
268 - ],
269 - );
270 - },
271 - );
272 - },
248 + ListTile(
249 + leading: const Icon(Icons.delete_outline, color: Colors.red),
250 + title: Text(
251 + sheetL10n.delete,
252 + style: const TextStyle(color: Colors.red),
253 ),
274 - ],
254 + onTap: () {
255 + Navigator.pop(ctx);
256 + showDialog(
257 + context: context,
258 + builder: (dCtx) {
259 + final dlgL10n = AppLocalizations.of(dCtx)!;
260 + return AlertDialog(
261 + title: Text(dlgL10n.delete_persona_title(persona.name)),
262 + content: Text(
263 + isBuiltIn
264 + ? dlgL10n.delete_builtin_persona_body
265 + : dlgL10n.delete_persona_body,
266 + ),
267 + actions: [
268 + TextButton(
269 + onPressed: () => Navigator.pop(dCtx),
270 + child: Text(dlgL10n.cancel),
271 + ),
272 + TextButton(
273 + onPressed: () {
274 + ref
275 + .read(personasNotifierProvider.notifier)
276 + .deletePersona(persona.id);
277 + Navigator.pop(dCtx);
278 + },
279 + style: TextButton.styleFrom(
280 + foregroundColor: Colors.red,
281 + ),
282 + child: Text(dlgL10n.delete),
283 + ),
284 + ],
285 + );
286 + },
287 + );
288 + },
289 + ),
290 ListTile(
291 leading: const Icon(Icons.copy),
292 title: Text(sheetL10n.clone_edit),
lib/features/saved_messages/data/models/saved_message.dart
+2
@@ -23,6 +23,7 @@ class SavedMessage {
23 this.modelId,
24 this.folderId,
25 required this.savedAt,
26 + this.isArchived = false,
27 });
28
29 final String id;
@@ -34,4 +35,5 @@ class SavedMessage {
35 final String? modelId;
36 final String? folderId;
37 final DateTime savedAt;
38 + final bool isArchived;
39 }
lib/features/saved_messages/providers/saved_message_providers.dart
+63 -5
@@ -13,7 +13,7 @@ final savedMessageFolderFilterProvider =
13 return SavedMessageFolderFilterNotifier();
14 });
15
16 -enum SavedMessageListFilter { all, tempChats, user, assistant }
16 +enum SavedMessageListFilter { all, tempChats, user, assistant, archived }
17
18 final savedMessageListFilterProvider =
19 NotifierProvider<SavedMessageListFilterNotifier, SavedMessageListFilter>(() {
@@ -34,6 +34,41 @@ class SavedMessageFolderFilterNotifier extends Notifier<String?> {
34 void setFilter(String? folderId) => state = folderId;
35 }
36
37 +final savedMessageSelectionModeProvider =
38 + NotifierProvider<SavedMessageSelectionModeNotifier, bool>(
39 + SavedMessageSelectionModeNotifier.new,
40 + );
41 +
42 +class SavedMessageSelectionModeNotifier extends Notifier<bool> {
43 + @override
44 + bool build() => false;
45 +
46 + void enable() => state = true;
47 +
48 + void disable() {
49 + state = false;
50 + ref.read(savedMessageSelectedIdsProvider.notifier).clear();
51 + }
52 +}
53 +
54 +final savedMessageSelectedIdsProvider =
55 + NotifierProvider<SavedMessageSelectedIdsNotifier, Set<String>>(
56 + SavedMessageSelectedIdsNotifier.new,
57 + );
58 +
59 +class SavedMessageSelectedIdsNotifier extends Notifier<Set<String>> {
60 + @override
61 + Set<String> build() => const {};
62 +
63 + void toggle(String id) {
64 + final updated = {...state};
65 + if (!updated.remove(id)) updated.add(id);
66 + state = updated;
67 + }
68 +
69 + void clear() => state = const {};
70 +}
71 +
72 final savedMessageFoldersProvider =
73 AsyncNotifierProvider<SavedMessageFoldersNotifier, List<SavedMessageFolder>>(
74 () => SavedMessageFoldersNotifier(),
@@ -127,6 +162,7 @@ class SavedMessagesNotifier extends AsyncNotifier<List<SavedMessage>> {
162 modelId: e.modelId,
163 folderId: e.folderId,
164 savedAt: e.savedAt,
165 + isArchived: e.isArchived,
166 ),
167 )
168 .toList();
@@ -162,6 +198,7 @@ class SavedMessagesNotifier extends AsyncNotifier<List<SavedMessage>> {
198 modelId: entity.modelId,
199 folderId: entity.folderId,
200 savedAt: entity.savedAt,
201 + isArchived: entity.isArchived,
202 );
203 }
204
@@ -235,6 +272,19 @@ class SavedMessagesNotifier extends AsyncNotifier<List<SavedMessage>> {
272 db.savedMessageBox.put(entity);
273 state = AsyncData(await _loadAll());
274 }
275 +
276 + Future<void> setArchived(String savedMessageId, bool archived) async {
277 + final db = ref.read(databaseProvider);
278 + final query = db.savedMessageBox
279 + .query(SavedMessageEntity_.id.equals(savedMessageId))
280 + .build();
281 + final entity = query.findFirst();
282 + query.close();
283 + if (entity == null) return;
284 + entity.isArchived = archived;
285 + db.savedMessageBox.put(entity);
286 + state = AsyncData(await _loadAll());
287 + }
288 }
289
290 final filteredSavedMessagesProvider =
@@ -247,20 +297,28 @@ final filteredSavedMessagesProvider =
297 var filtered = messages;
298
299 switch (listFilter) {
300 + case SavedMessageListFilter.archived:
301 + filtered = filtered.where((m) => m.isArchived).toList();
302 case SavedMessageListFilter.tempChats:
303 filtered = filtered
252 - .where((m) => m.conversationId.isEmpty)
304 + .where((m) => !m.isArchived && m.conversationId.isEmpty)
305 .toList();
306 case SavedMessageListFilter.user:
307 filtered = filtered
256 - .where((m) => m.roleIndex == MessageRole.user.index)
308 + .where(
309 + (m) => !m.isArchived && m.roleIndex == MessageRole.user.index,
310 + )
311 .toList();
312 case SavedMessageListFilter.assistant:
313 filtered = filtered
260 - .where((m) => m.roleIndex == MessageRole.assistant.index)
314 + .where(
315 + (m) =>
316 + !m.isArchived &&
317 + m.roleIndex == MessageRole.assistant.index,
318 + )
319 .toList();
320 case SavedMessageListFilter.all:
263 - break;
321 + filtered = filtered.where((m) => !m.isArchived).toList();
322 }
323
324 if (folderFilter == null) return filtered;
lib/features/saved_messages/views/components/saved_message_folder_sheet.dart
+18 -6
@@ -7,6 +7,14 @@ Future<void> showSavedMessageMoveToFolderSheet(
7 BuildContext context,
8 WidgetRef ref,
9 String savedMessageId,
10 +) {
11 + return showSavedMessagesBulkMoveToFolderSheet(context, ref, {savedMessageId});
12 +}
13 +
14 +Future<void> showSavedMessagesBulkMoveToFolderSheet(
15 + BuildContext context,
16 + WidgetRef ref,
17 + Set<String> savedMessageIds,
18 ) async {
19 final l10n = AppLocalizations.of(context)!;
20 final folders = ref.read(savedMessageFoldersProvider).value ?? [];
@@ -23,9 +31,11 @@ Future<void> showSavedMessageMoveToFolderSheet(
31 title: Text(l10n.remove_from_folder),
32 onTap: () async {
33 Navigator.pop(ctx);
26 - await ref
27 - .read(savedMessagesProvider.notifier)
28 - .moveToFolder(savedMessageId, null);
34 + for (final id in savedMessageIds) {
35 + await ref
36 + .read(savedMessagesProvider.notifier)
37 + .moveToFolder(id, null);
38 + }
39 },
40 ),
41 ...folders.map(
@@ -34,9 +44,11 @@ Future<void> showSavedMessageMoveToFolderSheet(
44 title: Text(folder.name),
45 onTap: () async {
46 Navigator.pop(ctx);
37 - await ref
38 - .read(savedMessagesProvider.notifier)
39 - .moveToFolder(savedMessageId, folder.id);
47 + for (final id in savedMessageIds) {
48 + await ref
49 + .read(savedMessagesProvider.notifier)
50 + .moveToFolder(id, folder.id);
51 + }
52 },
53 ),
54 ),
lib/features/saved_messages/views/components/saved_message_tile.dart
+69 -8
@@ -13,6 +13,10 @@ class SavedMessageTile extends StatelessWidget {
13 required this.onCopy,
14 required this.onMoveToFolder,
15 required this.onDelete,
16 + required this.onArchive,
17 + this.selectionMode = false,
18 + this.isSelected = false,
19 + this.onEnterSelectionMode,
20 });
21
22 final SavedMessage saved;
@@ -21,6 +25,10 @@ class SavedMessageTile extends StatelessWidget {
25 final VoidCallback onCopy;
26 final VoidCallback onMoveToFolder;
27 final VoidCallback onDelete;
28 + final VoidCallback onArchive;
29 + final bool selectionMode;
30 + final bool isSelected;
31 + final VoidCallback? onEnterSelectionMode;
32
33 @override
34 Widget build(BuildContext context) {
@@ -31,7 +39,33 @@ class SavedMessageTile extends StatelessWidget {
39 final mutedColor =
40 isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
41
34 - return Material(
42 + return Dismissible(
43 + key: Key(saved.id),
44 + direction: DismissDirection.horizontal,
45 + background: Container(
46 + alignment: AlignmentDirectional.centerStart,
47 + padding: const EdgeInsetsDirectional.only(start: 16),
48 + color: Colors.blue,
49 + child: Icon(
50 + saved.isArchived ? Icons.unarchive_outlined : Icons.archive_outlined,
51 + color: Colors.white,
52 + ),
53 + ),
54 + secondaryBackground: Container(
55 + alignment: AlignmentDirectional.centerEnd,
56 + padding: const EdgeInsetsDirectional.only(end: 16),
57 + color: Colors.red,
58 + child: const Icon(Icons.delete, color: Colors.white),
59 + ),
60 + confirmDismiss: (direction) async {
61 + if (direction == DismissDirection.startToEnd) {
62 + onArchive();
63 + } else {
64 + onDelete();
65 + }
66 + return false;
67 + },
68 + child: Material(
69 color: Colors.transparent,
70 child: InkWell(
71 onTap: onTap,
@@ -41,13 +75,16 @@ class SavedMessageTile extends StatelessWidget {
75 child: Row(
76 crossAxisAlignment: CrossAxisAlignment.start,
77 children: [
44 - Icon(
45 - isUser
46 - ? Icons.person_outline
47 - : Icons.auto_awesome_outlined,
48 - size: 20,
49 - color: mutedColor,
50 - ),
78 + if (selectionMode)
79 + Checkbox(value: isSelected, onChanged: (_) => onTap())
80 + else
81 + Icon(
82 + isUser
83 + ? Icons.person_outline
84 + : Icons.auto_awesome_outlined,
85 + size: 20,
86 + color: mutedColor,
87 + ),
88 const SizedBox(width: 12),
89 Expanded(
90 child: Column(
@@ -109,6 +146,7 @@ class SavedMessageTile extends StatelessWidget {
146 ),
147 ),
148 ),
149 + ),
150 );
151 }
152
@@ -124,6 +162,15 @@ class SavedMessageTile extends StatelessWidget {
162 child: Column(
163 mainAxisSize: MainAxisSize.min,
164 children: [
165 + if (onEnterSelectionMode != null)
166 + ListTile(
167 + leading: const Icon(Icons.checklist_outlined),
168 + title: Text(l10n.select),
169 + onTap: () {
170 + Navigator.pop(ctx);
171 + onEnterSelectionMode!();
172 + },
173 + ),
174 ListTile(
175 leading: const Icon(Icons.copy_outlined),
176 title: Text(l10n.copy),
@@ -140,6 +187,20 @@ class SavedMessageTile extends StatelessWidget {
187 onMoveToFolder();
188 },
189 ),
190 + ListTile(
191 + leading: Icon(
192 + saved.isArchived
193 + ? Icons.unarchive_outlined
194 + : Icons.archive_outlined,
195 + ),
196 + title: Text(
197 + saved.isArchived ? l10n.unarchive_chat : l10n.archive_chat,
198 + ),
199 + onTap: () {
200 + Navigator.pop(ctx);
201 + onArchive();
202 + },
203 + ),
204 ListTile(
205 leading: const Icon(Icons.delete_outline, color: Colors.red),
206 title: Text(
lib/features/saved_messages/views/saved_messages_screen.dart
+113 -48
@@ -24,6 +24,8 @@ class SavedMessagesScreen extends ConsumerWidget {
24 final isDark = theme.brightness == Brightness.dark;
25 final messagesAsync = ref.watch(filteredSavedMessagesProvider);
26 final topPadding = MediaQuery.of(context).padding.top;
27 + final selectionMode = ref.watch(savedMessageSelectionModeProvider);
28 + final selectedIds = ref.watch(savedMessageSelectedIdsProvider);
29
30 return Column(
31 children: [
@@ -44,55 +46,96 @@ class SavedMessagesScreen extends ConsumerWidget {
46 ),
47 ),
48 ),
47 - child: Row(
48 - children: [
49 - Builder(
50 - builder: (context) => IconButton(
51 - icon: const Icon(Icons.menu),
52 - onPressed: () => Scaffold.of(context).openDrawer(),
53 - ),
54 - ),
55 - const SizedBox(width: 8),
56 - Text(
57 - l10n.saved_messages_title,
58 - style: TextStyle(
59 - fontSize: 20,
60 - fontWeight: FontWeight.bold,
61 - color: isDark ? Colors.white : Colors.black,
49 + child: selectionMode
50 + ? Row(
51 + children: [
52 + IconButton(
53 + icon: const Icon(Icons.close),
54 + onPressed: () => ref
55 + .read(savedMessageSelectionModeProvider.notifier)
56 + .disable(),
57 + ),
58 + Expanded(
59 + child: Text(
60 + l10n.selected_count(selectedIds.length),
61 + style: TextStyle(
62 + fontSize: 16,
63 + fontWeight: FontWeight.w600,
64 + color: isDark ? Colors.white : Colors.black,
65 + ),
66 + ),
67 + ),
68 + IconButton(
69 + icon: const Icon(Icons.folder_outlined),
70 + tooltip: l10n.move_to_folder,
71 + onPressed: selectedIds.isEmpty
72 + ? null
73 + : () => showSavedMessagesBulkMoveToFolderSheet(
74 + context, ref, selectedIds),
75 + ),
76 + ],
77 + )
78 + : Row(
79 + children: [
80 + Builder(
81 + builder: (context) => IconButton(
82 + icon: const Icon(Icons.menu),
83 + onPressed: () => Scaffold.of(context).openDrawer(),
84 + ),
85 + ),
86 + const SizedBox(width: 8),
87 + Text(
88 + l10n.saved_messages_title,
89 + style: TextStyle(
90 + fontSize: 20,
91 + fontWeight: FontWeight.bold,
92 + color: isDark ? Colors.white : Colors.black,
93 + ),
94 + ),
95 + const Spacer(),
96 + IconButton(
97 + icon: const Icon(Icons.checklist_outlined),
98 + tooltip: l10n.select,
99 + onPressed: () => ref
100 + .read(savedMessageSelectionModeProvider.notifier)
101 + .enable(),
102 + ),
103 + ListFilterButton<SavedMessageListFilter>(
104 + tooltip: l10n.filter_title,
105 + selected: ref.watch(savedMessageListFilterProvider),
106 + onChanged: (filter) => ref
107 + .read(savedMessageListFilterProvider.notifier)
108 + .setFilter(filter),
109 + options: [
110 + ListFilterOption(
111 + value: SavedMessageListFilter.all,
112 + label: l10n.all_chats,
113 + icon: Icons.view_list_rounded,
114 + ),
115 + ListFilterOption(
116 + value: SavedMessageListFilter.tempChats,
117 + label: l10n.filter_temp_chats,
118 + icon: Icons.bolt_outlined,
119 + ),
120 + ListFilterOption(
121 + value: SavedMessageListFilter.user,
122 + label: l10n.filter_user_messages,
123 + icon: Icons.person_outline,
124 + ),
125 + ListFilterOption(
126 + value: SavedMessageListFilter.assistant,
127 + label: l10n.filter_assistant_messages,
128 + icon: Icons.auto_awesome_outlined,
129 + ),
130 + ListFilterOption(
131 + value: SavedMessageListFilter.archived,
132 + label: l10n.filter_archived,
133 + icon: Icons.archive_outlined,
134 + ),
135 + ],
136 + ),
137 + ],
138 ),
63 - ),
64 - const Spacer(),
65 - ListFilterButton<SavedMessageListFilter>(
66 - tooltip: l10n.filter_title,
67 - selected: ref.watch(savedMessageListFilterProvider),
68 - onChanged: (filter) => ref
69 - .read(savedMessageListFilterProvider.notifier)
70 - .setFilter(filter),
71 - options: [
72 - ListFilterOption(
73 - value: SavedMessageListFilter.all,
74 - label: l10n.all_chats,
75 - icon: Icons.view_list_rounded,
76 - ),
77 - ListFilterOption(
78 - value: SavedMessageListFilter.tempChats,
79 - label: l10n.filter_temp_chats,
80 - icon: Icons.bolt_outlined,
81 - ),
82 - ListFilterOption(
83 - value: SavedMessageListFilter.user,
84 - label: l10n.filter_user_messages,
85 - icon: Icons.person_outline,
86 - ),
87 - ListFilterOption(
88 - value: SavedMessageListFilter.assistant,
89 - label: l10n.filter_assistant_messages,
90 - icon: Icons.auto_awesome_outlined,
91 - ),
92 - ],
93 - ),
94 - ],
95 - ),
139 ),
140 const SavedMessageFolderBar(),
141 Expanded(
@@ -123,7 +166,23 @@ class SavedMessagesScreen extends ConsumerWidget {
166 return SavedMessageTile(
167 saved: saved,
168 isUser: role == MessageRole.user,
169 + selectionMode: selectionMode,
170 + isSelected: selectedIds.contains(saved.id),
171 + onEnterSelectionMode: () {
172 + ref
173 + .read(savedMessageSelectionModeProvider.notifier)
174 + .enable();
175 + ref
176 + .read(savedMessageSelectedIdsProvider.notifier)
177 + .toggle(saved.id);
178 + },
179 onTap: () async {
180 + if (selectionMode) {
181 + ref
182 + .read(savedMessageSelectedIdsProvider.notifier)
183 + .toggle(saved.id);
184 + return;
185 + }
186 if (saved.conversationId.isEmpty) {
187 ScaffoldMessenger.of(context).showSnackBar(
188 SnackBar(
@@ -149,6 +208,9 @@ class SavedMessagesScreen extends ConsumerWidget {
208 await ref
209 .read(chatProvider.notifier)
210 .loadConversation(conversation);
211 + ref
212 + .read(chatOriginProvider.notifier)
213 + .set(ChatOrigin.savedMessages);
214 if (context.mounted) {
215 if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
216 Navigator.pop(context);
@@ -174,6 +236,9 @@ class SavedMessagesScreen extends ConsumerWidget {
236 onDelete: () => ref
237 .read(savedMessagesProvider.notifier)
238 .deleteSavedMessage(saved.id),
239 + onArchive: () => ref
240 + .read(savedMessagesProvider.notifier)
241 + .setArchived(saved.id, !saved.isArchived),
242 );
243 },
244 );
lib/features/servers/data/models/server.dart
+15 -1
@@ -123,6 +123,10 @@ class Server {
123 final ConnectionStatus status;
124 final String? iconName;
125 final String? pathPrefix;
126 + /// Optional system RAM in GB for model compatibility hints in the browser.
127 + final int? availableRamGb;
128 + /// Optional GPU VRAM in GB for model compatibility hints in the browser.
129 + final int? availableVramGb;
130
131 Server({
132 required this.id,
@@ -137,6 +141,8 @@ class Server {
141 this.status = ConnectionStatus.disconnected,
142 this.iconName,
143 this.pathPrefix,
144 + this.availableRamGb,
145 + this.availableVramGb,
146 });
147
148 String get apiPathPrefix => normalizeServerPathPrefix(pathPrefix);
@@ -157,7 +163,7 @@ class Server {
163 String get chatEndpoint {
164 switch (type) {
165 case ServerType.lmStudio:
160 - return '$baseUrl/api/v1/chat';
166 + return '$baseUrl/v1/chat/completions';
167 case ServerType.openAICompatible:
168 return '$baseUrl/v1/chat/completions';
169 case ServerType.ollama:
@@ -246,7 +252,11 @@ class Server {
252 ConnectionStatus? status,
253 String? iconName,
254 String? pathPrefix,
255 + int? availableRamGb,
256 + int? availableVramGb,
257 bool clearPathPrefix = false,
258 + bool clearAvailableRamGb = false,
259 + bool clearAvailableVramGb = false,
260 }) {
261 return Server(
262 id: id ?? this.id,
@@ -261,6 +271,10 @@ class Server {
271 status: status ?? this.status,
272 iconName: iconName ?? this.iconName,
273 pathPrefix: clearPathPrefix ? null : (pathPrefix ?? this.pathPrefix),
274 + availableRamGb:
275 + clearAvailableRamGb ? null : (availableRamGb ?? this.availableRamGb),
276 + availableVramGb:
277 + clearAvailableVramGb ? null : (availableVramGb ?? this.availableVramGb),
278 );
279 }
280 }
lib/features/servers/data/server_api_service.dart
+31
@@ -215,6 +215,37 @@ class ServerApiService {
215 }
216 }
217
218 + /// Returns the context length the currently loaded model was actually
219 + /// loaded with — verified live against LM Studio's `/api/v0/models/{id}`,
220 + /// which reports `loaded_context_length` (e.g. 15000) separately from
221 + /// `max_context_length` (the model's architectural maximum, e.g. 131072).
222 + /// The loaded value can be much smaller than the max if the user picked a
223 + /// smaller context window at load time, so it's what "percent of context
224 + /// used" should be measured against. Falls back to `max_context_length`
225 + /// if the model isn't reported as loaded. Only supported for LM Studio;
226 + /// other server types return null so callers can fall back to their own
227 + /// configured context length.
228 + Future<int?> fetchLoadedContextLength(Server server, String modelId) async {
229 + if (server.type != ServerType.lmStudio) return null;
230 +
231 + try {
232 + final response = await _dio.get(
233 + '${server.baseUrl}/api/v0/models/${Uri.encodeComponent(modelId)}',
234 + options: Options(
235 + headers: buildServerAuthHeaders(server),
236 + validateStatus: (status) => status != null && status < 500,
237 + ),
238 + );
239 + final data = response.data;
240 + if (data is! Map) return null;
241 + return _toInt(data['loaded_context_length']) ??
242 + _toInt(data['max_context_length']);
243 + } catch (e) {
244 + Log.warning('Failed to fetch loaded context length: $e');
245 + return null;
246 + }
247 + }
248 +
249 Set<String> _parseRunningOpenAICompatibleModels(dynamic data) {
250 final runningModels = <String>{};
251 if (data == null) return runningModels;
lib/features/servers/views/add_server_screen.dart
+55
@@ -29,6 +29,8 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
29 late TextEditingController _hostController;
30 late TextEditingController _portController;
31 late TextEditingController _apiKeyController;
32 + late TextEditingController _ramGbController;
33 + late TextEditingController _vramGbController;
34
35 late ServerType _selectedType;
36 String? _selectedIconName;
@@ -54,6 +56,12 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
56 AppConstants.lmStudioDefaultPort.toString(),
57 );
58 _apiKeyController = TextEditingController(text: server?.apiKey ?? '');
59 + _ramGbController = TextEditingController(
60 + text: server?.availableRamGb?.toString() ?? '',
61 + );
62 + _vramGbController = TextEditingController(
63 + text: server?.availableVramGb?.toString() ?? '',
64 + );
65 }
66
67 @override
@@ -62,6 +70,8 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
70 _hostController.dispose();
71 _portController.dispose();
72 _apiKeyController.dispose();
73 + _ramGbController.dispose();
74 + _vramGbController.dispose();
75 super.dispose();
76 }
77
@@ -117,6 +127,12 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
127 }
128 }
129
130 + int? _parseOptionalGb(String value) {
131 + final trimmed = value.trim();
132 + if (trimmed.isEmpty) return null;
133 + return int.tryParse(trimmed);
134 + }
135 +
136 Server _buildServer() {
137 return Server(
138 id:
@@ -136,6 +152,12 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
152 lastConnectedAt: widget.editServer?.lastConnectedAt ?? DateTime.now(),
153 status: ConnectionStatus.disconnected,
154 iconName: _selectedIconName,
155 + availableRamGb: _selectedType == ServerType.lmStudio
156 + ? _parseOptionalGb(_ramGbController.text)
157 + : widget.editServer?.availableRamGb,
158 + availableVramGb: _selectedType == ServerType.lmStudio
159 + ? _parseOptionalGb(_vramGbController.text)
160 + : widget.editServer?.availableVramGb,
161 );
162 }
163
@@ -415,6 +437,39 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
437 ),
438 ),
439 ],
440 + if (_selectedType == ServerType.lmStudio) ...[
441 + const SizedBox(height: 12),
442 + _buildSectionCard(
443 + context,
444 + title: l10n.lm_studio_memory_settings_title,
445 + subtitle: l10n.lm_studio_memory_settings_desc,
446 + child: Row(
447 + children: [
448 + Expanded(
449 + child: TextFormField(
450 + controller: _ramGbController,
451 + decoration: InputDecoration(
452 + labelText: l10n.lm_studio_available_ram_gb,
453 + hintText: '32',
454 + ),
455 + keyboardType: TextInputType.number,
456 + ),
457 + ),
458 + const SizedBox(width: 12),
459 + Expanded(
460 + child: TextFormField(
461 + controller: _vramGbController,
462 + decoration: InputDecoration(
463 + labelText: l10n.lm_studio_available_vram_gb,
464 + hintText: '6',
465 + ),
466 + keyboardType: TextInputType.number,
467 + ),
468 + ),
469 + ],
470 + ),
471 + ),
472 + ],
473 const SizedBox(height: 12),
474 _buildSectionCard(
475 context,
lib/features/settings/data/models/app_settings.dart
+65
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
4 import 'package:flutter_gemma/flutter_gemma.dart';
5
6 import '../../../../core/models/enums.dart';
7 +import '../../../../features/chat/utils/image_upload_utils.dart';
8 import '../../../../features/tts/data/kitten_tts_model.dart';
9
10 enum SyntaxThemeName { light, dark }
@@ -44,6 +45,14 @@ class AppSettings {
45 final String? localeCode;
46 final String? huggingFaceToken;
47 final bool unloadModelsBeforeLoad;
48 + final bool tempChatKeyboardIncognito;
49 + final bool resumeLastChat;
50 + final bool imageCompressionEnabled;
51 + final ImageCompressionLevel imageCompressionLevel;
52 + final bool smartRepliesUsePersona;
53 + final bool keepPersonaOnNewChat;
54 + final bool roleSwapButtonEnabled;
55 + final bool showSystemMessagesInChat;
56
57 AppSettings({
58 this.temperature = 0.7,
@@ -79,6 +88,14 @@ class AppSettings {
88 this.localeCode,
89 this.huggingFaceToken,
90 this.unloadModelsBeforeLoad = false,
91 + this.tempChatKeyboardIncognito = true,
92 + this.resumeLastChat = true,
93 + this.imageCompressionEnabled = false,
94 + this.imageCompressionLevel = ImageCompressionLevel.medium,
95 + this.smartRepliesUsePersona = false,
96 + this.keepPersonaOnNewChat = false,
97 + this.roleSwapButtonEnabled = false,
98 + this.showSystemMessagesInChat = true,
99 });
100
101 AppSettings copyWith({
@@ -115,6 +132,14 @@ class AppSettings {
132 Object? localeCode = _unset,
133 Object? huggingFaceToken = _unset,
134 bool? unloadModelsBeforeLoad,
135 + bool? tempChatKeyboardIncognito,
136 + bool? resumeLastChat,
137 + bool? imageCompressionEnabled,
138 + ImageCompressionLevel? imageCompressionLevel,
139 + bool? smartRepliesUsePersona,
140 + bool? keepPersonaOnNewChat,
141 + bool? roleSwapButtonEnabled,
142 + bool? showSystemMessagesInChat,
143 }) {
144 return AppSettings(
145 temperature: temperature ?? this.temperature,
@@ -162,6 +187,20 @@ class AppSettings {
187 : huggingFaceToken as String?,
188 unloadModelsBeforeLoad:
189 unloadModelsBeforeLoad ?? this.unloadModelsBeforeLoad,
190 + tempChatKeyboardIncognito:
191 + tempChatKeyboardIncognito ?? this.tempChatKeyboardIncognito,
192 + resumeLastChat: resumeLastChat ?? this.resumeLastChat,
193 + imageCompressionEnabled:
194 + imageCompressionEnabled ?? this.imageCompressionEnabled,
195 + imageCompressionLevel:
196 + imageCompressionLevel ?? this.imageCompressionLevel,
197 + smartRepliesUsePersona:
198 + smartRepliesUsePersona ?? this.smartRepliesUsePersona,
199 + keepPersonaOnNewChat: keepPersonaOnNewChat ?? this.keepPersonaOnNewChat,
200 + roleSwapButtonEnabled:
201 + roleSwapButtonEnabled ?? this.roleSwapButtonEnabled,
202 + showSystemMessagesInChat:
203 + showSystemMessagesInChat ?? this.showSystemMessagesInChat,
204 );
205 }
206
@@ -200,6 +239,14 @@ class AppSettings {
239 'localeCode': localeCode,
240 'huggingFaceToken': huggingFaceToken,
241 'unloadModelsBeforeLoad': unloadModelsBeforeLoad,
242 + 'tempChatKeyboardIncognito': tempChatKeyboardIncognito,
243 + 'resumeLastChat': resumeLastChat,
244 + 'imageCompressionEnabled': imageCompressionEnabled,
245 + 'imageCompressionLevel': imageCompressionLevel.name,
246 + 'smartRepliesUsePersona': smartRepliesUsePersona,
247 + 'keepPersonaOnNewChat': keepPersonaOnNewChat,
248 + 'roleSwapButtonEnabled': roleSwapButtonEnabled,
249 + 'showSystemMessagesInChat': showSystemMessagesInChat,
250 };
251 }
252
@@ -238,9 +285,27 @@ class AppSettings {
285 localeCode: map['localeCode'] as String?,
286 huggingFaceToken: map['huggingFaceToken'] as String?,
287 unloadModelsBeforeLoad: map['unloadModelsBeforeLoad'] ?? false,
288 + tempChatKeyboardIncognito: map['tempChatKeyboardIncognito'] ?? true,
289 + resumeLastChat: map['resumeLastChat'] ?? true,
290 + imageCompressionEnabled: map['imageCompressionEnabled'] ?? false,
291 + imageCompressionLevel:
292 + _parseImageCompressionLevel(map['imageCompressionLevel']),
293 + smartRepliesUsePersona: map['smartRepliesUsePersona'] ?? false,
294 + keepPersonaOnNewChat: map['keepPersonaOnNewChat'] ?? false,
295 + roleSwapButtonEnabled: map['roleSwapButtonEnabled'] ?? false,
296 + showSystemMessagesInChat: map['showSystemMessagesInChat'] ?? true,
297 );
298 }
299
300 + static ImageCompressionLevel _parseImageCompressionLevel(dynamic value) {
301 + if (value is String) {
302 + try {
303 + return ImageCompressionLevel.values.byName(value);
304 + } catch (_) {}
305 + }
306 + return ImageCompressionLevel.medium;
307 + }
308 +
309 static PreferredBackend _parsePreferredBackend(dynamic value) {
310 if (value is int && value < PreferredBackend.values.length) {
311 return PreferredBackend.values[value];
lib/features/settings/views/settings_screen.dart
+150
@@ -10,6 +10,7 @@ import '../../../core/providers/app_providers.dart';
10 import '../../../core/routes/app_routes.dart';
11 import '../../../core/theme/app_theme.dart';
12 import '../../../core/utils/system_insets.dart';
13 +import '../../chat/utils/image_upload_utils.dart';
14 import '../../conversations/providers/conversation_providers.dart';
15 import '../../on_device/providers/on_device_providers.dart';
16 import '../../personas/providers/personas_providers.dart';
@@ -207,11 +208,20 @@ class SettingsViews extends ConsumerWidget {
208 ),
209 _ToggleSetting(
210 label: l10n.show_system_messages,
211 + description: l10n.show_system_messages_desc,
212 value: settings.showSystemMessages,
213 onChanged: (value) => ref
214 .read(settingsProvider.notifier)
215 .setShowSystemMessages(value),
216 ),
217 + _ToggleSetting(
218 + label: l10n.show_system_messages_in_chat,
219 + description: l10n.show_system_messages_in_chat_desc,
220 + value: settings.showSystemMessagesInChat,
221 + onChanged: (value) => ref
222 + .read(settingsProvider.notifier)
223 + .setShowSystemMessagesInChat(value),
224 + ),
225 _ToggleSetting(
226 label: l10n.haptic_feedback,
227 value: settings.hapticFeedbackEnabled,
@@ -235,6 +245,53 @@ class SettingsViews extends ConsumerWidget {
245 .read(settingsProvider.notifier)
246 .setNewChatMcpEnabled(value),
247 ),
248 + _ToggleSetting(
249 + label: l10n.temp_chat_keyboard_incognito,
250 + description: l10n.temp_chat_keyboard_incognito_desc,
251 + value: settings.tempChatKeyboardIncognito,
252 + onChanged: (value) => ref
253 + .read(settingsProvider.notifier)
254 + .setTempChatKeyboardIncognito(value),
255 + ),
256 + _ToggleSetting(
257 + label: l10n.resume_last_chat,
258 + description: l10n.resume_last_chat_desc,
259 + value: settings.resumeLastChat,
260 + onChanged: (value) => ref
261 + .read(settingsProvider.notifier)
262 + .setResumeLastChat(value),
263 + ),
264 + _ToggleSetting(
265 + label: l10n.keep_persona_on_new_chat,
266 + description: l10n.keep_persona_on_new_chat_desc,
267 + value: settings.keepPersonaOnNewChat,
268 + onChanged: (value) => ref
269 + .read(settingsProvider.notifier)
270 + .setKeepPersonaOnNewChat(value),
271 + ),
272 + _ToggleSetting(
273 + label: l10n.role_swap_button_enabled,
274 + description: l10n.role_swap_button_enabled_desc,
275 + value: settings.roleSwapButtonEnabled,
276 + onChanged: (value) => ref
277 + .read(settingsProvider.notifier)
278 + .setRoleSwapButtonEnabled(value),
279 + ),
280 + _ToggleSetting(
281 + label: l10n.enable_image_compression,
282 + description: l10n.enable_image_compression_desc,
283 + value: settings.imageCompressionEnabled,
284 + onChanged: (value) => ref
285 + .read(settingsProvider.notifier)
286 + .setImageCompressionEnabled(value),
287 + ),
288 + if (settings.imageCompressionEnabled)
289 + _ImageCompressionLevelSetting(
290 + value: settings.imageCompressionLevel,
291 + onChanged: (value) => ref
292 + .read(settingsProvider.notifier)
293 + .setImageCompressionLevel(value),
294 + ),
295 ],
296 );
297
@@ -250,8 +307,18 @@ class SettingsViews extends ConsumerWidget {
307 .read(settingsProvider.notifier)
308 .setSmartReplyEnabled(value),
309 ),
310 + if (settings.smartReplyEnabled)
311 + _ToggleSetting(
312 + label: l10n.smart_replies_use_persona,
313 + description: l10n.smart_replies_use_persona_desc,
314 + value: settings.smartRepliesUsePersona,
315 + onChanged: (value) => ref
316 + .read(settingsProvider.notifier)
317 + .setSmartRepliesUsePersona(value),
318 + ),
319 _ToggleSetting(
320 label: l10n.ai_user_response_enabled,
321 + description: l10n.ai_user_response_enabled_desc,
322 value: settings.aiUserResponseEnabled,
323 badges: [_FeatureBadge(label: l10n.experimental_label)],
324 onChanged: (value) => ref
@@ -314,6 +381,24 @@ class SettingsViews extends ConsumerWidget {
381 .setDefaultPersona(value),
382 icon: Icons.smart_toy_outlined,
383 ),
384 + const SizedBox(height: 8),
385 + _SectionActionButton(
386 + icon: Icons.restore_rounded,
387 + label: l10n.restore_builtin_personas,
388 + onPressed: () async {
389 + await ref
390 + .read(personasNotifierProvider.notifier)
391 + .restoreBuiltInPersonas();
392 + if (context.mounted) {
393 + ScaffoldMessenger.of(context).showSnackBar(
394 + SnackBar(
395 + content:
396 + Text(l10n.restore_builtin_personas_success),
397 + ),
398 + );
399 + }
400 + },
401 + ),
402 ],
403 );
404
@@ -338,6 +423,8 @@ class SettingsViews extends ConsumerWidget {
423 icon: Icons.restore_page_outlined,
424 accent: const Color(0xFFEF4444),
425 children: [
426 + const DataBackupActions(),
427 + const SizedBox(height: 8),
428 _DangerousAction(
429 label: l10n.delete_all_conversations,
430 icon: Icons.delete_outline_rounded,
@@ -890,6 +977,7 @@ class _LanguageSetting extends StatelessWidget {
977 ('ar', 'العربية', 'assets/images/flag_sa.png', '🇸🇦'),
978 ('bn', 'বাংলা', 'assets/images/flag_bd.png', '🇧🇩'),
979 ('hi', 'हिन्दी', 'assets/images/flag_in.png', '🇮🇳'),
980 + ('ru', 'Русский', 'assets/images/flag_ru.png', '🇷🇺'),
981 ];
982
983 final String? current;
@@ -1160,6 +1248,68 @@ class _ToggleSetting extends StatelessWidget {
1248 }
1249 }
1250
1251 +class _ImageCompressionLevelSetting extends StatelessWidget {
1252 + const _ImageCompressionLevelSetting({
1253 + required this.value,
1254 + required this.onChanged,
1255 + });
1256 +
1257 + final ImageCompressionLevel value;
1258 + final ValueChanged<ImageCompressionLevel> onChanged;
1259 +
1260 + @override
1261 + Widget build(BuildContext context) {
1262 + final l10n = AppLocalizations.of(context)!;
1263 + final theme = Theme.of(context);
1264 +
1265 + String labelFor(ImageCompressionLevel level) {
1266 + switch (level) {
1267 + case ImageCompressionLevel.low:
1268 + return l10n.image_compression_level_low;
1269 + case ImageCompressionLevel.medium:
1270 + return l10n.image_compression_level_medium;
1271 + case ImageCompressionLevel.high:
1272 + return l10n.image_compression_level_high;
1273 + }
1274 + }
1275 +
1276 + return _SettingPanel(
1277 + child: Column(
1278 + crossAxisAlignment: CrossAxisAlignment.start,
1279 + children: [
1280 + Text(
1281 + l10n.image_compression_level,
1282 + style: theme.textTheme.titleSmall?.copyWith(
1283 + fontWeight: FontWeight.w700,
1284 + ),
1285 + ),
1286 + const SizedBox(height: 4),
1287 + Text(
1288 + l10n.image_compression_level_desc,
1289 + style: theme.textTheme.bodySmall?.copyWith(
1290 + color: theme.colorScheme.onSurfaceVariant,
1291 + ),
1292 + ),
1293 + const SizedBox(height: 10),
1294 + Wrap(
1295 + spacing: 8,
1296 + runSpacing: 8,
1297 + children: ImageCompressionLevel.values.map((level) {
1298 + final selected = value == level;
1299 + return FilterChip(
1300 + label: Text(labelFor(level)),
1301 + selected: selected,
1302 + onSelected: (_) => onChanged(level),
1303 + showCheckmark: false,
1304 + );
1305 + }).toList(),
1306 + ),
1307 + ],
1308 + ),
1309 + );
1310 + }
1311 +}
1312 +
1313 class _TtsSkipSecondsSetting extends StatelessWidget {
1314 const _TtsSkipSecondsSetting({
1315 required this.value,
lib/features/sidebar/components/drawer_nav_item.dart
+6
@@ -10,6 +10,7 @@ class DrawerNavItem extends StatelessWidget {
10 required this.isSelected,
11 required this.onTap,
12 this.badgeText,
13 + this.trailing,
14 });
15
16 final List<List<dynamic>> iconData;
@@ -17,6 +18,7 @@ class DrawerNavItem extends StatelessWidget {
18 final bool isSelected;
19 final VoidCallback onTap;
20 final String? badgeText;
21 + final Widget? trailing;
22
23 @override
24 Widget build(BuildContext context) {
@@ -81,6 +83,10 @@ class DrawerNavItem extends StatelessWidget {
83 ),
84 ),
85 ],
86 + if (trailing != null) ...[
87 + const SizedBox(width: 8),
88 + trailing!,
89 + ],
90 ],
91 ),
92 ),
lib/features/sidebar/sidebar_widget.dart
+23 -1
@@ -6,8 +6,11 @@ import 'package:shadcn_ui/shadcn_ui.dart';
6
7 import '../../core/routes/app_routes.dart';
8 import '../../core/components/app_sizes.dart';
9 +import '../../core/models/enums.dart';
10 import '../../l10n/app_localizations.dart';
11 import '../chat/providers/chat_providers.dart';
12 +import '../lm_studio_catalog/views/lm_studio_download_widgets.dart';
13 +import '../servers/providers/server_providers.dart';
14 import 'components/active_server_indicator.dart';
15 import 'components/conversation_drawer_header.dart';
16 import 'components/drawer_nav_item.dart';
@@ -35,6 +38,9 @@ class SidebarWidget extends ConsumerWidget {
38 final isHome = location == AppRoutes.home || location == '/';
39 final hasActiveChat = ref.watch(hasActiveChatSessionProvider);
40 final isTemporary = ref.watch(chatProvider.select((s) => s.isTemporary));
41 + final activeServer = ref.watch(activeServerProvider);
42 + final isLmStudio =
43 + activeServer != null && activeServer.type == ServerType.lmStudio;
44
45 return Container(
46 width: AppSizes.sidebarWidth,
@@ -118,7 +124,7 @@ class SidebarWidget extends ConsumerWidget {
124 },
125 ),
126 DrawerNavItem(
121 - iconData: HugeIcons.strokeRoundedBookmark01,
127 + iconData: HugeIcons.strokeRoundedBookmark02,
128 label: l10n.nav_saved_messages,
129 isSelected: isSavedMessages,
130 onTap: () {
@@ -150,6 +156,22 @@ class SidebarWidget extends ConsumerWidget {
156 context.go(AppRoutes.mcpTools);
157 },
158 ),
159 + if (isLmStudio)
160 + DrawerNavItem(
161 + iconData: HugeIcons.strokeRoundedAiSearch,
162 + label: l10n.lm_studio_model_search,
163 + isSelected: false,
164 + trailing: const LmDownloadIndicatorButton(compact: true),
165 + onTap: () {
166 + if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
167 + Navigator.pop(context);
168 + }
169 + context.push(
170 + AppRoutes.lmStudioModelBrowser,
171 + extra: activeServer,
172 + );
173 + },
174 + ),
175 DrawerNavItem(
176 iconData: HugeIcons.strokeRoundedSmartPhone01,
177 label: l10n.nav_local_models,
lib/features/tts/data/sherpa_bundle_downloader.dart
+11 -1
@@ -30,11 +30,21 @@ void _extractTarBz2Sync(String archivePath, String targetDirPath) {
30 final tarBytes = BZip2Decoder().decodeBytes(archiveBytes);
31 final archive = TarDecoder().decodeBytes(tarBytes);
32
33 + final normalizedTargetDir = p.normalize(targetDirPath);
34 +
35 for (final file in archive) {
36 final relativePath = file.name;
37 if (relativePath.isEmpty) continue;
38
37 - final outputPath = p.join(targetDirPath, relativePath);
39 + final outputPath = p.normalize(p.join(targetDirPath, relativePath));
40 + // Reject any entry whose resolved path would land outside the target
41 + // directory (e.g. a `../` traversal sequence in the archive entry name)
42 + // rather than trusting the archive's own file names.
43 + if (outputPath != normalizedTargetDir &&
44 + !p.isWithin(normalizedTargetDir, outputPath)) {
45 + continue;
46 + }
47 +
48 if (file.isFile) {
49 final outputFile = File(outputPath);
50 outputFile.createSync(recursive: true);
lib/features/tts/providers/tts_providers.dart
+85 -61
@@ -36,6 +36,7 @@ class TtsState {
36 final Duration position;
37 final Duration duration;
38 final bool canSeek;
39 + final bool isSeekPending;
40 final double playbackSpeed;
41
42 const TtsState({
@@ -51,6 +52,7 @@ class TtsState {
52 this.position = Duration.zero,
53 this.duration = Duration.zero,
54 this.canSeek = false,
55 + this.isSeekPending = false,
56 this.playbackSpeed = 1.0,
57 });
58
@@ -67,6 +69,7 @@ class TtsState {
69 Duration? position,
70 Duration? duration,
71 bool? canSeek,
72 + bool? isSeekPending,
73 double? playbackSpeed,
74 bool clearPlayingTarget = false,
75 bool resetProgress = false,
@@ -87,6 +90,7 @@ class TtsState {
90 position: resetProgress ? Duration.zero : (position ?? this.position),
91 duration: resetProgress ? Duration.zero : (duration ?? this.duration),
92 canSeek: canSeek ?? this.canSeek,
93 + isSeekPending: isSeekPending ?? this.isSeekPending,
94 playbackSpeed: playbackSpeed ?? this.playbackSpeed,
95 );
96 }
@@ -388,6 +392,7 @@ class TtsNotifier extends Notifier<TtsState> {
392 void _onPlaybackFinished() {
393 if (_isStopping) return;
394 if (_currentSessionId != _playbackSessionId) return;
395 + _saveMessageAudioCache();
396 _cleanupSessionFiles(_currentSessionId, _chunks.length);
397 _resetProgress();
398 state = state.copyWith(
@@ -561,6 +566,37 @@ class TtsNotifier extends Notifier<TtsState> {
566 if (text.trim().isEmpty) return;
567 }
568
569 + engineId ??= settings.ttsEngine;
570 + final resolvedEngine = engineId;
571 +
572 + final availableVoices = voicesForEngine(resolvedEngine);
573 + final resolvedVoiceId =
574 + voiceId ??
575 + settings.ttsVoiceId ??
576 + (availableVoices.isNotEmpty ? availableVoices.first.id : null);
577 +
578 + Future<bool> tryPlayCached() async {
579 + if (isPreview ||
580 + messageId == null ||
581 + _messageAudioCache == null ||
582 + _messageAudioCache!.messageId != messageId ||
583 + _messageAudioCache!.text != text ||
584 + _messageAudioCache!.engine != resolvedEngine ||
585 + _messageAudioCache!.voice != resolvedVoiceId) {
586 + return false;
587 + }
588 + await _playFromCache(
589 + isPreview: isPreview,
590 + messageId: messageId,
591 + conversationId: conversationId,
592 + engineId: resolvedEngine,
593 + text: text,
594 + );
595 + return true;
596 + }
597 +
598 + if (await tryPlayCached()) return;
599 +
600 _isStopping = false;
601 if (state.isSpeaking) {
602 await stop();
@@ -568,15 +604,9 @@ class TtsNotifier extends Notifier<TtsState> {
604 _isStopping = false;
605 }
606
571 - engineId ??= settings.ttsEngine;
572 -
573 - final availableVoices = voicesForEngine(engineId);
574 - final resolvedVoiceId =
575 - voiceId ??
576 - settings.ttsVoiceId ??
577 - (availableVoices.isNotEmpty ? availableVoices.first.id : null);
607 + if (await tryPlayCached()) return;
608
579 - if (engineId == EngineId.system) {
609 + if (resolvedEngine == EngineId.system) {
610 _flutterTts ??= FlutterTts();
611 _resetSystemSeekState();
612 final speakGeneration = ++_systemSpeakGeneration;
@@ -645,23 +675,6 @@ class TtsNotifier extends Notifier<TtsState> {
675 return;
676 }
677
648 - if (!isPreview &&
649 - messageId != null &&
650 - _messageAudioCache != null &&
651 - _messageAudioCache!.messageId == messageId &&
652 - _messageAudioCache!.text == text &&
653 - _messageAudioCache!.engine == engineId &&
654 - _messageAudioCache!.voice == resolvedVoiceId) {
655 - await _playFromCache(
656 - isPreview: isPreview,
657 - messageId: messageId,
658 - conversationId: conversationId,
659 - engineId: engineId,
660 - text: text,
661 - );
662 - return;
663 - }
664 -
678 _resetProgress();
679 _chunks = _splitText(text);
680 _currentChunkIndex = 0;
@@ -697,7 +710,7 @@ class TtsNotifier extends Notifier<TtsState> {
710 );
711
712 try {
700 - await _initEngine(engineId, voiceId: resolvedVoiceId);
713 + await _initEngine(resolvedEngine, voiceId: resolvedVoiceId);
714 await _player.setSpeed(state.playbackSpeed);
715 state = state.copyWith(
716 isInitializing: false,
@@ -813,46 +826,47 @@ class TtsNotifier extends Notifier<TtsState> {
826 Future<void> seekTo(Duration target) async {
827 if (!state.isSpeaking ||
828 state.isPreview ||
816 - state.isInitializing ||
829 state.activeEngine == EngineId.system) {
830 return;
831 }
832
821 - final maxDuration = _estimatedTotalDuration();
822 - if (maxDuration <= Duration.zero) return;
833 + state = state.copyWith(isSeekPending: true);
834 + try {
835 + final maxDuration = _estimatedTotalDuration();
836 + if (maxDuration <= Duration.zero) return;
837
824 - var clamped = target;
825 - if (clamped.isNegative) clamped = Duration.zero;
826 - if (clamped > maxDuration) clamped = maxDuration;
838 + var clamped = target;
839 + if (clamped.isNegative) clamped = Duration.zero;
840 + if (clamped > maxDuration) clamped = maxDuration;
841
828 - final targetChunk = _chunkIndexForPosition(clamped);
829 - await _ensurePlaylistThrough(targetChunk);
842 + final targetChunk = _chunkIndexForPosition(clamped);
843 + await _ensurePlaylistThrough(targetChunk);
844
831 - var chunkStart = Duration.zero;
832 - for (var i = 0; i < targetChunk; i++) {
833 - chunkStart += _chunkDurations[i] ?? _averageChunkDuration();
834 - }
835 - final localPosition = clamped - chunkStart;
845 + var chunkStart = Duration.zero;
846 + for (var i = 0; i < targetChunk; i++) {
847 + chunkStart += _chunkDurations[i] ?? _averageChunkDuration();
848 + }
849 + final localPosition = clamped - chunkStart;
850
837 - if (_player.currentIndex != targetChunk) {
838 - await _player.seek(Duration.zero, index: targetChunk);
839 - }
840 - if (localPosition > Duration.zero) {
841 - await _player.seek(localPosition);
842 - } else {
843 - await _player.seek(Duration.zero);
851 + if (_player.currentIndex != targetChunk) {
852 + await _player.seek(Duration.zero, index: targetChunk);
853 + }
854 + if (localPosition > Duration.zero) {
855 + await _player.seek(localPosition);
856 + } else {
857 + await _player.seek(Duration.zero);
858 + }
859 + _currentChunkIndex = targetChunk;
860 + _updateChunkOffset();
861 + _updatePlaybackProgress();
862 + } finally {
863 + state = state.copyWith(isSeekPending: false);
864 }
845 - _currentChunkIndex = targetChunk;
846 - _updateChunkOffset();
847 - _updatePlaybackProgress();
865 }
866
867 Future<void> skipForward() async {
851 - if (state.isInitializing ||
852 - state.activeEngine == EngineId.system ||
853 - (!state.canSeek && !state.isSpeaking)) {
854 - return;
855 - }
868 + if (state.activeEngine == EngineId.system) return;
869 + if (!state.isSpeaking && !state.isPaused) return;
870 final skip = Duration(
871 seconds: ref.read(settingsProvider).ttsSkipSeconds,
872 );
@@ -860,11 +874,8 @@ class TtsNotifier extends Notifier<TtsState> {
874 }
875
876 Future<void> skipBackward() async {
863 - if (state.isInitializing ||
864 - state.activeEngine == EngineId.system ||
865 - (!state.canSeek && !state.isSpeaking)) {
866 - return;
867 - }
877 + if (state.activeEngine == EngineId.system) return;
878 + if (!state.isSpeaking && !state.isPaused) return;
879 final skip = Duration(
880 seconds: ref.read(settingsProvider).ttsSkipSeconds,
881 );
@@ -1037,13 +1048,26 @@ class TtsNotifier extends Notifier<TtsState> {
1048 isInitializing: false,
1049 canSeek: true,
1050 );
1051 + if (!state.isPaused) {
1052 + await _player.play();
1053 + }
1054 }
1055
1056 void _saveMessageAudioCache() {
1057 final messageId = state.playingMessageId;
1058 final text = state.playingContent;
1059 if (state.isPreview || messageId == null || text == null) return;
1046 - if (_chunks.isEmpty || _synthesizedChunks.length < _chunks.length) return;
1060 + if (_chunks.isEmpty) return;
1061 +
1062 + final settings = ref.read(settingsProvider);
1063 + final availableVoices = voicesForEngine(
1064 + state.activeEngine ?? _currentEngine ?? EngineId.kitten,
1065 + );
1066 + final resolvedVoice =
1067 + _currentVoice ??
1068 + settings.ttsVoiceId ??
1069 + (availableVoices.isNotEmpty ? availableVoices.first.id : null);
1070 +
1071 for (var i = 0; i < _chunks.length; i++) {
1072 final bytes = _synthesizedChunks[i];
1073 if (bytes == null || bytes.isEmpty) return;
@@ -1053,7 +1077,7 @@ class TtsNotifier extends Notifier<TtsState> {
1077 messageId: messageId,
1078 text: text,
1079 engine: state.activeEngine ?? _currentEngine ?? EngineId.kitten,
1056 - voice: _currentVoice,
1080 + voice: resolvedVoice,
1081 chunks: List<String>.from(_chunks),
1082 synthesizedChunks: Map<int, Uint8List>.from(_synthesizedChunks),
1083 chunkDurations: Map<int, Duration>.from(_chunkDurations),
lib/features/tts/views/components/tts_player_bar.dart
+57 -20
@@ -106,14 +106,19 @@ class TtsPlayerBar extends ConsumerWidget {
106 final preview = _truncateContent(ttsState.playingContent);
107 final canJumpToMessage = ttsState.playingMessageId != null;
108 final skipSeconds = ref.watch(settingsProvider).ttsSkipSeconds;
109 - final isSystemTts = ttsState.activeEngine == EngineId.system;
110 - final hasTimeline =
111 - !isSystemTts &&
112 - ttsState.duration > Duration.zero &&
113 - ttsState.canSeek;
114 - final progress = hasTimeline
115 - ? (ttsState.position.inMilliseconds / ttsState.duration.inMilliseconds)
116 - .clamp(0.0, 1.0)
109 + final settingsEngine =
110 + ref.watch(settingsProvider.select((s) => s.ttsEngine));
111 + final isSystemTts = ttsState.activeEngine == EngineId.system ||
112 + settingsEngine == EngineId.system ||
113 + (ttsState.activeEngine == null && settingsEngine == EngineId.system);
114 + final showSeekControls = !isSystemTts;
115 + final hasTimeline = showSeekControls && ttsState.duration > Duration.zero;
116 + final progress = showSeekControls
117 + ? (ttsState.duration > Duration.zero
118 + ? (ttsState.position.inMilliseconds /
119 + ttsState.duration.inMilliseconds)
120 + .clamp(0.0, 1.0)
121 + : 0.0)
122 : null;
123 final speedLabel =
124 '${ttsState.playbackSpeed.toStringAsFixed(2).replaceAll(RegExp(r'\.?0+$'), '')}x';
@@ -211,8 +216,10 @@ class TtsPlayerBar extends ConsumerWidget {
216 ),
217 ),
218 const SizedBox(width: 8),
214 - if (!ttsState.isInitializing)
219 + if (!ttsState.isInitializing) ...[
220 _WaveformIndicator(isPlaying: !ttsState.isPaused),
221 + const SizedBox(width: 12),
222 + ],
223 if (!ttsState.isInitializing)
224 TextButton(
225 onPressed: notifier.cyclePlaybackSpeed,
@@ -271,31 +278,31 @@ class TtsPlayerBar extends ConsumerWidget {
278 ),
279 ],
280 ),
274 - if (!ttsState.isInitializing && hasTimeline) ...[
275 - const SizedBox(height: 8),
281 + if (showSeekControls) ...[
282 + const SizedBox(height: 10),
283 Row(
284 children: [
278 - IconButton(
279 - icon: const Icon(Icons.fast_rewind_rounded, size: 22),
285 + _TtsSkipButton(
286 + icon: Icons.fast_rewind_rounded,
287 tooltip: '-${skipSeconds}s',
281 - visualDensity: VisualDensity.compact,
282 - onPressed: ttsState.canSeek ? notifier.skipBackward : null,
288 + isLoading: ttsState.isSeekPending && !ttsState.canSeek,
289 + onPressed: notifier.skipBackward,
290 ),
291 Expanded(
292 child: _TtsSeekSlider(
293 progress: progress ?? 0,
294 position: ttsState.position,
295 duration: ttsState.duration,
289 - enabled: hasTimeline,
296 + enabled: hasTimeline && !ttsState.isSeekPending,
297 onSeek: notifier.seekTo,
298 formatDuration: _formatDuration,
299 ),
300 ),
294 - IconButton(
295 - icon: const Icon(Icons.fast_forward_rounded, size: 22),
301 + _TtsSkipButton(
302 + icon: Icons.fast_forward_rounded,
303 tooltip: '+${skipSeconds}s',
297 - visualDensity: VisualDensity.compact,
298 - onPressed: ttsState.canSeek ? notifier.skipForward : null,
304 + isLoading: ttsState.isSeekPending && !ttsState.canSeek,
305 + onPressed: notifier.skipForward,
306 ),
307 ],
308 ),
@@ -307,6 +314,36 @@ class TtsPlayerBar extends ConsumerWidget {
314 }
315 }
316
317 +class _TtsSkipButton extends StatelessWidget {
318 + const _TtsSkipButton({
319 + required this.icon,
320 + required this.tooltip,
321 + required this.onPressed,
322 + this.isLoading = false,
323 + });
324 +
325 + final IconData icon;
326 + final String tooltip;
327 + final Future<void> Function() onPressed;
328 + final bool isLoading;
329 +
330 + @override
331 + Widget build(BuildContext context) {
332 + return IconButton(
333 + tooltip: tooltip,
334 + visualDensity: VisualDensity.compact,
335 + onPressed: isLoading ? null : onPressed,
336 + icon: isLoading
337 + ? const SizedBox(
338 + width: 18,
339 + height: 18,
340 + child: CircularProgressIndicator(strokeWidth: 2),
341 + )
342 + : Icon(icon, size: 22),
343 + );
344 + }
345 +}
346 +
347 class _TtsSeekSlider extends StatefulWidget {
348 const _TtsSeekSlider({
349 required this.progress,
lib/l10n/app_en.arb
+300 -5
@@ -364,6 +364,198 @@
364 "@delete_persona_body": {
365 "description": "Delete persona dialog body"
366 },
367 + "delete_builtin_persona_body": "This is a built-in persona. You can restore it later from Settings.",
368 + "@delete_builtin_persona_body": {
369 + "description": "Delete built-in persona dialog body"
370 + },
371 + "restore_builtin_personas": "Restore default personas",
372 + "@restore_builtin_personas": {
373 + "description": "Settings action to re-add any deleted built-in personas"
374 + },
375 + "restore_builtin_personas_desc": "Re-add any built-in personas you've deleted",
376 + "@restore_builtin_personas_desc": {
377 + "description": "Settings description for restore built-in personas"
378 + },
379 + "restore_builtin_personas_success": "Default personas restored",
380 + "@restore_builtin_personas_success": {
381 + "description": "Snackbar after restoring built-in personas"
382 + },
383 + "clear_personas": "Clear personas",
384 + "@clear_personas": {
385 + "description": "Button to clear all selected personas in the picker"
386 + },
387 + "enable_image_compression": "Compress images before sending",
388 + "@enable_image_compression": {
389 + "description": "Settings toggle to enable/disable automatic image compression"
390 + },
391 + "enable_image_compression_desc": "Resize and compress attached images so uploads stay within server limits",
392 + "@enable_image_compression_desc": {
393 + "description": "Description for the image compression toggle"
394 + },
395 + "image_compression_level": "Compression aggressiveness",
396 + "@image_compression_level": {
397 + "description": "Label for the image compression level selector"
398 + },
399 + "image_compression_level_desc": "Higher aggressiveness produces smaller uploads at lower quality",
400 + "@image_compression_level_desc": {
401 + "description": "Description for the image compression level selector"
402 + },
403 + "image_compression_level_low": "Low",
404 + "@image_compression_level_low": {
405 + "description": "Low image compression aggressiveness"
406 + },
407 + "image_compression_level_medium": "Medium",
408 + "@image_compression_level_medium": {
409 + "description": "Medium image compression aggressiveness"
410 + },
411 + "image_compression_level_high": "High",
412 + "@image_compression_level_high": {
413 + "description": "High image compression aggressiveness"
414 + },
415 + "sort_models_tooltip": "Sort models",
416 + "@sort_models_tooltip": {
417 + "description": "Tooltip for the model list sort button"
418 + },
419 + "sort_by_favorites": "Favorites first",
420 + "@sort_by_favorites": {
421 + "description": "Sort models with favorites first, then alphabetically"
422 + },
423 + "sort_by_name": "Name (A-Z)",
424 + "@sort_by_name": {
425 + "description": "Sort models alphabetically by name"
426 + },
427 + "sort_by_size_smallest": "Size (smallest first)",
428 + "@sort_by_size_smallest": {
429 + "description": "Sort models by file size, smallest first"
430 + },
431 + "sort_by_size_largest": "Size (largest first)",
432 + "@sort_by_size_largest": {
433 + "description": "Sort models by file size, largest first"
434 + },
435 + "sort_by_context_length": "Context length",
436 + "@sort_by_context_length": {
437 + "description": "Sort models by max context length, largest first"
438 + },
439 + "bulk_ai_rename_progress": "Renaming {done}/{total}...",
440 + "@bulk_ai_rename_progress": {
441 + "description": "Progress text while AI-renaming multiple conversations",
442 + "placeholders": {
443 + "done": {"type": "int"},
444 + "total": {"type": "int"}
445 + }
446 + },
447 + "selected_count": "{count} selected",
448 + "@selected_count": {
449 + "description": "Header shown in selection mode",
450 + "placeholders": {
451 + "count": {"type": "int"}
452 + }
453 + },
454 + "ai_rename_tooltip": "Rename selected with AI",
455 + "@ai_rename_tooltip": {
456 + "description": "Tooltip for bulk AI rename button"
457 + },
458 + "new_chat_in_folder_tooltip": "New chat in this folder",
459 + "@new_chat_in_folder_tooltip": {
460 + "description": "Tooltip for the history screen new-chat FAB"
461 + },
462 + "total_tokens_count": "{count} tokens",
463 + "@total_tokens_count": {
464 + "description": "Total token count shown under the chat text field",
465 + "placeholders": {
466 + "count": {"type": "int"}
467 + }
468 + },
469 + "smart_replies_use_persona": "Use persona in smart replies",
470 + "@smart_replies_use_persona": {
471 + "description": "Settings toggle to include persona system prompt when generating smart reply suggestions"
472 + },
473 + "smart_replies_use_persona_desc": "Suggested replies match the tone of the active persona instead of a generic assistant",
474 + "@smart_replies_use_persona_desc": {
475 + "description": "Description for the smart-replies-use-persona toggle"
476 + },
477 + "keep_persona_on_new_chat": "Keep persona on new chat",
478 + "@keep_persona_on_new_chat": {
479 + "description": "Settings toggle to not clear persona selection when starting a new chat"
480 + },
481 + "keep_persona_on_new_chat_desc": "Don't clear the selected persona(s) after starting a new chat",
482 + "@keep_persona_on_new_chat_desc": {
483 + "description": "Description for the keep-persona-on-new-chat toggle"
484 + },
485 + "role_swap_button_enabled": "Show role-swap button",
486 + "@role_swap_button_enabled": {
487 + "description": "Settings toggle to show a button that lets the user send a message as the assistant role"
488 + },
489 + "role_swap_button_enabled_desc": "Show a button in the chat input to send your message as the assistant instead of the user, without generating a response",
490 + "@role_swap_button_enabled_desc": {
491 + "description": "Description for the role-swap-button-enabled toggle"
492 + },
493 + "send_as_user_tooltip": "Send as user",
494 + "@send_as_user_tooltip": {
495 + "description": "Tooltip for the role-swap button when it will send as the user role"
496 + },
497 + "send_as_assistant_tooltip": "Send as assistant (no response)",
498 + "@send_as_assistant_tooltip": {
499 + "description": "Tooltip for the role-swap button when it will send as the assistant role"
500 + },
501 + "insert_without_generating_tooltip": "Insert without generating",
502 + "@insert_without_generating_tooltip": {
503 + "description": "Tooltip shown for long-pressing the send button to insert a message without generating a response"
504 + },
505 + "token_usage_title": "Token Usage",
506 + "@token_usage_title": {
507 + "description": "Title of the sheet shown when tapping the token usage indicator"
508 + },
509 + "total_tokens_label": "Tokens used",
510 + "@total_tokens_label": {
511 + "description": "Label for the total token count row in the token usage sheet"
512 + },
513 + "usage_percent_label": "Context used",
514 + "@usage_percent_label": {
515 + "description": "Label for the percentage-of-context-length row in the token usage sheet"
516 + },
517 + "export_choice_title": "Export",
518 + "@export_choice_title": {
519 + "description": "Title for the copy-vs-share export choice dialog"
520 + },
521 + "export_choice_body": "How would you like to export this?",
522 + "@export_choice_body": {
523 + "description": "Body text for the copy-vs-share export choice dialog"
524 + },
525 + "copy_to_clipboard": "Copy to Clipboard",
526 + "@copy_to_clipboard": {
527 + "description": "Button to copy export content to the clipboard"
528 + },
529 + "bulk_export_conversations_success": "Exported {count} conversations",
530 + "@bulk_export_conversations_success": {
531 + "description": "Success message after bulk-exporting conversations",
532 + "placeholders": {
533 + "count": {"type": "int"}
534 + }
535 + },
536 + "bulk_ai_rename_confirm_title": "Rename with AI?",
537 + "@bulk_ai_rename_confirm_title": {
538 + "description": "Title for the bulk AI rename confirmation dialog"
539 + },
540 + "bulk_ai_rename_confirm_body": "This will ask the AI to generate a new title for each of the {count} selected conversations, replacing their current titles. This can take a while and cannot be undone.",
541 + "@bulk_ai_rename_confirm_body": {
542 + "description": "Body text for the bulk AI rename confirmation dialog",
543 + "placeholders": {
544 + "count": {"type": "int"}
545 + }
546 + },
547 + "sort_by_modified_date": "Last modified",
548 + "@sort_by_modified_date": {
549 + "description": "Sort history by last modified date"
550 + },
551 + "sort_by_created_date": "Date created",
552 + "@sort_by_created_date": {
553 + "description": "Sort history by creation date"
554 + },
555 + "sort_title": "Sort",
556 + "@sort_title": {
557 + "description": "Tooltip for the history sort button"
558 + },
559 "clear_conversation_title": "Clear conversation?",
560 "@clear_conversation_title": {
561 "description": "Clear conversation dialog title"
@@ -660,9 +852,13 @@
852 "@enable_smart_reply": {
853 "description": "Toggle for on-device smart reply suggestions"
854 },
663 - "ai_user_response_enabled": "AI user message button",
855 + "ai_user_response_enabled": "AI user message (hold send)",
856 "@ai_user_response_enabled": {
665 - "description": "Settings toggle for AI-generated user message button in chat input"
857 + "description": "Settings toggle for AI-generated user message triggered by holding the send button"
858 + },
859 + "ai_user_response_enabled_desc": "Hold the send button for 3 seconds to have the AI write and send your next message",
860 + "@ai_user_response_enabled_desc": {
861 + "description": "Description for the hold-to-generate-AI-user-message toggle"
862 },
863 "ai_user_response_tooltip": "Generate user message with AI",
864 "@ai_user_response_tooltip": {
@@ -680,9 +876,21 @@
876 "@send_on_enter": {
877 "description": "Toggle for send on enter behavior"
878 },
683 - "show_system_messages": "Show System Messages",
879 + "show_system_messages": "Send Default System Prompt",
880 "@show_system_messages": {
685 - "description": "Toggle for system messages visibility"
881 + "description": "Toggle for sending a default 'You are LocalMind' system prompt to the model when no persona is selected"
882 + },
883 + "show_system_messages_desc": "When no persona is selected, send a default assistant system prompt with each request",
884 + "@show_system_messages_desc": {
885 + "description": "Description for the send-default-system-prompt toggle"
886 + },
887 + "show_system_messages_in_chat": "Show System Messages in Chat",
888 + "@show_system_messages_in_chat": {
889 + "description": "Toggle for whether system-role messages are displayed as bubbles in the chat transcript"
890 + },
891 + "show_system_messages_in_chat_desc": "Display system messages (e.g. from an imported backup) as visible bubbles in the conversation",
892 + "@show_system_messages_in_chat_desc": {
893 + "description": "Description for the show-system-messages-in-chat toggle"
894 },
895 "haptic_feedback": "Haptic Feedback",
896 "@haptic_feedback": {
@@ -753,6 +961,11 @@
961 "description": "Model selection placeholder"
962 },
963 "select_persona": "Select Persona",
964 + "manage_personas": "Manage personas",
965 + "personas_combine_hint": "Select multiple personas in chat to stack their system prompts.",
966 + "@personas_combine_hint": {
967 + "description": "Hint in persona manager about combining personas"
968 + },
969 "@select_persona": {
970 "description": "Persona selection sheet title"
971 },
@@ -2520,6 +2733,22 @@
2733 "@unload_models_before_load": {
2734 "description": "Setting to unload every loaded model instance before loading another"
2735 },
2736 + "temp_chat_keyboard_incognito": "Incognito keyboard in temporary chat",
2737 + "@temp_chat_keyboard_incognito": {
2738 + "description": "Use private/incognito keyboard mode while in a temporary chat"
2739 + },
2740 + "temp_chat_keyboard_incognito_desc": "Disables keyboard learning and suggestions in temporary chats (e.g. SwiftKey incognito).",
2741 + "@temp_chat_keyboard_incognito_desc": {
2742 + "description": "Description for temporary chat incognito keyboard setting"
2743 + },
2744 + "resume_last_chat": "Resume last chat on launch",
2745 + "@resume_last_chat": {
2746 + "description": "Reopen the most recently active conversation when the app starts"
2747 + },
2748 + "resume_last_chat_desc": "Restore your last open conversation when reopening the app.",
2749 + "@resume_last_chat_desc": {
2750 + "description": "Description for resume last chat setting"
2751 + },
2752 "export_all_data": "Export all data",
2753 "@export_all_data": {
2754 "description": "Export conversations and personas to a JSON file"
@@ -2678,5 +2907,71 @@
2907 },
2908 "generate_title_with_ai": "Generate with AI",
2909 "generating_title": "Generating...",
2681 - "generate_title_failed": "Could not generate a title"
2910 + "generate_title_failed": "Could not generate a title",
2911 + "lm_studio_model_browser_title": "Browse models",
2912 + "lm_studio_model_search_hint": "Search models by name or author…",
2913 + "lm_studio_staff_picks": "Staff picks",
2914 + "lm_studio_community_models": "Community models",
2915 + "lm_studio_no_models": "No models found",
2916 + "lm_studio_models_count": "{count} models",
2917 + "@lm_studio_models_count": {
2918 + "placeholders": {
2919 + "count": { "type": "int" }
2920 + }
2921 + },
2922 + "lm_studio_browse_models": "Browse & download",
2923 + "lm_studio_model_search": "LMS Model Search",
2924 + "lm_studio_downloads_title": "Downloads",
2925 + "lm_studio_choose_quant": "Choose a download option",
2926 + "lm_studio_use_default_quant": "Use default",
2927 + "lm_studio_recommended": "Recommended",
2928 + "lm_studio_clear_downloads": "Clear finished",
2929 + "lm_studio_no_downloads": "No downloads yet",
2930 + "lm_studio_downloads_disclaimer": "Downloads run on the LM Studio host. Pausing, stopping, and deleting models must be done on that computer — not from this app.",
2931 + "lm_studio_staff_pick": "Staff pick",
2932 + "lm_studio_params": "PARAMS",
2933 + "lm_studio_arch": "ARCH",
2934 + "lm_studio_domain": "DOMAIN",
2935 + "lm_studio_format": "FORMAT",
2936 + "lm_studio_vision": "Vision",
2937 + "lm_studio_tool_use": "Tool use",
2938 + "lm_studio_reasoning": "Reasoning",
2939 + "lm_studio_download_options": "Download options",
2940 + "lm_studio_download": "Download",
2941 + "lm_studio_download_size": "Download {size}",
2942 + "@lm_studio_download_size": {
2943 + "placeholders": {
2944 + "size": { "type": "String" }
2945 + }
2946 + },
2947 + "lm_studio_downloading_percent": "Downloading {percent}%",
2948 + "@lm_studio_downloading_percent": {
2949 + "placeholders": {
2950 + "percent": { "type": "int" }
2951 + }
2952 + },
2953 + "lm_studio_readme_unavailable": "README not available for this model.",
2954 + "lm_studio_full_gpu_offload": "Full GPU offload possible",
2955 + "lm_studio_partial_gpu_offload": "Partial GPU offload possible",
2956 + "lm_studio_likely_too_large": "Likely too large",
2957 + "lm_studio_available_ram_gb": "Available RAM (GB, optional)",
2958 + "lm_studio_available_vram_gb": "Available VRAM (GB, optional)",
2959 + "lm_studio_memory_settings_title": "Memory for recommendations",
2960 + "lm_studio_memory_settings_desc": "Used to estimate whether models fit on your machine in the model browser.",
2961 + "think_button_label": "Think",
2962 + "@think_button_label": {
2963 + "description": "Label for the reasoning-toggle button shown for reasoning-capable models"
2964 + },
2965 + "reasoning_effort_low": "Low",
2966 + "@reasoning_effort_low": {
2967 + "description": "Low reasoning effort level"
2968 + },
2969 + "reasoning_effort_medium": "Medium",
2970 + "@reasoning_effort_medium": {
2971 + "description": "Medium reasoning effort level"
2972 + },
2973 + "reasoning_effort_high": "High",
2974 + "@reasoning_effort_high": {
2975 + "description": "High reasoning effort level"
2976 + }
2977 }
lib/l10n/app_localizations.dart
+562 -4
@@ -593,6 +593,270 @@ abstract class AppLocalizations {
593 /// **'This cannot be undone.'**
594 String get delete_persona_body;
595
596 + /// Delete built-in persona dialog body
597 + ///
598 + /// In en, this message translates to:
599 + /// **'This is a built-in persona. You can restore it later from Settings.'**
600 + String get delete_builtin_persona_body;
601 +
602 + /// Settings action to re-add any deleted built-in personas
603 + ///
604 + /// In en, this message translates to:
605 + /// **'Restore default personas'**
606 + String get restore_builtin_personas;
607 +
608 + /// Settings description for restore built-in personas
609 + ///
610 + /// In en, this message translates to:
611 + /// **'Re-add any built-in personas you\'ve deleted'**
612 + String get restore_builtin_personas_desc;
613 +
614 + /// Snackbar after restoring built-in personas
615 + ///
616 + /// In en, this message translates to:
617 + /// **'Default personas restored'**
618 + String get restore_builtin_personas_success;
619 +
620 + /// Button to clear all selected personas in the picker
621 + ///
622 + /// In en, this message translates to:
623 + /// **'Clear personas'**
624 + String get clear_personas;
625 +
626 + /// Settings toggle to enable/disable automatic image compression
627 + ///
628 + /// In en, this message translates to:
629 + /// **'Compress images before sending'**
630 + String get enable_image_compression;
631 +
632 + /// Description for the image compression toggle
633 + ///
634 + /// In en, this message translates to:
635 + /// **'Resize and compress attached images so uploads stay within server limits'**
636 + String get enable_image_compression_desc;
637 +
638 + /// Label for the image compression level selector
639 + ///
640 + /// In en, this message translates to:
641 + /// **'Compression aggressiveness'**
642 + String get image_compression_level;
643 +
644 + /// Description for the image compression level selector
645 + ///
646 + /// In en, this message translates to:
647 + /// **'Higher aggressiveness produces smaller uploads at lower quality'**
648 + String get image_compression_level_desc;
649 +
650 + /// Low image compression aggressiveness
651 + ///
652 + /// In en, this message translates to:
653 + /// **'Low'**
654 + String get image_compression_level_low;
655 +
656 + /// Medium image compression aggressiveness
657 + ///
658 + /// In en, this message translates to:
659 + /// **'Medium'**
660 + String get image_compression_level_medium;
661 +
662 + /// High image compression aggressiveness
663 + ///
664 + /// In en, this message translates to:
665 + /// **'High'**
666 + String get image_compression_level_high;
667 +
668 + /// Tooltip for the model list sort button
669 + ///
670 + /// In en, this message translates to:
671 + /// **'Sort models'**
672 + String get sort_models_tooltip;
673 +
674 + /// Sort models with favorites first, then alphabetically
675 + ///
676 + /// In en, this message translates to:
677 + /// **'Favorites first'**
678 + String get sort_by_favorites;
679 +
680 + /// Sort models alphabetically by name
681 + ///
682 + /// In en, this message translates to:
683 + /// **'Name (A-Z)'**
684 + String get sort_by_name;
685 +
686 + /// Sort models by file size, smallest first
687 + ///
688 + /// In en, this message translates to:
689 + /// **'Size (smallest first)'**
690 + String get sort_by_size_smallest;
691 +
692 + /// Sort models by file size, largest first
693 + ///
694 + /// In en, this message translates to:
695 + /// **'Size (largest first)'**
696 + String get sort_by_size_largest;
697 +
698 + /// Sort models by max context length, largest first
699 + ///
700 + /// In en, this message translates to:
701 + /// **'Context length'**
702 + String get sort_by_context_length;
703 +
704 + /// Progress text while AI-renaming multiple conversations
705 + ///
706 + /// In en, this message translates to:
707 + /// **'Renaming {done}/{total}...'**
708 + String bulk_ai_rename_progress(int done, int total);
709 +
710 + /// Header shown in selection mode
711 + ///
712 + /// In en, this message translates to:
713 + /// **'{count} selected'**
714 + String selected_count(int count);
715 +
716 + /// Tooltip for bulk AI rename button
717 + ///
718 + /// In en, this message translates to:
719 + /// **'Rename selected with AI'**
720 + String get ai_rename_tooltip;
721 +
722 + /// Tooltip for the history screen new-chat FAB
723 + ///
724 + /// In en, this message translates to:
725 + /// **'New chat in this folder'**
726 + String get new_chat_in_folder_tooltip;
727 +
728 + /// Total token count shown under the chat text field
729 + ///
730 + /// In en, this message translates to:
731 + /// **'{count} tokens'**
732 + String total_tokens_count(int count);
733 +
734 + /// Settings toggle to include persona system prompt when generating smart reply suggestions
735 + ///
736 + /// In en, this message translates to:
737 + /// **'Use persona in smart replies'**
738 + String get smart_replies_use_persona;
739 +
740 + /// Description for the smart-replies-use-persona toggle
741 + ///
742 + /// In en, this message translates to:
743 + /// **'Suggested replies match the tone of the active persona instead of a generic assistant'**
744 + String get smart_replies_use_persona_desc;
745 +
746 + /// Settings toggle to not clear persona selection when starting a new chat
747 + ///
748 + /// In en, this message translates to:
749 + /// **'Keep persona on new chat'**
750 + String get keep_persona_on_new_chat;
751 +
752 + /// Description for the keep-persona-on-new-chat toggle
753 + ///
754 + /// In en, this message translates to:
755 + /// **'Don\'t clear the selected persona(s) after starting a new chat'**
756 + String get keep_persona_on_new_chat_desc;
757 +
758 + /// Settings toggle to show a button that lets the user send a message as the assistant role
759 + ///
760 + /// In en, this message translates to:
761 + /// **'Show role-swap button'**
762 + String get role_swap_button_enabled;
763 +
764 + /// Description for the role-swap-button-enabled toggle
765 + ///
766 + /// In en, this message translates to:
767 + /// **'Show a button in the chat input to send your message as the assistant instead of the user, without generating a response'**
768 + String get role_swap_button_enabled_desc;
769 +
770 + /// Tooltip for the role-swap button when it will send as the user role
771 + ///
772 + /// In en, this message translates to:
773 + /// **'Send as user'**
774 + String get send_as_user_tooltip;
775 +
776 + /// Tooltip for the role-swap button when it will send as the assistant role
777 + ///
778 + /// In en, this message translates to:
779 + /// **'Send as assistant (no response)'**
780 + String get send_as_assistant_tooltip;
781 +
782 + /// Tooltip shown for long-pressing the send button to insert a message without generating a response
783 + ///
784 + /// In en, this message translates to:
785 + /// **'Insert without generating'**
786 + String get insert_without_generating_tooltip;
787 +
788 + /// Title of the sheet shown when tapping the token usage indicator
789 + ///
790 + /// In en, this message translates to:
791 + /// **'Token Usage'**
792 + String get token_usage_title;
793 +
794 + /// Label for the total token count row in the token usage sheet
795 + ///
796 + /// In en, this message translates to:
797 + /// **'Tokens used'**
798 + String get total_tokens_label;
799 +
800 + /// Label for the percentage-of-context-length row in the token usage sheet
801 + ///
802 + /// In en, this message translates to:
803 + /// **'Context used'**
804 + String get usage_percent_label;
805 +
806 + /// Title for the copy-vs-share export choice dialog
807 + ///
808 + /// In en, this message translates to:
809 + /// **'Export'**
810 + String get export_choice_title;
811 +
812 + /// Body text for the copy-vs-share export choice dialog
813 + ///
814 + /// In en, this message translates to:
815 + /// **'How would you like to export this?'**
816 + String get export_choice_body;
817 +
818 + /// Button to copy export content to the clipboard
819 + ///
820 + /// In en, this message translates to:
821 + /// **'Copy to Clipboard'**
822 + String get copy_to_clipboard;
823 +
824 + /// Success message after bulk-exporting conversations
825 + ///
826 + /// In en, this message translates to:
827 + /// **'Exported {count} conversations'**
828 + String bulk_export_conversations_success(int count);
829 +
830 + /// Title for the bulk AI rename confirmation dialog
831 + ///
832 + /// In en, this message translates to:
833 + /// **'Rename with AI?'**
834 + String get bulk_ai_rename_confirm_title;
835 +
836 + /// Body text for the bulk AI rename confirmation dialog
837 + ///
838 + /// In en, this message translates to:
839 + /// **'This will ask the AI to generate a new title for each of the {count} selected conversations, replacing their current titles. This can take a while and cannot be undone.'**
840 + String bulk_ai_rename_confirm_body(int count);
841 +
842 + /// Sort history by last modified date
843 + ///
844 + /// In en, this message translates to:
845 + /// **'Last modified'**
846 + String get sort_by_modified_date;
847 +
848 + /// Sort history by creation date
849 + ///
850 + /// In en, this message translates to:
851 + /// **'Date created'**
852 + String get sort_by_created_date;
853 +
854 + /// Tooltip for the history sort button
855 + ///
856 + /// In en, this message translates to:
857 + /// **'Sort'**
858 + String get sort_title;
859 +
860 /// Clear conversation dialog title
861 ///
862 /// In en, this message translates to:
@@ -977,12 +1241,18 @@ abstract class AppLocalizations {
1241 /// **'On-Device Smart Replies'**
1242 String get enable_smart_reply;
1243
980 - /// Settings toggle for AI-generated user message button in chat input
1244 + /// Settings toggle for AI-generated user message triggered by holding the send button
1245 ///
1246 /// In en, this message translates to:
983 - /// **'AI user message button'**
1247 + /// **'AI user message (hold send)'**
1248 String get ai_user_response_enabled;
1249
1250 + /// Description for the hold-to-generate-AI-user-message toggle
1251 + ///
1252 + /// In en, this message translates to:
1253 + /// **'Hold the send button for 3 seconds to have the AI write and send your next message'**
1254 + String get ai_user_response_enabled_desc;
1255 +
1256 /// Tooltip for the AI user message button next to send
1257 ///
1258 /// In en, this message translates to:
@@ -1007,12 +1277,30 @@ abstract class AppLocalizations {
1277 /// **'Send on Enter'**
1278 String get send_on_enter;
1279
1010 - /// Toggle for system messages visibility
1280 + /// Toggle for sending a default 'You are LocalMind' system prompt to the model when no persona is selected
1281 ///
1282 /// In en, this message translates to:
1013 - /// **'Show System Messages'**
1283 + /// **'Send Default System Prompt'**
1284 String get show_system_messages;
1285
1286 + /// Description for the send-default-system-prompt toggle
1287 + ///
1288 + /// In en, this message translates to:
1289 + /// **'When no persona is selected, send a default assistant system prompt with each request'**
1290 + String get show_system_messages_desc;
1291 +
1292 + /// Toggle for whether system-role messages are displayed as bubbles in the chat transcript
1293 + ///
1294 + /// In en, this message translates to:
1295 + /// **'Show System Messages in Chat'**
1296 + String get show_system_messages_in_chat;
1297 +
1298 + /// Description for the show-system-messages-in-chat toggle
1299 + ///
1300 + /// In en, this message translates to:
1301 + /// **'Display system messages (e.g. from an imported backup) as visible bubbles in the conversation'**
1302 + String get show_system_messages_in_chat_desc;
1303 +
1304 /// Toggle for haptic feedback
1305 ///
1306 /// In en, this message translates to:
@@ -1121,6 +1409,18 @@ abstract class AppLocalizations {
1409 /// **'Select Persona'**
1410 String get select_persona;
1411
1412 + /// No description provided for @manage_personas.
1413 + ///
1414 + /// In en, this message translates to:
1415 + /// **'Manage personas'**
1416 + String get manage_personas;
1417 +
1418 + /// Hint in persona manager about combining personas
1419 + ///
1420 + /// In en, this message translates to:
1421 + /// **'Select multiple personas in chat to stack their system prompts.'**
1422 + String get personas_combine_hint;
1423 +
1424 /// Empty state heading
1425 ///
1426 /// In en, this message translates to:
@@ -3791,6 +4091,30 @@ abstract class AppLocalizations {
4091 /// **'Unload all models before loading a new one'**
4092 String get unload_models_before_load;
4093
4094 + /// Use private/incognito keyboard mode while in a temporary chat
4095 + ///
4096 + /// In en, this message translates to:
4097 + /// **'Incognito keyboard in temporary chat'**
4098 + String get temp_chat_keyboard_incognito;
4099 +
4100 + /// Description for temporary chat incognito keyboard setting
4101 + ///
4102 + /// In en, this message translates to:
4103 + /// **'Disables keyboard learning and suggestions in temporary chats (e.g. SwiftKey incognito).'**
4104 + String get temp_chat_keyboard_incognito_desc;
4105 +
4106 + /// Reopen the most recently active conversation when the app starts
4107 + ///
4108 + /// In en, this message translates to:
4109 + /// **'Resume last chat on launch'**
4110 + String get resume_last_chat;
4111 +
4112 + /// Description for resume last chat setting
4113 + ///
4114 + /// In en, this message translates to:
4115 + /// **'Restore your last open conversation when reopening the app.'**
4116 + String get resume_last_chat_desc;
4117 +
4118 /// Export conversations and personas to a JSON file
4119 ///
4120 /// In en, this message translates to:
@@ -4432,6 +4756,240 @@ abstract class AppLocalizations {
4756 /// In en, this message translates to:
4757 /// **'Could not generate a title'**
4758 String get generate_title_failed;
4759 +
4760 + /// No description provided for @lm_studio_model_browser_title.
4761 + ///
4762 + /// In en, this message translates to:
4763 + /// **'Browse models'**
4764 + String get lm_studio_model_browser_title;
4765 +
4766 + /// No description provided for @lm_studio_model_search_hint.
4767 + ///
4768 + /// In en, this message translates to:
4769 + /// **'Search models by name or author…'**
4770 + String get lm_studio_model_search_hint;
4771 +
4772 + /// No description provided for @lm_studio_staff_picks.
4773 + ///
4774 + /// In en, this message translates to:
4775 + /// **'Staff picks'**
4776 + String get lm_studio_staff_picks;
4777 +
4778 + /// No description provided for @lm_studio_community_models.
4779 + ///
4780 + /// In en, this message translates to:
4781 + /// **'Community models'**
4782 + String get lm_studio_community_models;
4783 +
4784 + /// No description provided for @lm_studio_no_models.
4785 + ///
4786 + /// In en, this message translates to:
4787 + /// **'No models found'**
4788 + String get lm_studio_no_models;
4789 +
4790 + /// No description provided for @lm_studio_models_count.
4791 + ///
4792 + /// In en, this message translates to:
4793 + /// **'{count} models'**
4794 + String lm_studio_models_count(int count);
4795 +
4796 + /// No description provided for @lm_studio_browse_models.
4797 + ///
4798 + /// In en, this message translates to:
4799 + /// **'Browse & download'**
4800 + String get lm_studio_browse_models;
4801 +
4802 + /// No description provided for @lm_studio_model_search.
4803 + ///
4804 + /// In en, this message translates to:
4805 + /// **'LMS Model Search'**
4806 + String get lm_studio_model_search;
4807 +
4808 + /// No description provided for @lm_studio_downloads_title.
4809 + ///
4810 + /// In en, this message translates to:
4811 + /// **'Downloads'**
4812 + String get lm_studio_downloads_title;
4813 +
4814 + /// No description provided for @lm_studio_choose_quant.
4815 + ///
4816 + /// In en, this message translates to:
4817 + /// **'Choose a download option'**
4818 + String get lm_studio_choose_quant;
4819 +
4820 + /// No description provided for @lm_studio_use_default_quant.
4821 + ///
4822 + /// In en, this message translates to:
4823 + /// **'Use default'**
4824 + String get lm_studio_use_default_quant;
4825 +
4826 + /// No description provided for @lm_studio_recommended.
4827 + ///
4828 + /// In en, this message translates to:
4829 + /// **'Recommended'**
4830 + String get lm_studio_recommended;
4831 +
4832 + /// No description provided for @lm_studio_clear_downloads.
4833 + ///
4834 + /// In en, this message translates to:
4835 + /// **'Clear finished'**
4836 + String get lm_studio_clear_downloads;
4837 +
4838 + /// No description provided for @lm_studio_no_downloads.
4839 + ///
4840 + /// In en, this message translates to:
4841 + /// **'No downloads yet'**
4842 + String get lm_studio_no_downloads;
4843 +
4844 + /// No description provided for @lm_studio_downloads_disclaimer.
4845 + ///
4846 + /// In en, this message translates to:
4847 + /// **'Downloads run on the LM Studio host. Pausing, stopping, and deleting models must be done on that computer — not from this app.'**
4848 + String get lm_studio_downloads_disclaimer;
4849 +
4850 + /// No description provided for @lm_studio_staff_pick.
4851 + ///
4852 + /// In en, this message translates to:
4853 + /// **'Staff pick'**
4854 + String get lm_studio_staff_pick;
4855 +
4856 + /// No description provided for @lm_studio_params.
4857 + ///
4858 + /// In en, this message translates to:
4859 + /// **'PARAMS'**
4860 + String get lm_studio_params;
4861 +
4862 + /// No description provided for @lm_studio_arch.
4863 + ///
4864 + /// In en, this message translates to:
4865 + /// **'ARCH'**
4866 + String get lm_studio_arch;
4867 +
4868 + /// No description provided for @lm_studio_domain.
4869 + ///
4870 + /// In en, this message translates to:
4871 + /// **'DOMAIN'**
4872 + String get lm_studio_domain;
4873 +
4874 + /// No description provided for @lm_studio_format.
4875 + ///
4876 + /// In en, this message translates to:
4877 + /// **'FORMAT'**
4878 + String get lm_studio_format;
4879 +
4880 + /// No description provided for @lm_studio_vision.
4881 + ///
4882 + /// In en, this message translates to:
4883 + /// **'Vision'**
4884 + String get lm_studio_vision;
4885 +
4886 + /// No description provided for @lm_studio_tool_use.
4887 + ///
4888 + /// In en, this message translates to:
4889 + /// **'Tool use'**
4890 + String get lm_studio_tool_use;
4891 +
4892 + /// No description provided for @lm_studio_reasoning.
4893 + ///
4894 + /// In en, this message translates to:
4895 + /// **'Reasoning'**
4896 + String get lm_studio_reasoning;
4897 +
4898 + /// No description provided for @lm_studio_download_options.
4899 + ///
4900 + /// In en, this message translates to:
4901 + /// **'Download options'**
4902 + String get lm_studio_download_options;
4903 +
4904 + /// No description provided for @lm_studio_download.
4905 + ///
4906 + /// In en, this message translates to:
4907 + /// **'Download'**
4908 + String get lm_studio_download;
4909 +
4910 + /// No description provided for @lm_studio_download_size.
4911 + ///
4912 + /// In en, this message translates to:
4913 + /// **'Download {size}'**
4914 + String lm_studio_download_size(String size);
4915 +
4916 + /// No description provided for @lm_studio_downloading_percent.
4917 + ///
4918 + /// In en, this message translates to:
4919 + /// **'Downloading {percent}%'**
4920 + String lm_studio_downloading_percent(int percent);
4921 +
4922 + /// No description provided for @lm_studio_readme_unavailable.
4923 + ///
4924 + /// In en, this message translates to:
4925 + /// **'README not available for this model.'**
4926 + String get lm_studio_readme_unavailable;
4927 +
4928 + /// No description provided for @lm_studio_full_gpu_offload.
4929 + ///
4930 + /// In en, this message translates to:
4931 + /// **'Full GPU offload possible'**
4932 + String get lm_studio_full_gpu_offload;
4933 +
4934 + /// No description provided for @lm_studio_partial_gpu_offload.
4935 + ///
4936 + /// In en, this message translates to:
4937 + /// **'Partial GPU offload possible'**
4938 + String get lm_studio_partial_gpu_offload;
4939 +
4940 + /// No description provided for @lm_studio_likely_too_large.
4941 + ///
4942 + /// In en, this message translates to:
4943 + /// **'Likely too large'**
4944 + String get lm_studio_likely_too_large;
4945 +
4946 + /// No description provided for @lm_studio_available_ram_gb.
4947 + ///
4948 + /// In en, this message translates to:
4949 + /// **'Available RAM (GB, optional)'**
4950 + String get lm_studio_available_ram_gb;
4951 +
4952 + /// No description provided for @lm_studio_available_vram_gb.
4953 + ///
4954 + /// In en, this message translates to:
4955 + /// **'Available VRAM (GB, optional)'**
4956 + String get lm_studio_available_vram_gb;
4957 +
4958 + /// No description provided for @lm_studio_memory_settings_title.
4959 + ///
4960 + /// In en, this message translates to:
4961 + /// **'Memory for recommendations'**
4962 + String get lm_studio_memory_settings_title;
4963 +
4964 + /// No description provided for @lm_studio_memory_settings_desc.
4965 + ///
4966 + /// In en, this message translates to:
4967 + /// **'Used to estimate whether models fit on your machine in the model browser.'**
4968 + String get lm_studio_memory_settings_desc;
4969 +
4970 + /// Label for the reasoning-toggle button shown for reasoning-capable models
4971 + ///
4972 + /// In en, this message translates to:
4973 + /// **'Think'**
4974 + String get think_button_label;
4975 +
4976 + /// Low reasoning effort level
4977 + ///
4978 + /// In en, this message translates to:
4979 + /// **'Low'**
4980 + String get reasoning_effort_low;
4981 +
4982 + /// Medium reasoning effort level
4983 + ///
4984 + /// In en, this message translates to:
4985 + /// **'Medium'**
4986 + String get reasoning_effort_medium;
4987 +
4988 + /// High reasoning effort level
4989 + ///
4990 + /// In en, this message translates to:
4991 + /// **'High'**
4992 + String get reasoning_effort_high;
4993 }
4994
4995 class _AppLocalizationsDelegate
lib/l10n/app_localizations_ar.dart
+313 -1
@@ -260,6 +260,155 @@ class AppLocalizationsAr extends AppLocalizations {
260 @override
261 String get delete_persona_body => 'لا يمكن التراجع عن هذا.';
262
263 + @override
264 + String get delete_builtin_persona_body =>
265 + 'This is a built-in persona. You can restore it later from Settings.';
266 +
267 + @override
268 + String get restore_builtin_personas => 'Restore default personas';
269 +
270 + @override
271 + String get restore_builtin_personas_desc =>
272 + 'Re-add any built-in personas you\'ve deleted';
273 +
274 + @override
275 + String get restore_builtin_personas_success => 'Default personas restored';
276 +
277 + @override
278 + String get clear_personas => 'Clear personas';
279 +
280 + @override
281 + String get enable_image_compression => 'Compress images before sending';
282 +
283 + @override
284 + String get enable_image_compression_desc =>
285 + 'Resize and compress attached images so uploads stay within server limits';
286 +
287 + @override
288 + String get image_compression_level => 'Compression aggressiveness';
289 +
290 + @override
291 + String get image_compression_level_desc =>
292 + 'Higher aggressiveness produces smaller uploads at lower quality';
293 +
294 + @override
295 + String get image_compression_level_low => 'Low';
296 +
297 + @override
298 + String get image_compression_level_medium => 'Medium';
299 +
300 + @override
301 + String get image_compression_level_high => 'High';
302 +
303 + @override
304 + String get sort_models_tooltip => 'Sort models';
305 +
306 + @override
307 + String get sort_by_favorites => 'Favorites first';
308 +
309 + @override
310 + String get sort_by_name => 'Name (A-Z)';
311 +
312 + @override
313 + String get sort_by_size_smallest => 'Size (smallest first)';
314 +
315 + @override
316 + String get sort_by_size_largest => 'Size (largest first)';
317 +
318 + @override
319 + String get sort_by_context_length => 'Context length';
320 +
321 + @override
322 + String bulk_ai_rename_progress(int done, int total) {
323 + return 'Renaming $done/$total...';
324 + }
325 +
326 + @override
327 + String selected_count(int count) {
328 + return '$count selected';
329 + }
330 +
331 + @override
332 + String get ai_rename_tooltip => 'Rename selected with AI';
333 +
334 + @override
335 + String get new_chat_in_folder_tooltip => 'New chat in this folder';
336 +
337 + @override
338 + String total_tokens_count(int count) {
339 + return '$count tokens';
340 + }
341 +
342 + @override
343 + String get smart_replies_use_persona => 'Use persona in smart replies';
344 +
345 + @override
346 + String get smart_replies_use_persona_desc =>
347 + 'Suggested replies match the tone of the active persona instead of a generic assistant';
348 +
349 + @override
350 + String get keep_persona_on_new_chat => 'Keep persona on new chat';
351 +
352 + @override
353 + String get keep_persona_on_new_chat_desc =>
354 + 'Don\'t clear the selected persona(s) after starting a new chat';
355 +
356 + @override
357 + String get role_swap_button_enabled => 'Show role-swap button';
358 +
359 + @override
360 + String get role_swap_button_enabled_desc =>
361 + 'Show a button in the chat input to send your message as the assistant instead of the user, without generating a response';
362 +
363 + @override
364 + String get send_as_user_tooltip => 'Send as user';
365 +
366 + @override
367 + String get send_as_assistant_tooltip => 'Send as assistant (no response)';
368 +
369 + @override
370 + String get insert_without_generating_tooltip => 'Insert without generating';
371 +
372 + @override
373 + String get token_usage_title => 'Token Usage';
374 +
375 + @override
376 + String get total_tokens_label => 'Tokens used';
377 +
378 + @override
379 + String get usage_percent_label => 'Context used';
380 +
381 + @override
382 + String get export_choice_title => 'Export';
383 +
384 + @override
385 + String get export_choice_body => 'How would you like to export this?';
386 +
387 + @override
388 + String get copy_to_clipboard => 'Copy to Clipboard';
389 +
390 + @override
391 + String bulk_export_conversations_success(int count) {
392 + return 'Exported $count conversations';
393 + }
394 +
395 + @override
396 + String get bulk_ai_rename_confirm_title => 'Rename with AI?';
397 +
398 + @override
399 + String bulk_ai_rename_confirm_body(int count) {
400 + return 'This will ask the AI to generate a new title for each of the $count selected conversations, replacing their current titles. This can take a while and cannot be undone.';
401 + }
402 +
403 + @override
404 + String get sort_by_modified_date => 'Last modified';
405 +
406 + @override
407 + String get sort_by_created_date => 'Date created';
408 +
409 + @override
410 + String get sort_title => 'Sort';
411 +
412 @override
413 String get clear_conversation_title => 'مسح المحادثة؟';
414
@@ -468,7 +617,11 @@ class AppLocalizationsAr extends AppLocalizations {
617 String get enable_smart_reply => 'الردود الذكية المحلية';
618
619 @override
471 - String get ai_user_response_enabled => 'AI user message button';
620 + String get ai_user_response_enabled => 'AI user message (hold send)';
621 +
622 + @override
623 + String get ai_user_response_enabled_desc =>
624 + 'Hold the send button for 3 seconds to have the AI write and send your next message';
625
626 @override
627 String get ai_user_response_tooltip => 'Generate user message with AI';
@@ -485,6 +638,17 @@ class AppLocalizationsAr extends AppLocalizations {
638 @override
639 String get show_system_messages => 'إظهار رسائل النظام';
640
641 + @override
642 + String get show_system_messages_desc =>
643 + 'When no persona is selected, send a default assistant system prompt with each request';
644 +
645 + @override
646 + String get show_system_messages_in_chat => 'Show System Messages in Chat';
647 +
648 + @override
649 + String get show_system_messages_in_chat_desc =>
650 + 'Display system messages (e.g. from an imported backup) as visible bubbles in the conversation';
651 +
652 @override
653 String get haptic_feedback => 'الاستجابة اللمسية';
654
@@ -539,6 +703,13 @@ class AppLocalizationsAr extends AppLocalizations {
703 @override
704 String get select_persona => 'اختيار شخصية';
705
706 + @override
707 + String get manage_personas => 'Manage personas';
708 +
709 + @override
710 + String get personas_combine_hint =>
711 + 'Select multiple personas in chat to stack their system prompts.';
712 +
713 @override
714 String get start_conversation => 'ابدأ محادثة';
715
@@ -1993,6 +2164,21 @@ class AppLocalizationsAr extends AppLocalizations {
2164 String get unload_models_before_load =>
2165 'Unload all models before loading a new one';
2166
2167 + @override
2168 + String get temp_chat_keyboard_incognito =>
2169 + 'Incognito keyboard in temporary chat';
2170 +
2171 + @override
2172 + String get temp_chat_keyboard_incognito_desc =>
2173 + 'Disables keyboard learning and suggestions in temporary chats (e.g. SwiftKey incognito).';
2174 +
2175 + @override
2176 + String get resume_last_chat => 'Resume last chat on launch';
2177 +
2178 + @override
2179 + String get resume_last_chat_desc =>
2180 + 'Restore your last open conversation when reopening the app.';
2181 +
2182 @override
2183 String get export_all_data => 'Export all data';
2184
@@ -2343,4 +2529,130 @@ class AppLocalizationsAr extends AppLocalizations {
2529
2530 @override
2531 String get generate_title_failed => 'Could not generate a title';
2532 +
2533 + @override
2534 + String get lm_studio_model_browser_title => 'Browse models';
2535 +
2536 + @override
2537 + String get lm_studio_model_search_hint => 'Search models by name or author…';
2538 +
2539 + @override
2540 + String get lm_studio_staff_picks => 'Staff picks';
2541 +
2542 + @override
2543 + String get lm_studio_community_models => 'Community models';
2544 +
2545 + @override
2546 + String get lm_studio_no_models => 'No models found';
2547 +
2548 + @override
2549 + String lm_studio_models_count(int count) {
2550 + return '$count models';
2551 + }
2552 +
2553 + @override
2554 + String get lm_studio_browse_models => 'Browse & download';
2555 +
2556 + @override
2557 + String get lm_studio_model_search => 'LMS Model Search';
2558 +
2559 + @override
2560 + String get lm_studio_downloads_title => 'Downloads';
2561 +
2562 + @override
2563 + String get lm_studio_choose_quant => 'Choose a download option';
2564 +
2565 + @override
2566 + String get lm_studio_use_default_quant => 'Use default';
2567 +
2568 + @override
2569 + String get lm_studio_recommended => 'Recommended';
2570 +
2571 + @override
2572 + String get lm_studio_clear_downloads => 'Clear finished';
2573 +
2574 + @override
2575 + String get lm_studio_no_downloads => 'No downloads yet';
2576 +
2577 + @override
2578 + String get lm_studio_downloads_disclaimer =>
2579 + 'Downloads run on the LM Studio host. Pausing, stopping, and deleting models must be done on that computer — not from this app.';
2580 +
2581 + @override
2582 + String get lm_studio_staff_pick => 'Staff pick';
2583 +
2584 + @override
2585 + String get lm_studio_params => 'PARAMS';
2586 +
2587 + @override
2588 + String get lm_studio_arch => 'ARCH';
2589 +
2590 + @override
2591 + String get lm_studio_domain => 'DOMAIN';
2592 +
2593 + @override
2594 + String get lm_studio_format => 'FORMAT';
2595 +
2596 + @override
2597 + String get lm_studio_vision => 'Vision';
2598 +
2599 + @override
2600 + String get lm_studio_tool_use => 'Tool use';
2601 +
2602 + @override
2603 + String get lm_studio_reasoning => 'Reasoning';
2604 +
2605 + @override
2606 + String get lm_studio_download_options => 'Download options';
2607 +
2608 + @override
2609 + String get lm_studio_download => 'Download';
2610 +
2611 + @override
2612 + String lm_studio_download_size(String size) {
2613 + return 'Download $size';
2614 + }
2615 +
2616 + @override
2617 + String lm_studio_downloading_percent(int percent) {
2618 + return 'Downloading $percent%';
2619 + }
2620 +
2621 + @override
2622 + String get lm_studio_readme_unavailable =>
2623 + 'README not available for this model.';
2624 +
2625 + @override
2626 + String get lm_studio_full_gpu_offload => 'Full GPU offload possible';
2627 +
2628 + @override
2629 + String get lm_studio_partial_gpu_offload => 'Partial GPU offload possible';
2630 +
2631 + @override
2632 + String get lm_studio_likely_too_large => 'Likely too large';
2633 +
2634 + @override
2635 + String get lm_studio_available_ram_gb => 'Available RAM (GB, optional)';
2636 +
2637 + @override
2638 + String get lm_studio_available_vram_gb => 'Available VRAM (GB, optional)';
2639 +
2640 + @override
2641 + String get lm_studio_memory_settings_title => 'Memory for recommendations';
2642 +
2643 + @override
2644 + String get lm_studio_memory_settings_desc =>
2645 + 'Used to estimate whether models fit on your machine in the model browser.';
2646 +
2647 + @override
2648 + String get think_button_label => 'Think';
2649 +
2650 + @override
2651 + String get reasoning_effort_low => 'Low';
2652 +
2653 + @override
2654 + String get reasoning_effort_medium => 'Medium';
2655 +
2656 + @override
2657 + String get reasoning_effort_high => 'High';
2658 }
lib/l10n/app_localizations_bn.dart
+313 -1
@@ -261,6 +261,155 @@ class AppLocalizationsBn extends AppLocalizations {
261 @override
262 String get delete_persona_body => 'এটি আর পূর্বাবস্থায় ফিরিয়ে আনা যাবে না।';
263
264 + @override
265 + String get delete_builtin_persona_body =>
266 + 'This is a built-in persona. You can restore it later from Settings.';
267 +
268 + @override
269 + String get restore_builtin_personas => 'Restore default personas';
270 +
271 + @override
272 + String get restore_builtin_personas_desc =>
273 + 'Re-add any built-in personas you\'ve deleted';
274 +
275 + @override
276 + String get restore_builtin_personas_success => 'Default personas restored';
277 +
278 + @override
279 + String get clear_personas => 'Clear personas';
280 +
281 + @override
282 + String get enable_image_compression => 'Compress images before sending';
283 +
284 + @override
285 + String get enable_image_compression_desc =>
286 + 'Resize and compress attached images so uploads stay within server limits';
287 +
288 + @override
289 + String get image_compression_level => 'Compression aggressiveness';
290 +
291 + @override
292 + String get image_compression_level_desc =>
293 + 'Higher aggressiveness produces smaller uploads at lower quality';
294 +
295 + @override
296 + String get image_compression_level_low => 'Low';
297 +
298 + @override
299 + String get image_compression_level_medium => 'Medium';
300 +
301 + @override
302 + String get image_compression_level_high => 'High';
303 +
304 + @override
305 + String get sort_models_tooltip => 'Sort models';
306 +
307 + @override
308 + String get sort_by_favorites => 'Favorites first';
309 +
310 + @override
311 + String get sort_by_name => 'Name (A-Z)';
312 +
313 + @override
314 + String get sort_by_size_smallest => 'Size (smallest first)';
315 +
316 + @override
317 + String get sort_by_size_largest => 'Size (largest first)';
318 +
319 + @override
320 + String get sort_by_context_length => 'Context length';
321 +
322 + @override
323 + String bulk_ai_rename_progress(int done, int total) {
324 + return 'Renaming $done/$total...';
325 + }
326 +
327 + @override
328 + String selected_count(int count) {
329 + return '$count selected';
330 + }
331 +
332 + @override
333 + String get ai_rename_tooltip => 'Rename selected with AI';
334 +
335 + @override
336 + String get new_chat_in_folder_tooltip => 'New chat in this folder';
337 +
338 + @override
339 + String total_tokens_count(int count) {
340 + return '$count tokens';
341 + }
342 +
343 + @override
344 + String get smart_replies_use_persona => 'Use persona in smart replies';
345 +
346 + @override
347 + String get smart_replies_use_persona_desc =>
348 + 'Suggested replies match the tone of the active persona instead of a generic assistant';
349 +
350 + @override
351 + String get keep_persona_on_new_chat => 'Keep persona on new chat';
352 +
353 + @override
354 + String get keep_persona_on_new_chat_desc =>
355 + 'Don\'t clear the selected persona(s) after starting a new chat';
356 +
357 + @override
358 + String get role_swap_button_enabled => 'Show role-swap button';
359 +
360 + @override
361 + String get role_swap_button_enabled_desc =>
362 + 'Show a button in the chat input to send your message as the assistant instead of the user, without generating a response';
363 +
364 + @override
365 + String get send_as_user_tooltip => 'Send as user';
366 +
367 + @override
368 + String get send_as_assistant_tooltip => 'Send as assistant (no response)';
369 +
370 + @override
371 + String get insert_without_generating_tooltip => 'Insert without generating';
372 +
373 + @override
374 + String get token_usage_title => 'Token Usage';
375 +
376 + @override
377 + String get total_tokens_label => 'Tokens used';
378 +
379 + @override
380 + String get usage_percent_label => 'Context used';
381 +
382 + @override
383 + String get export_choice_title => 'Export';
384 +
385 + @override
386 + String get export_choice_body => 'How would you like to export this?';
387 +
388 + @override
389 + String get copy_to_clipboard => 'Copy to Clipboard';
390 +
391 + @override
392 + String bulk_export_conversations_success(int count) {
393 + return 'Exported $count conversations';
394 + }
395 +
396 + @override
397 + String get bulk_ai_rename_confirm_title => 'Rename with AI?';
398 +
399 + @override
400 + String bulk_ai_rename_confirm_body(int count) {
401 + return 'This will ask the AI to generate a new title for each of the $count selected conversations, replacing their current titles. This can take a while and cannot be undone.';
402 + }
403 +
404 + @override
405 + String get sort_by_modified_date => 'Last modified';
406 +
407 + @override
408 + String get sort_by_created_date => 'Date created';
409 +
410 + @override
411 + String get sort_title => 'Sort';
412 +
413 @override
414 String get clear_conversation_title => 'কথোপকথন মুছে ফেলবেন?';
415
@@ -471,7 +620,11 @@ class AppLocalizationsBn extends AppLocalizations {
620 String get enable_smart_reply => 'অন-ডিভাইস স্মার্ট রিপ্লাই';
621
622 @override
474 - String get ai_user_response_enabled => 'AI user message button';
623 + String get ai_user_response_enabled => 'AI user message (hold send)';
624 +
625 + @override
626 + String get ai_user_response_enabled_desc =>
627 + 'Hold the send button for 3 seconds to have the AI write and send your next message';
628
629 @override
630 String get ai_user_response_tooltip => 'Generate user message with AI';
@@ -488,6 +641,17 @@ class AppLocalizationsBn extends AppLocalizations {
641 @override
642 String get show_system_messages => 'সিস্টেম বার্তাসমূহ দেখান';
643
644 + @override
645 + String get show_system_messages_desc =>
646 + 'When no persona is selected, send a default assistant system prompt with each request';
647 +
648 + @override
649 + String get show_system_messages_in_chat => 'Show System Messages in Chat';
650 +
651 + @override
652 + String get show_system_messages_in_chat_desc =>
653 + 'Display system messages (e.g. from an imported backup) as visible bubbles in the conversation';
654 +
655 @override
656 String get haptic_feedback => 'হ্যাপটিক ফিডব্যাক';
657
@@ -542,6 +706,13 @@ class AppLocalizationsBn extends AppLocalizations {
706 @override
707 String get select_persona => 'পার্সোনা নির্বাচন করুন';
708
709 + @override
710 + String get manage_personas => 'Manage personas';
711 +
712 + @override
713 + String get personas_combine_hint =>
714 + 'Select multiple personas in chat to stack their system prompts.';
715 +
716 @override
717 String get start_conversation => 'কথোপকথন শুরু করুন';
718
@@ -2006,6 +2177,21 @@ class AppLocalizationsBn extends AppLocalizations {
2177 String get unload_models_before_load =>
2178 'Unload all models before loading a new one';
2179
2180 + @override
2181 + String get temp_chat_keyboard_incognito =>
2182 + 'Incognito keyboard in temporary chat';
2183 +
2184 + @override
2185 + String get temp_chat_keyboard_incognito_desc =>
2186 + 'Disables keyboard learning and suggestions in temporary chats (e.g. SwiftKey incognito).';
2187 +
2188 + @override
2189 + String get resume_last_chat => 'Resume last chat on launch';
2190 +
2191 + @override
2192 + String get resume_last_chat_desc =>
2193 + 'Restore your last open conversation when reopening the app.';
2194 +
2195 @override
2196 String get export_all_data => 'Export all data';
2197
@@ -2356,4 +2542,130 @@ class AppLocalizationsBn extends AppLocalizations {
2542
2543 @override
2544 String get generate_title_failed => 'Could not generate a title';
2545 +
2546 + @override
2547 + String get lm_studio_model_browser_title => 'Browse models';
2548 +
2549 + @override
2550 + String get lm_studio_model_search_hint => 'Search models by name or author…';
2551 +
2552 + @override
2553 + String get lm_studio_staff_picks => 'Staff picks';
2554 +
2555 + @override
2556 + String get lm_studio_community_models => 'Community models';
2557 +
2558 + @override
2559 + String get lm_studio_no_models => 'No models found';
2560 +
2561 + @override
2562 + String lm_studio_models_count(int count) {
2563 + return '$count models';
2564 + }
2565 +
2566 + @override
2567 + String get lm_studio_browse_models => 'Browse & download';
2568 +
2569 + @override
2570 + String get lm_studio_model_search => 'LMS Model Search';
2571 +
2572 + @override
2573 + String get lm_studio_downloads_title => 'Downloads';
2574 +
2575 + @override
2576 + String get lm_studio_choose_quant => 'Choose a download option';
2577 +
2578 + @override
2579 + String get lm_studio_use_default_quant => 'Use default';
2580 +
2581 + @override
2582 + String get lm_studio_recommended => 'Recommended';
2583 +
2584 + @override
2585 + String get lm_studio_clear_downloads => 'Clear finished';
2586 +
2587 + @override
2588 + String get lm_studio_no_downloads => 'No downloads yet';
2589 +
2590 + @override
2591 + String get lm_studio_downloads_disclaimer =>
2592 + 'Downloads run on the LM Studio host. Pausing, stopping, and deleting models must be done on that computer — not from this app.';
2593 +
2594 + @override
2595 + String get lm_studio_staff_pick => 'Staff pick';
2596 +
2597 + @override
2598 + String get lm_studio_params => 'PARAMS';
2599 +
2600 + @override
2601 + String get lm_studio_arch => 'ARCH';
2602 +
2603 + @override
2604 + String get lm_studio_domain => 'DOMAIN';
2605 +
2606 + @override
2607 + String get lm_studio_format => 'FORMAT';
2608 +
2609 + @override
2610 + String get lm_studio_vision => 'Vision';
2611 +
2612 + @override
2613 + String get lm_studio_tool_use => 'Tool use';
2614 +
2615 + @override
2616 + String get lm_studio_reasoning => 'Reasoning';
2617 +
2618 + @override
2619 + String get lm_studio_download_options => 'Download options';
2620 +
2621 + @override
2622 + String get lm_studio_download => 'Download';
2623 +
2624 + @override
2625 + String lm_studio_download_size(String size) {
2626 + return 'Download $size';
2627 + }
2628 +
2629 + @override
2630 + String lm_studio_downloading_percent(int percent) {
2631 + return 'Downloading $percent%';
2632 + }
2633 +
2634 + @override
2635 + String get lm_studio_readme_unavailable =>
2636 + 'README not available for this model.';
2637 +
2638 + @override
2639 + String get lm_studio_full_gpu_offload => 'Full GPU offload possible';
2640 +
2641 + @override
2642 + String get lm_studio_partial_gpu_offload => 'Partial GPU offload possible';
2643 +
2644 + @override
2645 + String get lm_studio_likely_too_large => 'Likely too large';
2646 +
2647 + @override
2648 + String get lm_studio_available_ram_gb => 'Available RAM (GB, optional)';
2649 +
2650 + @override
2651 + String get lm_studio_available_vram_gb => 'Available VRAM (GB, optional)';
2652 +
2653 + @override
2654 + String get lm_studio_memory_settings_title => 'Memory for recommendations';
2655 +
2656 + @override
2657 + String get lm_studio_memory_settings_desc =>
2658 + 'Used to estimate whether models fit on your machine in the model browser.';
2659 +
2660 + @override
2661 + String get think_button_label => 'Think';
2662 +
2663 + @override
2664 + String get reasoning_effort_low => 'Low';
2665 +
2666 + @override
2667 + String get reasoning_effort_medium => 'Medium';
2668 +
2669 + @override
2670 + String get reasoning_effort_high => 'High';
2671 }
lib/l10n/app_localizations_en.dart
+314 -2
@@ -260,6 +260,155 @@ class AppLocalizationsEn extends AppLocalizations {
260 @override
261 String get delete_persona_body => 'This cannot be undone.';
262
263 + @override
264 + String get delete_builtin_persona_body =>
265 + 'This is a built-in persona. You can restore it later from Settings.';
266 +
267 + @override
268 + String get restore_builtin_personas => 'Restore default personas';
269 +
270 + @override
271 + String get restore_builtin_personas_desc =>
272 + 'Re-add any built-in personas you\'ve deleted';
273 +
274 + @override
275 + String get restore_builtin_personas_success => 'Default personas restored';
276 +
277 + @override
278 + String get clear_personas => 'Clear personas';
279 +
280 + @override
281 + String get enable_image_compression => 'Compress images before sending';
282 +
283 + @override
284 + String get enable_image_compression_desc =>
285 + 'Resize and compress attached images so uploads stay within server limits';
286 +
287 + @override
288 + String get image_compression_level => 'Compression aggressiveness';
289 +
290 + @override
291 + String get image_compression_level_desc =>
292 + 'Higher aggressiveness produces smaller uploads at lower quality';
293 +
294 + @override
295 + String get image_compression_level_low => 'Low';
296 +
297 + @override
298 + String get image_compression_level_medium => 'Medium';
299 +
300 + @override
301 + String get image_compression_level_high => 'High';
302 +
303 + @override
304 + String get sort_models_tooltip => 'Sort models';
305 +
306 + @override
307 + String get sort_by_favorites => 'Favorites first';
308 +
309 + @override
310 + String get sort_by_name => 'Name (A-Z)';
311 +
312 + @override
313 + String get sort_by_size_smallest => 'Size (smallest first)';
314 +
315 + @override
316 + String get sort_by_size_largest => 'Size (largest first)';
317 +
318 + @override
319 + String get sort_by_context_length => 'Context length';
320 +
321 + @override
322 + String bulk_ai_rename_progress(int done, int total) {
323 + return 'Renaming $done/$total...';
324 + }
325 +
326 + @override
327 + String selected_count(int count) {
328 + return '$count selected';
329 + }
330 +
331 + @override
332 + String get ai_rename_tooltip => 'Rename selected with AI';
333 +
334 + @override
335 + String get new_chat_in_folder_tooltip => 'New chat in this folder';
336 +
337 + @override
338 + String total_tokens_count(int count) {
339 + return '$count tokens';
340 + }
341 +
342 + @override
343 + String get smart_replies_use_persona => 'Use persona in smart replies';
344 +
345 + @override
346 + String get smart_replies_use_persona_desc =>
347 + 'Suggested replies match the tone of the active persona instead of a generic assistant';
348 +
349 + @override
350 + String get keep_persona_on_new_chat => 'Keep persona on new chat';
351 +
352 + @override
353 + String get keep_persona_on_new_chat_desc =>
354 + 'Don\'t clear the selected persona(s) after starting a new chat';
355 +
356 + @override
357 + String get role_swap_button_enabled => 'Show role-swap button';
358 +
359 + @override
360 + String get role_swap_button_enabled_desc =>
361 + 'Show a button in the chat input to send your message as the assistant instead of the user, without generating a response';
362 +
363 + @override
364 + String get send_as_user_tooltip => 'Send as user';
365 +
366 + @override
367 + String get send_as_assistant_tooltip => 'Send as assistant (no response)';
368 +
369 + @override
370 + String get insert_without_generating_tooltip => 'Insert without generating';
371 +
372 + @override
373 + String get token_usage_title => 'Token Usage';
374 +
375 + @override
376 + String get total_tokens_label => 'Tokens used';
377 +
378 + @override
379 + String get usage_percent_label => 'Context used';
380 +
381 + @override
382 + String get export_choice_title => 'Export';
383 +
384 + @override
385 + String get export_choice_body => 'How would you like to export this?';
386 +
387 + @override
388 + String get copy_to_clipboard => 'Copy to Clipboard';
389 +
390 + @override
391 + String bulk_export_conversations_success(int count) {
392 + return 'Exported $count conversations';
393 + }
394 +
395 + @override
396 + String get bulk_ai_rename_confirm_title => 'Rename with AI?';
397 +
398 + @override
399 + String bulk_ai_rename_confirm_body(int count) {
400 + return 'This will ask the AI to generate a new title for each of the $count selected conversations, replacing their current titles. This can take a while and cannot be undone.';
401 + }
402 +
403 + @override
404 + String get sort_by_modified_date => 'Last modified';
405 +
406 + @override
407 + String get sort_by_created_date => 'Date created';
408 +
409 + @override
410 + String get sort_title => 'Sort';
411 +
412 @override
413 String get clear_conversation_title => 'Clear conversation?';
414
@@ -469,7 +618,11 @@ class AppLocalizationsEn extends AppLocalizations {
618 String get enable_smart_reply => 'On-Device Smart Replies';
619
620 @override
472 - String get ai_user_response_enabled => 'AI user message button';
621 + String get ai_user_response_enabled => 'AI user message (hold send)';
622 +
623 + @override
624 + String get ai_user_response_enabled_desc =>
625 + 'Hold the send button for 3 seconds to have the AI write and send your next message';
626
627 @override
628 String get ai_user_response_tooltip => 'Generate user message with AI';
@@ -484,7 +637,18 @@ class AppLocalizationsEn extends AppLocalizations {
637 String get send_on_enter => 'Send on Enter';
638
639 @override
487 - String get show_system_messages => 'Show System Messages';
640 + String get show_system_messages => 'Send Default System Prompt';
641 +
642 + @override
643 + String get show_system_messages_desc =>
644 + 'When no persona is selected, send a default assistant system prompt with each request';
645 +
646 + @override
647 + String get show_system_messages_in_chat => 'Show System Messages in Chat';
648 +
649 + @override
650 + String get show_system_messages_in_chat_desc =>
651 + 'Display system messages (e.g. from an imported backup) as visible bubbles in the conversation';
652
653 @override
654 String get haptic_feedback => 'Haptic Feedback';
@@ -540,6 +704,13 @@ class AppLocalizationsEn extends AppLocalizations {
704 @override
705 String get select_persona => 'Select Persona';
706
707 + @override
708 + String get manage_personas => 'Manage personas';
709 +
710 + @override
711 + String get personas_combine_hint =>
712 + 'Select multiple personas in chat to stack their system prompts.';
713 +
714 @override
715 String get start_conversation => 'Start a conversation';
716
@@ -1998,6 +2169,21 @@ class AppLocalizationsEn extends AppLocalizations {
2169 String get unload_models_before_load =>
2170 'Unload all models before loading a new one';
2171
2172 + @override
2173 + String get temp_chat_keyboard_incognito =>
2174 + 'Incognito keyboard in temporary chat';
2175 +
2176 + @override
2177 + String get temp_chat_keyboard_incognito_desc =>
2178 + 'Disables keyboard learning and suggestions in temporary chats (e.g. SwiftKey incognito).';
2179 +
2180 + @override
2181 + String get resume_last_chat => 'Resume last chat on launch';
2182 +
2183 + @override
2184 + String get resume_last_chat_desc =>
2185 + 'Restore your last open conversation when reopening the app.';
2186 +
2187 @override
2188 String get export_all_data => 'Export all data';
2189
@@ -2348,4 +2534,130 @@ class AppLocalizationsEn extends AppLocalizations {
2534
2535 @override
2536 String get generate_title_failed => 'Could not generate a title';
2537 +
2538 + @override
2539 + String get lm_studio_model_browser_title => 'Browse models';
2540 +
2541 + @override
2542 + String get lm_studio_model_search_hint => 'Search models by name or author…';
2543 +
2544 + @override
2545 + String get lm_studio_staff_picks => 'Staff picks';
2546 +
2547 + @override
2548 + String get lm_studio_community_models => 'Community models';
2549 +
2550 + @override
2551 + String get lm_studio_no_models => 'No models found';
2552 +
2553 + @override
2554 + String lm_studio_models_count(int count) {
2555 + return '$count models';
2556 + }
2557 +
2558 + @override
2559 + String get lm_studio_browse_models => 'Browse & download';
2560 +
2561 + @override
2562 + String get lm_studio_model_search => 'LMS Model Search';
2563 +
2564 + @override
2565 + String get lm_studio_downloads_title => 'Downloads';
2566 +
2567 + @override
2568 + String get lm_studio_choose_quant => 'Choose a download option';
2569 +
2570 + @override
2571 + String get lm_studio_use_default_quant => 'Use default';
2572 +
2573 + @override
2574 + String get lm_studio_recommended => 'Recommended';
2575 +
2576 + @override
2577 + String get lm_studio_clear_downloads => 'Clear finished';
2578 +
2579 + @override
2580 + String get lm_studio_no_downloads => 'No downloads yet';
2581 +
2582 + @override
2583 + String get lm_studio_downloads_disclaimer =>
2584 + 'Downloads run on the LM Studio host. Pausing, stopping, and deleting models must be done on that computer — not from this app.';
2585 +
2586 + @override
2587 + String get lm_studio_staff_pick => 'Staff pick';
2588 +
2589 + @override
2590 + String get lm_studio_params => 'PARAMS';
2591 +
2592 + @override
2593 + String get lm_studio_arch => 'ARCH';
2594 +
2595 + @override
2596 + String get lm_studio_domain => 'DOMAIN';
2597 +
2598 + @override
2599 + String get lm_studio_format => 'FORMAT';
2600 +
2601 + @override
2602 + String get lm_studio_vision => 'Vision';
2603 +
2604 + @override
2605 + String get lm_studio_tool_use => 'Tool use';
2606 +
2607 + @override
2608 + String get lm_studio_reasoning => 'Reasoning';
2609 +
2610 + @override
2611 + String get lm_studio_download_options => 'Download options';
2612 +
2613 + @override
2614 + String get lm_studio_download => 'Download';
2615 +
2616 + @override
2617 + String lm_studio_download_size(String size) {
2618 + return 'Download $size';
2619 + }
2620 +
2621 + @override
2622 + String lm_studio_downloading_percent(int percent) {
2623 + return 'Downloading $percent%';
2624 + }
2625 +
2626 + @override
2627 + String get lm_studio_readme_unavailable =>
2628 + 'README not available for this model.';
2629 +
2630 + @override
2631 + String get lm_studio_full_gpu_offload => 'Full GPU offload possible';
2632 +
2633 + @override
2634 + String get lm_studio_partial_gpu_offload => 'Partial GPU offload possible';
2635 +
2636 + @override
2637 + String get lm_studio_likely_too_large => 'Likely too large';
2638 +
2639 + @override
2640 + String get lm_studio_available_ram_gb => 'Available RAM (GB, optional)';
2641 +
2642 + @override
2643 + String get lm_studio_available_vram_gb => 'Available VRAM (GB, optional)';
2644 +
2645 + @override
2646 + String get lm_studio_memory_settings_title => 'Memory for recommendations';
2647 +
2648 + @override
2649 + String get lm_studio_memory_settings_desc =>
2650 + 'Used to estimate whether models fit on your machine in the model browser.';
2651 +
2652 + @override
2653 + String get think_button_label => 'Think';
2654 +
2655 + @override
2656 + String get reasoning_effort_low => 'Low';
2657 +
2658 + @override
2659 + String get reasoning_effort_medium => 'Medium';
2660 +
2661 + @override
2662 + String get reasoning_effort_high => 'High';
2663 }
lib/l10n/app_localizations_es.dart
+313 -1
@@ -260,6 +260,155 @@ class AppLocalizationsEs extends AppLocalizations {
260 @override
261 String get delete_persona_body => 'Esto no se puede deshacer.';
262
263 + @override
264 + String get delete_builtin_persona_body =>
265 + 'This is a built-in persona. You can restore it later from Settings.';
266 +
267 + @override
268 + String get restore_builtin_personas => 'Restore default personas';
269 +
270 + @override
271 + String get restore_builtin_personas_desc =>
272 + 'Re-add any built-in personas you\'ve deleted';
273 +
274 + @override
275 + String get restore_builtin_personas_success => 'Default personas restored';
276 +
277 + @override
278 + String get clear_personas => 'Clear personas';
279 +
280 + @override
281 + String get enable_image_compression => 'Compress images before sending';
282 +
283 + @override
284 + String get enable_image_compression_desc =>
285 + 'Resize and compress attached images so uploads stay within server limits';
286 +
287 + @override
288 + String get image_compression_level => 'Compression aggressiveness';
289 +
290 + @override
291 + String get image_compression_level_desc =>
292 + 'Higher aggressiveness produces smaller uploads at lower quality';
293 +
294 + @override
295 + String get image_compression_level_low => 'Low';
296 +
297 + @override
298 + String get image_compression_level_medium => 'Medium';
299 +
300 + @override
301 + String get image_compression_level_high => 'High';
302 +
303 + @override
304 + String get sort_models_tooltip => 'Sort models';
305 +
306 + @override
307 + String get sort_by_favorites => 'Favorites first';
308 +
309 + @override
310 + String get sort_by_name => 'Name (A-Z)';
311 +
312 + @override
313 + String get sort_by_size_smallest => 'Size (smallest first)';
314 +
315 + @override
316 + String get sort_by_size_largest => 'Size (largest first)';
317 +
318 + @override
319 + String get sort_by_context_length => 'Context length';
320 +
321 + @override
322 + String bulk_ai_rename_progress(int done, int total) {
323 + return 'Renaming $done/$total...';
324 + }
325 +
326 + @override
327 + String selected_count(int count) {
328 + return '$count selected';
329 + }
330 +
331 + @override
332 + String get ai_rename_tooltip => 'Rename selected with AI';
333 +
334 + @override
335 + String get new_chat_in_folder_tooltip => 'New chat in this folder';
336 +
337 + @override
338 + String total_tokens_count(int count) {
339 + return '$count tokens';
340 + }
341 +
342 + @override
343 + String get smart_replies_use_persona => 'Use persona in smart replies';
344 +
345 + @override
346 + String get smart_replies_use_persona_desc =>
347 + 'Suggested replies match the tone of the active persona instead of a generic assistant';
348 +
349 + @override
350 + String get keep_persona_on_new_chat => 'Keep persona on new chat';
351 +
352 + @override
353 + String get keep_persona_on_new_chat_desc =>
354 + 'Don\'t clear the selected persona(s) after starting a new chat';
355 +
356 + @override
357 + String get role_swap_button_enabled => 'Show role-swap button';
358 +
359 + @override
360 + String get role_swap_button_enabled_desc =>
361 + 'Show a button in the chat input to send your message as the assistant instead of the user, without generating a response';
362 +
363 + @override
364 + String get send_as_user_tooltip => 'Send as user';
365 +
366 + @override
367 + String get send_as_assistant_tooltip => 'Send as assistant (no response)';
368 +
369 + @override
370 + String get insert_without_generating_tooltip => 'Insert without generating';
371 +
372 + @override
373 + String get token_usage_title => 'Token Usage';
374 +
375 + @override
376 + String get total_tokens_label => 'Tokens used';
377 +
378 + @override
379 + String get usage_percent_label => 'Context used';
380 +
381 + @override
382 + String get export_choice_title => 'Export';
383 +
384 + @override
385 + String get export_choice_body => 'How would you like to export this?';
386 +
387 + @override
388 + String get copy_to_clipboard => 'Copy to Clipboard';
389 +
390 + @override
391 + String bulk_export_conversations_success(int count) {
392 + return 'Exported $count conversations';
393 + }
394 +
395 + @override
396 + String get bulk_ai_rename_confirm_title => 'Rename with AI?';
397 +
398 + @override
399 + String bulk_ai_rename_confirm_body(int count) {
400 + return 'This will ask the AI to generate a new title for each of the $count selected conversations, replacing their current titles. This can take a while and cannot be undone.';
401 + }
402 +
403 + @override
404 + String get sort_by_modified_date => 'Last modified';
405 +
406 + @override
407 + String get sort_by_created_date => 'Date created';
408 +
409 + @override
410 + String get sort_title => 'Sort';
411 +
412 @override
413 String get clear_conversation_title => '¿Limpiar conversación?';
414
@@ -470,7 +619,11 @@ class AppLocalizationsEs extends AppLocalizations {
619 String get enable_smart_reply => 'Respuestas inteligentes locales';
620
621 @override
473 - String get ai_user_response_enabled => 'AI user message button';
622 + String get ai_user_response_enabled => 'AI user message (hold send)';
623 +
624 + @override
625 + String get ai_user_response_enabled_desc =>
626 + 'Hold the send button for 3 seconds to have the AI write and send your next message';
627
628 @override
629 String get ai_user_response_tooltip => 'Generate user message with AI';
@@ -487,6 +640,17 @@ class AppLocalizationsEs extends AppLocalizations {
640 @override
641 String get show_system_messages => 'Mostrar mensajes del sistema';
642
643 + @override
644 + String get show_system_messages_desc =>
645 + 'When no persona is selected, send a default assistant system prompt with each request';
646 +
647 + @override
648 + String get show_system_messages_in_chat => 'Show System Messages in Chat';
649 +
650 + @override
651 + String get show_system_messages_in_chat_desc =>
652 + 'Display system messages (e.g. from an imported backup) as visible bubbles in the conversation';
653 +
654 @override
655 String get haptic_feedback => 'Comentarios hápticos';
656
@@ -542,6 +706,13 @@ class AppLocalizationsEs extends AppLocalizations {
706 @override
707 String get select_persona => 'Seleccionar personaje';
708
709 + @override
710 + String get manage_personas => 'Manage personas';
711 +
712 + @override
713 + String get personas_combine_hint =>
714 + 'Select multiple personas in chat to stack their system prompts.';
715 +
716 @override
717 String get start_conversation => 'Comenzar una conversación';
718
@@ -2013,6 +2184,21 @@ class AppLocalizationsEs extends AppLocalizations {
2184 String get unload_models_before_load =>
2185 'Unload all models before loading a new one';
2186
2187 + @override
2188 + String get temp_chat_keyboard_incognito =>
2189 + 'Incognito keyboard in temporary chat';
2190 +
2191 + @override
2192 + String get temp_chat_keyboard_incognito_desc =>
2193 + 'Disables keyboard learning and suggestions in temporary chats (e.g. SwiftKey incognito).';
2194 +
2195 + @override
2196 + String get resume_last_chat => 'Resume last chat on launch';
2197 +
2198 + @override
2199 + String get resume_last_chat_desc =>
2200 + 'Restore your last open conversation when reopening the app.';
2201 +
2202 @override
2203 String get export_all_data => 'Export all data';
2204
@@ -2363,4 +2549,130 @@ class AppLocalizationsEs extends AppLocalizations {
2549
2550 @override
2551 String get generate_title_failed => 'Could not generate a title';
2552 +
2553 + @override
2554 + String get lm_studio_model_browser_title => 'Browse models';
2555 +
2556 + @override
2557 + String get lm_studio_model_search_hint => 'Search models by name or author…';
2558 +
2559 + @override
2560 + String get lm_studio_staff_picks => 'Staff picks';
2561 +
2562 + @override
2563 + String get lm_studio_community_models => 'Community models';
2564 +
2565 + @override
2566 + String get lm_studio_no_models => 'No models found';
2567 +
2568 + @override
2569 + String lm_studio_models_count(int count) {
2570 + return '$count models';
2571 + }
2572 +
2573 + @override
2574 + String get lm_studio_browse_models => 'Browse & download';
2575 +
2576 + @override
2577 + String get lm_studio_model_search => 'LMS Model Search';
2578 +
2579 + @override
2580 + String get lm_studio_downloads_title => 'Downloads';
2581 +
2582 + @override
2583 + String get lm_studio_choose_quant => 'Choose a download option';
2584 +
2585 + @override
2586 + String get lm_studio_use_default_quant => 'Use default';
2587 +
2588 + @override
2589 + String get lm_studio_recommended => 'Recommended';
2590 +
2591 + @override
2592 + String get lm_studio_clear_downloads => 'Clear finished';
2593 +
2594 + @override
2595 + String get lm_studio_no_downloads => 'No downloads yet';
2596 +
2597 + @override
2598 + String get lm_studio_downloads_disclaimer =>
2599 + 'Downloads run on the LM Studio host. Pausing, stopping, and deleting models must be done on that computer — not from this app.';
2600 +
2601 + @override
2602 + String get lm_studio_staff_pick => 'Staff pick';
2603 +
2604 + @override
2605 + String get lm_studio_params => 'PARAMS';
2606 +
2607 + @override
2608 + String get lm_studio_arch => 'ARCH';
2609 +
2610 + @override
2611 + String get lm_studio_domain => 'DOMAIN';
2612 +
2613 + @override
2614 + String get lm_studio_format => 'FORMAT';
2615 +
2616 + @override
2617 + String get lm_studio_vision => 'Vision';
2618 +
2619 + @override
2620 + String get lm_studio_tool_use => 'Tool use';
2621 +
2622 + @override
2623 + String get lm_studio_reasoning => 'Reasoning';
2624 +
2625 + @override
2626 + String get lm_studio_download_options => 'Download options';
2627 +
2628 + @override
2629 + String get lm_studio_download => 'Download';
2630 +
2631 + @override
2632 + String lm_studio_download_size(String size) {
2633 + return 'Download $size';
2634 + }
2635 +
2636 + @override
2637 + String lm_studio_downloading_percent(int percent) {
2638 + return 'Downloading $percent%';
2639 + }
2640 +
2641 + @override
2642 + String get lm_studio_readme_unavailable =>
2643 + 'README not available for this model.';
2644 +
2645 + @override
2646 + String get lm_studio_full_gpu_offload => 'Full GPU offload possible';
2647 +
2648 + @override
2649 + String get lm_studio_partial_gpu_offload => 'Partial GPU offload possible';
2650 +
2651 + @override
2652 + String get lm_studio_likely_too_large => 'Likely too large';
2653 +
2654 + @override
2655 + String get lm_studio_available_ram_gb => 'Available RAM (GB, optional)';
2656 +
2657 + @override
2658 + String get lm_studio_available_vram_gb => 'Available VRAM (GB, optional)';
2659 +
2660 + @override
2661 + String get lm_studio_memory_settings_title => 'Memory for recommendations';
2662 +
2663 + @override
2664 + String get lm_studio_memory_settings_desc =>
2665 + 'Used to estimate whether models fit on your machine in the model browser.';
2666 +
2667 + @override
2668 + String get think_button_label => 'Think';
2669 +
2670 + @override
2671 + String get reasoning_effort_low => 'Low';
2672 +
2673 + @override
2674 + String get reasoning_effort_medium => 'Medium';
2675 +
2676 + @override
2677 + String get reasoning_effort_high => 'High';
2678 }
lib/l10n/app_localizations_hi.dart
+313 -1
@@ -261,6 +261,155 @@ class AppLocalizationsHi extends AppLocalizations {
261 @override
262 String get delete_persona_body => 'इसे पूर्ववत नहीं किया जा सकता।';
263
264 + @override
265 + String get delete_builtin_persona_body =>
266 + 'This is a built-in persona. You can restore it later from Settings.';
267 +
268 + @override
269 + String get restore_builtin_personas => 'Restore default personas';
270 +
271 + @override
272 + String get restore_builtin_personas_desc =>
273 + 'Re-add any built-in personas you\'ve deleted';
274 +
275 + @override
276 + String get restore_builtin_personas_success => 'Default personas restored';
277 +
278 + @override
279 + String get clear_personas => 'Clear personas';
280 +
281 + @override
282 + String get enable_image_compression => 'Compress images before sending';
283 +
284 + @override
285 + String get enable_image_compression_desc =>
286 + 'Resize and compress attached images so uploads stay within server limits';
287 +
288 + @override
289 + String get image_compression_level => 'Compression aggressiveness';
290 +
291 + @override
292 + String get image_compression_level_desc =>
293 + 'Higher aggressiveness produces smaller uploads at lower quality';
294 +
295 + @override
296 + String get image_compression_level_low => 'Low';
297 +
298 + @override
299 + String get image_compression_level_medium => 'Medium';
300 +
301 + @override
302 + String get image_compression_level_high => 'High';
303 +
304 + @override
305 + String get sort_models_tooltip => 'Sort models';
306 +
307 + @override
308 + String get sort_by_favorites => 'Favorites first';
309 +
310 + @override
311 + String get sort_by_name => 'Name (A-Z)';
312 +
313 + @override
314 + String get sort_by_size_smallest => 'Size (smallest first)';
315 +
316 + @override
317 + String get sort_by_size_largest => 'Size (largest first)';
318 +
319 + @override
320 + String get sort_by_context_length => 'Context length';
321 +
322 + @override
323 + String bulk_ai_rename_progress(int done, int total) {
324 + return 'Renaming $done/$total...';
325 + }
326 +
327 + @override
328 + String selected_count(int count) {
329 + return '$count selected';
330 + }
331 +
332 + @override
333 + String get ai_rename_tooltip => 'Rename selected with AI';
334 +
335 + @override
336 + String get new_chat_in_folder_tooltip => 'New chat in this folder';
337 +
338 + @override
339 + String total_tokens_count(int count) {
340 + return '$count tokens';
341 + }
342 +
343 + @override
344 + String get smart_replies_use_persona => 'Use persona in smart replies';
345 +
346 + @override
347 + String get smart_replies_use_persona_desc =>
348 + 'Suggested replies match the tone of the active persona instead of a generic assistant';
349 +
350 + @override
351 + String get keep_persona_on_new_chat => 'Keep persona on new chat';
352 +
353 + @override
354 + String get keep_persona_on_new_chat_desc =>
355 + 'Don\'t clear the selected persona(s) after starting a new chat';
356 +
357 + @override
358 + String get role_swap_button_enabled => 'Show role-swap button';
359 +
360 + @override
361 + String get role_swap_button_enabled_desc =>
362 + 'Show a button in the chat input to send your message as the assistant instead of the user, without generating a response';
363 +
364 + @override
365 + String get send_as_user_tooltip => 'Send as user';
366 +
367 + @override
368 + String get send_as_assistant_tooltip => 'Send as assistant (no response)';
369 +
370 + @override
371 + String get insert_without_generating_tooltip => 'Insert without generating';
372 +
373 + @override
374 + String get token_usage_title => 'Token Usage';
375 +
376 + @override
377 + String get total_tokens_label => 'Tokens used';
378 +
379 + @override
380 + String get usage_percent_label => 'Context used';
381 +
382 + @override
383 + String get export_choice_title => 'Export';
384 +
385 + @override
386 + String get export_choice_body => 'How would you like to export this?';
387 +
388 + @override
389 + String get copy_to_clipboard => 'Copy to Clipboard';
390 +
391 + @override
392 + String bulk_export_conversations_success(int count) {
393 + return 'Exported $count conversations';
394 + }
395 +
396 + @override
397 + String get bulk_ai_rename_confirm_title => 'Rename with AI?';
398 +
399 + @override
400 + String bulk_ai_rename_confirm_body(int count) {
401 + return 'This will ask the AI to generate a new title for each of the $count selected conversations, replacing their current titles. This can take a while and cannot be undone.';
402 + }
403 +
404 + @override
405 + String get sort_by_modified_date => 'Last modified';
406 +
407 + @override
408 + String get sort_by_created_date => 'Date created';
409 +
410 + @override
411 + String get sort_title => 'Sort';
412 +
413 @override
414 String get clear_conversation_title => 'बातचीत साफ़ करें?';
415
@@ -471,7 +620,11 @@ class AppLocalizationsHi extends AppLocalizations {
620 String get enable_smart_reply => 'ऑन-डिवाइस स्मार्ट रिप्लाइ';
621
622 @override
474 - String get ai_user_response_enabled => 'AI user message button';
623 + String get ai_user_response_enabled => 'AI user message (hold send)';
624 +
625 + @override
626 + String get ai_user_response_enabled_desc =>
627 + 'Hold the send button for 3 seconds to have the AI write and send your next message';
628
629 @override
630 String get ai_user_response_tooltip => 'Generate user message with AI';
@@ -488,6 +641,17 @@ class AppLocalizationsHi extends AppLocalizations {
641 @override
642 String get show_system_messages => 'सिस्टम संदेश दिखाएं';
643
644 + @override
645 + String get show_system_messages_desc =>
646 + 'When no persona is selected, send a default assistant system prompt with each request';
647 +
648 + @override
649 + String get show_system_messages_in_chat => 'Show System Messages in Chat';
650 +
651 + @override
652 + String get show_system_messages_in_chat_desc =>
653 + 'Display system messages (e.g. from an imported backup) as visible bubbles in the conversation';
654 +
655 @override
656 String get haptic_feedback => 'हैप्टिक फीडबैक';
657
@@ -542,6 +706,13 @@ class AppLocalizationsHi extends AppLocalizations {
706 @override
707 String get select_persona => 'चरित्र चुनें';
708
709 + @override
710 + String get manage_personas => 'Manage personas';
711 +
712 + @override
713 + String get personas_combine_hint =>
714 + 'Select multiple personas in chat to stack their system prompts.';
715 +
716 @override
717 String get start_conversation => 'बातचीत शुरू करें';
718
@@ -2002,6 +2173,21 @@ class AppLocalizationsHi extends AppLocalizations {
2173 String get unload_models_before_load =>
2174 'Unload all models before loading a new one';
2175
2176 + @override
2177 + String get temp_chat_keyboard_incognito =>
2178 + 'Incognito keyboard in temporary chat';
2179 +
2180 + @override
2181 + String get temp_chat_keyboard_incognito_desc =>
2182 + 'Disables keyboard learning and suggestions in temporary chats (e.g. SwiftKey incognito).';
2183 +
2184 + @override
2185 + String get resume_last_chat => 'Resume last chat on launch';
2186 +
2187 + @override
2188 + String get resume_last_chat_desc =>
2189 + 'Restore your last open conversation when reopening the app.';
2190 +
2191 @override
2192 String get export_all_data => 'Export all data';
2193
@@ -2352,4 +2538,130 @@ class AppLocalizationsHi extends AppLocalizations {
2538
2539 @override
2540 String get generate_title_failed => 'Could not generate a title';
2541 +
2542 + @override
2543 + String get lm_studio_model_browser_title => 'Browse models';
2544 +
2545 + @override
2546 + String get lm_studio_model_search_hint => 'Search models by name or author…';
2547 +
2548 + @override
2549 + String get lm_studio_staff_picks => 'Staff picks';
2550 +
2551 + @override
2552 + String get lm_studio_community_models => 'Community models';
2553 +
2554 + @override
2555 + String get lm_studio_no_models => 'No models found';
2556 +
2557 + @override
2558 + String lm_studio_models_count(int count) {
2559 + return '$count models';
2560 + }
2561 +
2562 + @override
2563 + String get lm_studio_browse_models => 'Browse & download';
2564 +
2565 + @override
2566 + String get lm_studio_model_search => 'LMS Model Search';
2567 +
2568 + @override
2569 + String get lm_studio_downloads_title => 'Downloads';
2570 +
2571 + @override
2572 + String get lm_studio_choose_quant => 'Choose a download option';
2573 +
2574 + @override
2575 + String get lm_studio_use_default_quant => 'Use default';
2576 +
2577 + @override
2578 + String get lm_studio_recommended => 'Recommended';
2579 +
2580 + @override
2581 + String get lm_studio_clear_downloads => 'Clear finished';
2582 +
2583 + @override
2584 + String get lm_studio_no_downloads => 'No downloads yet';
2585 +
2586 + @override
2587 + String get lm_studio_downloads_disclaimer =>
2588 + 'Downloads run on the LM Studio host. Pausing, stopping, and deleting models must be done on that computer — not from this app.';
2589 +
2590 + @override
2591 + String get lm_studio_staff_pick => 'Staff pick';
2592 +
2593 + @override
2594 + String get lm_studio_params => 'PARAMS';
2595 +
2596 + @override
2597 + String get lm_studio_arch => 'ARCH';
2598 +
2599 + @override
2600 + String get lm_studio_domain => 'DOMAIN';
2601 +
2602 + @override
2603 + String get lm_studio_format => 'FORMAT';
2604 +
2605 + @override
2606 + String get lm_studio_vision => 'Vision';
2607 +
2608 + @override
2609 + String get lm_studio_tool_use => 'Tool use';
2610 +
2611 + @override
2612 + String get lm_studio_reasoning => 'Reasoning';
2613 +
2614 + @override
2615 + String get lm_studio_download_options => 'Download options';
2616 +
2617 + @override
2618 + String get lm_studio_download => 'Download';
2619 +
2620 + @override
2621 + String lm_studio_download_size(String size) {
2622 + return 'Download $size';
2623 + }
2624 +
2625 + @override
2626 + String lm_studio_downloading_percent(int percent) {
2627 + return 'Downloading $percent%';
2628 + }
2629 +
2630 + @override
2631 + String get lm_studio_readme_unavailable =>
2632 + 'README not available for this model.';
2633 +
2634 + @override
2635 + String get lm_studio_full_gpu_offload => 'Full GPU offload possible';
2636 +
2637 + @override
2638 + String get lm_studio_partial_gpu_offload => 'Partial GPU offload possible';
2639 +
2640 + @override
2641 + String get lm_studio_likely_too_large => 'Likely too large';
2642 +
2643 + @override
2644 + String get lm_studio_available_ram_gb => 'Available RAM (GB, optional)';
2645 +
2646 + @override
2647 + String get lm_studio_available_vram_gb => 'Available VRAM (GB, optional)';
2648 +
2649 + @override
2650 + String get lm_studio_memory_settings_title => 'Memory for recommendations';
2651 +
2652 + @override
2653 + String get lm_studio_memory_settings_desc =>
2654 + 'Used to estimate whether models fit on your machine in the model browser.';
2655 +
2656 + @override
2657 + String get think_button_label => 'Think';
2658 +
2659 + @override
2660 + String get reasoning_effort_low => 'Low';
2661 +
2662 + @override
2663 + String get reasoning_effort_medium => 'Medium';
2664 +
2665 + @override
2666 + String get reasoning_effort_high => 'High';
2667 }
lib/l10n/app_localizations_it.dart
+313 -1
@@ -262,6 +262,155 @@ class AppLocalizationsIt extends AppLocalizations {
262 @override
263 String get delete_persona_body => 'L\'operazione non può essere annullata.';
264
265 + @override
266 + String get delete_builtin_persona_body =>
267 + 'This is a built-in persona. You can restore it later from Settings.';
268 +
269 + @override
270 + String get restore_builtin_personas => 'Restore default personas';
271 +
272 + @override
273 + String get restore_builtin_personas_desc =>
274 + 'Re-add any built-in personas you\'ve deleted';
275 +
276 + @override
277 + String get restore_builtin_personas_success => 'Default personas restored';
278 +
279 + @override
280 + String get clear_personas => 'Clear personas';
281 +
282 + @override
283 + String get enable_image_compression => 'Compress images before sending';
284 +
285 + @override
286 + String get enable_image_compression_desc =>
287 + 'Resize and compress attached images so uploads stay within server limits';
288 +
289 + @override
290 + String get image_compression_level => 'Compression aggressiveness';
291 +
292 + @override
293 + String get image_compression_level_desc =>
294 + 'Higher aggressiveness produces smaller uploads at lower quality';
295 +
296 + @override
297 + String get image_compression_level_low => 'Low';
298 +
299 + @override
300 + String get image_compression_level_medium => 'Medium';
301 +
302 + @override
303 + String get image_compression_level_high => 'High';
304 +
305 + @override
306 + String get sort_models_tooltip => 'Sort models';
307 +
308 + @override
309 + String get sort_by_favorites => 'Favorites first';
310 +
311 + @override
312 + String get sort_by_name => 'Name (A-Z)';
313 +
314 + @override
315 + String get sort_by_size_smallest => 'Size (smallest first)';
316 +
317 + @override
318 + String get sort_by_size_largest => 'Size (largest first)';
319 +
320 + @override
321 + String get sort_by_context_length => 'Context length';
322 +
323 + @override
324 + String bulk_ai_rename_progress(int done, int total) {
325 + return 'Renaming $done/$total...';
326 + }
327 +
328 + @override
329 + String selected_count(int count) {
330 + return '$count selected';
331 + }
332 +
333 + @override
334 + String get ai_rename_tooltip => 'Rename selected with AI';
335 +
336 + @override
337 + String get new_chat_in_folder_tooltip => 'New chat in this folder';
338 +
339 + @override
340 + String total_tokens_count(int count) {
341 + return '$count tokens';
342 + }
343 +
344 + @override
345 + String get smart_replies_use_persona => 'Use persona in smart replies';
346 +
347 + @override
348 + String get smart_replies_use_persona_desc =>
349 + 'Suggested replies match the tone of the active persona instead of a generic assistant';
350 +
351 + @override
352 + String get keep_persona_on_new_chat => 'Keep persona on new chat';
353 +
354 + @override
355 + String get keep_persona_on_new_chat_desc =>
356 + 'Don\'t clear the selected persona(s) after starting a new chat';
357 +
358 + @override
359 + String get role_swap_button_enabled => 'Show role-swap button';
360 +
361 + @override
362 + String get role_swap_button_enabled_desc =>
363 + 'Show a button in the chat input to send your message as the assistant instead of the user, without generating a response';
364 +
365 + @override
366 + String get send_as_user_tooltip => 'Send as user';
367 +
368 + @override
369 + String get send_as_assistant_tooltip => 'Send as assistant (no response)';
370 +
371 + @override
372 + String get insert_without_generating_tooltip => 'Insert without generating';
373 +
374 + @override
375 + String get token_usage_title => 'Token Usage';
376 +
377 + @override
378 + String get total_tokens_label => 'Tokens used';
379 +
380 + @override
381 + String get usage_percent_label => 'Context used';
382 +
383 + @override
384 + String get export_choice_title => 'Export';
385 +
386 + @override
387 + String get export_choice_body => 'How would you like to export this?';
388 +
389 + @override
390 + String get copy_to_clipboard => 'Copy to Clipboard';
391 +
392 + @override
393 + String bulk_export_conversations_success(int count) {
394 + return 'Exported $count conversations';
395 + }
396 +
397 + @override
398 + String get bulk_ai_rename_confirm_title => 'Rename with AI?';
399 +
400 + @override
401 + String bulk_ai_rename_confirm_body(int count) {
402 + return 'This will ask the AI to generate a new title for each of the $count selected conversations, replacing their current titles. This can take a while and cannot be undone.';
403 + }
404 +
405 + @override
406 + String get sort_by_modified_date => 'Last modified';
407 +
408 + @override
409 + String get sort_by_created_date => 'Date created';
410 +
411 + @override
412 + String get sort_title => 'Sort';
413 +
414 @override
415 String get clear_conversation_title => 'Conversazioni chiare?';
416
@@ -471,7 +620,11 @@ class AppLocalizationsIt extends AppLocalizations {
620 String get enable_smart_reply => 'Risposte intelligenti sul dispositivo';
621
622 @override
474 - String get ai_user_response_enabled => 'AI user message button';
623 + String get ai_user_response_enabled => 'AI user message (hold send)';
624 +
625 + @override
626 + String get ai_user_response_enabled_desc =>
627 + 'Hold the send button for 3 seconds to have the AI write and send your next message';
628
629 @override
630 String get ai_user_response_tooltip => 'Generate user message with AI';
@@ -488,6 +641,17 @@ class AppLocalizationsIt extends AppLocalizations {
641 @override
642 String get show_system_messages => 'Mostra messaggi di sistema';
643
644 + @override
645 + String get show_system_messages_desc =>
646 + 'When no persona is selected, send a default assistant system prompt with each request';
647 +
648 + @override
649 + String get show_system_messages_in_chat => 'Show System Messages in Chat';
650 +
651 + @override
652 + String get show_system_messages_in_chat_desc =>
653 + 'Display system messages (e.g. from an imported backup) as visible bubbles in the conversation';
654 +
655 @override
656 String get haptic_feedback => 'Feedback aptico';
657
@@ -543,6 +707,13 @@ class AppLocalizationsIt extends AppLocalizations {
707 @override
708 String get select_persona => 'Seleziona Persona';
709
710 + @override
711 + String get manage_personas => 'Manage personas';
712 +
713 + @override
714 + String get personas_combine_hint =>
715 + 'Select multiple personas in chat to stack their system prompts.';
716 +
717 @override
718 String get start_conversation => 'Avvia una conversazione';
719
@@ -2013,6 +2184,21 @@ class AppLocalizationsIt extends AppLocalizations {
2184 String get unload_models_before_load =>
2185 'Unload all models before loading a new one';
2186
2187 + @override
2188 + String get temp_chat_keyboard_incognito =>
2189 + 'Incognito keyboard in temporary chat';
2190 +
2191 + @override
2192 + String get temp_chat_keyboard_incognito_desc =>
2193 + 'Disables keyboard learning and suggestions in temporary chats (e.g. SwiftKey incognito).';
2194 +
2195 + @override
2196 + String get resume_last_chat => 'Resume last chat on launch';
2197 +
2198 + @override
2199 + String get resume_last_chat_desc =>
2200 + 'Restore your last open conversation when reopening the app.';
2201 +
2202 @override
2203 String get export_all_data => 'Export all data';
2204
@@ -2363,4 +2549,130 @@ class AppLocalizationsIt extends AppLocalizations {
2549
2550 @override
2551 String get generate_title_failed => 'Could not generate a title';
2552 +
2553 + @override
2554 + String get lm_studio_model_browser_title => 'Browse models';
2555 +
2556 + @override
2557 + String get lm_studio_model_search_hint => 'Search models by name or author…';
2558 +
2559 + @override
2560 + String get lm_studio_staff_picks => 'Staff picks';
2561 +
2562 + @override
2563 + String get lm_studio_community_models => 'Community models';
2564 +
2565 + @override
2566 + String get lm_studio_no_models => 'No models found';
2567 +
2568 + @override
2569 + String lm_studio_models_count(int count) {
2570 + return '$count models';
2571 + }
2572 +
2573 + @override
2574 + String get lm_studio_browse_models => 'Browse & download';
2575 +
2576 + @override
2577 + String get lm_studio_model_search => 'LMS Model Search';
2578 +
2579 + @override
2580 + String get lm_studio_downloads_title => 'Downloads';
2581 +
2582 + @override
2583 + String get lm_studio_choose_quant => 'Choose a download option';
2584 +
2585 + @override
2586 + String get lm_studio_use_default_quant => 'Use default';
2587 +
2588 + @override
2589 + String get lm_studio_recommended => 'Recommended';
2590 +
2591 + @override
2592 + String get lm_studio_clear_downloads => 'Clear finished';
2593 +
2594 + @override
2595 + String get lm_studio_no_downloads => 'No downloads yet';
2596 +
2597 + @override
2598 + String get lm_studio_downloads_disclaimer =>
2599 + 'Downloads run on the LM Studio host. Pausing, stopping, and deleting models must be done on that computer — not from this app.';
2600 +
2601 + @override
2602 + String get lm_studio_staff_pick => 'Staff pick';
2603 +
2604 + @override
2605 + String get lm_studio_params => 'PARAMS';
2606 +
2607 + @override
2608 + String get lm_studio_arch => 'ARCH';
2609 +
2610 + @override
2611 + String get lm_studio_domain => 'DOMAIN';
2612 +
2613 + @override
2614 + String get lm_studio_format => 'FORMAT';
2615 +
2616 + @override
2617 + String get lm_studio_vision => 'Vision';
2618 +
2619 + @override
2620 + String get lm_studio_tool_use => 'Tool use';
2621 +
2622 + @override
2623 + String get lm_studio_reasoning => 'Reasoning';
2624 +
2625 + @override
2626 + String get lm_studio_download_options => 'Download options';
2627 +
2628 + @override
2629 + String get lm_studio_download => 'Download';
2630 +
2631 + @override
2632 + String lm_studio_download_size(String size) {
2633 + return 'Download $size';
2634 + }
2635 +
2636 + @override
2637 + String lm_studio_downloading_percent(int percent) {
2638 + return 'Downloading $percent%';
2639 + }
2640 +
2641 + @override
2642 + String get lm_studio_readme_unavailable =>
2643 + 'README not available for this model.';
2644 +
2645 + @override
2646 + String get lm_studio_full_gpu_offload => 'Full GPU offload possible';
2647 +
2648 + @override
2649 + String get lm_studio_partial_gpu_offload => 'Partial GPU offload possible';
2650 +
2651 + @override
2652 + String get lm_studio_likely_too_large => 'Likely too large';
2653 +
2654 + @override
2655 + String get lm_studio_available_ram_gb => 'Available RAM (GB, optional)';
2656 +
2657 + @override
2658 + String get lm_studio_available_vram_gb => 'Available VRAM (GB, optional)';
2659 +
2660 + @override
2661 + String get lm_studio_memory_settings_title => 'Memory for recommendations';
2662 +
2663 + @override
2664 + String get lm_studio_memory_settings_desc =>
2665 + 'Used to estimate whether models fit on your machine in the model browser.';
2666 +
2667 + @override
2668 + String get think_button_label => 'Think';
2669 +
2670 + @override
2671 + String get reasoning_effort_low => 'Low';
2672 +
2673 + @override
2674 + String get reasoning_effort_medium => 'Medium';
2675 +
2676 + @override
2677 + String get reasoning_effort_high => 'High';
2678 }
lib/l10n/app_localizations_ja.dart
+313 -1
@@ -260,6 +260,155 @@ class AppLocalizationsJa extends AppLocalizations {
260 @override
261 String get delete_persona_body => 'この操作は取り消せません。';
262
263 + @override
264 + String get delete_builtin_persona_body =>
265 + 'This is a built-in persona. You can restore it later from Settings.';
266 +
267 + @override
268 + String get restore_builtin_personas => 'Restore default personas';
269 +
270 + @override
271 + String get restore_builtin_personas_desc =>
272 + 'Re-add any built-in personas you\'ve deleted';
273 +
274 + @override
275 + String get restore_builtin_personas_success => 'Default personas restored';
276 +
277 + @override
278 + String get clear_personas => 'Clear personas';
279 +
280 + @override
281 + String get enable_image_compression => 'Compress images before sending';
282 +
283 + @override
284 + String get enable_image_compression_desc =>
285 + 'Resize and compress attached images so uploads stay within server limits';
286 +
287 + @override
288 + String get image_compression_level => 'Compression aggressiveness';
289 +
290 + @override
291 + String get image_compression_level_desc =>
292 + 'Higher aggressiveness produces smaller uploads at lower quality';
293 +
294 + @override
295 + String get image_compression_level_low => 'Low';
296 +
297 + @override
298 + String get image_compression_level_medium => 'Medium';
299 +
300 + @override
301 + String get image_compression_level_high => 'High';
302 +
303 + @override
304 + String get sort_models_tooltip => 'Sort models';
305 +
306 + @override
307 + String get sort_by_favorites => 'Favorites first';
308 +
309 + @override
310 + String get sort_by_name => 'Name (A-Z)';
311 +
312 + @override
313 + String get sort_by_size_smallest => 'Size (smallest first)';
314 +
315 + @override
316 + String get sort_by_size_largest => 'Size (largest first)';
317 +
318 + @override
319 + String get sort_by_context_length => 'Context length';
320 +
321 + @override
322 + String bulk_ai_rename_progress(int done, int total) {
323 + return 'Renaming $done/$total...';
324 + }
325 +
326 + @override
327 + String selected_count(int count) {
328 + return '$count selected';
329 + }
330 +
331 + @override
332 + String get ai_rename_tooltip => 'Rename selected with AI';
333 +
334 + @override
335 + String get new_chat_in_folder_tooltip => 'New chat in this folder';
336 +
337 + @override
338 + String total_tokens_count(int count) {
339 + return '$count tokens';
340 + }
341 +
342 + @override
343 + String get smart_replies_use_persona => 'Use persona in smart replies';
344 +
345 + @override
346 + String get smart_replies_use_persona_desc =>
347 + 'Suggested replies match the tone of the active persona instead of a generic assistant';
348 +
349 + @override
350 + String get keep_persona_on_new_chat => 'Keep persona on new chat';
351 +
352 + @override
353 + String get keep_persona_on_new_chat_desc =>
354 + 'Don\'t clear the selected persona(s) after starting a new chat';
355 +
356 + @override
357 + String get role_swap_button_enabled => 'Show role-swap button';
358 +
359 + @override
360 + String get role_swap_button_enabled_desc =>
361 + 'Show a button in the chat input to send your message as the assistant instead of the user, without generating a response';
362 +
363 + @override
364 + String get send_as_user_tooltip => 'Send as user';
365 +
366 + @override
367 + String get send_as_assistant_tooltip => 'Send as assistant (no response)';
368 +
369 + @override
370 + String get insert_without_generating_tooltip => 'Insert without generating';
371 +
372 + @override
373 + String get token_usage_title => 'Token Usage';
374 +
375 + @override
376 + String get total_tokens_label => 'Tokens used';
377 +
378 + @override
379 + String get usage_percent_label => 'Context used';
380 +
381 + @override
382 + String get export_choice_title => 'Export';
383 +
384 + @override
385 + String get export_choice_body => 'How would you like to export this?';
386 +
387 + @override
388 + String get copy_to_clipboard => 'Copy to Clipboard';
389 +
390 + @override
391 + String bulk_export_conversations_success(int count) {
392 + return 'Exported $count conversations';
393 + }
394 +
395 + @override
396 + String get bulk_ai_rename_confirm_title => 'Rename with AI?';
397 +
398 + @override
399 + String bulk_ai_rename_confirm_body(int count) {
400 + return 'This will ask the AI to generate a new title for each of the $count selected conversations, replacing their current titles. This can take a while and cannot be undone.';
401 + }
402 +
403 + @override
404 + String get sort_by_modified_date => 'Last modified';
405 +
406 + @override
407 + String get sort_by_created_date => 'Date created';
408 +
409 + @override
410 + String get sort_title => 'Sort';
411 +
412 @override
413 String get clear_conversation_title => 'チャット履歴をクリアしますか?';
414
@@ -467,7 +616,11 @@ class AppLocalizationsJa extends AppLocalizations {
616 String get enable_smart_reply => 'スマートリプライ';
617
618 @override
470 - String get ai_user_response_enabled => 'AI user message button';
619 + String get ai_user_response_enabled => 'AI user message (hold send)';
620 +
621 + @override
622 + String get ai_user_response_enabled_desc =>
623 + 'Hold the send button for 3 seconds to have the AI write and send your next message';
624
625 @override
626 String get ai_user_response_tooltip => 'Generate user message with AI';
@@ -484,6 +637,17 @@ class AppLocalizationsJa extends AppLocalizations {
637 @override
638 String get show_system_messages => 'システムメッセージを表示';
639
640 + @override
641 + String get show_system_messages_desc =>
642 + 'When no persona is selected, send a default assistant system prompt with each request';
643 +
644 + @override
645 + String get show_system_messages_in_chat => 'Show System Messages in Chat';
646 +
647 + @override
648 + String get show_system_messages_in_chat_desc =>
649 + 'Display system messages (e.g. from an imported backup) as visible bubbles in the conversation';
650 +
651 @override
652 String get haptic_feedback => '触覚フィードバック';
653
@@ -538,6 +702,13 @@ class AppLocalizationsJa extends AppLocalizations {
702 @override
703 String get select_persona => 'ペルソナを選択';
704
705 + @override
706 + String get manage_personas => 'Manage personas';
707 +
708 + @override
709 + String get personas_combine_hint =>
710 + 'Select multiple personas in chat to stack their system prompts.';
711 +
712 @override
713 String get start_conversation => 'チャットを開始しましょう';
714
@@ -1975,6 +2146,21 @@ class AppLocalizationsJa extends AppLocalizations {
2146 String get unload_models_before_load =>
2147 'Unload all models before loading a new one';
2148
2149 + @override
2150 + String get temp_chat_keyboard_incognito =>
2151 + 'Incognito keyboard in temporary chat';
2152 +
2153 + @override
2154 + String get temp_chat_keyboard_incognito_desc =>
2155 + 'Disables keyboard learning and suggestions in temporary chats (e.g. SwiftKey incognito).';
2156 +
2157 + @override
2158 + String get resume_last_chat => 'Resume last chat on launch';
2159 +
2160 + @override
2161 + String get resume_last_chat_desc =>
2162 + 'Restore your last open conversation when reopening the app.';
2163 +
2164 @override
2165 String get export_all_data => 'Export all data';
2166
@@ -2325,4 +2511,130 @@ class AppLocalizationsJa extends AppLocalizations {
2511
2512 @override
2513 String get generate_title_failed => 'Could not generate a title';
2514 +
2515 + @override
2516 + String get lm_studio_model_browser_title => 'Browse models';
2517 +
2518 + @override
2519 + String get lm_studio_model_search_hint => 'Search models by name or author…';
2520 +
2521 + @override
2522 + String get lm_studio_staff_picks => 'Staff picks';
2523 +
2524 + @override
2525 + String get lm_studio_community_models => 'Community models';
2526 +
2527 + @override
2528 + String get lm_studio_no_models => 'No models found';
2529 +
2530 + @override
2531 + String lm_studio_models_count(int count) {
2532 + return '$count models';
2533 + }
2534 +
2535 + @override
2536 + String get lm_studio_browse_models => 'Browse & download';
2537 +
2538 + @override
2539 + String get lm_studio_model_search => 'LMS Model Search';
2540 +
2541 + @override
2542 + String get lm_studio_downloads_title => 'Downloads';
2543 +
2544 + @override
2545 + String get lm_studio_choose_quant => 'Choose a download option';
2546 +
2547 + @override
2548 + String get lm_studio_use_default_quant => 'Use default';
2549 +
2550 + @override
2551 + String get lm_studio_recommended => 'Recommended';
2552 +
2553 + @override
2554 + String get lm_studio_clear_downloads => 'Clear finished';
2555 +
2556 + @override
2557 + String get lm_studio_no_downloads => 'No downloads yet';
2558 +
2559 + @override
2560 + String get lm_studio_downloads_disclaimer =>
2561 + 'Downloads run on the LM Studio host. Pausing, stopping, and deleting models must be done on that computer — not from this app.';
2562 +
2563 + @override
2564 + String get lm_studio_staff_pick => 'Staff pick';
2565 +
2566 + @override
2567 + String get lm_studio_params => 'PARAMS';
2568 +
2569 + @override
2570 + String get lm_studio_arch => 'ARCH';
2571 +
2572 + @override
2573 + String get lm_studio_domain => 'DOMAIN';
2574 +
2575 + @override
2576 + String get lm_studio_format => 'FORMAT';
2577 +
2578 + @override
2579 + String get lm_studio_vision => 'Vision';
2580 +
2581 + @override
2582 + String get lm_studio_tool_use => 'Tool use';
2583 +
2584 + @override
2585 + String get lm_studio_reasoning => 'Reasoning';
2586 +
2587 + @override
2588 + String get lm_studio_download_options => 'Download options';
2589 +
2590 + @override
2591 + String get lm_studio_download => 'Download';
2592 +
2593 + @override
2594 + String lm_studio_download_size(String size) {
2595 + return 'Download $size';
2596 + }
2597 +
2598 + @override
2599 + String lm_studio_downloading_percent(int percent) {
2600 + return 'Downloading $percent%';
2601 + }
2602 +
2603 + @override
2604 + String get lm_studio_readme_unavailable =>
2605 + 'README not available for this model.';
2606 +
2607 + @override
2608 + String get lm_studio_full_gpu_offload => 'Full GPU offload possible';
2609 +
2610 + @override
2611 + String get lm_studio_partial_gpu_offload => 'Partial GPU offload possible';
2612 +
2613 + @override
2614 + String get lm_studio_likely_too_large => 'Likely too large';
2615 +
2616 + @override
2617 + String get lm_studio_available_ram_gb => 'Available RAM (GB, optional)';
2618 +
2619 + @override
2620 + String get lm_studio_available_vram_gb => 'Available VRAM (GB, optional)';
2621 +
2622 + @override
2623 + String get lm_studio_memory_settings_title => 'Memory for recommendations';
2624 +
2625 + @override
2626 + String get lm_studio_memory_settings_desc =>
2627 + 'Used to estimate whether models fit on your machine in the model browser.';
2628 +
2629 + @override
2630 + String get think_button_label => 'Think';
2631 +
2632 + @override
2633 + String get reasoning_effort_low => 'Low';
2634 +
2635 + @override
2636 + String get reasoning_effort_medium => 'Medium';
2637 +
2638 + @override
2639 + String get reasoning_effort_high => 'High';
2640 }
lib/l10n/app_localizations_ru.dart
+314 -2
@@ -51,7 +51,7 @@ class AppLocalizationsRu extends AppLocalizations {
51 String get download => 'Скачать';
52
53 @override
54 - String get resume => 'Резюме';
54 + String get resume => 'Продолжить';
55
56 @override
57 String get pause => 'Пауза';
@@ -261,6 +261,155 @@ class AppLocalizationsRu extends AppLocalizations {
261 @override
262 String get delete_persona_body => 'Это невозможно отменить.';
263
264 + @override
265 + String get delete_builtin_persona_body =>
266 + 'This is a built-in persona. You can restore it later from Settings.';
267 +
268 + @override
269 + String get restore_builtin_personas => 'Restore default personas';
270 +
271 + @override
272 + String get restore_builtin_personas_desc =>
273 + 'Re-add any built-in personas you\'ve deleted';
274 +
275 + @override
276 + String get restore_builtin_personas_success => 'Default personas restored';
277 +
278 + @override
279 + String get clear_personas => 'Clear personas';
280 +
281 + @override
282 + String get enable_image_compression => 'Compress images before sending';
283 +
284 + @override
285 + String get enable_image_compression_desc =>
286 + 'Resize and compress attached images so uploads stay within server limits';
287 +
288 + @override
289 + String get image_compression_level => 'Compression aggressiveness';
290 +
291 + @override
292 + String get image_compression_level_desc =>
293 + 'Higher aggressiveness produces smaller uploads at lower quality';
294 +
295 + @override
296 + String get image_compression_level_low => 'Low';
297 +
298 + @override
299 + String get image_compression_level_medium => 'Medium';
300 +
301 + @override
302 + String get image_compression_level_high => 'High';
303 +
304 + @override
305 + String get sort_models_tooltip => 'Sort models';
306 +
307 + @override
308 + String get sort_by_favorites => 'Favorites first';
309 +
310 + @override
311 + String get sort_by_name => 'Name (A-Z)';
312 +
313 + @override
314 + String get sort_by_size_smallest => 'Size (smallest first)';
315 +
316 + @override
317 + String get sort_by_size_largest => 'Size (largest first)';
318 +
319 + @override
320 + String get sort_by_context_length => 'Context length';
321 +
322 + @override
323 + String bulk_ai_rename_progress(int done, int total) {
324 + return 'Renaming $done/$total...';
325 + }
326 +
327 + @override
328 + String selected_count(int count) {
329 + return '$count selected';
330 + }
331 +
332 + @override
333 + String get ai_rename_tooltip => 'Rename selected with AI';
334 +
335 + @override
336 + String get new_chat_in_folder_tooltip => 'New chat in this folder';
337 +
338 + @override
339 + String total_tokens_count(int count) {
340 + return '$count tokens';
341 + }
342 +
343 + @override
344 + String get smart_replies_use_persona => 'Use persona in smart replies';
345 +
346 + @override
347 + String get smart_replies_use_persona_desc =>
348 + 'Suggested replies match the tone of the active persona instead of a generic assistant';
349 +
350 + @override
351 + String get keep_persona_on_new_chat => 'Keep persona on new chat';
352 +
353 + @override
354 + String get keep_persona_on_new_chat_desc =>
355 + 'Don\'t clear the selected persona(s) after starting a new chat';
356 +
357 + @override
358 + String get role_swap_button_enabled => 'Show role-swap button';
359 +
360 + @override
361 + String get role_swap_button_enabled_desc =>
362 + 'Show a button in the chat input to send your message as the assistant instead of the user, without generating a response';
363 +
364 + @override
365 + String get send_as_user_tooltip => 'Send as user';
366 +
367 + @override
368 + String get send_as_assistant_tooltip => 'Send as assistant (no response)';
369 +
370 + @override
371 + String get insert_without_generating_tooltip => 'Insert without generating';
372 +
373 + @override
374 + String get token_usage_title => 'Token Usage';
375 +
376 + @override
377 + String get total_tokens_label => 'Tokens used';
378 +
379 + @override
380 + String get usage_percent_label => 'Context used';
381 +
382 + @override
383 + String get export_choice_title => 'Export';
384 +
385 + @override
386 + String get export_choice_body => 'How would you like to export this?';
387 +
388 + @override
389 + String get copy_to_clipboard => 'Copy to Clipboard';
390 +
391 + @override
392 + String bulk_export_conversations_success(int count) {
393 + return 'Exported $count conversations';
394 + }
395 +
396 + @override
397 + String get bulk_ai_rename_confirm_title => 'Rename with AI?';
398 +
399 + @override
400 + String bulk_ai_rename_confirm_body(int count) {
401 + return 'This will ask the AI to generate a new title for each of the $count selected conversations, replacing their current titles. This can take a while and cannot be undone.';
402 + }
403 +
404 + @override
405 + String get sort_by_modified_date => 'Last modified';
406 +
407 + @override
408 + String get sort_by_created_date => 'Date created';
409 +
410 + @override
411 + String get sort_title => 'Sort';
412 +
413 @override
414 String get clear_conversation_title => 'Ясный разговор?';
415
@@ -471,7 +620,11 @@ class AppLocalizationsRu extends AppLocalizations {
620 String get enable_smart_reply => 'Умные ответы на устройстве';
621
622 @override
474 - String get ai_user_response_enabled => 'AI user message button';
623 + String get ai_user_response_enabled => 'AI user message (hold send)';
624 +
625 + @override
626 + String get ai_user_response_enabled_desc =>
627 + 'Hold the send button for 3 seconds to have the AI write and send your next message';
628
629 @override
630 String get ai_user_response_tooltip => 'Generate user message with AI';
@@ -488,6 +641,17 @@ class AppLocalizationsRu extends AppLocalizations {
641 @override
642 String get show_system_messages => 'Показать системные сообщения';
643
644 + @override
645 + String get show_system_messages_desc =>
646 + 'When no persona is selected, send a default assistant system prompt with each request';
647 +
648 + @override
649 + String get show_system_messages_in_chat => 'Show System Messages in Chat';
650 +
651 + @override
652 + String get show_system_messages_in_chat_desc =>
653 + 'Display system messages (e.g. from an imported backup) as visible bubbles in the conversation';
654 +
655 @override
656 String get haptic_feedback => 'Тактильная обратная связь';
657
@@ -543,6 +707,13 @@ class AppLocalizationsRu extends AppLocalizations {
707 @override
708 String get select_persona => 'Выберите Персону';
709
710 + @override
711 + String get manage_personas => 'Manage personas';
712 +
713 + @override
714 + String get personas_combine_hint =>
715 + 'Select multiple personas in chat to stack their system prompts.';
716 +
717 @override
718 String get start_conversation => 'Начать разговор';
719
@@ -2014,6 +2185,21 @@ class AppLocalizationsRu extends AppLocalizations {
2185 String get unload_models_before_load =>
2186 'Unload all models before loading a new one';
2187
2188 + @override
2189 + String get temp_chat_keyboard_incognito =>
2190 + 'Приватная клавиатура во временном чате';
2191 +
2192 + @override
2193 + String get temp_chat_keyboard_incognito_desc =>
2194 + 'Отключает обучение и подсказки клавиатуры во временных чатах (например, режим инкогнито SwiftKey).';
2195 +
2196 + @override
2197 + String get resume_last_chat => 'Восстанавливать последний чат при запуске';
2198 +
2199 + @override
2200 + String get resume_last_chat_desc =>
2201 + 'Открывать последнюю активную беседу при повторном запуске приложения.';
2202 +
2203 @override
2204 String get export_all_data => 'Export all data';
2205
@@ -2366,4 +2552,130 @@ class AppLocalizationsRu extends AppLocalizations {
2552
2553 @override
2554 String get generate_title_failed => 'Не удалось сгенерировать заголовок';
2555 +
2556 + @override
2557 + String get lm_studio_model_browser_title => 'Browse models';
2558 +
2559 + @override
2560 + String get lm_studio_model_search_hint => 'Search models by name or author…';
2561 +
2562 + @override
2563 + String get lm_studio_staff_picks => 'Staff picks';
2564 +
2565 + @override
2566 + String get lm_studio_community_models => 'Community models';
2567 +
2568 + @override
2569 + String get lm_studio_no_models => 'No models found';
2570 +
2571 + @override
2572 + String lm_studio_models_count(int count) {
2573 + return '$count models';
2574 + }
2575 +
2576 + @override
2577 + String get lm_studio_browse_models => 'Browse & download';
2578 +
2579 + @override
2580 + String get lm_studio_model_search => 'LMS Model Search';
2581 +
2582 + @override
2583 + String get lm_studio_downloads_title => 'Downloads';
2584 +
2585 + @override
2586 + String get lm_studio_choose_quant => 'Choose a download option';
2587 +
2588 + @override
2589 + String get lm_studio_use_default_quant => 'Use default';
2590 +
2591 + @override
2592 + String get lm_studio_recommended => 'Recommended';
2593 +
2594 + @override
2595 + String get lm_studio_clear_downloads => 'Clear finished';
2596 +
2597 + @override
2598 + String get lm_studio_no_downloads => 'No downloads yet';
2599 +
2600 + @override
2601 + String get lm_studio_downloads_disclaimer =>
2602 + 'Downloads run on the LM Studio host. Pausing, stopping, and deleting models must be done on that computer — not from this app.';
2603 +
2604 + @override
2605 + String get lm_studio_staff_pick => 'Staff pick';
2606 +
2607 + @override
2608 + String get lm_studio_params => 'PARAMS';
2609 +
2610 + @override
2611 + String get lm_studio_arch => 'ARCH';
2612 +
2613 + @override
2614 + String get lm_studio_domain => 'DOMAIN';
2615 +
2616 + @override
2617 + String get lm_studio_format => 'FORMAT';
2618 +
2619 + @override
2620 + String get lm_studio_vision => 'Vision';
2621 +
2622 + @override
2623 + String get lm_studio_tool_use => 'Tool use';
2624 +
2625 + @override
2626 + String get lm_studio_reasoning => 'Reasoning';
2627 +
2628 + @override
2629 + String get lm_studio_download_options => 'Download options';
2630 +
2631 + @override
2632 + String get lm_studio_download => 'Download';
2633 +
2634 + @override
2635 + String lm_studio_download_size(String size) {
2636 + return 'Download $size';
2637 + }
2638 +
2639 + @override
2640 + String lm_studio_downloading_percent(int percent) {
2641 + return 'Downloading $percent%';
2642 + }
2643 +
2644 + @override
2645 + String get lm_studio_readme_unavailable =>
2646 + 'README not available for this model.';
2647 +
2648 + @override
2649 + String get lm_studio_full_gpu_offload => 'Full GPU offload possible';
2650 +
2651 + @override
2652 + String get lm_studio_partial_gpu_offload => 'Partial GPU offload possible';
2653 +
2654 + @override
2655 + String get lm_studio_likely_too_large => 'Likely too large';
2656 +
2657 + @override
2658 + String get lm_studio_available_ram_gb => 'Available RAM (GB, optional)';
2659 +
2660 + @override
2661 + String get lm_studio_available_vram_gb => 'Available VRAM (GB, optional)';
2662 +
2663 + @override
2664 + String get lm_studio_memory_settings_title => 'Memory for recommendations';
2665 +
2666 + @override
2667 + String get lm_studio_memory_settings_desc =>
2668 + 'Used to estimate whether models fit on your machine in the model browser.';
2669 +
2670 + @override
2671 + String get think_button_label => 'Think';
2672 +
2673 + @override
2674 + String get reasoning_effort_low => 'Low';
2675 +
2676 + @override
2677 + String get reasoning_effort_medium => 'Medium';
2678 +
2679 + @override
2680 + String get reasoning_effort_high => 'High';
2681 }
lib/l10n/app_localizations_zh.dart
+313 -1
@@ -260,6 +260,155 @@ class AppLocalizationsZh extends AppLocalizations {
260 @override
261 String get delete_persona_body => '此操作无法撤销。';
262
263 + @override
264 + String get delete_builtin_persona_body =>
265 + 'This is a built-in persona. You can restore it later from Settings.';
266 +
267 + @override
268 + String get restore_builtin_personas => 'Restore default personas';
269 +
270 + @override
271 + String get restore_builtin_personas_desc =>
272 + 'Re-add any built-in personas you\'ve deleted';
273 +
274 + @override
275 + String get restore_builtin_personas_success => 'Default personas restored';
276 +
277 + @override
278 + String get clear_personas => 'Clear personas';
279 +
280 + @override
281 + String get enable_image_compression => 'Compress images before sending';
282 +
283 + @override
284 + String get enable_image_compression_desc =>
285 + 'Resize and compress attached images so uploads stay within server limits';
286 +
287 + @override
288 + String get image_compression_level => 'Compression aggressiveness';
289 +
290 + @override
291 + String get image_compression_level_desc =>
292 + 'Higher aggressiveness produces smaller uploads at lower quality';
293 +
294 + @override
295 + String get image_compression_level_low => 'Low';
296 +
297 + @override
298 + String get image_compression_level_medium => 'Medium';
299 +
300 + @override
301 + String get image_compression_level_high => 'High';
302 +
303 + @override
304 + String get sort_models_tooltip => 'Sort models';
305 +
306 + @override
307 + String get sort_by_favorites => 'Favorites first';
308 +
309 + @override
310 + String get sort_by_name => 'Name (A-Z)';
311 +
312 + @override
313 + String get sort_by_size_smallest => 'Size (smallest first)';
314 +
315 + @override
316 + String get sort_by_size_largest => 'Size (largest first)';
317 +
318 + @override
319 + String get sort_by_context_length => 'Context length';
320 +
321 + @override
322 + String bulk_ai_rename_progress(int done, int total) {
323 + return 'Renaming $done/$total...';
324 + }
325 +
326 + @override
327 + String selected_count(int count) {
328 + return '$count selected';
329 + }
330 +
331 + @override
332 + String get ai_rename_tooltip => 'Rename selected with AI';
333 +
334 + @override
335 + String get new_chat_in_folder_tooltip => 'New chat in this folder';
336 +
337 + @override
338 + String total_tokens_count(int count) {
339 + return '$count tokens';
340 + }
341 +
342 + @override
343 + String get smart_replies_use_persona => 'Use persona in smart replies';
344 +
345 + @override
346 + String get smart_replies_use_persona_desc =>
347 + 'Suggested replies match the tone of the active persona instead of a generic assistant';
348 +
349 + @override
350 + String get keep_persona_on_new_chat => 'Keep persona on new chat';
351 +
352 + @override
353 + String get keep_persona_on_new_chat_desc =>
354 + 'Don\'t clear the selected persona(s) after starting a new chat';
355 +
356 + @override
357 + String get role_swap_button_enabled => 'Show role-swap button';
358 +
359 + @override
360 + String get role_swap_button_enabled_desc =>
361 + 'Show a button in the chat input to send your message as the assistant instead of the user, without generating a response';
362 +
363 + @override
364 + String get send_as_user_tooltip => 'Send as user';
365 +
366 + @override
367 + String get send_as_assistant_tooltip => 'Send as assistant (no response)';
368 +
369 + @override
370 + String get insert_without_generating_tooltip => 'Insert without generating';
371 +
372 + @override
373 + String get token_usage_title => 'Token Usage';
374 +
375 + @override
376 + String get total_tokens_label => 'Tokens used';
377 +
378 + @override
379 + String get usage_percent_label => 'Context used';
380 +
381 + @override
382 + String get export_choice_title => 'Export';
383 +
384 + @override
385 + String get export_choice_body => 'How would you like to export this?';
386 +
387 + @override
388 + String get copy_to_clipboard => 'Copy to Clipboard';
389 +
390 + @override
391 + String bulk_export_conversations_success(int count) {
392 + return 'Exported $count conversations';
393 + }
394 +
395 + @override
396 + String get bulk_ai_rename_confirm_title => 'Rename with AI?';
397 +
398 + @override
399 + String bulk_ai_rename_confirm_body(int count) {
400 + return 'This will ask the AI to generate a new title for each of the $count selected conversations, replacing their current titles. This can take a while and cannot be undone.';
401 + }
402 +
403 + @override
404 + String get sort_by_modified_date => 'Last modified';
405 +
406 + @override
407 + String get sort_by_created_date => 'Date created';
408 +
409 + @override
410 + String get sort_title => 'Sort';
411 +
412 @override
413 String get clear_conversation_title => '清除对话吗?';
414
@@ -465,7 +614,11 @@ class AppLocalizationsZh extends AppLocalizations {
614 String get enable_smart_reply => '设备端智能回复';
615
616 @override
468 - String get ai_user_response_enabled => 'AI user message button';
617 + String get ai_user_response_enabled => 'AI user message (hold send)';
618 +
619 + @override
620 + String get ai_user_response_enabled_desc =>
621 + 'Hold the send button for 3 seconds to have the AI write and send your next message';
622
623 @override
624 String get ai_user_response_tooltip => 'Generate user message with AI';
@@ -482,6 +635,17 @@ class AppLocalizationsZh extends AppLocalizations {
635 @override
636 String get show_system_messages => '显示系统消息';
637
638 + @override
639 + String get show_system_messages_desc =>
640 + 'When no persona is selected, send a default assistant system prompt with each request';
641 +
642 + @override
643 + String get show_system_messages_in_chat => 'Show System Messages in Chat';
644 +
645 + @override
646 + String get show_system_messages_in_chat_desc =>
647 + 'Display system messages (e.g. from an imported backup) as visible bubbles in the conversation';
648 +
649 @override
650 String get haptic_feedback => '触觉反馈';
651
@@ -536,6 +700,13 @@ class AppLocalizationsZh extends AppLocalizations {
700 @override
701 String get select_persona => '选择角色';
702
703 + @override
704 + String get manage_personas => 'Manage personas';
705 +
706 + @override
707 + String get personas_combine_hint =>
708 + 'Select multiple personas in chat to stack their system prompts.';
709 +
710 @override
711 String get start_conversation => '开始对话';
712
@@ -1968,6 +2139,21 @@ class AppLocalizationsZh extends AppLocalizations {
2139 String get unload_models_before_load =>
2140 'Unload all models before loading a new one';
2141
2142 + @override
2143 + String get temp_chat_keyboard_incognito =>
2144 + 'Incognito keyboard in temporary chat';
2145 +
2146 + @override
2147 + String get temp_chat_keyboard_incognito_desc =>
2148 + 'Disables keyboard learning and suggestions in temporary chats (e.g. SwiftKey incognito).';
2149 +
2150 + @override
2151 + String get resume_last_chat => 'Resume last chat on launch';
2152 +
2153 + @override
2154 + String get resume_last_chat_desc =>
2155 + 'Restore your last open conversation when reopening the app.';
2156 +
2157 @override
2158 String get export_all_data => 'Export all data';
2159
@@ -2318,6 +2504,132 @@ class AppLocalizationsZh extends AppLocalizations {
2504
2505 @override
2506 String get generate_title_failed => 'Could not generate a title';
2507 +
2508 + @override
2509 + String get lm_studio_model_browser_title => 'Browse models';
2510 +
2511 + @override
2512 + String get lm_studio_model_search_hint => 'Search models by name or author…';
2513 +
2514 + @override
2515 + String get lm_studio_staff_picks => 'Staff picks';
2516 +
2517 + @override
2518 + String get lm_studio_community_models => 'Community models';
2519 +
2520 + @override
2521 + String get lm_studio_no_models => 'No models found';
2522 +
2523 + @override
2524 + String lm_studio_models_count(int count) {
2525 + return '$count models';
2526 + }
2527 +
2528 + @override
2529 + String get lm_studio_browse_models => 'Browse & download';
2530 +
2531 + @override
2532 + String get lm_studio_model_search => 'LMS Model Search';
2533 +
2534 + @override
2535 + String get lm_studio_downloads_title => 'Downloads';
2536 +
2537 + @override
2538 + String get lm_studio_choose_quant => 'Choose a download option';
2539 +
2540 + @override
2541 + String get lm_studio_use_default_quant => 'Use default';
2542 +
2543 + @override
2544 + String get lm_studio_recommended => 'Recommended';
2545 +
2546 + @override
2547 + String get lm_studio_clear_downloads => 'Clear finished';
2548 +
2549 + @override
2550 + String get lm_studio_no_downloads => 'No downloads yet';
2551 +
2552 + @override
2553 + String get lm_studio_downloads_disclaimer =>
2554 + 'Downloads run on the LM Studio host. Pausing, stopping, and deleting models must be done on that computer — not from this app.';
2555 +
2556 + @override
2557 + String get lm_studio_staff_pick => 'Staff pick';
2558 +
2559 + @override
2560 + String get lm_studio_params => 'PARAMS';
2561 +
2562 + @override
2563 + String get lm_studio_arch => 'ARCH';
2564 +
2565 + @override
2566 + String get lm_studio_domain => 'DOMAIN';
2567 +
2568 + @override
2569 + String get lm_studio_format => 'FORMAT';
2570 +
2571 + @override
2572 + String get lm_studio_vision => 'Vision';
2573 +
2574 + @override
2575 + String get lm_studio_tool_use => 'Tool use';
2576 +
2577 + @override
2578 + String get lm_studio_reasoning => 'Reasoning';
2579 +
2580 + @override
2581 + String get lm_studio_download_options => 'Download options';
2582 +
2583 + @override
2584 + String get lm_studio_download => 'Download';
2585 +
2586 + @override
2587 + String lm_studio_download_size(String size) {
2588 + return 'Download $size';
2589 + }
2590 +
2591 + @override
2592 + String lm_studio_downloading_percent(int percent) {
2593 + return 'Downloading $percent%';
2594 + }
2595 +
2596 + @override
2597 + String get lm_studio_readme_unavailable =>
2598 + 'README not available for this model.';
2599 +
2600 + @override
2601 + String get lm_studio_full_gpu_offload => 'Full GPU offload possible';
2602 +
2603 + @override
2604 + String get lm_studio_partial_gpu_offload => 'Partial GPU offload possible';
2605 +
2606 + @override
2607 + String get lm_studio_likely_too_large => 'Likely too large';
2608 +
2609 + @override
2610 + String get lm_studio_available_ram_gb => 'Available RAM (GB, optional)';
2611 +
2612 + @override
2613 + String get lm_studio_available_vram_gb => 'Available VRAM (GB, optional)';
2614 +
2615 + @override
2616 + String get lm_studio_memory_settings_title => 'Memory for recommendations';
2617 +
2618 + @override
2619 + String get lm_studio_memory_settings_desc =>
2620 + 'Used to estimate whether models fit on your machine in the model browser.';
2621 +
2622 + @override
2623 + String get think_button_label => 'Think';
2624 +
2625 + @override
2626 + String get reasoning_effort_low => 'Low';
2627 +
2628 + @override
2629 + String get reasoning_effort_medium => 'Medium';
2630 +
2631 + @override
2632 + String get reasoning_effort_high => 'High';
2633 }
2634
2635 /// The translations for Chinese, as used in Taiwan (`zh_TW`).
lib/l10n/app_ru.arb
+6 -2
@@ -313,7 +313,7 @@
313 "rename_conversation": "Переименовать беседу",
314 "reset_defaults": "Сбросить настройки по умолчанию",
315 "reset_settings_defaults": "Сброс настроек к значениям по умолчанию",
316 - "resume": "Резюме",
316 + "resume": "Продолжить",
317 "retry": "Повторить попытку",
318 "save": "Сохранять",
319 "save_changes": "Сохранить изменения",
@@ -615,5 +615,9 @@
615 "conversation_character_count": "{count} симв.",
616 "generate_title_with_ai": "Сгенерировать с ИИ",
617 "generating_title": "Генерация...",
618 - "generate_title_failed": "Не удалось сгенерировать заголовок"
618 + "generate_title_failed": "Не удалось сгенерировать заголовок",
619 + "temp_chat_keyboard_incognito": "Приватная клавиатура во временном чате",
620 + "temp_chat_keyboard_incognito_desc": "Отключает обучение и подсказки клавиатуры во временных чатах (например, режим инкогнито SwiftKey).",
621 + "resume_last_chat": "Восстанавливать последний чат при запуске",
622 + "resume_last_chat_desc": "Открывать последнюю активную беседу при повторном запуске приложения."
623 }
lib/objectbox-model.json
+29 -4
@@ -5,7 +5,7 @@
5 "entities": [
6 {
7 "id": "1:6060443518060866028",
8 - "lastPropertyId": "23:3122805731877931734",
8 + "lastPropertyId": "24:2324488738774851555",
9 "name": "ConversationEntity",
10 "properties": [
11 {
@@ -125,13 +125,18 @@
125 "id": "23:3122805731877931734",
126 "name": "characterCount",
127 "type": 6
128 + },
129 + {
130 + "id": "24:2324488738774851555",
131 + "name": "totalTokenCount",
132 + "type": 6
133 }
134 ],
135 "relations": []
136 },
137 {
138 "id": "2:196881068437544233",
134 - "lastPropertyId": "28:8162956617579414275",
139 + "lastPropertyId": "29:6028398201373029019",
140 "name": "MessageEntity",
141 "properties": [
142 {
@@ -281,6 +286,11 @@
286 "id": "28:8162956617579414275",
287 "name": "stopReason",
288 "type": 9
289 + },
290 + {
291 + "id": "29:6028398201373029019",
292 + "name": "contentTokenCount",
293 + "type": 6
294 }
295 ],
296 "relations": []
@@ -353,7 +363,7 @@
363 },
364 {
365 "id": "4:6971506781199819057",
356 - "lastPropertyId": "13:6207430374144016939",
366 + "lastPropertyId": "15:6058370228992885505",
367 "name": "ServerEntity",
368 "properties": [
369 {
@@ -423,6 +433,16 @@
433 "id": "13:6207430374144016939",
434 "name": "pathPrefix",
435 "type": 9
436 + },
437 + {
438 + "id": "14:5253774322138008698",
439 + "name": "availableRamGb",
440 + "type": 6
441 + },
442 + {
443 + "id": "15:6058370228992885505",
444 + "name": "availableVramGb",
445 + "type": 6
446 }
447 ],
448 "relations": []
@@ -465,7 +485,7 @@
485 },
486 {
487 "id": "6:152573122645768072",
468 - "lastPropertyId": "10:431621328894537301",
488 + "lastPropertyId": "11:8907657125307211651",
489 "name": "SavedMessageEntity",
490 "properties": [
491 {
@@ -524,6 +544,11 @@
544 "id": "10:431621328894537301",
545 "name": "savedAt",
546 "type": 10
547 + },
548 + {
549 + "id": "11:8907657125307211651",
550 + "name": "isArchived",
551 + "type": 1
552 }
553 ],
554 "relations": []
lib/objectbox.g.dart
+99 -8
@@ -22,7 +22,7 @@ final _entities = <obx_int.ModelEntity>[
22 obx_int.ModelEntity(
23 id: const obx_int.IdUid(1, 6060443518060866028),
24 name: 'ConversationEntity',
25 - lastPropertyId: const obx_int.IdUid(23, 3122805731877931734),
25 + lastPropertyId: const obx_int.IdUid(24, 2324488738774851555),
26 flags: 0,
27 properties: <obx_int.ModelProperty>[
28 obx_int.ModelProperty(
@@ -164,6 +164,12 @@ final _entities = <obx_int.ModelEntity>[
164 type: 6,
165 flags: 0,
166 ),
167 + obx_int.ModelProperty(
168 + id: const obx_int.IdUid(24, 2324488738774851555),
169 + name: 'totalTokenCount',
170 + type: 6,
171 + flags: 0,
172 + ),
173 ],
174 relations: <obx_int.ModelRelation>[],
175 backlinks: <obx_int.ModelBacklink>[
@@ -177,7 +183,7 @@ final _entities = <obx_int.ModelEntity>[
183 obx_int.ModelEntity(
184 id: const obx_int.IdUid(2, 196881068437544233),
185 name: 'MessageEntity',
180 - lastPropertyId: const obx_int.IdUid(28, 8162956617579414275),
186 + lastPropertyId: const obx_int.IdUid(29, 6028398201373029019),
187 flags: 0,
188 properties: <obx_int.ModelProperty>[
189 obx_int.ModelProperty(
@@ -353,6 +359,12 @@ final _entities = <obx_int.ModelEntity>[
359 type: 9,
360 flags: 0,
361 ),
362 + obx_int.ModelProperty(
363 + id: const obx_int.IdUid(29, 6028398201373029019),
364 + name: 'contentTokenCount',
365 + type: 6,
366 + flags: 0,
367 + ),
368 ],
369 relations: <obx_int.ModelRelation>[],
370 backlinks: <obx_int.ModelBacklink>[],
@@ -437,7 +449,7 @@ final _entities = <obx_int.ModelEntity>[
449 obx_int.ModelEntity(
450 id: const obx_int.IdUid(4, 6971506781199819057),
451 name: 'ServerEntity',
440 - lastPropertyId: const obx_int.IdUid(13, 6207430374144016939),
452 + lastPropertyId: const obx_int.IdUid(15, 6058370228992885505),
453 flags: 0,
454 properties: <obx_int.ModelProperty>[
455 obx_int.ModelProperty(
@@ -519,6 +531,18 @@ final _entities = <obx_int.ModelEntity>[
531 type: 9,
532 flags: 0,
533 ),
534 + obx_int.ModelProperty(
535 + id: const obx_int.IdUid(14, 5253774322138008698),
536 + name: 'availableRamGb',
537 + type: 6,
538 + flags: 0,
539 + ),
540 + obx_int.ModelProperty(
541 + id: const obx_int.IdUid(15, 6058370228992885505),
542 + name: 'availableVramGb',
543 + type: 6,
544 + flags: 0,
545 + ),
546 ],
547 relations: <obx_int.ModelRelation>[],
548 backlinks: <obx_int.ModelBacklink>[],
@@ -567,7 +591,7 @@ final _entities = <obx_int.ModelEntity>[
591 obx_int.ModelEntity(
592 id: const obx_int.IdUid(6, 152573122645768072),
593 name: 'SavedMessageEntity',
570 - lastPropertyId: const obx_int.IdUid(10, 431621328894537301),
594 + lastPropertyId: const obx_int.IdUid(11, 8907657125307211651),
595 flags: 0,
596 properties: <obx_int.ModelProperty>[
597 obx_int.ModelProperty(
@@ -633,6 +657,12 @@ final _entities = <obx_int.ModelEntity>[
657 type: 10,
658 flags: 0,
659 ),
660 + obx_int.ModelProperty(
661 + id: const obx_int.IdUid(11, 8907657125307211651),
662 + name: 'isArchived',
663 + type: 1,
664 + flags: 0,
665 + ),
666 ],
667 relations: <obx_int.ModelRelation>[],
668 backlinks: <obx_int.ModelBacklink>[],
@@ -779,7 +809,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
809 final folderIdOffset = object.folderId == null
810 ? null
811 : fbb.writeString(object.folderId!);
782 - fbb.startTable(24);
812 + fbb.startTable(25);
813 fbb.addInt64(0, object.internalId);
814 fbb.addOffset(1, idOffset);
815 fbb.addOffset(2, titleOffset);
@@ -803,6 +833,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
833 fbb.addBool(20, object.isTemporary);
834 fbb.addBool(21, object.isArchived);
835 fbb.addInt64(22, object.characterCount);
836 + fbb.addInt64(23, object.totalTokenCount);
837 fbb.finish(fbb.endTable());
838 return object.internalId;
839 },
@@ -906,6 +937,11 @@ obx_int.ModelDefinition getObjectBoxModel() {
937 48,
938 0,
939 );
940 + final totalTokenCountParam = const fb.Int64Reader().vTableGetNullable(
941 + buffer,
942 + rootOffset,
943 + 50,
944 + );
945 final object = ConversationEntity(
946 internalId: internalIdParam,
947 id: idParam,
@@ -930,6 +966,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
966 isTemporary: isTemporaryParam,
967 isArchived: isArchivedParam,
968 characterCount: characterCountParam,
969 + totalTokenCount: totalTokenCountParam,
970 );
971 obx_int.InternalToManyAccess.setRelInfo<ConversationEntity>(
972 object.messages,
@@ -988,7 +1025,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
1025 final stopReasonOffset = object.stopReason == null
1026 ? null
1027 : fbb.writeString(object.stopReason!);
991 - fbb.startTable(29);
1028 + fbb.startTable(30);
1029 fbb.addInt64(0, object.internalId);
1030 fbb.addOffset(1, idOffset);
1031 fbb.addInt64(2, object.conversation.targetId);
@@ -1017,6 +1054,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
1054 fbb.addFloat64(25, object.tokensPerSecond);
1055 fbb.addInt64(26, object.ttftMs);
1056 fbb.addOffset(27, stopReasonOffset);
1057 + fbb.addInt64(28, object.contentTokenCount);
1058 fbb.finish(fbb.endTable());
1059 return object.internalId;
1060 },
@@ -1135,6 +1173,11 @@ obx_int.ModelDefinition getObjectBoxModel() {
1173 final parentMessageIdParam = const fb.StringReader(
1174 asciiOptimization: true,
1175 ).vTableGetNullable(buffer, rootOffset, 50);
1176 + final contentTokenCountParam = const fb.Int64Reader().vTableGetNullable(
1177 + buffer,
1178 + rootOffset,
1179 + 60,
1180 + );
1181 final object = MessageEntity(
1182 internalId: internalIdParam,
1183 id: idParam,
@@ -1163,6 +1206,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
1206 threadOrder: threadOrderParam,
1207 isActiveVariant: isActiveVariantParam,
1208 parentMessageId: parentMessageIdParam,
1209 + contentTokenCount: contentTokenCountParam,
1210 );
1211 object.conversation.targetId = const fb.Int64Reader().vTableGet(
1212 buffer,
@@ -1291,7 +1335,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
1335 final pathPrefixOffset = object.pathPrefix == null
1336 ? null
1337 : fbb.writeString(object.pathPrefix!);
1294 - fbb.startTable(14);
1338 + fbb.startTable(16);
1339 fbb.addInt64(0, object.internalId);
1340 fbb.addOffset(1, idOffset);
1341 fbb.addOffset(2, nameOffset);
@@ -1305,6 +1349,8 @@ obx_int.ModelDefinition getObjectBoxModel() {
1349 fbb.addInt32(10, object.statusIndex);
1350 fbb.addOffset(11, iconNameOffset);
1351 fbb.addOffset(12, pathPrefixOffset);
1352 + fbb.addInt64(13, object.availableRamGb);
1353 + fbb.addInt64(14, object.availableVramGb);
1354 fbb.finish(fbb.endTable());
1355 return object.internalId;
1356 },
@@ -1365,6 +1411,16 @@ obx_int.ModelDefinition getObjectBoxModel() {
1411 final pathPrefixParam = const fb.StringReader(
1412 asciiOptimization: true,
1413 ).vTableGetNullable(buffer, rootOffset, 28);
1414 + final availableRamGbParam = const fb.Int64Reader().vTableGetNullable(
1415 + buffer,
1416 + rootOffset,
1417 + 30,
1418 + );
1419 + final availableVramGbParam = const fb.Int64Reader().vTableGetNullable(
1420 + buffer,
1421 + rootOffset,
1422 + 32,
1423 + );
1424 final object = ServerEntity(
1425 internalId: internalIdParam,
1426 id: idParam,
@@ -1379,6 +1435,8 @@ obx_int.ModelDefinition getObjectBoxModel() {
1435 statusIndex: statusIndexParam,
1436 iconName: iconNameParam,
1437 pathPrefix: pathPrefixParam,
1438 + availableRamGb: availableRamGbParam,
1439 + availableVramGb: availableVramGbParam,
1440 );
1441
1442 return object;
@@ -1462,7 +1520,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
1520 final folderIdOffset = object.folderId == null
1521 ? null
1522 : fbb.writeString(object.folderId!);
1465 - fbb.startTable(11);
1523 + fbb.startTable(12);
1524 fbb.addInt64(0, object.internalId);
1525 fbb.addOffset(1, idOffset);
1526 fbb.addOffset(2, sourceMessageIdOffset);
@@ -1473,6 +1531,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
1531 fbb.addOffset(7, modelIdOffset);
1532 fbb.addOffset(8, folderIdOffset);
1533 fbb.addInt64(9, object.savedAt.millisecondsSinceEpoch);
1534 + fbb.addBool(10, object.isArchived);
1535 fbb.finish(fbb.endTable());
1536 return object.internalId;
1537 },
@@ -1515,6 +1574,12 @@ obx_int.ModelDefinition getObjectBoxModel() {
1574 final savedAtParam = DateTime.fromMillisecondsSinceEpoch(
1575 const fb.Int64Reader().vTableGet(buffer, rootOffset, 22, 0),
1576 );
1577 + final isArchivedParam = const fb.BoolReader().vTableGet(
1578 + buffer,
1579 + rootOffset,
1580 + 24,
1581 + false,
1582 + );
1583 final object = SavedMessageEntity(
1584 internalId: internalIdParam,
1585 id: idParam,
@@ -1526,6 +1591,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
1591 modelId: modelIdParam,
1592 folderId: folderIdParam,
1593 savedAt: savedAtParam,
1594 + isArchived: isArchivedParam,
1595 );
1596
1597 return object;
@@ -1708,6 +1774,11 @@ class ConversationEntity_ {
1774 _entities[0].properties[22],
1775 );
1776
1777 + /// See [ConversationEntity.totalTokenCount].
1778 + static final totalTokenCount = obx.QueryIntegerProperty<ConversationEntity>(
1779 + _entities[0].properties[23],
1780 + );
1781 +
1782 /// see [ConversationEntity.messages]
1783 static final messages =
1784 obx.QueryBacklinkToMany<MessageEntity, ConversationEntity>(
@@ -1857,6 +1928,11 @@ class MessageEntity_ {
1928 static final stopReason = obx.QueryStringProperty<MessageEntity>(
1929 _entities[1].properties[27],
1930 );
1931 +
1932 + /// See [MessageEntity.contentTokenCount].
1933 + static final contentTokenCount = obx.QueryIntegerProperty<MessageEntity>(
1934 + _entities[1].properties[28],
1935 + );
1936 }
1937
1938 /// [PersonaEntity] entity fields to define ObjectBox queries.
@@ -1983,6 +2059,16 @@ class ServerEntity_ {
2059 static final pathPrefix = obx.QueryStringProperty<ServerEntity>(
2060 _entities[3].properties[12],
2061 );
2062 +
2063 + /// See [ServerEntity.availableRamGb].
2064 + static final availableRamGb = obx.QueryIntegerProperty<ServerEntity>(
2065 + _entities[3].properties[13],
2066 + );
2067 +
2068 + /// See [ServerEntity.availableVramGb].
2069 + static final availableVramGb = obx.QueryIntegerProperty<ServerEntity>(
2070 + _entities[3].properties[14],
2071 + );
2072 }
2073
2074 /// [ConversationFolderEntity] entity fields to define ObjectBox queries.
@@ -2064,6 +2150,11 @@ class SavedMessageEntity_ {
2150 static final savedAt = obx.QueryDateProperty<SavedMessageEntity>(
2151 _entities[5].properties[9],
2152 );
2153 +
2154 + /// See [SavedMessageEntity.isArchived].
2155 + static final isArchived = obx.QueryBooleanProperty<SavedMessageEntity>(
2156 + _entities[5].properties[10],
2157 + );
2158 }
2159
2160 /// [SavedMessageFolderEntity] entity fields to define ObjectBox queries.