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.