@tduncan / Cortana / commits / d912c4c

Add temporary chats, saved messages & TTS features

Introduce temporary (in-memory) chat mode and UI (banner, mode button), Saved Messages UI/workflow (picker, folders, save sheet), sharing service, and AI title generation service. Enhance chat streaming/metrics and persistence (new conversation/message fields + ObjectBox schema updates). Add TTS improvements: seeking, skip interval setting, download merged WAV, system-TTS seek heuristics, and player UI/controls. New reusable UI components (FolderFilterBar, ListFilterButton, ModelInfoSheet, Server picker) and image/attachment support (permissions + image_picker). Add Russian localization and many l10n updates.

onlypuppy7 committed Jun 30, 2026 at 22:40 UTC d912c4c5b7d00cade19f838adf4b9701ec5a80c6
61 files changed +9416 -852
android/app/src/main/AndroidManifest.xml
+2
@@ -7,6 +7,8 @@
7 <uses-permission android:name="android.permission.WAKE_LOCK" />
8 <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
9 <uses-permission android:name="android.permission.RECORD_AUDIO" />
10 + <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
11 + <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
12
13 <application
14 android:label="LocalMind"
lib/app.dart
+1
@@ -234,6 +234,7 @@ class App extends ConsumerWidget {
234 Locale('hi'),
235 Locale('it'),
236 Locale('ja'),
237 + Locale('ru'),
238 ],
239 locale: localeCode != null ? Locale(localeCode) : null,
240 localeResolutionCallback: (locale, supportedLocales) {
lib/core/components/folder_filter_bar.dart new
+155
@@ -0,0 +1,155 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:localmind/l10n/app_localizations.dart';
3 +
4 +class FolderFilterItem {
5 + const FolderFilterItem({required this.id, required this.name});
6 +
7 + final String id;
8 + final String name;
9 +}
10 +
11 +class FolderFilterBar extends StatelessWidget {
12 + const FolderFilterBar({
13 + super.key,
14 + required this.folders,
15 + required this.selectedFolderId,
16 + required this.onFilterChanged,
17 + required this.onCreateFolder,
18 + this.isLoading = false,
19 + this.showCreateFolder = true,
20 + });
21 +
22 + /// `null` = all, `''` = unfiled, otherwise folder id.
23 + final List<FolderFilterItem> folders;
24 + final String? selectedFolderId;
25 + final ValueChanged<String?> onFilterChanged;
26 + final VoidCallback onCreateFolder;
27 + final bool isLoading;
28 + final bool showCreateFolder;
29 +
30 + @override
31 + Widget build(BuildContext context) {
32 + final l10n = AppLocalizations.of(context)!;
33 + final theme = Theme.of(context);
34 + final isDark = theme.brightness == Brightness.dark;
35 +
36 + if (isLoading) {
37 + return const SizedBox(height: 44);
38 + }
39 +
40 + return SingleChildScrollView(
41 + scrollDirection: Axis.horizontal,
42 + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
43 + child: Row(
44 + children: [
45 + SystemFolderFilterChip(
46 + label: l10n.all_chats,
47 + icon: Icons.view_list_rounded,
48 + selected: selectedFolderId == null,
49 + isDark: isDark,
50 + onSelected: (_) => onFilterChanged(null),
51 + ),
52 + const SizedBox(width: 6),
53 + SystemFolderFilterChip(
54 + label: l10n.unfiled_chats,
55 + icon: Icons.inbox_outlined,
56 + selected: selectedFolderId != null && selectedFolderId!.isEmpty,
57 + isDark: isDark,
58 + outlined: true,
59 + onSelected: (_) => onFilterChanged(''),
60 + ),
61 + ...folders.map(
62 + (folder) => Padding(
63 + padding: const EdgeInsets.only(left: 6),
64 + child: FilterChip(
65 + avatar: Icon(
66 + Icons.folder_outlined,
67 + size: 16,
68 + color: selectedFolderId == folder.id
69 + ? theme.colorScheme.onSecondaryContainer
70 + : theme.colorScheme.primary,
71 + ),
72 + label: Text(folder.name),
73 + selected: selectedFolderId == folder.id,
74 + showCheckmark: false,
75 + onSelected: (_) => onFilterChanged(folder.id),
76 + ),
77 + ),
78 + ),
79 + if (showCreateFolder) ...[
80 + const SizedBox(width: 6),
81 + ActionChip(
82 + avatar: Icon(
83 + Icons.create_new_folder_outlined,
84 + size: 16,
85 + color: theme.colorScheme.primary,
86 + ),
87 + label: Text(l10n.new_folder),
88 + side: BorderSide(
89 + color: theme.colorScheme.primary.withValues(alpha: 0.5),
90 + style: BorderStyle.solid,
91 + ),
92 + onPressed: onCreateFolder,
93 + ),
94 + ],
95 + ],
96 + ),
97 + );
98 + }
99 +}
100 +
101 +class SystemFolderFilterChip extends StatelessWidget {
102 + const SystemFolderFilterChip({
103 + super.key,
104 + required this.label,
105 + required this.icon,
106 + required this.selected,
107 + required this.isDark,
108 + required this.onSelected,
109 + this.outlined = false,
110 + });
111 +
112 + final String label;
113 + final IconData icon;
114 + final bool selected;
115 + final bool isDark;
116 + final bool outlined;
117 + final ValueChanged<bool> onSelected;
118 +
119 + @override
120 + Widget build(BuildContext context) {
121 + final theme = Theme.of(context);
122 + final bgColor = selected
123 + ? (outlined
124 + ? theme.colorScheme.surfaceContainerHighest
125 + : theme.colorScheme.secondaryContainer)
126 + : Colors.transparent;
127 + final borderColor = outlined
128 + ? (isDark ? const Color(0xFF444444) : const Color(0xFFCCCCCC))
129 + : (selected
130 + ? theme.colorScheme.secondaryContainer
131 + : (isDark ? const Color(0xFF333333) : const Color(0xFFE0E0E0)));
132 +
133 + return FilterChip(
134 + avatar: Icon(
135 + icon,
136 + size: 16,
137 + color: selected
138 + ? theme.colorScheme.onSecondaryContainer
139 + : theme.colorScheme.onSurfaceVariant,
140 + ),
141 + label: Text(
142 + label,
143 + style: TextStyle(
144 + fontWeight: outlined ? FontWeight.normal : FontWeight.w600,
145 + fontStyle: outlined ? FontStyle.italic : FontStyle.normal,
146 + ),
147 + ),
148 + selected: selected,
149 + showCheckmark: false,
150 + backgroundColor: bgColor,
151 + side: BorderSide(color: borderColor),
152 + onSelected: onSelected,
153 + );
154 + }
155 +}
lib/core/components/list_filter_button.dart new
+93
@@ -0,0 +1,93 @@
1 +import 'package:flutter/material.dart';
2 +
3 +class ListFilterOption<T> {
4 + const ListFilterOption({
5 + required this.value,
6 + required this.label,
7 + this.icon,
8 + });
9 +
10 + final T value;
11 + final String label;
12 + final IconData? icon;
13 +}
14 +
15 +class ListFilterButton<T> extends StatelessWidget {
16 + const ListFilterButton({
17 + super.key,
18 + required this.options,
19 + required this.selected,
20 + required this.onChanged,
21 + this.tooltip,
22 + });
23 +
24 + final List<ListFilterOption<T>> options;
25 + final T selected;
26 + final ValueChanged<T> onChanged;
27 + final String? tooltip;
28 +
29 + bool get _hasActiveFilter {
30 + if (options.isEmpty) return false;
31 + return selected != options.first.value;
32 + }
33 +
34 + @override
35 + Widget build(BuildContext context) {
36 + final theme = Theme.of(context);
37 +
38 + return IconButton(
39 + tooltip: tooltip,
40 + onPressed: () => _showFilterSheet(context),
41 + icon: Badge(
42 + isLabelVisible: _hasActiveFilter,
43 + smallSize: 8,
44 + child: Icon(
45 + Icons.filter_list_rounded,
46 + color: _hasActiveFilter
47 + ? theme.colorScheme.primary
48 + : theme.iconTheme.color,
49 + ),
50 + ),
51 + );
52 + }
53 +
54 + void _showFilterSheet(BuildContext context) {
55 + showModalBottomSheet<void>(
56 + context: context,
57 + builder: (ctx) {
58 + return SafeArea(
59 + child: Column(
60 + mainAxisSize: MainAxisSize.min,
61 + children: [
62 + const SizedBox(height: 8),
63 + ...options.map(
64 + (option) => ListTile(
65 + leading: option.icon != null
66 + ? Icon(
67 + option.icon,
68 + color: selected == option.value
69 + ? Theme.of(ctx).colorScheme.primary
70 + : null,
71 + )
72 + : null,
73 + title: Text(option.label),
74 + trailing: selected == option.value
75 + ? Icon(
76 + Icons.check,
77 + color: Theme.of(ctx).colorScheme.primary,
78 + )
79 + : null,
80 + onTap: () {
81 + onChanged(option.value);
82 + Navigator.pop(ctx);
83 + },
84 + ),
85 + ),
86 + const SizedBox(height: 8),
87 + ],
88 + ),
89 + );
90 + },
91 + );
92 + }
93 +}
lib/core/providers/app_providers.dart
+2
@@ -100,6 +100,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
100 _update(state.copyWith(autoSpeakEnabled: value));
101 void setTtsProcessMarkdown(bool value) =>
102 _update(state.copyWith(ttsProcessMarkdown: value));
103 + void setTtsSkipSeconds(int value) =>
104 + _update(state.copyWith(ttsSkipSeconds: value));
105 void setSmartReplyEnabled(bool value) =>
106 _update(state.copyWith(smartReplyEnabled: value));
107 void setLocaleCode(String? value) =>
lib/core/services/share_service.dart new
+10
@@ -0,0 +1,10 @@
1 +import 'package:share_plus/share_plus.dart';
2 +
3 +class ShareService {
4 + ShareService._();
5 +
6 + static Future<void> shareText(String text, {String? subject}) async {
7 + if (text.trim().isEmpty) return;
8 + await SharePlus.instance.share(ShareParams(text: text, subject: subject));
9 + }
10 +}
lib/core/storage/entities.dart
+28
@@ -188,6 +188,9 @@ class ConversationEntity {
188 String? smartRepliesJson;
189 String? smartRepliesLastMessageId;
190 String? folderId;
191 + bool isTemporary;
192 + bool isArchived;
193 + int characterCount;
194
195 @Backlink()
196 final messages = ToMany<MessageEntity>();
@@ -213,6 +216,9 @@ class ConversationEntity {
216 this.smartRepliesJson,
217 this.smartRepliesLastMessageId,
218 this.folderId,
219 + this.isTemporary = false,
220 + this.isArchived = false,
221 + this.characterCount = 0,
222 });
223
224 factory ConversationEntity.fromDomain(Conversation conversation) {
@@ -238,6 +244,9 @@ class ConversationEntity {
244 : null,
245 smartRepliesLastMessageId: conversation.smartRepliesLastMessageId,
246 folderId: conversation.folderId,
247 + isTemporary: conversation.isTemporary,
248 + isArchived: conversation.isArchived,
249 + characterCount: conversation.characterCount,
250 );
251 }
252
@@ -264,6 +273,9 @@ class ConversationEntity {
273 : null,
274 smartRepliesLastMessageId: smartRepliesLastMessageId,
275 folderId: folderId,
276 + isTemporary: isTemporary,
277 + isArchived: isArchived,
278 + characterCount: characterCount,
279 );
280 }
281
@@ -379,6 +391,10 @@ class MessageEntity {
391
392 String? modelId;
393 int? tokenCount;
394 + int? inputTokenCount;
395 + double? tokensPerSecond;
396 + int? ttftMs;
397 + String? stopReason;
398 String? errorMessage;
399 String? attachmentPathsJson;
400 int? generationTimeMs;
@@ -404,6 +420,10 @@ class MessageEntity {
420 required this.statusIndex,
421 this.modelId,
422 this.tokenCount,
423 + this.inputTokenCount,
424 + this.tokensPerSecond,
425 + this.ttftMs,
426 + this.stopReason,
427 this.errorMessage,
428 this.attachmentPathsJson,
429 this.generationTimeMs,
@@ -430,6 +450,10 @@ class MessageEntity {
450 statusIndex: message.status.index,
451 modelId: message.modelId,
452 tokenCount: message.tokenCount,
453 + inputTokenCount: message.inputTokenCount,
454 + tokensPerSecond: message.tokensPerSecond,
455 + ttftMs: message.ttftMs,
456 + stopReason: message.stopReason,
457 errorMessage: message.errorMessage,
458 attachmentPathsJson: message.attachmentPaths != null
459 ? jsonEncode(message.attachmentPaths)
@@ -463,6 +487,10 @@ class MessageEntity {
487 status: MessageStatus.values[statusIndex],
488 modelId: modelId,
489 tokenCount: tokenCount,
490 + inputTokenCount: inputTokenCount,
491 + tokensPerSecond: tokensPerSecond,
492 + ttftMs: ttftMs,
493 + stopReason: stopReason,
494 errorMessage: errorMessage,
495 attachmentPaths: attachmentPathsJson != null
496 ? List<String>.from(jsonDecode(attachmentPathsJson!))
lib/features/chat/data/models/message.dart
+16
@@ -12,6 +12,10 @@ class Message {
12 final MessageStatus status;
13 final String? modelId;
14 final int? tokenCount;
15 + final int? inputTokenCount;
16 + final double? tokensPerSecond;
17 + final int? ttftMs;
18 + final String? stopReason;
19 final String? errorMessage;
20 final List<String>? attachmentPaths;
21 final int? generationTimeMs;
@@ -36,6 +40,10 @@ class Message {
40 this.status = MessageStatus.complete,
41 this.modelId,
42 this.tokenCount,
43 + this.inputTokenCount,
44 + this.tokensPerSecond,
45 + this.ttftMs,
46 + this.stopReason,
47 this.errorMessage,
48 this.attachmentPaths,
49 this.generationTimeMs,
@@ -61,6 +69,10 @@ class Message {
69 MessageStatus? status,
70 String? modelId,
71 int? tokenCount,
72 + int? inputTokenCount,
73 + double? tokensPerSecond,
74 + int? ttftMs,
75 + String? stopReason,
76 String? errorMessage,
77 List<String>? attachmentPaths,
78 int? generationTimeMs,
@@ -85,6 +97,10 @@ class Message {
97 status: status ?? this.status,
98 modelId: modelId ?? this.modelId,
99 tokenCount: tokenCount ?? this.tokenCount,
100 + inputTokenCount: inputTokenCount ?? this.inputTokenCount,
101 + tokensPerSecond: tokensPerSecond ?? this.tokensPerSecond,
102 + ttftMs: ttftMs ?? this.ttftMs,
103 + stopReason: stopReason ?? this.stopReason,
104 errorMessage: errorMessage ?? this.errorMessage,
105 attachmentPaths: attachmentPaths ?? this.attachmentPaths,
106 generationTimeMs: generationTimeMs ?? this.generationTimeMs,
lib/features/chat/data/title_generation_service.dart new
+131
@@ -0,0 +1,131 @@
1 +import 'dart:async';
2 +
3 +import '../../../core/logger/app_logger.dart';
4 +import '../../../core/models/enums.dart';
5 +import '../../servers/data/models/server.dart';
6 +import 'chat_service.dart';
7 +import 'models/chat_parameters.dart';
8 +import 'models/message.dart';
9 +
10 +class TitleGenerationService {
11 + static const _defaultTitle = 'New Chat';
12 +
13 + Future<String?> generateTitleWithLLM({
14 + required ChatService chatService,
15 + required Server server,
16 + required String modelId,
17 + required List<Message> messages,
18 + required ChatParameters params,
19 + }) async {
20 + if (messages.isEmpty) return null;
21 +
22 + final promptMessage = Message(
23 + id: 'title-generation-prompt',
24 + conversationId: messages.first.conversationId,
25 + role: MessageRole.user,
26 + content:
27 + 'Based on the conversation above, generate a short, descriptive title for this chat. '
28 + 'Return ONLY the title text — no quotes, markdown, labels, or explanation. '
29 + 'Keep it under 8 words.',
30 + createdAt: DateTime.now(),
31 + );
32 +
33 + final titleParams = params.copyWith(
34 + temperature: 0.3,
35 + maxTokens: 32,
36 + systemPrompt:
37 + 'You generate concise chat titles. Output only the title string, nothing else.',
38 + );
39 +
40 + try {
41 + final completer = Completer<String>();
42 + var accumulatedContent = '';
43 +
44 + final stream = chatService.sendMessage(
45 + server: server,
46 + modelId: modelId,
47 + messages: [...messages, promptMessage],
48 + params: titleParams,
49 + );
50 +
51 + StreamSubscription? subscription;
52 + subscription = stream.listen(
53 + (response) {
54 + if (response.type == ChatResponseType.message &&
55 + response.content != null) {
56 + accumulatedContent += response.content!;
57 + } else if (response.type == ChatResponseType.done) {
58 + subscription?.cancel();
59 + if (!completer.isCompleted) {
60 + completer.complete(accumulatedContent);
61 + }
62 + } else if (response.type == ChatResponseType.error) {
63 + subscription?.cancel();
64 + if (!completer.isCompleted) {
65 + completer.completeError(
66 + response.content ?? 'Unknown streaming error',
67 + );
68 + }
69 + }
70 + },
71 + onError: (err) {
72 + subscription?.cancel();
73 + if (!completer.isCompleted) {
74 + completer.completeError(err);
75 + }
76 + },
77 + onDone: () {
78 + subscription?.cancel();
79 + if (!completer.isCompleted) {
80 + completer.complete(accumulatedContent);
81 + }
82 + },
83 + cancelOnError: true,
84 + );
85 +
86 + final rawResponse = await completer.future.timeout(
87 + const Duration(seconds: 15),
88 + onTimeout: () {
89 + subscription?.cancel();
90 + return accumulatedContent;
91 + },
92 + );
93 +
94 + return _parseTitle(rawResponse);
95 + } catch (e) {
96 + Log.warning('LLM title generation failed: $e');
97 + return null;
98 + }
99 + }
100 +
101 + String truncateFirstMessageTitle(String content, {int maxLength = 50}) {
102 + final trimmed = content.trim();
103 + if (trimmed.isEmpty) return _defaultTitle;
104 + if (trimmed.length <= maxLength) return trimmed;
105 + return '${trimmed.substring(0, maxLength)}...';
106 + }
107 +
108 + String _parseTitle(String text) {
109 + var title = text.trim();
110 + if (title.isEmpty) return '';
111 +
112 + title = title.replaceFirst(
113 + RegExp(r'^(title|chat title|subject)\s*:\s*', caseSensitive: false),
114 + '',
115 + );
116 + title = title.replaceAll(RegExp(r'```.*?```', dotAll: true), '');
117 + title = title.replaceAll('`', '');
118 + title = title.split('\n').first.trim();
119 +
120 + if ((title.startsWith('"') && title.endsWith('"')) ||
121 + (title.startsWith("'") && title.endsWith("'"))) {
122 + title = title.substring(1, title.length - 1).trim();
123 + }
124 +
125 + if (title.length > 80) {
126 + title = '${title.substring(0, 80).trim()}...';
127 + }
128 +
129 + return title;
130 + }
131 +}
lib/features/chat/providers/chat_notifier.dart
+375 -72
@@ -8,6 +8,7 @@ import 'package:localmind/core/models/enums.dart';
8 import 'package:localmind/core/providers/app_providers.dart';
9 import 'package:localmind/core/providers/review_prompt_providers.dart';
10 import 'package:localmind/core/providers/chat_background_service_provider.dart';
11 +import 'package:localmind/core/providers/service_providers.dart';
12 import 'package:localmind/core/providers/storage_providers.dart';
13 import 'package:localmind/core/services/message_save_service.dart';
14 import 'package:localmind/core/storage/entities.dart';
@@ -22,6 +23,7 @@ import 'package:localmind/features/servers/providers/server_providers.dart';
23 import 'package:localmind/objectbox.g.dart';
24 import '../data/chat_service.dart';
25 import '../data/models/message.dart' hide ToolCallData;
26 +import '../data/title_generation_service.dart';
27 import '../data/tools/tool_definition.dart';
28 import '../data/tools/tool_execution_loop.dart';
29 import '../data/tools/adapters/tool_transport_adapter.dart' show ParsedToolCall;
@@ -47,6 +49,7 @@ class ChatState {
49 final String? errorMessage;
50 final Message? streamingMessage;
51 final PendingToolApproval? pendingToolApproval;
52 + final bool isTemporary;
53
54 const ChatState({
55 this.messages = const [],
@@ -56,6 +59,7 @@ class ChatState {
59 this.errorMessage,
60 this.streamingMessage,
61 this.pendingToolApproval,
62 + this.isTemporary = false,
63 });
64
65 ChatState copyWith({
@@ -66,6 +70,7 @@ class ChatState {
70 String? errorMessage,
71 Message? streamingMessage,
72 PendingToolApproval? pendingToolApproval,
73 + bool? isTemporary,
74 bool clearError = false,
75 bool clearStreaming = false,
76 bool clearPendingApproval = false,
@@ -82,6 +87,7 @@ class ChatState {
87 pendingToolApproval: clearPendingApproval
88 ? null
89 : (pendingToolApproval ?? this.pendingToolApproval),
90 + isTemporary: isTemporary ?? this.isTemporary,
91 );
92 }
93 }
@@ -104,6 +110,60 @@ class ChatNotifier extends Notifier<ChatState> {
110 int _lastSavedReasoningLength = 0;
111
112 MessageSaveService? _saveService;
113 + bool _pendingTemporaryChat = false;
114 + String? _ephemeralConversationId;
115 + ChatStats? _streamStats;
116 + DateTime? _streamStartTime;
117 + DateTime? _firstTokenTime;
118 +
119 + String? get _activeConversationId =>
120 + _currentConversationId ?? _ephemeralConversationId;
121 +
122 + bool get _isInMemoryChat => state.isTemporary;
123 +
124 + void _resetStreamMetrics() {
125 + _streamStats = null;
126 + _streamStartTime = DateTime.now();
127 + _firstTokenTime = null;
128 + }
129 +
130 + void _noteFirstToken() {
131 + _firstTokenTime ??= DateTime.now();
132 + }
133 +
134 + Message _finalizeStreamMessage(Message msg, {String? stopReason}) {
135 + final stats = _streamStats;
136 + final start = _streamStartTime;
137 + final firstToken = _firstTokenTime;
138 + final now = DateTime.now();
139 +
140 + int? ttftMs;
141 + if (firstToken != null && start != null) {
142 + ttftMs = firstToken.difference(start).inMilliseconds;
143 + } else if (stats?.timeToFirstTokenSeconds != null) {
144 + ttftMs = (stats!.timeToFirstTokenSeconds! * 1000).round();
145 + }
146 +
147 + int? genMs;
148 + if (start != null) {
149 + genMs = now.difference(start).inMilliseconds;
150 + }
151 +
152 + double? tps = stats?.tokensPerSecond;
153 + final outputTokens = stats?.totalOutputTokens;
154 + if (tps == null && outputTokens != null && genMs != null && genMs > 0) {
155 + tps = outputTokens / (genMs / 1000);
156 + }
157 +
158 + return msg.copyWith(
159 + tokenCount: outputTokens ?? msg.tokenCount,
160 + inputTokenCount: stats?.inputTokens ?? msg.inputTokenCount,
161 + tokensPerSecond: tps ?? msg.tokensPerSecond,
162 + generationTimeMs: genMs ?? msg.generationTimeMs,
163 + ttftMs: ttftMs ?? msg.ttftMs,
164 + stopReason: stopReason ?? msg.stopReason,
165 + );
166 + }
167
168 void approveTool(bool approved) {
169 final pending = state.pendingToolApproval;
@@ -161,6 +221,7 @@ class ChatNotifier extends Notifier<ChatState> {
221 allMessages: messages,
222 messages: MessageVariants.resolveActiveTimeline(messages),
223 isLoading: false,
224 + isTemporary: conversation.isTemporary,
225 );
226 ref
227 .read(conv.activeConversationProvider.notifier)
@@ -272,8 +333,11 @@ class ChatNotifier extends Notifier<ChatState> {
333 }
334
335 Future<void> startNewConversation() async {
275 - await cancelStream();
336 + _abortStreamImmediately();
337 +
338 _currentConversationId = null;
339 + _ephemeralConversationId = null;
340 + _pendingTemporaryChat = false;
341 ref.read(smartReplyServiceProvider).reset();
342 state = const ChatState();
343 ref
@@ -286,6 +350,61 @@ class ChatNotifier extends Notifier<ChatState> {
350 .setEnabled(settings.newChatMcpEnabled);
351 }
352
353 + void _abortStreamImmediately() {
354 + _uiUpdateTimer?.cancel();
355 + _uiUpdateTimer = null;
356 + _clearPendingApproval();
357 +
358 + final subscription = _streamSubscription;
359 + _streamSubscription = null;
360 + if (subscription != null) {
361 + unawaited(subscription.cancel());
362 + }
363 +
364 + ref.read(chatServiceProvider)?.cancelStream();
365 + ref.read(chatBackgroundServiceProvider).stop();
366 + ref.read(isStreamingProvider.notifier).setStreaming(false);
367 +
368 + _chunkCount = 0;
369 + _lastCheckpointTime = null;
370 + _lastSavedContentLength = 0;
371 + _lastSavedReasoningLength = 0;
372 +
373 + final convId = _currentConversationId;
374 + final streamingMessage = _latestStreamingMessage ?? state.streamingMessage;
375 + _latestStreamingMessage = null;
376 +
377 + if (streamingMessage != null && convId != null) {
378 + unawaited(_persistCancelledMessageInBackground(streamingMessage, convId));
379 + }
380 + }
381 +
382 + Future<void> _persistCancelledMessageInBackground(
383 + Message streamingMessage,
384 + String conversationId,
385 + ) async {
386 + try {
387 + final finalMessage = _finalizeStreamMessage(
388 + streamingMessage.copyWith(
389 + conversationId: conversationId,
390 + status: MessageStatus.cancelled,
391 + isProcessing: false,
392 + ),
393 + stopReason: 'cancelled',
394 + );
395 + await _saveService?.flush();
396 + await _saveMessage(finalMessage);
397 + } catch (e) {
398 + Log.error('Failed to persist cancelled stream message: $e');
399 + }
400 + }
401 +
402 + void setTemporaryMode(bool enabled) {
403 + if (state.messages.isNotEmpty && enabled != state.isTemporary) return;
404 + _pendingTemporaryChat = enabled;
405 + state = state.copyWith(isTemporary: enabled);
406 + }
407 +
408 String generateUuid() {
409 final random = Random.secure();
410 final bytes = List<int>.generate(16, (_) => random.nextInt(256));
@@ -333,6 +452,7 @@ class ChatNotifier extends Notifier<ChatState> {
452 }
453
454 Future<void> checkpointStreamingMessage({bool flush = false}) async {
455 + if (_isInMemoryChat) return;
456 final latest = _latestStreamingMessage ?? state.streamingMessage;
457 if (latest == null) return;
458
@@ -368,37 +488,52 @@ class ChatNotifier extends Notifier<ChatState> {
488
489 final trimmedContent = content.trim();
490
371 - if (_currentConversationId == null) {
372 - final selectedPersona = ref.read(selectedPersonaProvider);
373 - final titleSource = trimmedContent.isNotEmpty
374 - ? trimmedContent
375 - : (attachments?.isNotEmpty == true
376 - ? attachments!.first.path.split(Platform.pathSeparator).last
377 - : 'New chat');
378 - final conversation = await ref
379 - .read(conv.conversationsProvider.notifier)
380 - .createConversation(
381 - title: titleSource.length > 50
382 - ? '${titleSource.substring(0, 50)}...'
383 - : titleSource,
384 - serverId: server.id,
385 - modelId: selectedModel?.id,
386 - personaId: selectedPersona?.id,
387 - systemPrompt: selectedPersona?.systemPrompt,
388 - mcpEnabled: settings.newChatMcpEnabled,
389 - );
390 - _currentConversationId = conversation.id;
391 - ref
392 - .read(conv.activeConversationProvider.notifier)
393 - .setActiveConversation(conversation);
491 + if (_currentConversationId == null && _ephemeralConversationId == null) {
492 + final isTemp = state.isTemporary || _pendingTemporaryChat;
493 + if (isTemp) {
494 + _ephemeralConversationId = generateUuid();
495 + _pendingTemporaryChat = false;
496 + state = state.copyWith(isTemporary: true);
497 + } else {
498 + final titleService = ref.read(titleGenerationServiceProvider);
499 + final String initialTitle;
500 + if (settings.autoGenerateTitle) {
501 + initialTitle = 'New Chat';
502 + } else {
503 + final titleSource = trimmedContent.isNotEmpty
504 + ? trimmedContent
505 + : (attachments?.isNotEmpty == true
506 + ? attachments!.first.path.split(Platform.pathSeparator).last
507 + : 'New Chat');
508 + initialTitle = titleService.truncateFirstMessageTitle(titleSource);
509 + }
510 + final conversation = await ref
511 + .read(conv.conversationsProvider.notifier)
512 + .createConversation(
513 + title: initialTitle,
514 + serverId: server.id,
515 + modelId: selectedModel?.id,
516 + personaId: ref.read(selectedPersonaProvider)?.id,
517 + systemPrompt: ref.read(selectedPersonaProvider)?.systemPrompt,
518 + mcpEnabled: settings.newChatMcpEnabled,
519 + isTemporary: false,
520 + );
521 + _currentConversationId = conversation.id;
522 + ref
523 + .read(conv.activeConversationProvider.notifier)
524 + .setActiveConversation(conversation);
525
395 - ref
396 - .read(chatMcpConfigProvider.notifier)
397 - .setEnabled(settings.newChatMcpEnabled);
526 + ref
527 + .read(chatMcpConfigProvider.notifier)
528 + .setEnabled(settings.newChatMcpEnabled);
529
399 - ref.read(selectedPersonaProvider.notifier).clear();
530 + ref.read(selectedPersonaProvider.notifier).clear();
531 + }
532 }
533
534 + final convId = _activeConversationId;
535 + if (convId == null) return;
536 +
537 final List<String> savedPaths = [];
538 if (attachments != null && attachments.isNotEmpty) {
539 final appDir = await ref.read(storageDirectoryProvider.future);
@@ -424,7 +559,7 @@ class ChatNotifier extends Notifier<ChatState> {
559
560 final userMessage = Message(
561 id: generateUuid(),
427 - conversationId: _currentConversationId!,
562 + conversationId: convId,
563 role: MessageRole.user,
564 content: trimmedContent,
565 createdAt: DateTime.now(),
@@ -440,7 +575,7 @@ class ChatNotifier extends Notifier<ChatState> {
575 final assistantMessageId = generateUuid();
576 var assistantMessage = Message(
577 id: assistantMessageId,
443 - conversationId: _currentConversationId!,
578 + conversationId: convId,
579 role: MessageRole.assistant,
580 content: '',
581 createdAt: DateTime.now(),
@@ -468,6 +603,7 @@ class ChatNotifier extends Notifier<ChatState> {
603
604 ref.read(chatBackgroundServiceProvider).start();
605
606 + _resetStreamMetrics();
607 _resetCheckpointMetrics();
608 _updateSavedMetrics(assistantMessage);
609
@@ -529,6 +665,9 @@ class ChatNotifier extends Notifier<ChatState> {
665
666 switch (response.type) {
667 case ChatResponseType.message:
668 + if (response.content?.isNotEmpty ?? false) {
669 + _noteFirstToken();
670 + }
671 streamingAssistantMessage = streamingAssistantMessage
672 .copyWith(
673 content:
@@ -540,7 +679,8 @@ class ChatNotifier extends Notifier<ChatState> {
679
680 _chunkCount++;
681
543 - if (_shouldCheckpointSave(streamingAssistantMessage)) {
682 + if (!_isInMemoryChat &&
683 + _shouldCheckpointSave(streamingAssistantMessage)) {
684 _saveService?.enqueue(streamingAssistantMessage);
685 _updateSavedMetrics(streamingAssistantMessage);
686 _resetCheckpointMetrics();
@@ -549,6 +689,9 @@ class ChatNotifier extends Notifier<ChatState> {
689 stateNeedsUpdate = true;
690 break;
691 case ChatResponseType.reasoning:
692 + if (response.reasoningContent?.isNotEmpty ?? false) {
693 + _noteFirstToken();
694 + }
695 reasoningContent += response.reasoningContent ?? '';
696 streamingAssistantMessage = streamingAssistantMessage
697 .copyWith(
@@ -559,7 +702,8 @@ class ChatNotifier extends Notifier<ChatState> {
702
703 _chunkCount++;
704
562 - if (_shouldCheckpointSave(streamingAssistantMessage)) {
705 + if (!_isInMemoryChat &&
706 + _shouldCheckpointSave(streamingAssistantMessage)) {
707 _saveService?.enqueue(streamingAssistantMessage);
708 _updateSavedMetrics(streamingAssistantMessage);
709 _resetCheckpointMetrics();
@@ -610,18 +754,23 @@ class ChatNotifier extends Notifier<ChatState> {
754 break;
755 case ChatResponseType.invalidToolCall:
756 case ChatResponseType.done:
757 + if (response.stats != null) {
758 + _streamStats = response.stats;
759 + }
760 break;
761 }
762 },
763 onDone: () async {
617 - _uiUpdateTimer?.cancel();
618 - _uiUpdateTimer = null;
619 - updateUiState();
764 + _uiUpdateTimer?.cancel();
765 + _uiUpdateTimer = null;
766 + updateUiState();
767
768 + if (!_isInMemoryChat) {
769 await _saveService?.flush();
622 - ref.read(chatBackgroundServiceProvider).stop();
623 - final streamConvId = assistantMessage.conversationId;
624 - final isCurrentContext = _currentConversationId == streamConvId;
770 + }
771 + ref.read(chatBackgroundServiceProvider).stop();
772 + final streamConvId = assistantMessage.conversationId;
773 + final isCurrentContext = _activeConversationId == streamConvId;
774 final streamingMessage = streamingAssistantMessage;
775 final hasContent =
776 streamingMessage.content.isNotEmpty ||
@@ -650,9 +799,12 @@ class ChatNotifier extends Notifier<ChatState> {
799 ref.read(isStreamingProvider.notifier).setStreaming(false);
800 _latestStreamingMessage = null;
801 } else {
653 - var finalMessage = streamingMessage.copyWith(
654 - status: MessageStatus.complete,
655 - isProcessing: false,
802 + var finalMessage = _finalizeStreamMessage(
803 + streamingMessage.copyWith(
804 + status: MessageStatus.complete,
805 + isProcessing: false,
806 + ),
807 + stopReason: 'complete',
808 );
809
810 if (mcpConfig.enabled && collectedToolCalls.isNotEmpty) {
@@ -722,27 +874,28 @@ class ChatNotifier extends Notifier<ChatState> {
874 }
875 ref.read(isStreamingProvider.notifier).setStreaming(false);
876 _latestStreamingMessage = null;
725 - if (_currentConversationId != null) {
877 + if (_currentConversationId != null) {
878 final preview = finalMessage.content.length > 100
879 ? '${finalMessage.content.substring(0, 100)}...'
880 : finalMessage.content;
881 + final totalChars = state.messages.fold<int>(
882 + 0,
883 + (sum, message) => sum + message.content.length,
884 + );
885 await ref
886 .read(conv.conversationsProvider.notifier)
887 .updatePreview(
888 _currentConversationId!,
889 preview,
890 DateTime.now(),
891 + characterCount: totalChars,
892 );
893
894 final userMessage = state.messages
895 .where((m) => m.role == MessageRole.user)
896 .firstOrNull;
740 - if (userMessage != null &&
741 - userMessage.content.length > 10) {
742 - _autoGenerateTitle(
743 - userMessage.content,
744 - finalMessage.content,
745 - );
897 + if (userMessage != null) {
898 + _maybeAutoGenerateTitleAfterFirstReply();
899 }
900 }
901
@@ -900,22 +1053,132 @@ class ChatNotifier extends Notifier<ChatState> {
1053 return result;
1054 }
1055
903 - void _autoGenerateTitle(String userContent, String assistantContent) {
1056 + void _maybeAutoGenerateTitleAfterFirstReply() {
1057 + if (_currentConversationId == null || _isInMemoryChat) return;
1058 +
1059 final settings = ref.read(settingsProvider);
1060 if (!settings.autoGenerateTitle) return;
906 - if (_currentConversationId == null) return;
1061
1062 final activeConv = ref.read(conv.activeConversationProvider);
909 - if (activeConv == null) return;
910 - if (activeConv.title != 'New Chat') return;
1063 + if (activeConv == null || activeConv.title != 'New Chat') return;
1064
912 - final title = userContent.length > 40
913 - ? '${userContent.substring(0, 40)}...'
914 - : userContent;
1065 + final assistantCount = state.messages
1066 + .where((m) => m.role == MessageRole.assistant)
1067 + .length;
1068 + if (assistantCount != 1) return;
1069
916 - ref
1070 + unawaited(_generateAndApplyTitle(_currentConversationId!));
1071 + }
1072 +
1073 + Future<void> _generateAndApplyTitle(String conversationId) async {
1074 + final title = await generateTitleWithAi(conversationId);
1075 + if (title == null || title.isEmpty || title == 'New Chat') return;
1076 +
1077 + await ref
1078 .read(conv.conversationsProvider.notifier)
918 - .renameConversation(_currentConversationId!, title);
1079 + .renameConversation(conversationId, title);
1080 + }
1081 +
1082 + Future<String?> generateTitleWithAi(String conversationId) async {
1083 + final messages = await _loadMessagesForConversation(conversationId);
1084 + if (messages.isEmpty) return null;
1085 +
1086 + final timeline = MessageVariants.resolveActiveTimeline(messages);
1087 + if (timeline.isEmpty) return null;
1088 +
1089 + final conversation = ref
1090 + .read(conv.conversationsProvider)
1091 + .value
1092 + ?.where((c) => c.id == conversationId)
1093 + .firstOrNull;
1094 +
1095 + final server = _resolveServerForTitle(conversation);
1096 + final modelId = _resolveModelIdForTitle(conversation);
1097 + final titleService = ref.read(titleGenerationServiceProvider);
1098 +
1099 + if (server == null || modelId == null) {
1100 + return _fallbackTitleFromMessages(timeline, titleService);
1101 + }
1102 +
1103 + final chatService = _resolveChatServiceForTitle(server);
1104 + if (chatService == null) {
1105 + return _fallbackTitleFromMessages(timeline, titleService);
1106 + }
1107 +
1108 + final generated = await titleService.generateTitleWithLLM(
1109 + chatService: chatService,
1110 + server: server,
1111 + modelId: modelId,
1112 + messages: timeline,
1113 + params: ref.read(chatParamsProvider),
1114 + );
1115 +
1116 + if (generated != null && generated.isNotEmpty) {
1117 + return generated;
1118 + }
1119 + return _fallbackTitleFromMessages(timeline, titleService);
1120 + }
1121 +
1122 + Future<List<Message>> _loadMessagesForConversation(
1123 + String conversationId,
1124 + ) async {
1125 + if (_currentConversationId == conversationId && state.allMessages.isNotEmpty) {
1126 + return MessageVariants.resolveActiveTimeline(state.allMessages);
1127 + }
1128 +
1129 + final db = ref.read(databaseProvider);
1130 + final loaded = await db.store.runInTransactionAsync(
1131 + TxMode.read,
1132 + _loadMessagesInBackground,
1133 + conversationId,
1134 + );
1135 + return MessageVariants.resolveActiveTimeline(loaded);
1136 + }
1137 +
1138 + Server? _resolveServerForTitle(Conversation? conversation) {
1139 + if (conversation?.serverId != null) {
1140 + final servers = ref.read(serversProvider).value ?? [];
1141 + for (final server in servers) {
1142 + if (server.id == conversation!.serverId) {
1143 + return server;
1144 + }
1145 + }
1146 + }
1147 + return ref.read(activeServerProvider);
1148 + }
1149 +
1150 + String? _resolveModelIdForTitle(Conversation? conversation) {
1151 + return conversation?.modelId ?? ref.read(selectedModelProvider)?.id;
1152 + }
1153 +
1154 + ChatService? _resolveChatServiceForTitle(Server server) {
1155 + final active = ref.read(activeServerProvider);
1156 + if (active?.id == server.id) {
1157 + return ref.read(chatServiceProvider);
1158 + }
1159 +
1160 + return createChatServiceForServer(
1161 + server: server,
1162 + dio: ref.read(dioProvider),
1163 + onDeviceGemmaService: ref.read(onDeviceGemmaServiceProvider),
1164 + onDeviceLlamaService: ref.read(onDeviceLlamaServiceProvider),
1165 + loadedOnDeviceRuntime: ref.read(
1166 + onDeviceEngineProvider.select((s) => s.loadedRuntime),
1167 + ),
1168 + );
1169 + }
1170 +
1171 + String _fallbackTitleFromMessages(
1172 + List<Message> messages,
1173 + TitleGenerationService titleService,
1174 + ) {
1175 + final userMessage = messages
1176 + .where((m) => m.role == MessageRole.user)
1177 + .firstOrNull;
1178 + if (userMessage == null || userMessage.content.trim().isEmpty) {
1179 + return 'New Chat';
1180 + }
1181 + return titleService.truncateFirstMessageTitle(userMessage.content);
1182 }
1183
1184 void _maybeRequestReviewAfterSuccessfulCompletion({
@@ -974,9 +1237,12 @@ class ChatNotifier extends Notifier<ChatState> {
1237
1238 final streamingMessage = _latestStreamingMessage ?? state.streamingMessage;
1239 if (streamingMessage != null) {
977 - final finalMessage = streamingMessage.copyWith(
978 - status: MessageStatus.cancelled,
979 - isProcessing: false,
1240 + final finalMessage = _finalizeStreamMessage(
1241 + streamingMessage.copyWith(
1242 + status: MessageStatus.cancelled,
1243 + isProcessing: false,
1244 + ),
1245 + stopReason: 'cancelled',
1246 );
1247 await _saveMessage(finalMessage);
1248 _replaceMessageInState(finalMessage, clearStreaming: true);
@@ -1211,7 +1477,7 @@ class ChatNotifier extends Notifier<ChatState> {
1477 void updateUiState() {
1478 if (!stateNeedsUpdate) return;
1479 stateNeedsUpdate = false;
1214 - if (_currentConversationId == assistantMessage.conversationId) {
1480 + if (_activeConversationId == assistantMessage.conversationId) {
1481 state = state.copyWith(streamingMessage: streamingAssistantMessage);
1482 }
1483 }
@@ -1232,12 +1498,15 @@ class ChatNotifier extends Notifier<ChatState> {
1498 )
1499 .listen(
1500 (response) async {
1235 - if (_currentConversationId != assistantMessage.conversationId) {
1501 + if (_activeConversationId != assistantMessage.conversationId) {
1502 return;
1503 }
1504
1505 switch (response.type) {
1506 case ChatResponseType.message:
1507 + if (response.content?.isNotEmpty ?? false) {
1508 + _noteFirstToken();
1509 + }
1510 streamingAssistantMessage = streamingAssistantMessage
1511 .copyWith(
1512 content:
@@ -1247,7 +1516,8 @@ class ChatNotifier extends Notifier<ChatState> {
1516 );
1517 _latestStreamingMessage = streamingAssistantMessage;
1518 _chunkCount++;
1250 - if (_shouldCheckpointSave(streamingAssistantMessage)) {
1519 + if (!_isInMemoryChat &&
1520 + _shouldCheckpointSave(streamingAssistantMessage)) {
1521 _saveService?.enqueue(streamingAssistantMessage);
1522 _updateSavedMetrics(streamingAssistantMessage);
1523 _resetCheckpointMetrics();
@@ -1255,6 +1525,9 @@ class ChatNotifier extends Notifier<ChatState> {
1525 stateNeedsUpdate = true;
1526 break;
1527 case ChatResponseType.reasoning:
1528 + if (response.reasoningContent?.isNotEmpty ?? false) {
1529 + _noteFirstToken();
1530 + }
1531 reasoningContent += response.reasoningContent ?? '';
1532 streamingAssistantMessage = streamingAssistantMessage
1533 .copyWith(
@@ -1263,7 +1536,8 @@ class ChatNotifier extends Notifier<ChatState> {
1536 );
1537 _latestStreamingMessage = streamingAssistantMessage;
1538 _chunkCount++;
1266 - if (_shouldCheckpointSave(streamingAssistantMessage)) {
1539 + if (!_isInMemoryChat &&
1540 + _shouldCheckpointSave(streamingAssistantMessage)) {
1541 _saveService?.enqueue(streamingAssistantMessage);
1542 _updateSavedMetrics(streamingAssistantMessage);
1543 _resetCheckpointMetrics();
@@ -1280,10 +1554,13 @@ class ChatNotifier extends Notifier<ChatState> {
1554 case ChatResponseType.error:
1555 _uiUpdateTimer?.cancel();
1556 _uiUpdateTimer = null;
1283 - streamingAssistantMessage = streamingAssistantMessage.copyWith(
1284 - status: MessageStatus.error,
1285 - errorMessage: response.content,
1286 - isProcessing: false,
1557 + streamingAssistantMessage = _finalizeStreamMessage(
1558 + streamingAssistantMessage.copyWith(
1559 + status: MessageStatus.error,
1560 + errorMessage: response.content,
1561 + isProcessing: false,
1562 + ),
1563 + stopReason: 'error',
1564 );
1565 await _saveMessage(streamingAssistantMessage);
1566 _replaceMessageInAll(
@@ -1294,6 +1571,11 @@ class ChatNotifier extends Notifier<ChatState> {
1571 ref.read(isStreamingProvider.notifier).setStreaming(false);
1572 ref.read(chatBackgroundServiceProvider).stop();
1573 break;
1574 + case ChatResponseType.done:
1575 + if (response.stats != null) {
1576 + _streamStats = response.stats;
1577 + }
1578 + break;
1579 default:
1580 break;
1581 }
@@ -1301,9 +1583,12 @@ class ChatNotifier extends Notifier<ChatState> {
1583 onDone: () async {
1584 _uiUpdateTimer?.cancel();
1585 _uiUpdateTimer = null;
1304 - final finalMessage = streamingAssistantMessage.copyWith(
1305 - status: MessageStatus.complete,
1306 - isProcessing: false,
1586 + final finalMessage = _finalizeStreamMessage(
1587 + streamingAssistantMessage.copyWith(
1588 + status: MessageStatus.complete,
1589 + isProcessing: false,
1590 + ),
1591 + stopReason: 'complete',
1592 );
1593 await _saveMessage(finalMessage);
1594 _replaceMessageInAll(finalMessage, clearStreaming: true);
@@ -1429,6 +1714,10 @@ class ChatNotifier extends Notifier<ChatState> {
1714 preview,
1715 DateTime.now(),
1716 messageCount: messagesToCopy.length,
1717 + characterCount: messagesToCopy.fold<int>(
1718 + 0,
1719 + (sum, message) => sum + message.content.length,
1720 + ),
1721 );
1722
1723 final refreshedConversations = ref.read(conv.conversationsProvider).value;
@@ -1528,6 +1817,7 @@ class ChatNotifier extends Notifier<ChatState> {
1817 }
1818
1819 Future<void> _saveMessage(Message message) async {
1820 + if (_isInMemoryChat) return;
1821 final db = ref.read(databaseProvider);
1822 await db.store.runInTransactionAsync(
1823 TxMode.write,
@@ -1567,7 +1857,7 @@ class ChatNotifier extends Notifier<ChatState> {
1857
1858 Future<void> clearConversation() async {
1859 final db = ref.read(databaseProvider);
1570 - if (_currentConversationId != null) {
1860 + if (_currentConversationId != null && !_isInMemoryChat) {
1861 final query = db.messageBox
1862 .query(MessageEntity_.conversationUid.equals(_currentConversationId!))
1863 .build();
@@ -1575,10 +1865,23 @@ class ChatNotifier extends Notifier<ChatState> {
1865 query.close();
1866 }
1867 _currentConversationId = null;
1868 + _ephemeralConversationId = null;
1869 + _pendingTemporaryChat = false;
1870 state = const ChatState();
1871 + ref
1872 + .read(conv.activeConversationProvider.notifier)
1873 + .setActiveConversation(null);
1874 }
1875
1876 void clearError() {
1877 state = state.copyWith(clearError: true);
1878 }
1879 }
1880 +
1881 +final hasActiveChatSessionProvider = Provider<bool>((ref) {
1882 + final chat = ref.watch(chatProvider);
1883 + final activeConv = ref.watch(conv.activeConversationProvider);
1884 + return chat.messages.isNotEmpty ||
1885 + chat.isStreaming ||
1886 + activeConv != null;
1887 +});
lib/features/chat/providers/chat_service_providers.dart
+5
@@ -11,6 +11,7 @@ import 'package:localmind/features/on_device/data/on_device_llama_service.dart';
11 import 'package:localmind/features/on_device/data/models/on_device_model.dart';
12 import '../data/chat_service.dart';
13 import '../data/smart_reply_service.dart';
14 +import '../data/title_generation_service.dart';
15
16 final chatServiceProvider = Provider<ChatService?>((ref) {
17 final server = ref.watch(activeServerProvider);
@@ -65,3 +66,7 @@ final smartReplyServiceProvider = Provider<SmartReplyService>((ref) {
66 ref.onDispose(() => service.dispose());
67 return service;
68 });
69 +
70 +final titleGenerationServiceProvider = Provider<TitleGenerationService>((ref) {
71 + return TitleGenerationService();
72 +});
lib/features/chat/views/chat_screen.dart
+243 -116
@@ -12,6 +12,7 @@ import 'package:localmind/core/models/enums.dart';
12 import 'package:localmind/core/providers/app_providers.dart';
13 import 'package:localmind/core/providers/storage_providers.dart';
14 import 'package:localmind/core/services/data_backup_service.dart';
15 +import 'package:localmind/core/services/share_service.dart';
16 import 'package:localmind/core/routes/app_routes.dart';
17 import 'package:localmind/core/utils/system_insets.dart';
18 import 'package:localmind/l10n/app_localizations.dart';
@@ -21,18 +22,18 @@ import 'package:localmind/features/conversations/data/models/conversation.dart';
22 import 'package:localmind/features/models/screens/model_picker_sheet.dart';
23 import 'package:localmind/features/personas/providers/personas_providers.dart';
24 import 'package:localmind/features/servers/providers/server_providers.dart';
24 -import 'package:localmind/features/servers/data/models/server.dart';
25 -import 'package:localmind/features/servers/views/components/server_icon_picker.dart';
25 import 'package:localmind/features/tts/views/components/tts_player_bar.dart';
27 -import 'package:localmind/features/saved_messages/providers/saved_message_providers.dart';
26 +import 'package:localmind/features/saved_messages/views/components/save_message_sheet.dart';
27 +import '../data/export_service.dart';
28 import '../data/models/message.dart';
29 import '../providers/chat_mcp_providers.dart';
30 +import '../../conversations/views/components/rename_conversation_dialog.dart';
31 import '../providers/chat_providers.dart';
32 import 'components/chat_auto_scroll_controller.dart';
33 +import 'components/model_info_sheet.dart';
34 import 'components/chat_input_bar.dart';
35 import 'components/chat_settings_sheet.dart';
36 import 'components/edit_message_dialog.dart';
35 -import 'components/edit_message_result.dart';
37 import 'components/persona_picker_sheet.dart';
38 import 'components/notification_permission_banner.dart';
39 import 'components/message_list/message_list.dart';
@@ -54,6 +55,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
55 with WidgetsBindingObserver {
56 final ScrollController _scrollController = ScrollController();
57 final FocusNode _inputFocusNode = FocusNode();
58 + final GlobalKey<ChatInputBarState> _inputBarKey = GlobalKey();
59 bool _isApprovalDialogOpen = false;
60 bool _showScrollToBottom = false;
61 late final ChatAutoScrollController _autoScroll;
@@ -120,11 +122,14 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
122 return _ChatBody(
123 scrollController: _scrollController,
124 inputFocusNode: _inputFocusNode,
125 + inputBarKey: _inputBarKey,
126 autoScroll: _autoScroll,
127 showScrollToBottom: _showScrollToBottom,
128 onScrollToBottom: _scrollToBottom,
129 onModelPicker: () => _showModelPicker(context),
130 onMenuAction: (action) => _handleMenuAction(action, context),
131 + onShareMessage: _shareMessage,
132 + onSaveMessage: (message) => _saveMessage(context, message),
133 );
134 }
135
@@ -141,6 +146,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
146 switch (action) {
147 case 'new_chat':
148 ref.read(chatProvider.notifier).startNewConversation();
149 + case 'share_chat':
150 + _shareConversation(context);
151 case 'persona':
152 showPersonaPickerSheet(context);
153 case 'remove_persona':
@@ -184,6 +191,31 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
191 }
192 }
193
194 + Future<void> _shareConversation(BuildContext context) async {
195 + final messages = ref.read(chatProvider).messages;
196 + if (messages.isEmpty) return;
197 + final activeConv = ref.read(conv.activeConversationProvider);
198 + final text = ExportService.exportAsText(
199 + messages,
200 + title: activeConv?.title,
201 + );
202 + await ShareService.shareText(text, subject: activeConv?.title);
203 + }
204 +
205 + void _shareMessage(String content) {
206 + ShareService.shareText(content);
207 + }
208 +
209 + Future<void> _saveMessage(BuildContext context, Message message) async {
210 + final isTemporary = ref.read(chatProvider.select((s) => s.isTemporary));
211 + await showSaveMessageSheet(
212 + context,
213 + ref,
214 + message,
215 + isTemporaryChat: isTemporary,
216 + );
217 + }
218 +
219 void _showToolApprovalDialog(
220 BuildContext context,
221 PendingToolApproval approval,
@@ -261,64 +293,74 @@ class _ChatScreenState extends ConsumerState<ChatScreen>
293 }
294
295 void _showRenameDialog(BuildContext context, Conversation conversation) {
264 - final l10n = AppLocalizations.of(context)!;
265 - final controller = TextEditingController(text: conversation.title);
296 + showRenameConversationDialog(context, ref, conversation: conversation);
297 + }
298 +}
299 +
300 +void _handleChatModeAction(
301 + BuildContext context,
302 + WidgetRef ref, {
303 + required bool hasMessages,
304 + required bool isTemporary,
305 +}) {
306 + final l10n = AppLocalizations.of(context)!;
307 +
308 + if (!hasMessages) {
309 + ref.read(chatProvider.notifier).setTemporaryMode(!isTemporary);
310 + return;
311 + }
312
267 - showDialog(
313 + if (isTemporary) {
314 + showDialog<void>(
315 context: context,
269 - builder: (context) {
270 - return AlertDialog(
271 - title: Text(l10n.rename_conversation),
272 - content: TextField(
273 - controller: controller,
274 - autofocus: true,
275 - decoration: InputDecoration(
276 - hintText: l10n.enter_new_title,
277 - border: const OutlineInputBorder(),
278 - ),
316 + builder: (ctx) => AlertDialog(
317 + title: Text(l10n.exit_temporary_chat_title),
318 + content: Text(l10n.exit_temporary_chat_body),
319 + actions: [
320 + TextButton(
321 + onPressed: () => Navigator.pop(ctx),
322 + child: Text(l10n.cancel),
323 ),
280 - actions: [
281 - TextButton(
282 - onPressed: () => Navigator.pop(context),
283 - child: Text(l10n.cancel),
284 - ),
285 - TextButton(
286 - onPressed: () {
287 - final newTitle = controller.text.trim();
288 - if (newTitle.isNotEmpty) {
289 - ref
290 - .read(conv.conversationsProvider.notifier)
291 - .renameConversation(conversation.id, newTitle);
292 - }
293 - Navigator.pop(context);
294 - },
295 - child: Text(l10n.rename),
296 - ),
297 - ],
298 - );
299 - },
300 - ).then((_) => controller.dispose());
324 + TextButton(
325 + onPressed: () {
326 + Navigator.pop(ctx);
327 + ref.read(chatProvider.notifier).startNewConversation();
328 + },
329 + child: Text(l10n.nav_new_chat),
330 + ),
331 + ],
332 + ),
333 + );
334 + return;
335 }
336 +
337 + ref.read(chatProvider.notifier).startNewConversation();
338 }
339
340 class _ChatBody extends ConsumerWidget {
341 const _ChatBody({
342 required this.scrollController,
343 required this.inputFocusNode,
344 + required this.inputBarKey,
345 required this.autoScroll,
346 required this.showScrollToBottom,
347 required this.onScrollToBottom,
348 required this.onModelPicker,
349 required this.onMenuAction,
350 + required this.onShareMessage,
351 + required this.onSaveMessage,
352 });
353
354 final ScrollController scrollController;
355 final FocusNode inputFocusNode;
356 + final GlobalKey<ChatInputBarState> inputBarKey;
357 final ChatAutoScrollController autoScroll;
358 final bool showScrollToBottom;
359 final VoidCallback onScrollToBottom;
360 final VoidCallback onModelPicker;
361 final void Function(String) onMenuAction;
362 + final void Function(String content) onShareMessage;
363 + final Future<void> Function(Message message) onSaveMessage;
364
365 @override
366 Widget build(BuildContext context, WidgetRef ref) {
@@ -333,8 +375,8 @@ class _ChatBody extends ConsumerWidget {
375 final errorMessage = ref.watch(chatProvider.select((s) => s.errorMessage));
376 final selectedModel = ref.watch(selectedModelProvider);
377 final connectionStatus = ref.watch(connectionStatusProvider);
336 - final activeServer = ref.watch(activeServerProvider);
378 final activeConversation = ref.watch(conv.activeConversationProvider);
379 + final isTemporary = ref.watch(chatProvider.select((s) => s.isTemporary));
380 final personaId = activeConversation?.personaId;
381 final persona = personaId != null
382 ? ref.watch(personaByIdProvider(personaId))
@@ -359,15 +401,24 @@ class _ChatBody extends ConsumerWidget {
401
402 return Column(
403 children: [
362 - ModelTopBar(selectedModel: selectedModel, onTap: onModelPicker),
404 + ModelTopBar(
405 + selectedModel: selectedModel,
406 + onModelTap: onModelPicker,
407 + ),
408 _ScreenAppBar(
364 - activeServer: activeServer,
409 activeConversation: activeConversation,
366 - connectionStatus: connectionStatus,
410 isDark: isDark,
411 persona: persona,
412 + isTemporary: isTemporary,
413 + hasMessages: messages.isNotEmpty,
414 onMenuAction: onMenuAction,
415 onPersonaPicker: () => showPersonaPickerSheet(context),
416 + onChatModeAction: () => _handleChatModeAction(
417 + context,
418 + ref,
419 + hasMessages: messages.isNotEmpty,
420 + isTemporary: isTemporary,
421 + ),
422 ),
423 const NotificationPermissionBanner(),
424 if (connectionStatus == ConnectionStatus.disconnected ||
@@ -388,36 +439,56 @@ class _ChatBody extends ConsumerWidget {
439 ),
440 const TtsPlayerBar(),
441 Expanded(
391 - child: Stack(
392 - children: [
393 - _MessageArea(
394 - isLoading: isLoading,
395 - messages: messages,
396 - activeConversation: activeConversation,
397 - errorMessage: errorMessage,
398 - selectedModel: selectedModel,
399 - isStreaming: isStreaming,
400 - scrollController: scrollController,
401 - effectiveBottomInset: effectiveBottomInset,
402 - keyboardBottomInset: keyboardBottomInset,
403 - onModelPicker: onModelPicker,
404 - ),
405 - _ChatBottomBar(
406 - isStreaming: isStreaming,
407 - keyboardBottomInset: keyboardBottomInset,
408 - inputFocusNode: inputFocusNode,
409 - ),
410 - if (showScrollToBottom && messages.isNotEmpty)
411 - Positioned(
412 - right: 16,
413 - bottom: 120 + keyboardBottomInset,
414 - child: FloatingActionButton.small(
415 - onPressed: onScrollToBottom,
416 - tooltip: AppLocalizations.of(context)!.scroll_to_bottom,
417 - child: const Icon(Icons.arrow_downward),
418 - ),
442 + child: DecoratedBox(
443 + decoration: isTemporary
444 + ? BoxDecoration(
445 + color: isDark
446 + ? const Color(0xFF15120C)
447 + : const Color(0xFFFFFBF0),
448 + border: Border.all(
449 + color: isDark
450 + ? const Color(0xFF6B5A2E)
451 + : const Color(0xFFE6C35C),
452 + width: 2,
453 + ),
454 + )
455 + : const BoxDecoration(),
456 + child: Stack(
457 + clipBehavior: Clip.hardEdge,
458 + children: [
459 + _MessageArea(
460 + isLoading: isLoading,
461 + messages: messages,
462 + activeConversation: activeConversation,
463 + errorMessage: errorMessage,
464 + selectedModel: selectedModel,
465 + isStreaming: isStreaming,
466 + scrollController: scrollController,
467 + effectiveBottomInset: effectiveBottomInset,
468 + keyboardBottomInset: keyboardBottomInset,
469 + onModelPicker: onModelPicker,
470 + onShareMessage: onShareMessage,
471 + onSaveMessage: onSaveMessage,
472 + isTemporary: isTemporary,
473 ),
420 - ],
474 + _ChatBottomBar(
475 + isStreaming: isStreaming,
476 + keyboardBottomInset: keyboardBottomInset,
477 + inputFocusNode: inputFocusNode,
478 + inputBarKey: inputBarKey,
479 + ),
480 + if (showScrollToBottom && messages.isNotEmpty)
481 + Positioned(
482 + right: 16,
483 + bottom: 120 + keyboardBottomInset,
484 + child: FloatingActionButton.small(
485 + onPressed: onScrollToBottom,
486 + tooltip: AppLocalizations.of(context)!.scroll_to_bottom,
487 + child: const Icon(Icons.arrow_downward),
488 + ),
489 + ),
490 + ],
491 + ),
492 ),
493 ),
494 ],
@@ -437,6 +508,9 @@ class _MessageArea extends ConsumerWidget {
508 required this.effectiveBottomInset,
509 required this.keyboardBottomInset,
510 required this.onModelPicker,
511 + required this.onShareMessage,
512 + required this.onSaveMessage,
513 + required this.isTemporary,
514 });
515
516 final bool isLoading;
@@ -449,6 +523,9 @@ class _MessageArea extends ConsumerWidget {
523 final double effectiveBottomInset;
524 final double keyboardBottomInset;
525 final VoidCallback onModelPicker;
526 + final void Function(String content) onShareMessage;
527 + final Future<void> Function(Message message) onSaveMessage;
528 + final bool isTemporary;
529
530 @override
531 Widget build(BuildContext context, WidgetRef ref) {
@@ -535,11 +612,13 @@ class _MessageArea extends ConsumerWidget {
612 onCycleVariant: (messageId, direction) => ref
613 .read(chatProvider.notifier)
614 .cycleMessageVariant(messageId, direction),
538 - onSave: (message) =>
539 - ref.read(savedMessagesProvider.notifier).saveMessage(message),
615 + onSave: (message) => onSaveMessage(message),
616 + onShare: (message) => onShareMessage(message.content),
617 onGenerateResponse: () =>
618 ref.read(chatProvider.notifier).generateResponseForLastUser(),
619 onModelPicker: onModelPicker,
620 + onModelLongPress: (modelId) =>
621 + showModelInfoSheet(context, ref, modelId),
622 hasSmartReplies:
623 !isStreaming &&
624 keyboardBottomInset == 0 &&
@@ -551,22 +630,24 @@ class _MessageArea extends ConsumerWidget {
630
631 class _ScreenAppBar extends ConsumerWidget {
632 const _ScreenAppBar({
554 - required this.activeServer,
633 required this.activeConversation,
556 - required this.connectionStatus,
634 required this.isDark,
635 required this.persona,
636 + required this.isTemporary,
637 + required this.hasMessages,
638 required this.onMenuAction,
639 required this.onPersonaPicker,
640 + required this.onChatModeAction,
641 });
642
563 - final dynamic activeServer;
643 final Conversation? activeConversation;
565 - final ConnectionStatus connectionStatus;
644 final bool isDark;
645 final dynamic persona;
646 + final bool isTemporary;
647 + final bool hasMessages;
648 final void Function(String) onMenuAction;
649 final VoidCallback onPersonaPicker;
650 + final VoidCallback onChatModeAction;
651
652 @override
653 Widget build(BuildContext context, WidgetRef ref) {
@@ -593,34 +674,15 @@ class _ScreenAppBar extends ConsumerWidget {
674 ),
675 const SizedBox(width: 8),
676 Expanded(
596 - child: Row(
597 - children: [
598 - if (activeServer != null) ...[
599 - _buildServerIcon(context, activeServer as Server?),
600 - const SizedBox(width: 8),
601 - ],
602 - Text(
603 - activeConversation?.title.isNotEmpty == true
604 - ? activeConversation!.title
605 - : (activeServer?.name ?? l10n.chat_title),
606 - style: const TextStyle(
607 - fontSize: 18,
608 - fontWeight: FontWeight.bold,
609 - ),
610 - overflow: TextOverflow.ellipsis,
611 - ),
612 - const SizedBox(width: 8),
613 - Container(
614 - width: 8,
615 - height: 8,
616 - decoration: BoxDecoration(
617 - shape: BoxShape.circle,
618 - color: connectionStatus == ConnectionStatus.connected
619 - ? Colors.green
620 - : Colors.grey,
621 - ),
622 - ),
623 - ],
677 + child: Text(
678 + activeConversation?.title.isNotEmpty == true
679 + ? activeConversation!.title
680 + : l10n.nav_new_chat,
681 + style: const TextStyle(
682 + fontSize: 18,
683 + fontWeight: FontWeight.bold,
684 + ),
685 + overflow: TextOverflow.ellipsis,
686 ),
687 ),
688 Stack(
@@ -653,6 +715,12 @@ class _ScreenAppBar extends ConsumerWidget {
715 ),
716 ],
717 ),
718 + _ChatModeIconButton(
719 + hasMessages: hasMessages,
720 + isTemporary: isTemporary,
721 + isDark: isDark,
722 + onPressed: onChatModeAction,
723 + ),
724 PopupMenuButton<String>(
725 icon: const Icon(Icons.more_vert),
726 onSelected: onMenuAction,
@@ -683,6 +751,15 @@ class _ScreenAppBar extends ConsumerWidget {
751 ),
752 ),
753 ],
754 + if (hasMessages)
755 + PopupMenuItem(
756 + value: 'share_chat',
757 + child: ListTile(
758 + leading: const Icon(Icons.share_outlined),
759 + title: Text(l10n.share_conversation),
760 + contentPadding: EdgeInsets.zero,
761 + ),
762 + ),
763 PopupMenuItem(
764 value: 'persona',
765 child: ListTile(
@@ -722,20 +799,67 @@ class _ScreenAppBar extends ConsumerWidget {
799 }
800 }
801
725 -Widget _buildServerIcon(BuildContext context, Server? server) {
726 - if (server == null) return const SizedBox.shrink();
802 +class _ChatModeIconButton extends StatelessWidget {
803 + const _ChatModeIconButton({
804 + required this.hasMessages,
805 + required this.isTemporary,
806 + required this.isDark,
807 + required this.onPressed,
808 + });
809
728 - final iconData = server.iconName != null
729 - ? getHugeIconByName(server.iconName)
730 - : getDefaultServerIcon(server.type.name);
810 + final bool hasMessages;
811 + final bool isTemporary;
812 + final bool isDark;
813 + final VoidCallback onPressed;
814
732 - if (iconData == null) return const Icon(Icons.dns, size: 18);
815 + @override
816 + Widget build(BuildContext context) {
817 + final l10n = AppLocalizations.of(context)!;
818 + final showGhost = !hasMessages || isTemporary;
819 + final ghostActive = isTemporary;
820 +
821 + if (showGhost) {
822 + final activeColor =
823 + isDark ? const Color(0xFFE6C35C) : const Color(0xFF9A7B1A);
824 + final inactiveColor =
825 + isDark ? Colors.white54 : Colors.black45;
826 +
827 + return IconButton(
828 + onPressed: onPressed,
829 + tooltip: ghostActive
830 + ? l10n.exit_temporary_chat_title
831 + : l10n.temporary_chat,
832 + icon: ghostActive
833 + ? Container(
834 + padding: const EdgeInsets.all(4),
835 + decoration: BoxDecoration(
836 + color: activeColor.withValues(alpha: 0.25),
837 + shape: BoxShape.circle,
838 + ),
839 + child: Icon(
840 + LucideIcons.ghost,
841 + size: 20,
842 + color: activeColor,
843 + ),
844 + )
845 + : Icon(
846 + LucideIcons.ghost,
847 + size: 22,
848 + color: inactiveColor,
849 + ),
850 + );
851 + }
852
734 - return HugeIcon(
735 - icon: iconData.icon,
736 - size: 18,
737 - color: Theme.of(context).colorScheme.primary,
738 - );
853 + return IconButton(
854 + onPressed: onPressed,
855 + tooltip: l10n.nav_new_chat,
856 + icon: Icon(
857 + Icons.add_comment_outlined,
858 + size: 22,
859 + color: isDark ? Colors.white70 : Colors.black87,
860 + ),
861 + );
862 + }
863 }
864
865 class _ChatBottomBar extends ConsumerWidget {
@@ -743,11 +867,13 @@ class _ChatBottomBar extends ConsumerWidget {
867 required this.isStreaming,
868 required this.keyboardBottomInset,
869 required this.inputFocusNode,
870 + required this.inputBarKey,
871 });
872
873 final bool isStreaming;
874 final double keyboardBottomInset;
875 final FocusNode inputFocusNode;
876 + final GlobalKey<ChatInputBarState> inputBarKey;
877
878 @override
879 Widget build(BuildContext context, WidgetRef ref) {
@@ -776,6 +902,7 @@ class _ChatBottomBar extends ConsumerWidget {
902 _SmartReplyChipsWrapper(),
903 ],
904 ChatInputBar(
905 + key: inputBarKey,
906 focusNode: inputFocusNode,
907 isStreaming: isStreaming,
908 onSend: (message, {attachments}) {
lib/features/chat/views/components/chat_bubble.dart
+7
@@ -19,8 +19,10 @@ class ChatBubble extends StatelessWidget {
19 this.onBranch,
20 this.onContinue,
21 this.onModelTap,
22 + this.onModelLongPress,
23 this.onCycleVariant,
24 this.onSave,
25 + this.onShare,
26 this.isStreaming = false,
27 });
28
@@ -33,8 +35,10 @@ class ChatBubble extends StatelessWidget {
35 final VoidCallback? onBranch;
36 final VoidCallback? onContinue;
37 final VoidCallback? onModelTap;
38 + final VoidCallback? onModelLongPress;
39 final void Function(int direction)? onCycleVariant;
40 final void Function(Message message)? onSave;
41 + final VoidCallback? onShare;
42 final bool isStreaming;
43
44 @override
@@ -56,6 +60,7 @@ class ChatBubble extends StatelessWidget {
60 onBranch: onBranch,
61 onCycleVariant: onCycleVariant,
62 onSave: onSave,
63 + onShare: onShare,
64 ),
65 );
66 case MessageRole.assistant:
@@ -71,8 +76,10 @@ class ChatBubble extends StatelessWidget {
76 onBranch: onBranch,
77 onContinue: onContinue,
78 onModelTap: onModelTap,
79 + onModelLongPress: onModelLongPress,
80 onCycleVariant: onCycleVariant,
81 onSave: onSave,
82 + onShare: onShare,
83 isStreaming: isStreaming,
84 ),
85 );
lib/features/chat/views/components/chat_bubble/assistant_bubble.dart
+13 -4
@@ -23,8 +23,10 @@ class AssistantBubble extends StatelessWidget {
23 this.onBranch,
24 this.onContinue,
25 this.onModelTap,
26 + this.onModelLongPress,
27 this.onCycleVariant,
28 this.onSave,
29 + this.onShare,
30 this.allMessages = const [],
31 this.isStreaming = false,
32 });
@@ -38,7 +40,9 @@ class AssistantBubble extends StatelessWidget {
40 final VoidCallback? onBranch;
41 final VoidCallback? onContinue;
42 final void Function(Message message)? onSave;
43 + final VoidCallback? onShare;
44 final VoidCallback? onModelTap;
45 + final VoidCallback? onModelLongPress;
46 final void Function(int direction)? onCycleVariant;
47 final bool isStreaming;
48
@@ -60,9 +64,8 @@ class AssistantBubble extends StatelessWidget {
64 !isStreaming)
65 Padding(
66 padding: const EdgeInsets.only(bottom: 6),
63 - child: InkWell(
64 - onTap: onModelTap,
65 - borderRadius: BorderRadius.circular(4),
67 + child: GestureDetector(
68 + onLongPress: onModelLongPress,
69 child: Text(
70 message.modelId!,
71 style: TextStyle(
@@ -156,9 +159,14 @@ class AssistantBubble extends StatelessWidget {
159 content: message.content.isEmpty
160 ? AppLocalizations.of(context)!.no_response
161 : message.content,
159 - tokenCount: message.tokenCount,
162 messageId: message.id,
163 conversationId: message.conversationId,
164 + tokenCount: message.tokenCount,
165 + inputTokenCount: message.inputTokenCount,
166 + generationTimeMs: message.generationTimeMs,
167 + ttftMs: message.ttftMs,
168 + tokensPerSecond: message.tokensPerSecond,
169 + stopReason: message.stopReason,
170 onCopy: onCopy,
171 onRetry: onRetry,
172 onDelete: onDelete,
@@ -166,6 +174,7 @@ class AssistantBubble extends StatelessWidget {
174 onBranch: onBranch,
175 onContinue: onContinue,
176 onSave: () => onSave?.call(message),
177 + onShare: onShare,
178 ),
179 ],
180 ),
lib/features/chat/views/components/chat_bubble/user_bubble.dart
+3
@@ -17,6 +17,7 @@ class UserBubble extends StatelessWidget {
17 this.onBranch,
18 this.onCycleVariant,
19 this.onSave,
20 + this.onShare,
21 this.allMessages = const [],
22 });
23
@@ -28,6 +29,7 @@ class UserBubble extends StatelessWidget {
29 final VoidCallback? onBranch;
30 final void Function(int direction)? onCycleVariant;
31 final void Function(Message message)? onSave;
32 + final VoidCallback? onShare;
33
34 @override
35 Widget build(BuildContext context) {
@@ -123,6 +125,7 @@ class UserBubble extends StatelessWidget {
125 onEdit: onEdit,
126 onBranch: onBranch,
127 onSave: onSave == null ? null : () => onSave!(message),
128 + onShare: onShare,
129 ),
130 ],
131 ),
lib/features/chat/views/components/chat_input_bar.dart
+805 -157
@@ -1,461 +1,1109 @@
1 import 'dart:io';
2
3 +
4 +
5 import 'package:file_picker/file_picker.dart';
6 +
7 import 'package:flutter/material.dart';
8 +
9 import 'package:flutter_riverpod/flutter_riverpod.dart';
10 +
11 import 'package:haptic_feedback/haptic_feedback.dart';
12 +
13 import 'package:hugeicons/hugeicons.dart';
14 +
15 +import 'package:image_picker/image_picker.dart';
16 +
17 import 'package:localmind/l10n/app_localizations.dart';
18 +
19 import 'package:localmind/core/theme/colors.dart';
20
21 +import 'package:permission_handler/permission_handler.dart';
22 +
23 +
24 +
25 import '../../../../core/models/enums.dart';
26 +
27 import '../../../servers/providers/server_providers.dart';
28 +
29 import '../../../stt/providers/stt_providers.dart';
30
31 +import '../../../saved_messages/views/components/saved_message_picker_sheet.dart';
32 +
33 +
34 +
35 class ChatInputBar extends ConsumerStatefulWidget {
36 +
37 const ChatInputBar({
38 +
39 super.key,
40 +
41 required this.onSend,
42 +
43 required this.onStop,
44 +
45 this.onAttach,
46 +
47 this.enabled = true,
48 +
49 this.isStreaming = false,
50 +
51 this.focusNode,
52 +
53 });
54
55 +
56 +
57 final void Function(String message, {List<File>? attachments}) onSend;
58 +
59 final VoidCallback onStop;
60 +
61 final void Function(List<File> attachments)? onAttach;
62 +
63 final bool enabled;
64 +
65 final bool isStreaming;
66 +
67 final FocusNode? focusNode;
68
69 +
70 +
71 @override
34 - ConsumerState<ChatInputBar> createState() => _ChatInputBarState();
72 +
73 + ConsumerState<ChatInputBar> createState() => ChatInputBarState();
74 +
75 }
76
37 -class _ChatInputBarState extends ConsumerState<ChatInputBar>
77 +
78 +
79 +class ChatInputBarState extends ConsumerState<ChatInputBar>
80 +
81 with TickerProviderStateMixin {
82 +
83 final _controller = TextEditingController();
84 +
85 late final FocusNode _focusNode;
86 +
87 final List<File> _attachedFiles = [];
88 +
89 late AnimationController _sendButtonAnimController;
90 +
91 late Animation<double> _sendButtonScale;
92 +
93 late AnimationController _micAnimController;
94 +
95 String _preSpeechText = '';
96
97 +
98 +
99 @override
100 +
101 void initState() {
102 +
103 super.initState();
104 +
105 _focusNode = widget.focusNode ?? FocusNode();
106 +
107 _sendButtonAnimController = AnimationController(
108 +
109 duration: const Duration(milliseconds: 200),
110 +
111 vsync: this,
112 +
113 );
114 +
115 _sendButtonScale = Tween<double>(begin: 1.0, end: 1.1).animate(
116 +
117 CurvedAnimation(parent: _sendButtonAnimController, curve: Curves.easeOut),
118 +
119 );
120 +
121 _micAnimController = AnimationController(
122 +
123 duration: const Duration(milliseconds: 800),
124 +
125 vsync: this,
126 +
127 );
128 +
129 }
130
131 +
132 +
133 @override
134 +
135 void dispose() {
136 +
137 _controller.dispose();
138 +
139 if (widget.focusNode == null) {
140 +
141 _focusNode.dispose();
142 +
143 }
144 +
145 _sendButtonAnimController.dispose();
146 +
147 _micAnimController.dispose();
148 +
149 super.dispose();
150 +
151 }
152
153 +
154 +
155 + void insertText(String text) {
156 +
157 + final insert = text.trim();
158 +
159 + if (insert.isEmpty) return;
160 +
161 + final current = _controller.text;
162 +
163 + if (current.isEmpty) {
164 +
165 + _controller.text = insert;
166 +
167 + } else {
168 +
169 + _controller.text = '$current\n\n$insert';
170 +
171 + }
172 +
173 + setState(() {});
174 +
175 + _focusNode.requestFocus();
176 +
177 + _controller.selection = TextSelection.fromPosition(
178 +
179 + TextPosition(offset: _controller.text.length),
180 +
181 + );
182 +
183 + }
184 +
185 +
186 +
187 + Future<bool> _ensurePhotoPermission() async {
188 +
189 + if (!Platform.isAndroid && !Platform.isIOS) return true;
190 +
191 +
192 +
193 + var status = await Permission.photos.status;
194 +
195 + if (status.isGranted || status.isLimited) return true;
196 +
197 +
198 +
199 + status = await Permission.photos.request();
200 +
201 + if (status.isGranted || status.isLimited) return true;
202 +
203 +
204 +
205 + if (Platform.isAndroid) {
206 +
207 + final storage = await Permission.storage.request();
208 +
209 + return storage.isGranted;
210 +
211 + }
212 +
213 + return false;
214 +
215 + }
216 +
217 +
218 +
219 + Future<void> _pickImages() async {
220 +
221 + final granted = await _ensurePhotoPermission();
222 +
223 + if (!granted) {
224 +
225 + if (!mounted) return;
226 +
227 + ScaffoldMessenger.of(context).showSnackBar(
228 +
229 + SnackBar(
230 +
231 + content: Text(AppLocalizations.of(context)!.photo_permission_denied),
232 +
233 + ),
234 +
235 + );
236 +
237 + return;
238 +
239 + }
240 +
241 +
242 +
243 + final picker = ImagePicker();
244 +
245 + final images = await picker.pickMultiImage(imageQuality: 85);
246 +
247 + if (images.isEmpty) return;
248 +
249 +
250 +
251 + setState(() {
252 +
253 + _attachedFiles.addAll(images.map((x) => File(x.path)));
254 +
255 + });
256 +
257 + widget.onAttach?.call(_attachedFiles);
258 +
259 + }
260 +
261 +
262 +
263 + Future<void> _pickTextDocument() async {
264 +
265 + final result = await FilePicker.pickFiles(
266 +
267 + type: FileType.custom,
268 +
269 + allowedExtensions: const ['txt', 'md'],
270 +
271 + allowMultiple: true,
272 +
273 + );
274 +
275 + if (result == null || result.files.isEmpty) return;
276 +
277 +
278 +
279 + setState(() {
280 +
281 + _attachedFiles.addAll(
282 +
283 + result.files.where((f) => f.path != null).map((f) => File(f.path!)),
284 +
285 + );
286 +
287 + });
288 +
289 + widget.onAttach?.call(_attachedFiles);
290 +
291 + }
292 +
293 +
294 +
295 + Future<void> _handleInsertSavedMessage() async {
296 +
297 + final content = await showSavedMessagePickerSheet(context);
298 +
299 + if (content != null && content.isNotEmpty) {
300 +
301 + insertText(content);
302 +
303 + }
304 +
305 + }
306 +
307 +
308 +
309 + void _showAttachMenu() {
310 +
311 + final l10n = AppLocalizations.of(context)!;
312 +
313 + showModalBottomSheet(
314 +
315 + context: context,
316 +
317 + builder: (ctx) => SafeArea(
318 +
319 + child: Column(
320 +
321 + mainAxisSize: MainAxisSize.min,
322 +
323 + children: [
324 +
325 + ListTile(
326 +
327 + leading: const Icon(Icons.image_outlined),
328 +
329 + title: Text(l10n.attach_image),
330 +
331 + onTap: () {
332 +
333 + Navigator.pop(ctx);
334 +
335 + _pickImages();
336 +
337 + },
338 +
339 + ),
340 +
341 + ListTile(
342 +
343 + leading: const Icon(Icons.description_outlined),
344 +
345 + title: Text(l10n.attach_text_document),
346 +
347 + onTap: () {
348 +
349 + Navigator.pop(ctx);
350 +
351 + _pickTextDocument();
352 +
353 + },
354 +
355 + ),
356 +
357 + ListTile(
358 +
359 + leading: const Icon(Icons.bookmark_outline),
360 +
361 + title: Text(l10n.insert_saved_message),
362 +
363 + onTap: () {
364 +
365 + Navigator.pop(ctx);
366 +
367 + _handleInsertSavedMessage();
368 +
369 + },
370 +
371 + ),
372 +
373 + const SizedBox(height: 8),
374 +
375 + ],
376 +
377 + ),
378 +
379 + ),
380 +
381 + );
382 +
383 + }
384 +
385 +
386 +
387 Future<void> _handleMicToggle() async {
388 +
389 final stt = ref.read(sttProvider.notifier);
390 +
391 final isListening = ref.read(sttProvider).isListening;
392
393 +
394 +
395 Haptics.vibrate(HapticsType.light);
396
397 +
398 +
399 if (isListening) {
400 +
401 await stt.stopListening();
402 +
403 } else {
404 +
405 _preSpeechText = _controller.text;
406 +
407 await stt.startListening(
408 +
409 onResult: (words) {
410 +
411 if (words.isNotEmpty) {
412 +
413 setState(() {
414 +
415 if (_preSpeechText.isEmpty) {
416 +
417 _controller.text = words;
418 +
419 } else {
420 +
421 _controller.text = '$_preSpeechText $words';
422 +
423 }
94 - // Position the cursor at the end of the text
424 +
425 _controller.selection = TextSelection.fromPosition(
426 +
427 TextPosition(offset: _controller.text.length),
428 +
429 );
430 +
431 });
432 +
433 }
434 +
435 },
436 +
437 );
438 +
439 }
440 +
441 }
442
443 +
444 +
445 Widget _buildMicButton(bool isListening, ThemeData theme) {
446 +
447 final l10n = AppLocalizations.of(context)!;
448 +
449 final isDark = theme.brightness == Brightness.dark;
450 +
451 return AnimatedBuilder(
452 +
453 animation: _micAnimController,
454 +
455 builder: (context, child) {
456 +
457 final pulseValue = isListening ? _micAnimController.value : 0.0;
458 +
459 return Container(
460 +
461 decoration: BoxDecoration(
462 +
463 shape: BoxShape.circle,
464 +
465 boxShadow: [
466 +
467 if (isListening)
468 +
469 BoxShadow(
470 +
471 color: Colors.red.withValues(alpha: 0.4 * pulseValue),
472 +
473 blurRadius: 8 + (8 * pulseValue),
474 +
475 spreadRadius: 1 + (3 * pulseValue),
476 +
477 ),
478 +
479 ],
480 +
481 ),
482 +
483 child: Container(
125 - width: 44,
126 - height: 44,
484 +
485 + width: 36,
486 +
487 + height: 36,
488 +
489 decoration: BoxDecoration(
490 +
491 color: isListening
492 +
493 ? Colors.red
494 +
495 : theme.colorScheme.onSurface.withValues(alpha: 0.05),
496 +
497 shape: BoxShape.circle,
498 +
499 ),
500 +
501 child: IconButton(
134 - icon: HugeIcon(
135 - icon: isListening
136 - ? HugeIcons.strokeRoundedVoice
137 - : HugeIcons.strokeRoundedMic01,
502 +
503 + padding: EdgeInsets.zero,
504 +
505 + icon: Icon(
506 +
507 + Icons.mic_none_rounded,
508 +
509 + size: 20,
510 +
511 color: isListening
512 +
513 ? (isDark ? Colors.white : Colors.black)
514 +
515 : theme.colorScheme.onSurface.withValues(alpha: 0.7),
141 - size: 20,
516 +
517 ),
518 +
519 onPressed: _handleMicToggle,
144 - tooltip: isListening ? l10n.stop_listening_tooltip : l10n.start_listening_tooltip,
520 +
521 + tooltip: isListening
522 +
523 + ? l10n.stop_listening_tooltip
524 +
525 + : l10n.start_listening_tooltip,
526 +
527 ),
528 +
529 ),
530 +
531 );
532 +
533 },
149 - );
150 - }
534
152 - Future<void> _handleAttach() async {
153 - final result = await FilePicker.pickFiles(
154 - type: FileType.custom,
155 - allowedExtensions: const ['jpg', 'jpeg', 'png', 'gif', 'webp', 'txt', 'md'],
156 - allowMultiple: true,
535 );
158 - if (result != null && result.files.isNotEmpty) {
159 - setState(() {
160 - _attachedFiles.addAll(
161 - result.files.where((f) => f.path != null).map((f) => File(f.path!)),
162 - );
163 - });
164 - widget.onAttach?.call(_attachedFiles);
165 - }
536 +
537 }
538
539 +
540 +
541 void _handleSubmit() {
542 +
543 final text = _controller.text.trim();
544 +
545 if (text.isEmpty && _attachedFiles.isEmpty) return;
546
547 +
548 +
549 Haptics.vibrate(HapticsType.medium);
550 +
551 widget.onSend(text, attachments: List.from(_attachedFiles));
552 +
553 _controller.clear();
554 +
555 setState(() {
556 +
557 _attachedFiles.clear();
558 +
559 });
560 +
561 }
562
563 +
564 +
565 void _handleStop() {
566 +
567 Haptics.vibrate(HapticsType.light);
568 +
569 widget.onStop();
570 +
571 }
572
573 +
574 +
575 Widget _buildActionButton(bool canSend, ThemeData theme) {
576 +
577 final l10n = AppLocalizations.of(context)!;
578 +
579 final backgroundColor = widget.isStreaming
580 +
581 ? Colors.red
582 +
583 : theme.colorScheme.primary;
584 +
585 final iconColor = widget.isStreaming
586 +
587 ? Colors.white
588 +
589 : theme.colorScheme.onPrimary;
590
591 +
592 +
593 return GestureDetector(
594 +
595 onTapDown: canSend ? (_) => _sendButtonAnimController.forward() : null,
596 +
597 onTapUp: canSend ? (_) => _sendButtonAnimController.reverse() : null,
598 +
599 onTapCancel: canSend ? () => _sendButtonAnimController.reverse() : null,
600 +
601 child: ScaleTransition(
602 +
603 scale: _sendButtonScale,
604 +
605 child: AnimatedContainer(
606 +
607 duration: const Duration(milliseconds: 200),
608 +
609 curve: Curves.easeOut,
203 - width: 44,
204 - height: 44,
610 +
611 + width: 36,
612 +
613 + height: 36,
614 +
615 decoration: BoxDecoration(
616 +
617 color: backgroundColor.withValues(
618 +
619 alpha: (canSend || widget.isStreaming) ? 1.0 : 0.2,
620 +
621 ),
622 +
623 shape: BoxShape.circle,
624 +
625 ),
626 +
627 child: IconButton(
628 +
629 + padding: EdgeInsets.zero,
630 +
631 icon: AnimatedSwitcher(
632 +
633 duration: const Duration(milliseconds: 150),
634 +
635 transitionBuilder: (child, animation) {
636 +
637 return ScaleTransition(scale: animation, child: child);
638 +
639 },
640 +
641 child: widget.isStreaming
642 +
643 ? HugeIcon(
644 +
645 icon: HugeIcons.strokeRoundedStop,
646 +
647 key: const ValueKey('stop'),
648 +
649 color: iconColor,
222 - size: 20,
650 +
651 + size: 18,
652 +
653 )
654 +
655 : HugeIcon(
656 +
657 icon: HugeIcons.strokeRoundedArrowUp01,
658 +
659 key: ValueKey(canSend),
660 +
661 color: iconColor,
228 - size: 20,
662 +
663 + size: 18,
664 +
665 ),
666 +
667 ),
668 +
669 onPressed: widget.isStreaming
670 +
671 ? _handleStop
672 +
673 : (canSend ? _handleSubmit : null),
234 - tooltip: widget.isStreaming ? l10n.stop_generation_tooltip : l10n.send_message_tooltip,
674 +
675 + tooltip: widget.isStreaming
676 +
677 + ? l10n.stop_generation_tooltip
678 +
679 + : l10n.send_message_tooltip,
680 +
681 ),
682 +
683 ),
684 +
685 ),
686 +
687 );
688 +
689 }
690
691 +
692 +
693 String? _mapSttError(String error) {
694 +
695 switch (error) {
696 +
697 case 'error_no_match':
698 +
699 return 'No speech recognized. Please try speaking again and check your microphone.';
700 +
701 case 'error_speech_timeout':
702 +
703 return 'No speech detected. Timed out.';
704 +
705 case 'error_permission':
706 +
707 return 'Microphone permission denied.';
708 +
709 case 'error_busy':
710 +
711 return 'Speech recognition is busy. Please try again.';
712 +
713 case 'error_network':
714 +
715 case 'error_network_timeout':
716 +
717 return 'Network error. Please check your connection and try again.';
718 +
719 case 'error_audio':
720 +
721 return 'Audio recording error. Please check your microphone.';
722 +
723 default:
724 +
725 if (error.startsWith('error_')) {
258 - final cleanName = error.replaceFirst('error_', '').replaceAll('_', ' ');
726 +
727 + final cleanName =
728 +
729 + error.replaceFirst('error_', '').replaceAll('_', ' ');
730 +
731 return 'Speech recognition error: $cleanName';
732 +
733 }
734 +
735 return error;
736 +
737 }
738 +
739 }
740
741 +
742 +
743 @override
744 +
745 Widget build(BuildContext context) {
746 +
747 final l10n = AppLocalizations.of(context)!;
748 +
749 final theme = Theme.of(context);
750 +
751 final isDark = theme.brightness == Brightness.dark;
752 +
753 final connectionStatus = ref.watch(connectionStatusProvider);
754 +
755 final isConnected = connectionStatus == ConnectionStatus.connected;
756
757 +
758 +
759 final sttState = ref.watch(sttProvider);
760 +
761 final isListening = sttState.isListening;
762
763 +
764 +
765 if (isListening) {
766 +
767 if (!_micAnimController.isAnimating) {
768 +
769 _micAnimController.repeat(reverse: true);
770 +
771 }
772 +
773 } else {
774 +
775 if (_micAnimController.isAnimating) {
776 +
777 _micAnimController.stop();
778 +
779 _micAnimController.reset();
780 +
781 }
782 +
783 }
784
785 +
786 +
787 ref.listen<String?>(
788 +
789 sttProvider.select((s) => s.error),
790 +
791 (previous, next) {
792 +
793 if (next != null) {
794 +
795 final message = _mapSttError(next);
796 +
797 if (message != null) {
798 +
799 ScaffoldMessenger.of(context).showSnackBar(
800 +
801 SnackBar(content: Text(message)),
802 +
803 );
804 +
805 }
806 +
807 }
808 +
809 },
810 +
811 );
812
301 - final canSend =
302 - widget.enabled &&
813 +
814 +
815 + final canSend = widget.enabled &&
816 +
817 isConnected &&
818 +
819 (_controller.text.trim().isNotEmpty || _attachedFiles.isNotEmpty) &&
820 +
821 !widget.isStreaming;
822
823 +
824 +
825 return SafeArea(
826 +
827 top: false,
828 +
829 child: Container(
310 - margin: const EdgeInsets.only(left: 20, right: 20, top: 8, bottom: 16),
311 - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
830 +
831 + margin: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 12),
832 +
833 + padding: const EdgeInsets.fromLTRB(12, 10, 12, 8),
834 +
835 decoration: BoxDecoration(
836 +
837 color: isDark ? AppColors.darkSurfaceInput : AppColors.lightSurface,
314 - borderRadius: BorderRadius.circular(12),
838 +
839 + borderRadius: BorderRadius.circular(16),
840 +
841 border: Border.all(
842 +
843 color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
317 - width: 1,
844 +
845 ),
846 +
847 ),
848 +
849 child: Column(
850 +
851 mainAxisSize: MainAxisSize.min,
852 +
853 + crossAxisAlignment: CrossAxisAlignment.stretch,
854 +
855 children: [
323 - AnimatedSize(
324 - duration: const Duration(milliseconds: 200),
325 - curve: Curves.easeOut,
326 - child: _attachedFiles.isEmpty
327 - ? const SizedBox.shrink()
328 - : Container(
329 - height: 70,
330 - padding: const EdgeInsets.only(
331 - left: 12,
332 - right: 12,
333 - top: 8,
334 - bottom: 4,
335 - ),
336 - child: ListView.separated(
337 - scrollDirection: Axis.horizontal,
338 - itemCount: _attachedFiles.length,
339 - separatorBuilder: (_, _) => const SizedBox(width: 8),
340 - itemBuilder: (context, index) {
341 - final file = _attachedFiles[index];
342 - final ext = file.path.split('.').last.toLowerCase();
343 - final isImage = !['txt', 'md'].contains(ext);
344 - return Stack(
345 - clipBehavior: Clip.none,
346 - children: [
347 - Container(
348 - decoration: BoxDecoration(
349 - borderRadius: BorderRadius.circular(12),
350 - border: Border.all(
351 - color: isDark
352 - ? AppColors.darkBorder
353 - : AppColors.lightBorder,
354 - ),
355 - ),
356 - child: ClipRRect(
357 - borderRadius: BorderRadius.circular(11),
358 - child: isImage
359 - ? Image.file(
360 - file,
361 - width: 48,
362 - height: 48,
363 - fit: BoxFit.cover,
364 - )
365 - : Container(
366 - width: 48,
367 - height: 48,
368 - color: theme.colorScheme.surfaceContainerHighest,
369 - child: Icon(
370 - Icons.description_outlined,
371 - size: 22,
372 - color: theme.colorScheme.primary,
373 - ),
374 - ),
375 - ),
376 - ),
377 - PositionedDirectional(
378 - top: -4,
379 - end: -4,
380 - child: GestureDetector(
381 - onTap: () => setState(
382 - () => _attachedFiles.removeAt(index),
383 - ),
384 - child: Container(
385 - width: 18,
386 - height: 18,
387 - decoration: const BoxDecoration(
388 - color: Colors.black,
389 - shape: BoxShape.circle,
390 - ),
391 - child: const Icon(
392 - Icons.close,
393 - size: 10,
394 - color: Colors.white,
856 +
857 + if (_attachedFiles.isNotEmpty)
858 +
859 + SizedBox(
860 +
861 + height: 68,
862 +
863 + child: ListView.separated(
864 +
865 + scrollDirection: Axis.horizontal,
866 +
867 + itemCount: _attachedFiles.length,
868 +
869 + separatorBuilder: (_, __) => const SizedBox(width: 8),
870 +
871 + itemBuilder: (context, index) {
872 +
873 + final file = _attachedFiles[index];
874 +
875 + final ext = file.path.split('.').last.toLowerCase();
876 +
877 + final isImage = !['txt', 'md'].contains(ext);
878 +
879 + return Stack(
880 +
881 + clipBehavior: Clip.none,
882 +
883 + children: [
884 +
885 + Container(
886 +
887 + decoration: BoxDecoration(
888 +
889 + borderRadius: BorderRadius.circular(10),
890 +
891 + border: Border.all(
892 +
893 + color: isDark
894 +
895 + ? AppColors.darkBorder
896 +
897 + : AppColors.lightBorder,
898 +
899 + ),
900 +
901 + ),
902 +
903 + child: ClipRRect(
904 +
905 + borderRadius: BorderRadius.circular(9),
906 +
907 + child: isImage
908 +
909 + ? Image.file(
910 +
911 + file,
912 +
913 + width: 48,
914 +
915 + height: 48,
916 +
917 + fit: BoxFit.cover,
918 +
919 + )
920 +
921 + : Container(
922 +
923 + width: 48,
924 +
925 + height: 48,
926 +
927 + color: theme
928 +
929 + .colorScheme.surfaceContainerHighest,
930 +
931 + child: Icon(
932 +
933 + Icons.description_outlined,
934 +
935 + size: 22,
936 +
937 + color: theme.colorScheme.primary,
938 +
939 ),
940 +
941 ),
397 - ),
942 +
943 + ),
944 +
945 + ),
946 +
947 + PositionedDirectional(
948 +
949 + top: -4,
950 +
951 + end: -4,
952 +
953 + child: GestureDetector(
954 +
955 + onTap: () =>
956 +
957 + setState(() => _attachedFiles.removeAt(index)),
958 +
959 + child: Container(
960 +
961 + width: 18,
962 +
963 + height: 18,
964 +
965 + decoration: const BoxDecoration(
966 +
967 + color: Colors.black,
968 +
969 + shape: BoxShape.circle,
970 +
971 ),
399 - ],
400 - );
401 - },
402 - ),
403 - ),
972 +
973 + child: const Icon(
974 +
975 + Icons.close,
976 +
977 + size: 10,
978 +
979 + color: Colors.white,
980 +
981 + ),
982 +
983 + ),
984 +
985 + ),
986 +
987 + ),
988 +
989 + ],
990 +
991 + );
992 +
993 + },
994 +
995 + ),
996 +
997 + ),
998 +
999 + TextField(
1000 +
1001 + controller: _controller,
1002 +
1003 + focusNode: _focusNode,
1004 +
1005 + enabled: widget.enabled,
1006 +
1007 + maxLines: 6,
1008 +
1009 + minLines: 1,
1010 +
1011 + textInputAction: TextInputAction.newline,
1012 +
1013 + keyboardType: TextInputType.multiline,
1014 +
1015 + onChanged: (_) => setState(() {}),
1016 +
1017 + style: TextStyle(
1018 +
1019 + fontSize: 15,
1020 +
1021 + color: theme.colorScheme.onSurface,
1022 +
1023 + ),
1024 +
1025 + decoration: InputDecoration(
1026 +
1027 + filled: false,
1028 +
1029 + border: InputBorder.none,
1030 +
1031 + enabledBorder: InputBorder.none,
1032 +
1033 + focusedBorder: InputBorder.none,
1034 +
1035 + disabledBorder: InputBorder.none,
1036 +
1037 + hintText: l10n.chat_input_hint,
1038 +
1039 + hintStyle: TextStyle(
1040 +
1041 + color: theme.colorScheme.onSurface.withValues(alpha: 0.38),
1042 +
1043 + fontSize: 15,
1044 +
1045 + ),
1046 +
1047 + isDense: true,
1048 +
1049 + contentPadding: const EdgeInsets.symmetric(vertical: 4),
1050 +
1051 + ),
1052 +
1053 ),
1054 +
1055 + const SizedBox(height: 6),
1056 +
1057 Row(
1058 +
1059 children: [
1060 +
1061 IconButton(
408 - icon: HugeIcon(
409 - icon: HugeIcons.strokeRoundedPlusSign,
410 - color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
1062 +
1063 + visualDensity: VisualDensity.compact,
1064 +
1065 + padding: const EdgeInsets.all(8),
1066 +
1067 + constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
1068 +
1069 + icon: Icon(
1070 +
1071 + Icons.add,
1072 +
1073 size: 22,
1074 +
1075 + color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
1076 +
1077 ),
413 - onPressed: isConnected ? _handleAttach : null,
414 - tooltip: l10n.attach_images_tooltip,
415 - ),
416 - const SizedBox(width: 4),
417 - Expanded(
418 - child: TextField(
419 - controller: _controller,
420 - focusNode: _focusNode,
421 - enabled: widget.enabled,
422 - maxLines: 5,
423 - minLines: 1,
424 - textInputAction: TextInputAction.newline,
425 - keyboardType: TextInputType.multiline,
426 - onChanged: (text) {
427 - setState(() {});
428 - },
429 - style: TextStyle(
430 - fontSize: 15,
431 - color: theme.colorScheme.onSurface,
432 - ),
433 - decoration: InputDecoration(
434 - filled: false,
435 - border: InputBorder.none,
436 - enabledBorder: InputBorder.none,
437 - focusedBorder: InputBorder.none,
438 - disabledBorder: InputBorder.none,
439 - hintText: l10n.chat_input_hint,
440 - hintStyle: TextStyle(
441 - color: theme.colorScheme.onSurface.withValues(alpha: 0.38),
442 - fontSize: 15,
443 - fontWeight: FontWeight.w400,
444 - ),
445 - isDense: true,
446 - contentPadding: const EdgeInsets.symmetric(vertical: 12),
447 - ),
448 - ),
1078 +
1079 + onPressed: isConnected ? _showAttachMenu : null,
1080 +
1081 + tooltip: l10n.add_attachment,
1082 +
1083 ),
450 - const SizedBox(width: 8),
1084 +
1085 + const Spacer(),
1086 +
1087 _buildMicButton(isListening, theme),
452 - const SizedBox(width: 8),
1088 +
1089 + const SizedBox(width: 6),
1090 +
1091 _buildActionButton(canSend, theme),
1092 +
1093 ],
1094 +
1095 ),
1096 +
1097 ],
1098 +
1099 ),
1100 +
1101 ),
1102 +
1103 );
1104 +
1105 }
1106 +
1107 }
1108 +
1109 +
lib/features/chat/views/components/message_action_bar.dart
+231 -80
@@ -4,15 +4,21 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
4 import 'package:shadcn_ui/shadcn_ui.dart';
5 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
10 class MessageActionBar extends ConsumerStatefulWidget {
11 const MessageActionBar({
12 super.key,
13 required this.content,
13 - this.tokenCount,
14 this.messageId,
15 this.conversationId,
16 + this.tokenCount,
17 + this.inputTokenCount,
18 + this.generationTimeMs,
19 + this.ttftMs,
20 + this.tokensPerSecond,
21 + this.stopReason,
22 this.onCopy,
23 this.onRetry,
24 this.onDelete,
@@ -24,9 +30,14 @@ class MessageActionBar extends ConsumerStatefulWidget {
30 });
31
32 final String content;
27 - final int? tokenCount;
33 final String? messageId;
34 final String? conversationId;
35 + final int? tokenCount;
36 + final int? inputTokenCount;
37 + final int? generationTimeMs;
38 + final int? ttftMs;
39 + final double? tokensPerSecond;
40 + final String? stopReason;
41 final VoidCallback? onCopy;
42 final VoidCallback? onRetry;
43 final VoidCallback? onDelete;
@@ -41,7 +52,6 @@ class MessageActionBar extends ConsumerStatefulWidget {
52 }
53
54 class _MessageActionBarState extends ConsumerState<MessageActionBar> {
44 -
55 void _toggleTts() async {
56 final ttsNotifier = ref.read(tts.ttsProvider.notifier);
57 final ttsState = ref.read(tts.ttsProvider);
@@ -72,6 +82,13 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
82 (ttsState.isSpeaking || ttsState.isPaused);
83 }
84
85 + String _formatMs(int ms) {
86 + if (ms >= 1000) {
87 + return '${(ms / 1000).toStringAsFixed(2)}s';
88 + }
89 + return '${ms}ms';
90 + }
91 +
92 @override
93 Widget build(BuildContext context) {
94 final l10n = AppLocalizations.of(context)!;
@@ -79,8 +96,10 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
96 final isThisActive = _isMessageActive(ttsState);
97 final isThisPlaying =
98 isThisActive && ttsState.isSpeaking && !ttsState.isPaused;
82 - final isThisInitializing =
83 - isThisActive && ttsState.isInitializing;
99 + final isThisInitializing = isThisActive && ttsState.isInitializing;
100 + final isSaved = widget.messageId != null
101 + ? ref.watch(isMessageSavedProvider(widget.messageId!)).value ?? false
102 + : false;
103
104 return Row(
105 mainAxisSize: MainAxisSize.min,
@@ -101,6 +120,15 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
120 }
121 },
122 ),
123 + if (widget.onSave != null) ...[
124 + const SizedBox(width: 4),
125 + _ActionButton(
126 + icon: isSaved ? Icons.bookmark : Icons.bookmark_border,
127 + label: isSaved ? l10n.message_already_saved : l10n.save_message,
128 + onTap: widget.onSave,
129 + isActive: isSaved,
130 + ),
131 + ],
132 if (widget.onRetry != null) ...[
133 const SizedBox(width: 4),
134 _ActionButton(
@@ -141,7 +169,9 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
169 const SizedBox(width: 4),
170 _ActionButton(
171 icon: isThisActive
144 - ? (isThisPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled)
172 + ? (isThisPlaying
173 + ? Icons.pause_circle_filled
174 + : Icons.play_circle_filled)
175 : (isThisInitializing ? Icons.hourglass_top : Icons.volume_up),
176 label: isThisActive
177 ? (isThisPlaying ? l10n.pause : l10n.resume)
@@ -152,7 +182,7 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
182 _ActionButton(
183 icon: Icons.more_horiz,
184 label: l10n.more,
155 - onTap: () => _showMoreOptions(context),
185 + onTap: () => _showMoreOptions(context, isSaved),
186 ),
187 ],
188 );
@@ -182,108 +212,227 @@ class _MessageActionBarState extends ConsumerState<MessageActionBar> {
212 );
213 }
214
185 - void _showMoreOptions(BuildContext context) {
215 + void _showMoreOptions(BuildContext context, bool isSaved) {
216 final sheetL10n = AppLocalizations.of(context)!;
217 final ttsState = ref.read(tts.ttsProvider);
218 final isThisActive = _isMessageActive(ttsState);
219 final isThisPlaying =
220 isThisActive && ttsState.isSpeaking && !ttsState.isPaused;
221 + final theme = Theme.of(context);
222
192 - showShadSheet(
223 + final stats = <({String label, String value})>[];
224 + stats.add((
225 + label: sheetL10n.characters_label,
226 + value: '${widget.content.length}',
227 + ));
228 + if (widget.ttftMs != null) {
229 + stats.add((
230 + label: sheetL10n.stream_ttft,
231 + value: _formatMs(widget.ttftMs!),
232 + ));
233 + }
234 + if (widget.generationTimeMs != null) {
235 + stats.add((
236 + label: sheetL10n.stream_generation_time,
237 + value: _formatMs(widget.generationTimeMs!),
238 + ));
239 + }
240 + if (widget.inputTokenCount != null) {
241 + stats.add((
242 + label: sheetL10n.stream_input_tokens,
243 + value: '${widget.inputTokenCount}',
244 + ));
245 + }
246 + if (widget.tokenCount != null) {
247 + stats.add((
248 + label: sheetL10n.stream_output_tokens,
249 + value: '${widget.tokenCount}',
250 + ));
251 + }
252 + if (widget.tokensPerSecond != null) {
253 + stats.add((
254 + label: sheetL10n.stream_tokens_per_sec,
255 + value: widget.tokensPerSecond!.toStringAsFixed(1),
256 + ));
257 + }
258 + if (widget.stopReason != null && widget.stopReason!.isNotEmpty) {
259 + stats.add((
260 + label: sheetL10n.stream_stop_reason,
261 + value: widget.stopReason!,
262 + ));
263 + }
264 +
265 + showModalBottomSheet(
266 context: context,
194 - builder: (context) => ShadSheet(
195 - title: Text(sheetL10n.message_options),
196 - child: Column(
197 - mainAxisSize: MainAxisSize.min,
198 - children: [
199 - ListTile(
200 - leading: const Icon(Icons.code),
201 - title: Text(sheetL10n.copy_markdown),
202 - onTap: () {
203 - Navigator.of(context).pop();
204 - Clipboard.setData(ClipboardData(text: widget.content));
205 - ScaffoldMessenger.of(context).showSnackBar(
206 - SnackBar(content: Text(sheetL10n.copied_markdown)),
207 - );
208 - },
209 - ),
210 - ListTile(
211 - leading: Icon(
212 - isThisActive
213 - ? (isThisPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled)
214 - : Icons.volume_up,
215 - ),
216 - title: Text(
217 - isThisActive
218 - ? (isThisPlaying ? sheetL10n.pause : sheetL10n.resume)
219 - : sheetL10n.read_aloud,
220 - ),
221 - onTap: () {
222 - Navigator.of(context).pop();
223 - _toggleTts();
224 - },
225 - ),
226 - if (widget.onShare != null)
227 - ListTile(
228 - leading: const Icon(Icons.ios_share),
229 - title: Text(sheetL10n.share),
230 - onTap: () {
231 - Navigator.of(context).pop();
232 - widget.onShare?.call();
233 - },
234 - ),
235 - if (widget.onBranch != null)
236 - ListTile(
237 - leading: const Icon(Icons.call_split),
238 - title: Text(sheetL10n.branch_chat),
239 - subtitle: Text(sheetL10n.branch_chat_desc),
240 - onTap: () {
241 - Navigator.of(context).pop();
242 - widget.onBranch?.call();
243 - },
267 + builder: (ctx) => SafeArea(
268 + child: Padding(
269 + padding: const EdgeInsets.fromLTRB(8, 8, 8, 12),
270 + child: Column(
271 + mainAxisSize: MainAxisSize.min,
272 + crossAxisAlignment: CrossAxisAlignment.stretch,
273 + children: [
274 + Padding(
275 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
276 + child: Text(
277 + sheetL10n.message_options,
278 + style: theme.textTheme.titleSmall,
279 + ),
280 ),
245 - if (widget.onSave != null)
246 - ListTile(
247 - leading: const Icon(Icons.bookmark_outline),
248 - title: Text(sheetL10n.save_message),
281 + _CompactOptionTile(
282 + icon: Icons.code,
283 + label: sheetL10n.copy_markdown,
284 onTap: () {
250 - Navigator.of(context).pop();
251 - widget.onSave?.call();
285 + Navigator.pop(ctx);
286 + Clipboard.setData(ClipboardData(text: widget.content));
287 ScaffoldMessenger.of(context).showSnackBar(
253 - SnackBar(content: Text(sheetL10n.message_saved)),
288 + SnackBar(content: Text(sheetL10n.copied_markdown)),
289 );
290 },
291 ),
257 - ListTile(
258 - leading: const Icon(Icons.text_fields),
259 - title: Text(sheetL10n.character_count(widget.content.length)),
260 - enabled: false,
261 - ),
262 - if (widget.tokenCount != null)
263 - ListTile(
264 - leading: const Icon(Icons.numbers),
265 - title: Text(sheetL10n.token_count(widget.tokenCount!)),
266 - enabled: false,
292 + _CompactOptionTile(
293 + icon: isThisActive
294 + ? (isThisPlaying
295 + ? Icons.pause_circle_filled
296 + : Icons.play_circle_filled)
297 + : Icons.volume_up,
298 + label: isThisActive
299 + ? (isThisPlaying ? sheetL10n.pause : sheetL10n.resume)
300 + : sheetL10n.read_aloud,
301 + onTap: () {
302 + Navigator.pop(ctx);
303 + _toggleTts();
304 + },
305 ),
268 - ],
306 + if (widget.onShare != null)
307 + _CompactOptionTile(
308 + icon: Icons.ios_share,
309 + label: sheetL10n.share,
310 + onTap: () {
311 + Navigator.pop(ctx);
312 + widget.onShare?.call();
313 + },
314 + ),
315 + if (widget.onBranch != null)
316 + _CompactOptionTile(
317 + icon: Icons.call_split,
318 + label: sheetL10n.branch_chat,
319 + onTap: () {
320 + Navigator.pop(ctx);
321 + widget.onBranch?.call();
322 + },
323 + ),
324 + if (widget.onSave != null)
325 + _CompactOptionTile(
326 + icon: isSaved ? Icons.bookmark : Icons.bookmark_outline,
327 + label: isSaved
328 + ? sheetL10n.message_already_saved
329 + : sheetL10n.save_message,
330 + onTap: () {
331 + Navigator.pop(ctx);
332 + widget.onSave?.call();
333 + },
334 + ),
335 + if (stats.isNotEmpty) ...[
336 + const SizedBox(height: 8),
337 + Padding(
338 + padding: const EdgeInsets.symmetric(horizontal: 8),
339 + child: GridView.builder(
340 + shrinkWrap: true,
341 + physics: const NeverScrollableScrollPhysics(),
342 + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
343 + crossAxisCount: 2,
344 + childAspectRatio: 3.2,
345 + crossAxisSpacing: 8,
346 + mainAxisSpacing: 4,
347 + ),
348 + itemCount: stats.length,
349 + itemBuilder: (context, index) {
350 + final stat = stats[index];
351 + return Container(
352 + padding: const EdgeInsets.symmetric(
353 + horizontal: 8,
354 + vertical: 6,
355 + ),
356 + decoration: BoxDecoration(
357 + color: theme.colorScheme.surfaceContainerHighest
358 + .withValues(alpha: 0.5),
359 + borderRadius: BorderRadius.circular(8),
360 + ),
361 + child: Column(
362 + crossAxisAlignment: CrossAxisAlignment.start,
363 + mainAxisAlignment: MainAxisAlignment.center,
364 + children: [
365 + Text(
366 + stat.label,
367 + maxLines: 1,
368 + overflow: TextOverflow.ellipsis,
369 + style: TextStyle(
370 + fontSize: 10,
371 + color: theme.colorScheme.onSurfaceVariant,
372 + ),
373 + ),
374 + Text(
375 + stat.value,
376 + maxLines: 1,
377 + overflow: TextOverflow.ellipsis,
378 + style: const TextStyle(
379 + fontSize: 12,
380 + fontWeight: FontWeight.w600,
381 + ),
382 + ),
383 + ],
384 + ),
385 + );
386 + },
387 + ),
388 + ),
389 + ],
390 + ],
391 + ),
392 ),
393 ),
394 );
395 }
396 }
397
398 +class _CompactOptionTile extends StatelessWidget {
399 + const _CompactOptionTile({
400 + required this.icon,
401 + required this.label,
402 + required this.onTap,
403 + });
404 +
405 + final IconData icon;
406 + final String label;
407 + final VoidCallback onTap;
408 +
409 + @override
410 + Widget build(BuildContext context) {
411 + return ListTile(
412 + dense: true,
413 + visualDensity: VisualDensity.compact,
414 + contentPadding: const EdgeInsets.symmetric(horizontal: 8),
415 + leading: Icon(icon, size: 20),
416 + title: Text(label, style: const TextStyle(fontSize: 14)),
417 + onTap: onTap,
418 + );
419 + }
420 +}
421 +
422 class _ActionButton extends StatefulWidget {
423 const _ActionButton({
424 required this.icon,
425 required this.label,
426 required this.onTap,
427 this.isDestructive = false,
428 + this.isActive = false,
429 });
430
431 final IconData icon;
432 final String label;
433 final VoidCallback? onTap;
434 final bool isDestructive;
435 + final bool isActive;
436
437 @override
438 State<_ActionButton> createState() => _ActionButtonState();
@@ -319,9 +468,11 @@ class _ActionButtonState extends State<_ActionButton>
468 final theme = Theme.of(context);
469 final isDark = theme.brightness == Brightness.dark;
470
322 - final baseColor = widget.isDestructive
323 - ? (isDark ? Colors.red[300] : Colors.red[600])
324 - : (isDark ? AppColors.darkMutedText : AppColors.lightMutedText);
471 + final baseColor = widget.isActive
472 + ? theme.colorScheme.primary
473 + : widget.isDestructive
474 + ? (isDark ? Colors.red[300] : Colors.red[600])
475 + : (isDark ? AppColors.darkMutedText : AppColors.lightMutedText);
476
477 final hoverColor = widget.isDestructive
478 ? (isDark ? Colors.red[200] : Colors.red[500])
lib/features/chat/views/components/message_list/empty_state.dart
+49 -31
@@ -1,6 +1,7 @@
1 import 'dart:async';
2
3 import 'package:flutter/material.dart';
4 +import 'package:shadcn_ui/shadcn_ui.dart';
5 import 'package:localmind/core/theme/colors.dart';
6 import 'package:localmind/l10n/app_localizations.dart';
7 import 'recent_conversation_item.dart';
@@ -103,40 +104,52 @@ class _EmptyStateState extends State<EmptyState>
104 final theme = Theme.of(context);
105 final isDark = theme.brightness == Brightness.dark;
106
106 - return Center(
107 - child: SingleChildScrollView(
108 - padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
109 - child: Column(
110 - mainAxisAlignment: MainAxisAlignment.center,
111 - children: [
112 - const SizedBox(height: 24),
113 - AnimatedSwitcher(
114 - duration: const Duration(milliseconds: 400),
115 - child: Text(
116 - _welcomeMessages(l10n)[_welcomeIndex],
117 - key: ValueKey(_welcomeIndex),
118 - textAlign: TextAlign.center,
119 - style: theme.textTheme.headlineSmall?.copyWith(
120 - fontWeight: FontWeight.w600,
121 - color: isDark ? Colors.white : Colors.black87,
107 + return SingleChildScrollView(
108 + padding: const EdgeInsets.fromLTRB(32, 8, 32, 24),
109 + child: Column(
110 + crossAxisAlignment: CrossAxisAlignment.center,
111 + children: [
112 + SizedBox(
113 + height: 72,
114 + child: Center(
115 + child: AnimatedSwitcher(
116 + duration: const Duration(milliseconds: 400),
117 + layoutBuilder: (current, previous) => Stack(
118 + alignment: Alignment.center,
119 + children: [...previous, if (current != null) current],
120 + ),
121 + child: Text(
122 + _welcomeMessages(l10n)[_welcomeIndex],
123 + key: ValueKey(_welcomeIndex),
124 + textAlign: TextAlign.center,
125 + maxLines: 2,
126 + overflow: TextOverflow.ellipsis,
127 + style: theme.textTheme.headlineSmall?.copyWith(
128 + fontWeight: FontWeight.w600,
129 + color: isDark ? Colors.white : Colors.black87,
130 + ),
131 ),
132 ),
133 ),
125 - const SizedBox(height: 32),
126 - _buildModelAndPersonaSelectors(isDark, theme, l10n),
127 - const SizedBox(height: 32),
128 - _buildQuickPrompts(isDark),
129 - if (widget.recentConversations.isNotEmpty) ...[
130 - const SizedBox(height: 32),
131 - _buildRecentSection(isDark, l10n),
132 - ],
134 + ),
135 + const SizedBox(height: 8),
136 + _buildModelAndPersonaSelectors(isDark, theme, l10n),
137 + const SizedBox(height: 24),
138 + _buildQuickPrompts(isDark),
139 + if (widget.recentConversations.isNotEmpty) ...[
140 + const SizedBox(height: 28),
141 + _buildRecentSection(isDark, l10n),
142 ],
134 - ),
143 + ],
144 ),
145 );
146 }
147
139 - Widget _buildModelAndPersonaSelectors(bool isDark, ThemeData theme, AppLocalizations l10n) {
148 + Widget _buildModelAndPersonaSelectors(
149 + bool isDark,
150 + ThemeData theme,
151 + AppLocalizations l10n,
152 + ) {
153 return TweenAnimationBuilder<double>(
154 tween: Tween(begin: 0.0, end: 1.0),
155 duration: const Duration(milliseconds: 300),
@@ -160,7 +173,8 @@ class _EmptyStateState extends State<EmptyState>
173 child: Row(
174 mainAxisSize: MainAxisSize.min,
175 children: [
163 - Icon(Icons.settings_suggest, size: 18, color: theme.colorScheme.primary),
176 + Icon(Icons.settings_suggest,
177 + size: 18, color: theme.colorScheme.primary),
178 const SizedBox(width: 8),
179 Flexible(
180 child: Text(
@@ -190,7 +204,8 @@ class _EmptyStateState extends State<EmptyState>
204 mainAxisSize: MainAxisSize.min,
205 children: [
206 if (widget.selectedPersona != null) ...[
193 - Text(widget.selectedPersona!.emoji, style: const TextStyle(fontSize: 18)),
207 + Text(widget.selectedPersona!.emoji,
208 + style: const TextStyle(fontSize: 18)),
209 const SizedBox(width: 8),
210 Flexible(
211 child: Text(
@@ -205,7 +220,8 @@ class _EmptyStateState extends State<EmptyState>
220 ),
221 ),
222 ] else ...[
208 - Icon(Icons.smart_toy_outlined, size: 18, color: theme.colorScheme.primary),
223 + Icon(Icons.smart_toy_outlined,
224 + size: 18, color: theme.colorScheme.primary),
225 const SizedBox(width: 8),
226 Flexible(
227 child: Text(
@@ -278,7 +294,8 @@ class _EmptyStateState extends State<EmptyState>
294 child: ActionChip(
295 label: Text(prompt),
296 onPressed: () => widget.onQuickPrompt(prompt),
281 - backgroundColor: isDark ? AppColors.darkSurfaceCard : AppColors.lightSurface,
297 + backgroundColor:
298 + isDark ? AppColors.darkSurfaceCard : AppColors.lightSurface,
299 side: BorderSide(
300 color: isDark ? AppColors.darkBorder : AppColors.lightBorder,
301 ),
@@ -299,7 +316,8 @@ class _EmptyStateState extends State<EmptyState>
316 style: TextStyle(
317 fontSize: 14,
318 fontWeight: FontWeight.w500,
302 - color: isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
319 + color:
320 + isDark ? AppColors.darkMutedText : AppColors.lightMutedText,
321 ),
322 ),
323 const SizedBox(width: 8),
lib/features/chat/views/components/message_list/message_list.dart
+24
@@ -22,7 +22,9 @@ class MessageList extends StatelessWidget {
22 required this.onContinue,
23 required this.onCycleVariant,
24 required this.onModelPicker,
25 + this.onModelLongPress,
26 this.onSave,
27 + this.onShare,
28 this.onGenerateResponse,
29 this.hasSmartReplies = false,
30 this.bottomInset = 0,
@@ -40,7 +42,9 @@ class MessageList extends StatelessWidget {
42 final void Function(String messageId) onContinue;
43 final void Function(String messageId, int direction) onCycleVariant;
44 final VoidCallback onModelPicker;
45 + final void Function(String modelId)? onModelLongPress;
46 final void Function(Message message)? onSave;
47 + final void Function(Message message)? onShare;
48 final VoidCallback? onGenerateResponse;
49 final bool hasSmartReplies;
50 final double bottomInset;
@@ -60,7 +64,9 @@ class MessageList extends StatelessWidget {
64 onContinue: onContinue,
65 onCycleVariant: onCycleVariant,
66 onModelPicker: onModelPicker,
67 + onModelLongPress: onModelLongPress,
68 onSave: onSave,
69 + onShare: onShare,
70 onGenerateResponse: onGenerateResponse,
71 hasSmartReplies: hasSmartReplies,
72 bottomInset: bottomInset,
@@ -82,7 +88,9 @@ class _MessageListConsumer extends ConsumerStatefulWidget {
88 required this.onContinue,
89 required this.onCycleVariant,
90 required this.onModelPicker,
91 + this.onModelLongPress,
92 this.onSave,
93 + this.onShare,
94 this.onGenerateResponse,
95 this.hasSmartReplies = false,
96 this.bottomInset = 0,
@@ -100,7 +108,9 @@ class _MessageListConsumer extends ConsumerStatefulWidget {
108 final void Function(String messageId) onContinue;
109 final void Function(String messageId, int direction) onCycleVariant;
110 final VoidCallback onModelPicker;
111 + final void Function(String modelId)? onModelLongPress;
112 final void Function(Message message)? onSave;
113 + final void Function(Message message)? onShare;
114 final VoidCallback? onGenerateResponse;
115 final bool hasSmartReplies;
116 final double bottomInset;
@@ -169,7 +179,9 @@ class _MessageListConsumerState extends ConsumerState<_MessageListConsumer> {
179 onContinue: widget.onContinue,
180 onCycleVariant: widget.onCycleVariant,
181 onModelPicker: widget.onModelPicker,
182 + onModelLongPress: widget.onModelLongPress,
183 onSave: widget.onSave,
184 + onShare: widget.onShare,
185 onGenerateResponse: widget.onGenerateResponse,
186 hasSmartReplies: widget.hasSmartReplies,
187 bottomInset: widget.bottomInset,
@@ -193,7 +205,9 @@ class _MessageList extends StatelessWidget {
205 required this.onContinue,
206 required this.onCycleVariant,
207 required this.onModelPicker,
208 + this.onModelLongPress,
209 this.onSave,
210 + this.onShare,
211 this.onGenerateResponse,
212 this.hasSmartReplies = false,
213 this.bottomInset = 0,
@@ -213,7 +227,9 @@ class _MessageList extends StatelessWidget {
227 final void Function(String messageId) onContinue;
228 final void Function(String messageId, int direction) onCycleVariant;
229 final VoidCallback onModelPicker;
230 + final void Function(String modelId)? onModelLongPress;
231 final void Function(Message message)? onSave;
232 + final void Function(Message message)? onShare;
233 final VoidCallback? onGenerateResponse;
234 final bool hasSmartReplies;
235 final double bottomInset;
@@ -281,7 +297,15 @@ class _MessageList extends StatelessWidget {
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 ),
310 if (!isStreaming &&
311 isLast &&
lib/features/chat/views/components/model_info_sheet.dart new
+85
@@ -0,0 +1,85 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter/services.dart';
3 +import 'package:flutter_riverpod/flutter_riverpod.dart';
4 +import 'package:shadcn_ui/shadcn_ui.dart';
5 +import 'package:localmind/features/chat/providers/chat_params_providers.dart';
6 +import 'package:localmind/features/models/data/models/model_info.dart';
7 +import 'package:localmind/features/servers/providers/server_providers.dart';
8 +import 'package:localmind/l10n/app_localizations.dart';
9 +
10 +Future<void> showModelInfoSheet(
11 + BuildContext context,
12 + WidgetRef ref,
13 + String modelId,
14 +) async {
15 + final l10n = AppLocalizations.of(context)!;
16 + final server = ref.read(activeServerProvider);
17 + ModelInfo? model;
18 + if (server != null) {
19 + final models = await ref.read(availableModelsProvider(server.id).future);
20 + for (final m in models) {
21 + if (m is ModelInfo && m.id == modelId) {
22 + model = m;
23 + break;
24 + }
25 + }
26 + }
27 +
28 + if (!context.mounted) return;
29 +
30 + final displayName = model?.displayName ?? modelId;
31 + final contextLength = ref.read(chatParamsProvider).contextLength;
32 + final identifier = model?.id ?? modelId;
33 +
34 + await showShadSheet(
35 + context: context,
36 + builder: (ctx) => ShadSheet(
37 + title: Text(l10n.model_info),
38 + child: Column(
39 + mainAxisSize: MainAxisSize.min,
40 + children: [
41 + _ModelInfoRow(
42 + label: l10n.model_name,
43 + value: displayName,
44 + ),
45 + _ModelInfoRow(
46 + label: l10n.model_identifier,
47 + value: identifier,
48 + ),
49 + _ModelInfoRow(
50 + label: l10n.context_length,
51 + value: contextLength.toString(),
52 + ),
53 + ],
54 + ),
55 + ),
56 + );
57 +}
58 +
59 +class _ModelInfoRow extends StatelessWidget {
60 + const _ModelInfoRow({required this.label, required this.value});
61 +
62 + final String label;
63 + final String value;
64 +
65 + @override
66 + Widget build(BuildContext context) {
67 + final l10n = AppLocalizations.of(context)!;
68 + return ListTile(
69 + title: Text(label, style: const TextStyle(fontSize: 12)),
70 + subtitle: SelectableText(value),
71 + trailing: IconButton(
72 + icon: const Icon(Icons.copy, size: 18),
73 + tooltip: l10n.copy,
74 + onPressed: () async {
75 + await Clipboard.setData(ClipboardData(text: value));
76 + if (context.mounted) {
77 + ScaffoldMessenger.of(context).showSnackBar(
78 + SnackBar(content: Text(l10n.copied_to_clipboard)),
79 + );
80 + }
81 + },
82 + ),
83 + );
84 + }
85 +}
lib/features/chat/views/components/temporary_chat_banner.dart new
+69
@@ -0,0 +1,69 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:localmind/l10n/app_localizations.dart';
3 +
4 +class TemporaryChatBanner extends StatelessWidget {
5 + const TemporaryChatBanner({
6 + super.key,
7 + required this.onSaveToHistory,
8 + });
9 +
10 + final VoidCallback onSaveToHistory;
11 +
12 + @override
13 + Widget build(BuildContext context) {
14 + final l10n = AppLocalizations.of(context)!;
15 + final theme = Theme.of(context);
16 + final isDark = theme.brightness == Brightness.dark;
17 +
18 + return Container(
19 + width: double.infinity,
20 + margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
21 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
22 + decoration: BoxDecoration(
23 + color: isDark
24 + ? const Color(0xFF2A2418)
25 + : const Color(0xFFFFF8E7),
26 + borderRadius: BorderRadius.circular(10),
27 + border: Border.all(
28 + color: isDark
29 + ? const Color(0xFF6B5A2E)
30 + : const Color(0xFFE6C35C),
31 + ),
32 + ),
33 + child: Row(
34 + children: [
35 + Icon(
36 + Icons.hourglass_empty_outlined,
37 + size: 18,
38 + color: isDark ? const Color(0xFFE6C35C) : const Color(0xFF9A7B1A),
39 + ),
40 + const SizedBox(width: 10),
41 + Expanded(
42 + child: Text(
43 + l10n.temporary_chat_banner,
44 + style: TextStyle(
45 + fontSize: 12,
46 + fontWeight: FontWeight.w600,
47 + color: isDark ? const Color(0xFFF5E6B8) : const Color(0xFF5C4A12),
48 + ),
49 + ),
50 + ),
51 + TextButton(
52 + onPressed: onSaveToHistory,
53 + style: TextButton.styleFrom(
54 + visualDensity: VisualDensity.compact,
55 + padding: const EdgeInsets.symmetric(horizontal: 8),
56 + ),
57 + child: Text(
58 + l10n.save_to_history,
59 + style: TextStyle(
60 + fontSize: 12,
61 + color: isDark ? const Color(0xFFE6C35C) : const Color(0xFF9A7B1A),
62 + ),
63 + ),
64 + ),
65 + ],
66 + ),
67 + );
68 + }
69 +}
lib/features/chat/views/components/top_bar/model_top_bar.dart
+142 -29
@@ -1,60 +1,173 @@
1 import 'package:flutter/material.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:hugeicons/hugeicons.dart';
4 +
5 +import 'package:localmind/core/models/enums.dart';
6 import 'package:localmind/l10n/app_localizations.dart';
7 +import '../../../../servers/data/models/server.dart';
8 +import '../../../../servers/providers/server_providers.dart';
9 +import '../../../../servers/views/components/server_icon_picker.dart';
10 +import '../../../../servers/views/components/server_picker_sheet.dart';
11
4 -class ModelTopBar extends StatelessWidget {
12 +class ModelTopBar extends ConsumerWidget {
13 const ModelTopBar({
14 super.key,
15 required this.selectedModel,
8 - required this.onTap,
16 + required this.onModelTap,
17 });
18
19 final dynamic selectedModel;
12 - final VoidCallback onTap;
20 + final VoidCallback onModelTap;
21
22 @override
15 - Widget build(BuildContext context) {
23 + Widget build(BuildContext context, WidgetRef ref) {
24 final l10n = AppLocalizations.of(context)!;
25 final theme = Theme.of(context);
26 final isDark = theme.brightness == Brightness.dark;
27 + final activeServer = ref.watch(activeServerProvider);
28 + final connectionStatus = ref.watch(connectionStatusProvider);
29 + final mutedColor = isDark ? Colors.white54 : Colors.black45;
30 + final textColor = isDark ? Colors.white70 : Colors.black87;
31
32 return Container(
33 width: double.infinity,
34 color: theme.colorScheme.surface,
35 child: SafeArea(
36 bottom: false,
25 - child: GestureDetector(
26 - onTap: onTap,
27 - behavior: HitTestBehavior.opaque,
28 - child: Padding(
29 - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
30 - child: Row(
31 - mainAxisAlignment: MainAxisAlignment.center,
32 - mainAxisSize: MainAxisSize.min,
33 - children: [
34 - Flexible(
35 - child: Text(
36 - selectedModel?.displayName ?? l10n.select_model,
37 - style: TextStyle(
38 - fontSize: 13,
39 - fontWeight: FontWeight.w500,
40 - color: isDark ? Colors.white70 : Colors.black87,
37 + child: Padding(
38 + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
39 + child: Row(
40 + children: [
41 + if (activeServer != null) ...[
42 + InkWell(
43 + onTap: () => showServerPickerSheet(context, ref),
44 + borderRadius: BorderRadius.circular(6),
45 + child: Padding(
46 + padding: const EdgeInsets.symmetric(
47 + horizontal: 4,
48 + vertical: 2,
49 + ),
50 + child: Row(
51 + mainAxisSize: MainAxisSize.min,
52 + children: [
53 + _ServerGlyph(
54 + server: activeServer,
55 + connectionStatus: connectionStatus,
56 + isDark: isDark,
57 + ),
58 + const SizedBox(width: 4),
59 + ConstrainedBox(
60 + constraints: BoxConstraints(
61 + maxWidth: MediaQuery.sizeOf(context).width * 0.28,
62 + ),
63 + child: Text(
64 + activeServer.name,
65 + style: TextStyle(
66 + fontSize: 11,
67 + fontWeight: FontWeight.w500,
68 + color: textColor,
69 + ),
70 + maxLines: 1,
71 + overflow: TextOverflow.ellipsis,
72 + ),
73 + ),
74 + ],
75 ),
42 - maxLines: 2,
43 - textAlign: TextAlign.center,
44 - overflow: TextOverflow.ellipsis,
76 ),
77 ),
47 - const SizedBox(width: 4),
48 - Icon(
49 - Icons.expand_more,
50 - size: 14,
51 - color: isDark ? Colors.white38 : Colors.black38,
78 + Padding(
79 + padding: const EdgeInsets.symmetric(horizontal: 6),
80 + child: Text('·', style: TextStyle(color: mutedColor, fontSize: 12)),
81 ),
82 ],
54 - ),
83 + Expanded(
84 + child: InkWell(
85 + onTap: onModelTap,
86 + borderRadius: BorderRadius.circular(6),
87 + child: Padding(
88 + padding: const EdgeInsets.symmetric(
89 + horizontal: 4,
90 + vertical: 2,
91 + ),
92 + child: Row(
93 + children: [
94 + Expanded(
95 + child: Text(
96 + selectedModel?.displayName ?? l10n.select_model,
97 + style: TextStyle(
98 + fontSize: 11,
99 + fontWeight: FontWeight.w500,
100 + color: textColor,
101 + ),
102 + maxLines: 1,
103 + overflow: TextOverflow.ellipsis,
104 + ),
105 + ),
106 + Icon(Icons.expand_more, size: 14, color: mutedColor),
107 + ],
108 + ),
109 + ),
110 + ),
111 + ),
112 + ],
113 ),
114 ),
115 ),
116 );
117 }
118 }
119 +
120 +class _ServerGlyph extends StatelessWidget {
121 + const _ServerGlyph({
122 + required this.server,
123 + required this.connectionStatus,
124 + required this.isDark,
125 + });
126 +
127 + final Server server;
128 + final ConnectionStatus connectionStatus;
129 + final bool isDark;
130 +
131 + @override
132 + Widget build(BuildContext context) {
133 + final iconName = server.iconName;
134 + final serverIcon = iconName != null
135 + ? (getHugeIconByName(iconName)?.icon ??
136 + getDefaultServerIcon(server.type.name)?.icon)
137 + : getDefaultServerIcon(server.type.name)?.icon;
138 +
139 + final statusColor = switch (connectionStatus) {
140 + ConnectionStatus.connected => Colors.green,
141 + ConnectionStatus.error => Colors.red,
142 + ConnectionStatus.checking => Colors.orange,
143 + ConnectionStatus.disconnected => Colors.grey,
144 + };
145 +
146 + return Stack(
147 + clipBehavior: Clip.none,
148 + children: [
149 + HugeIcon(
150 + icon: serverIcon ?? HugeIcons.strokeRoundedServerStack01,
151 + size: 14,
152 + color: isDark ? Colors.white70 : Colors.black87,
153 + ),
154 + Positioned(
155 + right: -2,
156 + bottom: -2,
157 + child: Container(
158 + width: 6,
159 + height: 6,
160 + decoration: BoxDecoration(
161 + color: statusColor,
162 + shape: BoxShape.circle,
163 + border: Border.all(
164 + color: Theme.of(context).colorScheme.surface,
165 + width: 1,
166 + ),
167 + ),
168 + ),
169 + ),
170 + ],
171 + );
172 + }
173 +}
lib/features/conversations/data/models/conversation.dart
+12 -1
@@ -18,6 +18,9 @@ class Conversation {
18 final List<String>? smartReplies;
19 final String? smartRepliesLastMessageId;
20 final String? folderId;
21 + final bool isTemporary;
22 + final bool isArchived;
23 + final int characterCount;
24
25 Conversation({
26 required this.id,
@@ -39,6 +42,9 @@ class Conversation {
42 this.smartReplies,
43 this.smartRepliesLastMessageId,
44 this.folderId,
45 + this.isTemporary = false,
46 + this.isArchived = false,
47 + this.characterCount = 0,
48 });
49
50 Conversation copyWith({
@@ -71,6 +77,9 @@ class Conversation {
77 bool clearSmartRepliesLastMessageId = false,
78 String? folderId,
79 bool clearFolderId = false,
80 + bool? isTemporary,
81 + bool? isArchived,
82 + int? characterCount,
83 }) {
84 return Conversation(
85 id: id ?? this.id,
@@ -96,7 +105,9 @@ class Conversation {
105 ? null
106 : (smartRepliesLastMessageId ?? this.smartRepliesLastMessageId),
107 folderId: clearFolderId ? null : (folderId ?? this.folderId),
108 + isTemporary: isTemporary ?? this.isTemporary,
109 + isArchived: isArchived ?? this.isArchived,
110 + characterCount: characterCount ?? this.characterCount,
111 );
112 }
101 -
113 }
lib/features/conversations/providers/conversation_providers.dart
+102 -2
@@ -49,6 +49,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
49 String? serverId,
50 String? modelId,
51 bool? mcpEnabled,
52 + bool isTemporary = false,
53 }) async {
54 final db = ref.read(databaseProvider);
55 final now = DateTime.now();
@@ -67,6 +68,7 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
68 lastMessagePreview: null,
69 systemPrompt: systemPrompt,
70 mcpEnabled: mcpEnabled,
71 + isTemporary: isTemporary,
72 );
73
74 db.conversationBox.put(ConversationEntity.fromDomain(conversation));
@@ -140,6 +142,45 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
142 }
143 }
144
145 + Future<void> setTemporary(String id, bool isTemporary) async {
146 + final db = ref.read(databaseProvider);
147 + final conversations = state.value ?? [];
148 + final existing = conversations.firstWhere(
149 + (c) => c.id == id,
150 + orElse: () => throw Exception('Conversation not found in state'),
151 + );
152 +
153 + final updated = existing.copyWith(
154 + isTemporary: isTemporary,
155 + updatedAt: DateTime.now(),
156 + );
157 +
158 + final query = db.conversationBox
159 + .query(ConversationEntity_.id.equals(id))
160 + .build();
161 + final existingEntity = query.findFirst();
162 + query.close();
163 +
164 + final entity = ConversationEntity.fromDomain(updated);
165 + if (existingEntity != null) {
166 + entity.internalId = existingEntity.internalId;
167 + }
168 + db.conversationBox.put(entity);
169 +
170 + state = AsyncData(await _loadAll());
171 +
172 + final activeId = ref.read(activeConversationProvider)?.id;
173 + if (activeId == id) {
174 + final refreshed = state.value?.firstWhere(
175 + (c) => c.id == id,
176 + orElse: () => updated,
177 + );
178 + ref
179 + .read(activeConversationProvider.notifier)
180 + .setActiveConversation(refreshed);
181 + }
182 + }
183 +
184 Future<void> deleteConversation(String id) async {
185 final db = ref.read(databaseProvider);
186
@@ -188,11 +229,41 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
229 state = AsyncData(await _loadAll());
230 }
231
232 + Future<void> setArchived(String id, bool archived) async {
233 + final db = ref.read(databaseProvider);
234 + final conversations = state.value ?? [];
235 + final existing = conversations.firstWhere(
236 + (c) => c.id == id,
237 + orElse: () => throw Exception('Conversation not found in state'),
238 + );
239 +
240 + final updated = existing.copyWith(
241 + isArchived: archived,
242 + updatedAt: DateTime.now(),
243 + );
244 +
245 + final query = db.conversationBox
246 + .query(ConversationEntity_.id.equals(id))
247 + .build();
248 + final existingEntity = query.findFirst();
249 + query.close();
250 +
251 + final entity = ConversationEntity.fromDomain(updated);
252 + if (existingEntity != null) {
253 + entity.internalId = existingEntity.internalId;
254 + }
255 + db.conversationBox.put(entity);
256 +
257 + state = AsyncData(await _loadAll());
258 + }
259 +
260 Future<void> updatePreview(
261 String id,
262 String preview,
263 DateTime updatedAt, {
264 int? messageCount,
265 + int? characterCountDelta,
266 + int? characterCount,
267 }) async {
268 final db = ref.read(databaseProvider);
269 final conversations = state.value ?? [];
@@ -203,6 +274,10 @@ class ConversationsNotifier extends AsyncNotifier<List<Conversation>> {
274 lastMessagePreview: preview,
275 updatedAt: updatedAt,
276 messageCount: messageCount ?? existing.messageCount + 1,
277 + characterCount: characterCount ??
278 + (characterCountDelta != null
279 + ? existing.characterCount + characterCountDelta
280 + : existing.characterCount),
281 );
282
283 final query = db.conversationBox
@@ -459,9 +534,20 @@ final filteredConversationsProvider = Provider<AsyncValue<List<Conversation>>>((
534 final conversationsAsync = ref.watch(conversationsProvider);
535 final query = ref.watch(conversationSearchProvider).toLowerCase();
536 final folderFilter = ref.watch(historyFolderFilterProvider);
537 + final listFilter = ref.watch(historyListFilterProvider);
538
539 return conversationsAsync.whenData((conversations) {
464 - var filtered = conversations;
540 + var filtered = conversations.where((c) => !c.isTemporary).toList();
541 +
542 + switch (listFilter) {
543 + case HistoryListFilter.archived:
544 + filtered = filtered.where((c) => c.isArchived).toList();
545 + case HistoryListFilter.pinned:
546 + filtered = filtered.where((c) => c.isPinned && !c.isArchived).toList();
547 + case HistoryListFilter.all:
548 + filtered = filtered.where((c) => !c.isArchived).toList();
549 + }
550 +
551 if (folderFilter != null) {
552 if (folderFilter.isEmpty) {
553 filtered = filtered
@@ -482,7 +568,7 @@ final filteredConversationsProvider = Provider<AsyncValue<List<Conversation>>>((
568 final recentConversationsProvider = Provider<List<Conversation>>((ref) {
569 final allAsync = ref.watch(conversationsProvider);
570 final all = allAsync.value ?? [];
485 - return all.take(3).toList();
571 + return all.where((c) => !c.isTemporary).take(3).toList();
572 });
573
574 final groupedConversationsProvider =
@@ -532,6 +618,20 @@ final historyFolderFilterProvider =
618 return HistoryFolderFilterNotifier();
619 });
620
621 +enum HistoryListFilter { all, pinned, archived }
622 +
623 +final historyListFilterProvider =
624 + NotifierProvider<HistoryListFilterNotifier, HistoryListFilter>(() {
625 + return HistoryListFilterNotifier();
626 + });
627 +
628 +class HistoryListFilterNotifier extends Notifier<HistoryListFilter> {
629 + @override
630 + HistoryListFilter build() => HistoryListFilter.all;
631 +
632 + void setFilter(HistoryListFilter filter) => state = filter;
633 +}
634 +
635 class HistoryFolderFilterNotifier extends Notifier<String?> {
636 @override
637 String? build() => null;
lib/features/conversations/views/chat_history_screen.dart
+25
@@ -1,5 +1,6 @@
1 import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:localmind/core/components/list_filter_button.dart';
4 import 'package:localmind/l10n/app_localizations.dart';
5 import '../providers/conversation_providers.dart';
6 import 'components/conversation_folder_bar.dart';
@@ -60,6 +61,30 @@ class ChatHistoryScreen extends ConsumerWidget {
61 ),
62 ),
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,
85 + ),
86 + ],
87 + ),
88 ],
89 ),
90 ),
lib/features/conversations/views/components/conversation_folder_bar.dart
+10 -122
@@ -1,5 +1,6 @@
1 import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:localmind/core/components/folder_filter_bar.dart';
4 import 'package:localmind/l10n/app_localizations.dart';
5 import '../../providers/conversation_providers.dart';
6
@@ -37,134 +38,21 @@ class ConversationFolderBar extends ConsumerWidget {
38
39 @override
40 Widget build(BuildContext context, WidgetRef ref) {
40 - final l10n = AppLocalizations.of(context)!;
41 final foldersAsync = ref.watch(conversationFoldersProvider);
42 final selected = ref.watch(historyFolderFilterProvider);
43 - final theme = Theme.of(context);
44 - final isDark = theme.brightness == Brightness.dark;
43
44 return foldersAsync.when(
47 - data: (folders) {
48 - return SingleChildScrollView(
49 - scrollDirection: Axis.horizontal,
50 - padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
51 - child: Row(
52 - children: [
53 - _SystemFilterChip(
54 - label: l10n.all_chats,
55 - icon: Icons.all_inbox_outlined,
56 - selected: selected == null,
57 - isDark: isDark,
58 - onSelected: (_) => ref
59 - .read(historyFolderFilterProvider.notifier)
60 - .setFilter(null),
61 - ),
62 - const SizedBox(width: 6),
63 - _SystemFilterChip(
64 - label: l10n.unfiled_chats,
65 - icon: Icons.inbox_outlined,
66 - selected: selected != null && selected.isEmpty,
67 - isDark: isDark,
68 - outlined: true,
69 - onSelected: (_) => ref
70 - .read(historyFolderFilterProvider.notifier)
71 - .setFilter(''),
72 - ),
73 - ...folders.map(
74 - (folder) => Padding(
75 - padding: const EdgeInsets.only(left: 6),
76 - child: FilterChip(
77 - avatar: Icon(
78 - Icons.folder_outlined,
79 - size: 16,
80 - color: selected == folder.id
81 - ? theme.colorScheme.onSecondaryContainer
82 - : theme.colorScheme.primary,
83 - ),
84 - label: Text(folder.name),
85 - selected: selected == folder.id,
86 - showCheckmark: false,
87 - onSelected: (_) => ref
88 - .read(historyFolderFilterProvider.notifier)
89 - .setFilter(folder.id),
90 - ),
91 - ),
92 - ),
93 - const SizedBox(width: 6),
94 - ActionChip(
95 - avatar: Icon(
96 - Icons.create_new_folder_outlined,
97 - size: 16,
98 - color: theme.colorScheme.primary,
99 - ),
100 - label: Text(l10n.new_folder),
101 - side: BorderSide(
102 - color: theme.colorScheme.primary.withValues(alpha: 0.5),
103 - style: BorderStyle.solid,
104 - ),
105 - onPressed: () => _createFolder(context, ref),
106 - ),
107 - ],
108 - ),
109 - );
110 - },
45 + data: (folders) => FolderFilterBar(
46 + folders: folders
47 + .map((f) => FolderFilterItem(id: f.id, name: f.name))
48 + .toList(),
49 + selectedFolderId: selected,
50 + onFilterChanged: (id) =>
51 + ref.read(historyFolderFilterProvider.notifier).setFilter(id),
52 + onCreateFolder: () => _createFolder(context, ref),
53 + ),
54 loading: () => const SizedBox(height: 44),
55 error: (_, __) => const SizedBox.shrink(),
56 );
57 }
58 }
116 -
117 -class _SystemFilterChip extends StatelessWidget {
118 - const _SystemFilterChip({
119 - required this.label,
120 - required this.icon,
121 - required this.selected,
122 - required this.isDark,
123 - required this.onSelected,
124 - this.outlined = false,
125 - });
126 -
127 - final String label;
128 - final IconData icon;
129 - final bool selected;
130 - final bool isDark;
131 - final bool outlined;
132 - final ValueChanged<bool> onSelected;
133 -
134 - @override
135 - Widget build(BuildContext context) {
136 - final theme = Theme.of(context);
137 - final bgColor = selected
138 - ? (outlined
139 - ? theme.colorScheme.surfaceContainerHighest
140 - : theme.colorScheme.secondaryContainer)
141 - : Colors.transparent;
142 - final borderColor = outlined
143 - ? (isDark ? const Color(0xFF444444) : const Color(0xFFCCCCCC))
144 - : (selected
145 - ? theme.colorScheme.secondaryContainer
146 - : (isDark ? const Color(0xFF333333) : const Color(0xFFE0E0E0)));
147 -
148 - return FilterChip(
149 - avatar: Icon(
150 - icon,
151 - size: 16,
152 - color: selected
153 - ? theme.colorScheme.onSecondaryContainer
154 - : theme.colorScheme.onSurfaceVariant,
155 - ),
156 - label: Text(
157 - label,
158 - style: TextStyle(
159 - fontWeight: outlined ? FontWeight.normal : FontWeight.w600,
160 - fontStyle: outlined ? FontStyle.italic : FontStyle.normal,
161 - ),
162 - ),
163 - selected: selected,
164 - showCheckmark: false,
165 - backgroundColor: bgColor,
166 - side: BorderSide(color: borderColor),
167 - onSelected: onSelected,
168 - );
169 - }
170 -}
lib/features/conversations/views/components/conversation_list.dart
+9 -37
@@ -12,8 +12,9 @@ import '../../../../core/routes/app_routes.dart';
12 import '../../../chat/providers/chat_providers.dart';
13 import '../../data/models/conversation.dart';
14 import '../../providers/conversation_providers.dart';
15 -import 'conversation_tile.dart';
15 +import 'rename_conversation_dialog.dart';
16 import 'date_section_header.dart';
17 +import 'conversation_tile.dart';
18
19 class ConversationList extends ConsumerWidget {
20 const ConversationList({
@@ -96,6 +97,12 @@ class ConversationList extends ConsumerWidget {
97 onExport: () {
98 _exportConversation(context, ref, l10n, conversation);
99 },
100 + onArchive: () {
101 + ref.read(conversationsProvider.notifier).setArchived(
102 + conversation.id,
103 + !conversation.isArchived,
104 + );
105 + },
106 );
107 }),
108 ],
@@ -110,42 +117,7 @@ class ConversationList extends ConsumerWidget {
117 AppLocalizations l10n,
118 Conversation conversation,
119 ) {
113 - final controller = TextEditingController(text: conversation.title);
114 -
115 - showDialog(
116 - context: context,
117 - builder: (context) {
118 - return AlertDialog(
119 - title: Text(l10n.rename_conversation),
120 - content: TextField(
121 - controller: controller,
122 - autofocus: true,
123 - decoration: InputDecoration(
124 - hintText: l10n.enter_new_title,
125 - border: const OutlineInputBorder(),
126 - ),
127 - ),
128 - actions: [
129 - TextButton(
130 - onPressed: () => Navigator.pop(context),
131 - child: Text(l10n.cancel),
132 - ),
133 - TextButton(
134 - onPressed: () {
135 - final newTitle = controller.text.trim();
136 - if (newTitle.isNotEmpty) {
137 - ref
138 - .read(conversationsProvider.notifier)
139 - .renameConversation(conversation.id, newTitle);
140 - }
141 - Navigator.pop(context);
142 - },
143 - child: Text(l10n.rename),
144 - ),
145 - ],
146 - );
147 - },
148 - );
120 + showRenameConversationDialog(context, ref, conversation: conversation);
121 }
122
123 void _showDeleteConfirmation(
lib/features/conversations/views/components/conversation_tile.dart
+28
@@ -16,6 +16,7 @@ class ConversationTile extends StatelessWidget {
16 required this.onDuplicate,
17 required this.onMoveToFolder,
18 required this.onExport,
19 + required this.onArchive,
20 });
21
22 final Conversation conversation;
@@ -27,6 +28,7 @@ class ConversationTile extends StatelessWidget {
28 final VoidCallback onDuplicate;
29 final VoidCallback onMoveToFolder;
30 final VoidCallback onExport;
31 + final VoidCallback onArchive;
32
33 String _formatTimestamp(AppLocalizations l10n, DateTime dateTime) {
34 final now = DateTime.now();
@@ -123,6 +125,16 @@ class ConversationTile extends StatelessWidget {
125 overflow: TextOverflow.ellipsis,
126 ),
127 ],
128 + const SizedBox(height: 2),
129 + Text(
130 + '${l10n.conversation_message_count(conversation.messageCount)} · ${l10n.conversation_character_count(conversation.characterCount)}',
131 + style: TextStyle(
132 + fontSize: 11,
133 + color: isDark
134 + ? AppColors.darkMutedText
135 + : AppColors.lightMutedText,
136 + ),
137 + ),
138 ],
139 ),
140 ),
@@ -211,6 +223,22 @@ class ConversationTile extends StatelessWidget {
223 onExport();
224 },
225 ),
226 + ListTile(
227 + leading: Icon(
228 + conversation.isArchived
229 + ? Icons.unarchive_outlined
230 + : Icons.archive_outlined,
231 + ),
232 + title: Text(
233 + conversation.isArchived
234 + ? l10n.unarchive_chat
235 + : l10n.archive_chat,
236 + ),
237 + onTap: () {
238 + Navigator.pop(ctx);
239 + onArchive();
240 + },
241 + ),
242 ListTile(
243 leading: const Icon(Icons.delete_outline, color: Colors.red),
244 title: Text(
lib/features/conversations/views/components/rename_conversation_dialog.dart new
+106
@@ -0,0 +1,106 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:localmind/l10n/app_localizations.dart';
4 +
5 +import '../../../conversations/data/models/conversation.dart';
6 +import '../../../conversations/providers/conversation_providers.dart';
7 +import '../../../chat/providers/chat_providers.dart';
8 +
9 +Future<void> showRenameConversationDialog(
10 + BuildContext context,
11 + WidgetRef ref, {
12 + required Conversation conversation,
13 +}) async {
14 + final l10n = AppLocalizations.of(context)!;
15 + final controller = TextEditingController(text: conversation.title);
16 + var isGenerating = false;
17 +
18 + await showDialog<void>(
19 + context: context,
20 + builder: (dialogContext) {
21 + return StatefulBuilder(
22 + builder: (context, setState) {
23 + Future<void> generateTitle() async {
24 + setState(() => isGenerating = true);
25 + try {
26 + final title = await ref
27 + .read(chatProvider.notifier)
28 + .generateTitleWithAi(conversation.id);
29 + if (!context.mounted) return;
30 + if (title != null && title.isNotEmpty) {
31 + controller.text = title;
32 + } else {
33 + ScaffoldMessenger.of(context).showSnackBar(
34 + SnackBar(content: Text(l10n.generate_title_failed)),
35 + );
36 + }
37 + } finally {
38 + if (context.mounted) {
39 + setState(() => isGenerating = false);
40 + }
41 + }
42 + }
43 +
44 + return AlertDialog(
45 + title: Text(l10n.rename_conversation),
46 + content: Column(
47 + mainAxisSize: MainAxisSize.min,
48 + crossAxisAlignment: CrossAxisAlignment.stretch,
49 + children: [
50 + TextField(
51 + controller: controller,
52 + autofocus: true,
53 + enabled: !isGenerating,
54 + decoration: InputDecoration(
55 + hintText: l10n.enter_new_title,
56 + border: const OutlineInputBorder(),
57 + ),
58 + ),
59 + const SizedBox(height: 12),
60 + OutlinedButton.icon(
61 + onPressed: isGenerating ? null : generateTitle,
62 + icon: isGenerating
63 + ? const SizedBox(
64 + width: 16,
65 + height: 16,
66 + child: CircularProgressIndicator(strokeWidth: 2),
67 + )
68 + : const Icon(Icons.auto_awesome_outlined, size: 18),
69 + label: Text(
70 + isGenerating
71 + ? l10n.generating_title
72 + : l10n.generate_title_with_ai,
73 + ),
74 + ),
75 + ],
76 + ),
77 + actions: [
78 + TextButton(
79 + onPressed: isGenerating
80 + ? null
81 + : () => Navigator.pop(dialogContext),
82 + child: Text(l10n.cancel),
83 + ),
84 + TextButton(
85 + onPressed: isGenerating
86 + ? null
87 + : () {
88 + final newTitle = controller.text.trim();
89 + if (newTitle.isNotEmpty) {
90 + ref
91 + .read(conversationsProvider.notifier)
92 + .renameConversation(conversation.id, newTitle);
93 + }
94 + Navigator.pop(dialogContext);
95 + },
96 + child: Text(l10n.rename),
97 + ),
98 + ],
99 + );
100 + },
101 + );
102 + },
103 + );
104 +
105 + controller.dispose();
106 +}
lib/features/onboarding/screens/onboarding_language_screen.dart
+10
@@ -112,6 +112,16 @@ const _languages = [
112 shortText: 'Ja',
113 gradient: [Color(0xFFF43F5E), Color(0xFFBE123C)],
114 ),
115 + _LanguageOption(
116 + code: 'ru',
117 + nativeName: 'Русский',
118 + englishName: 'Russian',
119 + flag: '🇷🇺',
120 + flagAsset: 'assets/images/flag_ru.png',
121 + countryCode: 'RU',
122 + shortText: 'Ru',
123 + gradient: [Color(0xFF3B82F6), Color(0xFF1D4ED8)],
124 + ),
125 ];
126
127 class OnboardingLanguageScreen extends ConsumerStatefulWidget {
lib/features/saved_messages/providers/saved_message_providers.dart
+72 -9
@@ -12,6 +12,20 @@ final savedMessageFolderFilterProvider =
12 return SavedMessageFolderFilterNotifier();
13 });
14
15 +enum SavedMessageListFilter { all, tempChats, user, assistant }
16 +
17 +final savedMessageListFilterProvider =
18 + NotifierProvider<SavedMessageListFilterNotifier, SavedMessageListFilter>(() {
19 + return SavedMessageListFilterNotifier();
20 + });
21 +
22 +class SavedMessageListFilterNotifier extends Notifier<SavedMessageListFilter> {
23 + @override
24 + SavedMessageListFilter build() => SavedMessageListFilter.all;
25 +
26 + void setFilter(SavedMessageListFilter filter) => state = filter;
27 +}
28 +
29 class SavedMessageFolderFilterNotifier extends Notifier<String?> {
30 @override
31 String? build() => null;
@@ -129,26 +143,59 @@ class SavedMessagesNotifier extends AsyncNotifier<List<SavedMessage>> {
143 return exists;
144 }
145
132 - Future<void> saveMessage(Message message) async {
146 + Future<SavedMessage?> getBySourceMessageId(String sourceMessageId) async {
147 + final db = ref.read(databaseProvider);
148 + final query = db.savedMessageBox
149 + .query(SavedMessageEntity_.sourceMessageId.equals(sourceMessageId))
150 + .build();
151 + final entity = query.findFirst();
152 + query.close();
153 + if (entity == null) return null;
154 + return SavedMessage(
155 + id: entity.id,
156 + sourceMessageId: entity.sourceMessageId,
157 + conversationId: entity.conversationId,
158 + conversationTitle: entity.conversationTitle,
159 + roleIndex: entity.roleIndex,
160 + content: entity.content,
161 + modelId: entity.modelId,
162 + folderId: entity.folderId,
163 + savedAt: entity.savedAt,
164 + );
165 + }
166 +
167 + Future<void> saveMessage(
168 + Message message, {
169 + String? folderId,
170 + bool isTemporaryChat = false,
171 + }) async {
172 final db = ref.read(databaseProvider);
173 final existingQuery = db.savedMessageBox
174 .query(SavedMessageEntity_.sourceMessageId.equals(message.id))
175 .build();
137 - if (existingQuery.findFirst() != null) {
138 - existingQuery.close();
176 + final existing = existingQuery.findFirst();
177 + existingQuery.close();
178 +
179 + if (existing != null) {
180 + existing.folderId = folderId;
181 + existing.content = message.content;
182 + db.savedMessageBox.put(existing);
183 + state = AsyncData(await _loadAll());
184 return;
185 }
141 - existingQuery.close();
186
187 final conversation = ref.read(activeConversationProvider);
188 final entity = SavedMessageEntity(
189 id: DateTime.now().millisecondsSinceEpoch.toString(),
190 sourceMessageId: message.id,
147 - conversationId: message.conversationId,
148 - conversationTitle: conversation?.title ?? 'Chat',
191 + conversationId: isTemporaryChat ? '' : message.conversationId,
192 + conversationTitle: isTemporaryChat
193 + ? ''
194 + : (conversation?.title ?? 'Chat'),
195 roleIndex: message.role.index,
196 content: message.content,
197 modelId: message.modelId,
198 + folderId: folderId,
199 savedAt: DateTime.now(),
200 );
201 db.savedMessageBox.put(entity);
@@ -193,15 +240,31 @@ final filteredSavedMessagesProvider =
240 Provider<AsyncValue<List<SavedMessage>>>((ref) {
241 final messagesAsync = ref.watch(savedMessagesProvider);
242 final folderFilter = ref.watch(savedMessageFolderFilterProvider);
243 + final listFilter = ref.watch(savedMessageListFilterProvider);
244
245 return messagesAsync.whenData((messages) {
198 - if (folderFilter == null) return messages;
246 + var filtered = messages;
247 +
248 + switch (listFilter) {
249 + case SavedMessageListFilter.tempChats:
250 + filtered = filtered
251 + .where((m) => m.conversationId.isEmpty)
252 + .toList();
253 + case SavedMessageListFilter.user:
254 + filtered = filtered.where((m) => m.roleIndex == 0).toList();
255 + case SavedMessageListFilter.assistant:
256 + filtered = filtered.where((m) => m.roleIndex == 1).toList();
257 + case SavedMessageListFilter.all:
258 + break;
259 + }
260 +
261 + if (folderFilter == null) return filtered;
262 if (folderFilter.isEmpty) {
200 - return messages
263 + return filtered
264 .where((m) => m.folderId == null || m.folderId!.isEmpty)
265 .toList();
266 }
204 - return messages.where((m) => m.folderId == folderFilter).toList();
267 + return filtered.where((m) => m.folderId == folderFilter).toList();
268 });
269 });
270
lib/features/saved_messages/views/components/save_message_sheet.dart new
+98
@@ -0,0 +1,98 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:shadcn_ui/shadcn_ui.dart';
4 +import 'package:localmind/features/chat/data/models/message.dart';
5 +import 'package:localmind/l10n/app_localizations.dart';
6 +import '../../providers/saved_message_providers.dart';
7 +
8 +Future<void> showSaveMessageSheet(
9 + BuildContext context,
10 + WidgetRef ref,
11 + Message message, {
12 + bool isTemporaryChat = false,
13 +}) async {
14 + final l10n = AppLocalizations.of(context)!;
15 + final foldersAsync = ref.read(savedMessageFoldersProvider);
16 + final folders = foldersAsync.value ?? [];
17 + final existing = await ref
18 + .read(savedMessagesProvider.notifier)
19 + .getBySourceMessageId(message.id);
20 + String? selectedFolderId = existing?.folderId;
21 +
22 + if (!context.mounted) return;
23 +
24 + await showShadSheet(
25 + context: context,
26 + builder: (ctx) => StatefulBuilder(
27 + builder: (context, setState) {
28 + return ShadSheet(
29 + title: Text(l10n.save_message_folders),
30 + child: Column(
31 + mainAxisSize: MainAxisSize.min,
32 + crossAxisAlignment: CrossAxisAlignment.stretch,
33 + children: [
34 + if (existing != null)
35 + Padding(
36 + padding: const EdgeInsets.only(bottom: 12),
37 + child: Text(
38 + l10n.message_already_saved,
39 + style: TextStyle(
40 + color: Theme.of(context).colorScheme.primary,
41 + fontWeight: FontWeight.w600,
42 + ),
43 + ),
44 + ),
45 + ListTile(
46 + leading: Icon(
47 + selectedFolderId == null
48 + ? Icons.radio_button_checked
49 + : Icons.radio_button_off,
50 + ),
51 + title: Text(l10n.unfiled_chats),
52 + onTap: () => setState(() => selectedFolderId = null),
53 + ),
54 + ...folders.map(
55 + (folder) => ListTile(
56 + leading: Icon(
57 + selectedFolderId == folder.id
58 + ? Icons.radio_button_checked
59 + : Icons.radio_button_off,
60 + ),
61 + title: Text(folder.name),
62 + onTap: () => setState(() => selectedFolderId = folder.id),
63 + ),
64 + ),
65 + const SizedBox(height: 8),
66 + if (existing != null)
67 + OutlinedButton.icon(
68 + onPressed: () async {
69 + await ref
70 + .read(savedMessagesProvider.notifier)
71 + .removeBySourceMessageId(message.id);
72 + ref.invalidate(isMessageSavedProvider(message.id));
73 + if (context.mounted) Navigator.pop(context);
74 + },
75 + icon: const Icon(Icons.bookmark_remove_outlined),
76 + label: Text(l10n.remove_from_saved),
77 + ),
78 + const SizedBox(height: 8),
79 + ShadButton(
80 + width: double.infinity,
81 + onPressed: () async {
82 + await ref.read(savedMessagesProvider.notifier).saveMessage(
83 + message,
84 + folderId: selectedFolderId,
85 + isTemporaryChat: isTemporaryChat,
86 + );
87 + ref.invalidate(isMessageSavedProvider(message.id));
88 + if (context.mounted) Navigator.pop(context);
89 + },
90 + child: Text(l10n.save),
91 + ),
92 + ],
93 + ),
94 + );
95 + },
96 + ),
97 + );
98 +}
lib/features/saved_messages/views/components/saved_message_folder_bar.dart
+14 -63
@@ -1,10 +1,13 @@
1 import 'package:flutter/material.dart';
2 import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:localmind/core/components/folder_filter_bar.dart';
4 import 'package:localmind/l10n/app_localizations.dart';
5 import '../../providers/saved_message_providers.dart';
6
7 class SavedMessageFolderBar extends ConsumerWidget {
7 - const SavedMessageFolderBar({super.key});
8 + const SavedMessageFolderBar({super.key, this.showCreateFolder = true});
9 +
10 + final bool showCreateFolder;
11
12 Future<void> _createFolder(BuildContext context, WidgetRef ref) async {
13 final l10n = AppLocalizations.of(context)!;
@@ -37,72 +40,20 @@ class SavedMessageFolderBar extends ConsumerWidget {
40
41 @override
42 Widget build(BuildContext context, WidgetRef ref) {
40 - final l10n = AppLocalizations.of(context)!;
43 final foldersAsync = ref.watch(savedMessageFoldersProvider);
44 final selected = ref.watch(savedMessageFolderFilterProvider);
43 - final theme = Theme.of(context);
45
46 return foldersAsync.when(
46 - data: (folders) {
47 - return SingleChildScrollView(
48 - scrollDirection: Axis.horizontal,
49 - padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
50 - child: Row(
51 - children: [
52 - FilterChip(
53 - avatar: const Icon(Icons.all_inbox_outlined, size: 16),
54 - label: Text(l10n.all_chats),
55 - selected: selected == null,
56 - onSelected: (_) => ref
57 - .read(savedMessageFolderFilterProvider.notifier)
58 - .setFilter(null),
59 - ),
60 - const SizedBox(width: 6),
61 - FilterChip(
62 - avatar: const Icon(Icons.inbox_outlined, size: 16),
63 - label: Text(l10n.unfiled_chats),
64 - selected: selected != null && selected.isEmpty,
65 - onSelected: (_) => ref
66 - .read(savedMessageFolderFilterProvider.notifier)
67 - .setFilter(''),
68 - ),
69 - ...folders.map(
70 - (folder) => Padding(
71 - padding: const EdgeInsets.only(left: 6),
72 - child: FilterChip(
73 - avatar: Icon(
74 - Icons.folder_outlined,
75 - size: 16,
76 - color: selected == folder.id
77 - ? theme.colorScheme.onSecondaryContainer
78 - : theme.colorScheme.primary,
79 - ),
80 - label: Text(folder.name),
81 - selected: selected == folder.id,
82 - showCheckmark: false,
83 - onSelected: (_) => ref
84 - .read(savedMessageFolderFilterProvider.notifier)
85 - .setFilter(folder.id),
86 - ),
87 - ),
88 - ),
89 - const SizedBox(width: 6),
90 - ActionChip(
91 - avatar: Icon(
92 - Icons.create_new_folder_outlined,
93 - size: 16,
94 - color: theme.colorScheme.primary,
95 - ),
96 - label: Text(l10n.new_folder),
97 - side: BorderSide(
98 - color: theme.colorScheme.primary.withValues(alpha: 0.5),
99 - ),
100 - onPressed: () => _createFolder(context, ref),
101 - ),
102 - ],
103 - ),
104 - );
105 - },
47 + data: (folders) => FolderFilterBar(
48 + folders: folders
49 + .map((f) => FolderFilterItem(id: f.id, name: f.name))
50 + .toList(),
51 + selectedFolderId: selected,
52 + onFilterChanged: (id) =>
53 + ref.read(savedMessageFolderFilterProvider.notifier).setFilter(id),
54 + onCreateFolder: () => _createFolder(context, ref),
55 + showCreateFolder: showCreateFolder,
56 + ),
57 loading: () => const SizedBox(height: 44),
58 error: (_, __) => const SizedBox.shrink(),
59 );
lib/features/saved_messages/views/components/saved_message_folder_sheet.dart new
+49
@@ -0,0 +1,49 @@
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/saved_message_providers.dart';
5 +
6 +Future<void> showSavedMessageMoveToFolderSheet(
7 + BuildContext context,
8 + WidgetRef ref,
9 + String savedMessageId,
10 +) async {
11 + final l10n = AppLocalizations.of(context)!;
12 + final folders = ref.read(savedMessageFoldersProvider).value ?? [];
13 +
14 + await showModalBottomSheet<void>(
15 + context: context,
16 + builder: (ctx) {
17 + return SafeArea(
18 + child: Column(
19 + mainAxisSize: MainAxisSize.min,
20 + children: [
21 + ListTile(
22 + leading: const Icon(Icons.folder_off_outlined),
23 + title: Text(l10n.remove_from_folder),
24 + onTap: () async {
25 + Navigator.pop(ctx);
26 + await ref
27 + .read(savedMessagesProvider.notifier)
28 + .moveToFolder(savedMessageId, null);
29 + },
30 + ),
31 + ...folders.map(
32 + (folder) => ListTile(
33 + leading: const Icon(Icons.folder_outlined),
34 + title: Text(folder.name),
35 + onTap: () async {
36 + Navigator.pop(ctx);
37 + await ref
38 + .read(savedMessagesProvider.notifier)
39 + .moveToFolder(savedMessageId, folder.id);
40 + },
41 + ),
42 + ),
43 + const SizedBox(height: 8),
44 + ],
45 + ),
46 + );
47 + },
48 + );
49 +}
lib/features/saved_messages/views/components/saved_message_picker_sheet.dart new
+69
@@ -0,0 +1,69 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:flutter_riverpod/flutter_riverpod.dart';
3 +import 'package:shadcn_ui/shadcn_ui.dart';
4 +import 'package:localmind/l10n/app_localizations.dart';
5 +import '../../providers/saved_message_providers.dart';
6 +import 'saved_message_folder_bar.dart';
7 +import 'saved_message_picker_tile.dart';
8 +
9 +Future<String?> showSavedMessagePickerSheet(BuildContext context) async {
10 + return showShadSheet<String>(
11 + context: context,
12 + builder: (ctx) => const _SavedMessagePickerSheet(),
13 + );
14 +}
15 +
16 +class _SavedMessagePickerSheet extends ConsumerWidget {
17 + const _SavedMessagePickerSheet();
18 +
19 + @override
20 + Widget build(BuildContext context, WidgetRef ref) {
21 + final l10n = AppLocalizations.of(context)!;
22 + final theme = Theme.of(context);
23 + final isDark = theme.brightness == Brightness.dark;
24 + final messagesAsync = ref.watch(filteredSavedMessagesProvider);
25 +
26 + return ShadSheet(
27 + title: Text(l10n.insert_saved_message),
28 + description: Text(l10n.insert_saved_message_desc),
29 + child: SizedBox(
30 + height: MediaQuery.of(context).size.height * 0.55,
31 + child: Column(
32 + crossAxisAlignment: CrossAxisAlignment.stretch,
33 + children: [
34 + const SavedMessageFolderBar(showCreateFolder: false),
35 + Expanded(
36 + child: messagesAsync.when(
37 + data: (messages) {
38 + if (messages.isEmpty) {
39 + return Center(child: Text(l10n.saved_messages_empty));
40 + }
41 + return ListView.separated(
42 + padding: const EdgeInsets.only(bottom: 8),
43 + itemCount: messages.length,
44 + separatorBuilder: (_, __) => Divider(
45 + height: 1,
46 + color: isDark
47 + ? const Color(0xFF2A2A2A)
48 + : const Color(0xFFE5E5E5),
49 + ),
50 + itemBuilder: (context, index) {
51 + final saved = messages[index];
52 + return SavedMessagePickerTile(
53 + saved: saved,
54 + onTap: () => Navigator.pop(context, saved.content),
55 + );
56 + },
57 + );
58 + },
59 + loading: () =>
60 + const Center(child: CircularProgressIndicator()),
61 + error: (e, _) => Center(child: Text(e.toString())),
62 + ),
63 + ),
64 + ],
65 + ),
66 + ),
67 + );
68 + }
69 +}
lib/features/saved_messages/views/components/saved_message_picker_tile.dart new
+94
@@ -0,0 +1,94 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:localmind/core/models/enums.dart';
3 +import 'package:localmind/core/theme/colors.dart';
4 +import 'package:localmind/l10n/app_localizations.dart';
5 +import '../../data/models/saved_message.dart';
6 +
7 +class SavedMessagePickerTile extends StatelessWidget {
8 + const SavedMessagePickerTile({
9 + super.key,
10 + required this.saved,
11 + required this.onTap,
12 + });
13 +
14 + final SavedMessage saved;
15 + final VoidCallback onTap;
16 +
17 + @override
18 + Widget build(BuildContext context) {
19 + final l10n = AppLocalizations.of(context)!;
20 + final theme = Theme.of(context);
21 + final isDark = theme.brightness == Brightness.dark;
22 + final role = MessageRole.values[saved.roleIndex];
23 + final isUser = role == MessageRole.user;
24 + final isFromTempChat = saved.conversationId.isEmpty;
25 + final mutedColor =
26 + isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
27 +
28 + return Material(
29 + color: Colors.transparent,
30 + child: InkWell(
31 + onTap: onTap,
32 + child: Container(
33 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
34 + child: Row(
35 + crossAxisAlignment: CrossAxisAlignment.start,
36 + children: [
37 + Icon(
38 + isUser
39 + ? Icons.person_outline
40 + : Icons.auto_awesome_outlined,
41 + size: 20,
42 + color: mutedColor,
43 + ),
44 + const SizedBox(width: 12),
45 + Expanded(
46 + child: Column(
47 + crossAxisAlignment: CrossAxisAlignment.start,
48 + children: [
49 + if (isFromTempChat)
50 + Text(
51 + l10n.temporary_chat,
52 + style: TextStyle(
53 + fontSize: 14,
54 + fontWeight: FontWeight.w500,
55 + fontStyle: FontStyle.italic,
56 + color: mutedColor,
57 + ),
58 + maxLines: 1,
59 + overflow: TextOverflow.ellipsis,
60 + )
61 + else if (saved.conversationTitle.isNotEmpty)
62 + Text(
63 + saved.conversationTitle,
64 + style: TextStyle(
65 + fontSize: 14,
66 + fontWeight: FontWeight.w600,
67 + color: isDark
68 + ? AppColors.darkPrimaryText
69 + : AppColors.lightPrimaryText,
70 + ),
71 + maxLines: 1,
72 + overflow: TextOverflow.ellipsis,
73 + ),
74 + if (isFromTempChat || saved.conversationTitle.isNotEmpty)
75 + const SizedBox(height: 2),
76 + Text(
77 + saved.content,
78 + style: TextStyle(
79 + fontSize: 13,
80 + color: mutedColor,
81 + ),
82 + maxLines: 3,
83 + overflow: TextOverflow.ellipsis,
84 + ),
85 + ],
86 + ),
87 + ),
88 + ],
89 + ),
90 + ),
91 + ),
92 + );
93 + }
94 +}
lib/features/saved_messages/views/components/saved_message_tile.dart new
+161
@@ -0,0 +1,161 @@
1 +import 'package:flutter/material.dart';
2 +import 'package:hugeicons/hugeicons.dart';
3 +import 'package:localmind/core/theme/colors.dart';
4 +import 'package:localmind/l10n/app_localizations.dart';
5 +import '../../data/models/saved_message.dart';
6 +
7 +class SavedMessageTile extends StatelessWidget {
8 + const SavedMessageTile({
9 + super.key,
10 + required this.saved,
11 + required this.isUser,
12 + required this.onTap,
13 + required this.onCopy,
14 + required this.onMoveToFolder,
15 + required this.onDelete,
16 + });
17 +
18 + final SavedMessage saved;
19 + final bool isUser;
20 + final VoidCallback onTap;
21 + final VoidCallback onCopy;
22 + final VoidCallback onMoveToFolder;
23 + final VoidCallback onDelete;
24 +
25 + @override
26 + Widget build(BuildContext context) {
27 + final l10n = AppLocalizations.of(context)!;
28 + final theme = Theme.of(context);
29 + final isDark = theme.brightness == Brightness.dark;
30 + final isFromTempChat = saved.conversationId.isEmpty;
31 + final mutedColor =
32 + isDark ? AppColors.darkMutedText : AppColors.lightMutedText;
33 +
34 + return Material(
35 + color: Colors.transparent,
36 + child: InkWell(
37 + onTap: onTap,
38 + onLongPress: () => _showContextMenu(context, l10n, isDark),
39 + child: Container(
40 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
41 + child: Row(
42 + crossAxisAlignment: CrossAxisAlignment.start,
43 + children: [
44 + Icon(
45 + isUser
46 + ? Icons.person_outline
47 + : Icons.auto_awesome_outlined,
48 + size: 20,
49 + color: mutedColor,
50 + ),
51 + const SizedBox(width: 12),
52 + Expanded(
53 + child: Column(
54 + crossAxisAlignment: CrossAxisAlignment.start,
55 + children: [
56 + if (isFromTempChat)
57 + Text(
58 + l10n.temporary_chat,
59 + style: TextStyle(
60 + fontSize: 14,
61 + fontWeight: FontWeight.w500,
62 + fontStyle: FontStyle.italic,
63 + color: mutedColor,
64 + ),
65 + maxLines: 1,
66 + overflow: TextOverflow.ellipsis,
67 + )
68 + else if (saved.conversationTitle.isNotEmpty)
69 + Text(
70 + saved.conversationTitle,
71 + style: TextStyle(
72 + fontSize: 14,
73 + fontWeight: FontWeight.w600,
74 + color: isDark
75 + ? AppColors.darkPrimaryText
76 + : AppColors.lightPrimaryText,
77 + ),
78 + maxLines: 1,
79 + overflow: TextOverflow.ellipsis,
80 + ),
81 + if (isFromTempChat || saved.conversationTitle.isNotEmpty)
82 + const SizedBox(height: 2),
83 + Text(
84 + saved.content,
85 + style: TextStyle(
86 + fontSize: 13,
87 + color: mutedColor,
88 + ),
89 + maxLines: 3,
90 + overflow: TextOverflow.ellipsis,
91 + ),
92 + ],
93 + ),
94 + ),
95 + const SizedBox(width: 4),
96 + IconButton(
97 + onPressed: () => _showContextMenu(context, l10n, isDark),
98 + icon: HugeIcon(
99 + icon: HugeIcons.strokeRoundedMoreVertical,
100 + size: 18,
101 + color: mutedColor,
102 + ),
103 + padding: EdgeInsets.zero,
104 + constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
105 + visualDensity: VisualDensity.compact,
106 + tooltip: l10n.options_tooltip,
107 + ),
108 + ],
109 + ),
110 + ),
111 + ),
112 + );
113 + }
114 +
115 + void _showContextMenu(
116 + BuildContext context,
117 + AppLocalizations l10n,
118 + bool isDark,
119 + ) {
120 + showModalBottomSheet(
121 + context: context,
122 + builder: (ctx) {
123 + return SafeArea(
124 + child: Column(
125 + mainAxisSize: MainAxisSize.min,
126 + children: [
127 + ListTile(
128 + leading: const Icon(Icons.copy_outlined),
129 + title: Text(l10n.copy),
130 + onTap: () {
131 + Navigator.pop(ctx);
132 + onCopy();
133 + },
134 + ),
135 + ListTile(
136 + leading: const Icon(Icons.folder_outlined),
137 + title: Text(l10n.move_to_folder),
138 + onTap: () {
139 + Navigator.pop(ctx);
140 + onMoveToFolder();
141 + },
142 + ),
143 + ListTile(
144 + leading: const Icon(Icons.delete_outline, color: Colors.red),
145 + title: Text(
146 + l10n.delete,
147 + style: const TextStyle(color: Colors.red),
148 + ),
149 + onTap: () {
150 + Navigator.pop(ctx);
151 + onDelete();
152 + },
153 + ),
154 + const SizedBox(height: 8),
155 + ],
156 + ),
157 + );
158 + },
159 + );
160 + }
161 +}
lib/features/saved_messages/views/saved_messages_screen.dart
+90 -101
@@ -4,12 +4,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
4 import 'package:go_router/go_router.dart';
5 import 'package:localmind/core/models/enums.dart';
6 import 'package:localmind/core/routes/app_routes.dart';
7 +import 'package:localmind/core/components/list_filter_button.dart';
8 import 'package:localmind/l10n/app_localizations.dart';
9 import '../../chat/providers/chat_providers.dart';
10 import '../../conversations/data/models/conversation.dart';
11 import '../../conversations/providers/conversation_providers.dart';
12 import '../providers/saved_message_providers.dart';
13 import 'components/saved_message_folder_bar.dart';
14 +import 'components/saved_message_folder_sheet.dart';
15 +import 'components/saved_message_tile.dart';
16
17 class SavedMessagesScreen extends ConsumerWidget {
18 const SavedMessagesScreen({super.key});
@@ -58,6 +61,36 @@ class SavedMessagesScreen extends ConsumerWidget {
61 color: isDark ? Colors.white : Colors.black,
62 ),
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 ),
96 ),
@@ -76,115 +109,71 @@ class SavedMessagesScreen extends ConsumerWidget {
109 );
110 }
111 return ListView.separated(
79 - padding: const EdgeInsets.all(16),
112 + padding: const EdgeInsets.only(bottom: 8),
113 itemCount: messages.length,
81 - separatorBuilder: (_, _) => const SizedBox(height: 8),
114 + separatorBuilder: (_, __) => Divider(
115 + height: 1,
116 + color: isDark
117 + ? const Color(0xFF2A2A2A)
118 + : const Color(0xFFE5E5E5),
119 + ),
120 itemBuilder: (context, index) {
121 final saved = messages[index];
122 final role = MessageRole.values[saved.roleIndex];
85 - return Card(
86 - child: ListTile(
87 - leading: Icon(
88 - role == MessageRole.user
89 - ? Icons.person_outline
90 - : Icons.smart_toy_outlined,
91 - ),
92 - title: Text(
93 - saved.conversationTitle,
94 - maxLines: 1,
95 - overflow: TextOverflow.ellipsis,
96 - ),
97 - subtitle: Text(
98 - saved.content,
99 - maxLines: 3,
100 - overflow: TextOverflow.ellipsis,
101 - ),
102 - isThreeLine: true,
103 - onTap: () async {
104 - final conversations =
105 - ref.read(conversationsProvider).value ?? [];
106 - Conversation? conversation;
107 - for (final conv in conversations) {
108 - if (conv.id == saved.conversationId) {
109 - conversation = conv;
110 - break;
111 - }
123 + return SavedMessageTile(
124 + saved: saved,
125 + isUser: role == MessageRole.user,
126 + onTap: () async {
127 + if (saved.conversationId.isEmpty) {
128 + ScaffoldMessenger.of(context).showSnackBar(
129 + SnackBar(
130 + content: Text(l10n.saved_message_temp_snap_unavailable),
131 + ),
132 + );
133 + return;
134 + }
135 + final conversations =
136 + ref.read(conversationsProvider).value ?? [];
137 + Conversation? conversation;
138 + for (final conv in conversations) {
139 + if (conv.id == saved.conversationId) {
140 + conversation = conv;
141 + break;
142 }
113 - if (conversation == null) return;
143 + }
144 + if (conversation == null) return;
145
115 - ref
116 - .read(scrollToMessageIdProvider.notifier)
117 - .scrollTo(saved.sourceMessageId);
118 - await ref
119 - .read(chatProvider.notifier)
120 - .loadConversation(conversation);
121 - if (context.mounted) {
122 - if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
123 - Navigator.pop(context);
124 - }
125 - context.go(AppRoutes.home);
146 + ref
147 + .read(scrollToMessageIdProvider.notifier)
148 + .scrollTo(saved.sourceMessageId);
149 + await ref
150 + .read(chatProvider.notifier)
151 + .loadConversation(conversation);
152 + if (context.mounted) {
153 + if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
154 + Navigator.pop(context);
155 }
127 - },
128 - trailing: Row(
129 - mainAxisSize: MainAxisSize.min,
130 - children: [
131 - IconButton(
132 - icon: const Icon(Icons.copy_outlined, size: 20),
133 - tooltip: l10n.copy,
134 - onPressed: () async {
135 - await Clipboard.setData(
136 - ClipboardData(text: saved.content),
137 - );
138 - if (context.mounted) {
139 - ScaffoldMessenger.of(context).showSnackBar(
140 - SnackBar(
141 - content: Text(l10n.copied_to_clipboard),
142 - ),
143 - );
144 - }
145 - },
146 - ),
147 - PopupMenuButton<String>(
148 - onSelected: (value) async {
149 - if (value == 'delete') {
150 - await ref
151 - .read(savedMessagesProvider.notifier)
152 - .deleteSavedMessage(saved.id);
153 - } else if (value.startsWith('folder:')) {
154 - final folderId = value.substring(7);
155 - await ref
156 - .read(savedMessagesProvider.notifier)
157 - .moveToFolder(
158 - saved.id,
159 - folderId.isEmpty ? null : folderId,
160 - );
161 - }
162 - },
163 - itemBuilder: (context) {
164 - final folders =
165 - ref.read(savedMessageFoldersProvider).value ??
166 - [];
167 - return [
168 - ...folders.map(
169 - (folder) => PopupMenuItem(
170 - value: 'folder:${folder.id}',
171 - child: Text(folder.name),
172 - ),
173 - ),
174 - PopupMenuItem(
175 - value: 'folder:',
176 - child: Text(l10n.unfiled_chats),
177 - ),
178 - PopupMenuItem(
179 - value: 'delete',
180 - child: Text(l10n.delete),
181 - ),
182 - ];
183 - },
184 - ),
185 - ],
186 - ),
156 + context.go(AppRoutes.home);
157 + }
158 + },
159 + onCopy: () async {
160 + await Clipboard.setData(
161 + ClipboardData(text: saved.content),
162 + );
163 + if (context.mounted) {
164 + ScaffoldMessenger.of(context).showSnackBar(
165 + SnackBar(content: Text(l10n.copied_to_clipboard)),
166 + );
167 + }
168 + },
169 + onMoveToFolder: () => showSavedMessageMoveToFolderSheet(
170 + context,
171 + ref,
172 + saved.id,
173 ),
174 + onDelete: () => ref
175 + .read(savedMessagesProvider.notifier)
176 + .deleteSavedMessage(saved.id),
177 );
178 },
179 );
lib/features/servers/views/components/server_picker_sheet.dart new
+217
@@ -0,0 +1,217 @@
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:hugeicons/hugeicons.dart';
5 +import 'package:shadcn_ui/shadcn_ui.dart';
6 +import '../../../../core/routes/app_routes.dart';
7 +import '../../../../l10n/app_localizations.dart';
8 +import '../../providers/server_providers.dart';
9 +import 'server_icon_picker.dart';
10 +
11 +void showServerPickerSheet(BuildContext context, WidgetRef ref) {
12 + final l10n = AppLocalizations.of(context)!;
13 + final theme = Theme.of(context);
14 + final colorScheme = theme.colorScheme;
15 + final isDark = theme.brightness == Brightness.dark;
16 + final activeServer = ref.read(activeServerProvider);
17 + final serversAsync = ref.read(serversProvider);
18 + final servers = serversAsync.value ?? [];
19 +
20 + showModalBottomSheet<void>(
21 + context: context,
22 + isScrollControlled: true,
23 + backgroundColor: Colors.transparent,
24 + builder: (context) => Container(
25 + padding: EdgeInsets.only(
26 + bottom: MediaQuery.of(context).padding.bottom,
27 + ),
28 + decoration: BoxDecoration(
29 + color: theme.colorScheme.surface,
30 + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
31 + ),
32 + child: Column(
33 + mainAxisSize: MainAxisSize.min,
34 + children: [
35 + const SizedBox(height: 12),
36 + Container(
37 + width: 40,
38 + height: 4,
39 + decoration: BoxDecoration(
40 + color: colorScheme.onSurfaceVariant.withAlpha(50),
41 + borderRadius: BorderRadius.circular(2),
42 + ),
43 + ),
44 + const SizedBox(height: 16),
45 + Padding(
46 + padding: const EdgeInsets.symmetric(horizontal: 20),
47 + child: Row(
48 + crossAxisAlignment: CrossAxisAlignment.start,
49 + children: [
50 + Expanded(
51 + child: Column(
52 + crossAxisAlignment: CrossAxisAlignment.start,
53 + children: [
54 + Text(
55 + l10n.switch_server,
56 + style: theme.textTheme.titleLarge?.copyWith(
57 + fontWeight: FontWeight.bold,
58 + ),
59 + ),
60 + const SizedBox(height: 4),
61 + Text(
62 + l10n.switch_server_subtitle,
63 + style: theme.textTheme.bodyMedium?.copyWith(
64 + color: colorScheme.onSurfaceVariant,
65 + ),
66 + ),
67 + ],
68 + ),
69 + ),
70 + IconButton(
71 + onPressed: () => Navigator.pop(context),
72 + icon: const Icon(Icons.close, size: 20),
73 + visualDensity: VisualDensity.compact,
74 + color: colorScheme.onSurfaceVariant,
75 + ),
76 + ],
77 + ),
78 + ),
79 + const SizedBox(height: 8),
80 + ConstrainedBox(
81 + constraints: BoxConstraints(
82 + maxHeight: MediaQuery.sizeOf(context).height * 0.6,
83 + ),
84 + child: SingleChildScrollView(
85 + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
86 + child: Column(
87 + mainAxisSize: MainAxisSize.min,
88 + children: [
89 + if (activeServer == null)
90 + Padding(
91 + padding: const EdgeInsets.only(bottom: 8),
92 + child: Text(
93 + l10n.no_server_selected,
94 + style: theme.textTheme.bodyMedium?.copyWith(
95 + color: colorScheme.onSurfaceVariant,
96 + ),
97 + ),
98 + ),
99 + ...servers.map((server) {
100 + final isCurrentlyActive = server.id == activeServer?.id;
101 + final serverIconName = server.iconName;
102 + final currentServerIcon = serverIconName != null
103 + ? (getHugeIconByName(serverIconName)?.icon ??
104 + getDefaultServerIcon(server.type.name)?.icon)
105 + : getDefaultServerIcon(server.type.name)?.icon;
106 +
107 + return Padding(
108 + padding: const EdgeInsets.only(bottom: 8),
109 + child: InkWell(
110 + onTap: isCurrentlyActive
111 + ? null
112 + : () {
113 + ref
114 + .read(activeServerProvider.notifier)
115 + .setActiveServer(server);
116 + Navigator.pop(context);
117 + },
118 + borderRadius: BorderRadius.circular(16),
119 + child: Container(
120 + padding: const EdgeInsets.all(16),
121 + decoration: BoxDecoration(
122 + color: isCurrentlyActive
123 + ? (isDark
124 + ? colorScheme.primary.withAlpha(30)
125 + : colorScheme.primary.withAlpha(20))
126 + : (isDark
127 + ? Colors.white.withAlpha(10)
128 + : Colors.black.withAlpha(5)),
129 + borderRadius: BorderRadius.circular(16),
130 + border: Border.all(
131 + color: isCurrentlyActive
132 + ? colorScheme.primary.withAlpha(100)
133 + : Colors.transparent,
134 + ),
135 + ),
136 + child: Row(
137 + children: [
138 + HugeIcon(
139 + icon: currentServerIcon ??
140 + HugeIcons.strokeRoundedServerStack01,
141 + size: 20,
142 + color: isCurrentlyActive
143 + ? colorScheme.primary
144 + : colorScheme.onSurface,
145 + ),
146 + const SizedBox(width: 12),
147 + Expanded(
148 + child: Column(
149 + crossAxisAlignment: CrossAxisAlignment.start,
150 + children: [
151 + Text(
152 + server.name,
153 + style: TextStyle(
154 + fontWeight: isCurrentlyActive
155 + ? FontWeight.w600
156 + : FontWeight.w500,
157 + color: isCurrentlyActive
158 + ? colorScheme.primary
159 + : colorScheme.onSurface,
160 + ),
161 + ),
162 + Text(
163 + isCurrentlyActive
164 + ? l10n.active
165 + : (server.host.isEmpty
166 + ? 'Local'
167 + : server.host),
168 + style: TextStyle(
169 + fontSize: 12,
170 + color: isCurrentlyActive
171 + ? colorScheme.primary.withAlpha(150)
172 + : colorScheme.onSurfaceVariant,
173 + ),
174 + ),
175 + ],
176 + ),
177 + ),
178 + if (isCurrentlyActive)
179 + Icon(
180 + Icons.check_circle,
181 + size: 20,
182 + color: colorScheme.primary,
183 + )
184 + else
185 + const Icon(
186 + Icons.chevron_right,
187 + size: 20,
188 + color: Colors.grey,
189 + ),
190 + ],
191 + ),
192 + ),
193 + ),
194 + );
195 + }),
196 + const SizedBox(height: 12),
197 + const ShadSeparator.horizontal(),
198 + const SizedBox(height: 12),
199 + ShadButton.ghost(
200 + width: double.infinity,
201 + leading: const Icon(Icons.settings, size: 18),
202 + onPressed: () {
203 + Navigator.pop(context);
204 + context.go(AppRoutes.servers);
205 + },
206 + child: Text(l10n.manage_servers),
207 + ),
208 + ],
209 + ),
210 + ),
211 + ),
212 + const SizedBox(height: 12),
213 + ],
214 + ),
215 + ),
216 + );
217 +}
lib/features/settings/data/models/app_settings.dart
+12
@@ -38,6 +38,7 @@ class AppSettings {
38 final KittenTtsModelVariant kittenTtsModelVariant;
39 final bool autoSpeakEnabled;
40 final bool ttsProcessMarkdown;
41 + final int ttsSkipSeconds;
42 final bool smartReplyEnabled;
43 final String? localeCode;
44 final String? huggingFaceToken;
@@ -71,6 +72,7 @@ class AppSettings {
72 this.kittenTtsModelVariant = KittenTtsModelVariant.nanoInt8,
73 this.autoSpeakEnabled = false,
74 this.ttsProcessMarkdown = true,
75 + this.ttsSkipSeconds = 10,
76 this.smartReplyEnabled = true,
77 this.localeCode,
78 this.huggingFaceToken,
@@ -105,6 +107,7 @@ class AppSettings {
107 KittenTtsModelVariant? kittenTtsModelVariant,
108 bool? autoSpeakEnabled,
109 bool? ttsProcessMarkdown,
110 + int? ttsSkipSeconds,
111 bool? smartReplyEnabled,
112 Object? localeCode = _unset,
113 Object? huggingFaceToken = _unset,
@@ -144,6 +147,7 @@ class AppSettings {
147 kittenTtsModelVariant ?? this.kittenTtsModelVariant,
148 autoSpeakEnabled: autoSpeakEnabled ?? this.autoSpeakEnabled,
149 ttsProcessMarkdown: ttsProcessMarkdown ?? this.ttsProcessMarkdown,
150 + ttsSkipSeconds: ttsSkipSeconds ?? this.ttsSkipSeconds,
151 smartReplyEnabled: smartReplyEnabled ?? this.smartReplyEnabled,
152 localeCode: identical(localeCode, _unset)
153 ? this.localeCode
@@ -185,6 +189,7 @@ class AppSettings {
189 'kittenTtsModelVariant': kittenTtsModelVariant.name,
190 'autoSpeakEnabled': autoSpeakEnabled,
191 'ttsProcessMarkdown': ttsProcessMarkdown,
192 + 'ttsSkipSeconds': ttsSkipSeconds,
193 'smartReplyEnabled': smartReplyEnabled,
194 'localeCode': localeCode,
195 'huggingFaceToken': huggingFaceToken,
@@ -221,6 +226,7 @@ class AppSettings {
226 kittenTtsModelVariant: _parseKittenVariant(map['kittenTtsModelVariant']),
227 autoSpeakEnabled: map['autoSpeakEnabled'] ?? false,
228 ttsProcessMarkdown: map['ttsProcessMarkdown'] ?? true,
229 + ttsSkipSeconds: _parseTtsSkipSeconds(map['ttsSkipSeconds']),
230 smartReplyEnabled: map['smartReplyEnabled'] ?? true,
231 localeCode: map['localeCode'] as String?,
232 huggingFaceToken: map['huggingFaceToken'] as String?,
@@ -257,6 +263,12 @@ class AppSettings {
263 return KittenTtsModelVariant.nanoInt8;
264 }
265
266 + static int _parseTtsSkipSeconds(dynamic value) {
267 + const allowed = {5, 10, 15, 30};
268 + if (value is int && allowed.contains(value)) return value;
269 + return 10;
270 + }
271 +
272 String toJson() => json.encode(toMap());
273
274 factory AppSettings.fromJson(String source) =>
lib/features/settings/views/settings_screen.dart
+60
@@ -170,6 +170,12 @@ class SettingsViews extends ConsumerWidget {
170 .read(settingsProvider.notifier)
171 .setTtsProcessMarkdown(value),
172 ),
173 + _TtsSkipSecondsSetting(
174 + value: settings.ttsSkipSeconds,
175 + onChanged: (value) => ref
176 + .read(settingsProvider.notifier)
177 + .setTtsSkipSeconds(value),
178 + ),
179 ],
180 );
181
@@ -877,6 +883,7 @@ class _LanguageSetting extends StatelessWidget {
883 ('ar', 'العربية', 'assets/images/flag_sa.png', '🇸🇦'),
884 ('bn', 'বাংলা', 'assets/images/flag_bd.png', '🇧🇩'),
885 ('hi', 'हिन्दी', 'assets/images/flag_in.png', '🇮🇳'),
886 + ('ru', 'Русский', 'assets/images/flag_ru.png', '🇷🇺'),
887 ];
888
889 final String? current;
@@ -1553,6 +1560,59 @@ class _SectionActionButton extends StatelessWidget {
1560 }
1561 }
1562
1563 +class _TtsSkipSecondsSetting extends StatelessWidget {
1564 + const _TtsSkipSecondsSetting({
1565 + required this.value,
1566 + required this.onChanged,
1567 + });
1568 +
1569 + final int value;
1570 + final ValueChanged<int> onChanged;
1571 +
1572 + static const _options = [5, 10, 15, 30];
1573 +
1574 + @override
1575 + Widget build(BuildContext context) {
1576 + final l10n = AppLocalizations.of(context)!;
1577 + final theme = Theme.of(context);
1578 +
1579 + return _SettingPanel(
1580 + child: Column(
1581 + crossAxisAlignment: CrossAxisAlignment.start,
1582 + children: [
1583 + Text(
1584 + l10n.tts_skip_seconds,
1585 + style: theme.textTheme.titleSmall?.copyWith(
1586 + fontWeight: FontWeight.w700,
1587 + ),
1588 + ),
1589 + const SizedBox(height: 4),
1590 + Text(
1591 + l10n.tts_skip_seconds_desc,
1592 + style: theme.textTheme.bodySmall?.copyWith(
1593 + color: theme.colorScheme.onSurfaceVariant,
1594 + ),
1595 + ),
1596 + const SizedBox(height: 10),
1597 + Wrap(
1598 + spacing: 8,
1599 + runSpacing: 8,
1600 + children: _options.map((seconds) {
1601 + final selected = value == seconds;
1602 + return FilterChip(
1603 + label: Text(l10n.tts_skip_seconds_value(seconds)),
1604 + selected: selected,
1605 + onSelected: (_) => onChanged(seconds),
1606 + showCheckmark: false,
1607 + );
1608 + }).toList(),
1609 + ),
1610 + ],
1611 + ),
1612 + );
1613 + }
1614 +}
1615 +
1616 class _EngineDropdown extends StatelessWidget {
1617 const _EngineDropdown({required this.current, required this.onChanged});
1618
lib/features/sidebar/sidebar_widget.dart
+33
@@ -32,6 +32,9 @@ class SidebarWidget extends ConsumerWidget {
32 final isLocalModels = location.startsWith(AppRoutes.onDeviceModels);
33 final isTtsModels = location.startsWith(AppRoutes.ttsModels);
34 final isSettings = location.startsWith(AppRoutes.settings);
35 + final isHome = location == AppRoutes.home || location == '/';
36 + final hasActiveChat = ref.watch(hasActiveChatSessionProvider);
37 + final isTemporary = ref.watch(chatProvider.select((s) => s.isTemporary));
38
39 return Container(
40 width: AppSizes.sidebarWidth,
@@ -63,6 +66,36 @@ class SidebarWidget extends ConsumerWidget {
66 ),
67 ),
68
69 + if (hasActiveChat)
70 + Padding(
71 + padding: EdgeInsets.fromLTRB(16, 0, 16, isHome ? 8 : 8),
72 + child: isTemporary
73 + ? ShadButton.outline(
74 + width: double.infinity,
75 + leading: const Icon(Icons.arrow_back, size: 18),
76 + onPressed: () {
77 + if (!isHome) context.go(AppRoutes.home);
78 + if (Scaffold.maybeOf(context)?.isDrawerOpen ??
79 + false) {
80 + Navigator.pop(context);
81 + }
82 + },
83 + child: Text(l10n.return_to_temp_chat),
84 + )
85 + : ShadButton.secondary(
86 + width: double.infinity,
87 + leading: const Icon(Icons.arrow_back, size: 18),
88 + onPressed: () {
89 + if (!isHome) context.go(AppRoutes.home);
90 + if (Scaffold.maybeOf(context)?.isDrawerOpen ??
91 + false) {
92 + Navigator.pop(context);
93 + }
94 + },
95 + child: Text(l10n.return_to_chat),
96 + ),
97 + ),
98 +
99 const SidebarSearchButton(),
100 const SizedBox(height: 8),
101 const Divider(height: 1, indent: 16, endIndent: 16),
lib/features/tts/providers/tts_providers.dart
+331 -1
@@ -4,6 +4,7 @@ import 'dart:isolate';
4 import 'dart:typed_data';
5
6 import 'package:audio_session/audio_session.dart';
7 +import 'package:file_picker/file_picker.dart';
8 import 'package:just_audio/just_audio.dart';
9 import 'package:just_audio_background/just_audio_background.dart';
10 import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -16,6 +17,7 @@ import '../utils/tts_text_processor.dart';
17 import 'tts_model_providers.dart';
18 import '../data/piper_tts_model.dart';
19 import 'tts_worker.dart';
20 +import '../utils/wav_merge.dart';
21
22 final ttsProvider = NotifierProvider<TtsNotifier, TtsState>(() {
23 return TtsNotifier();
@@ -31,6 +33,9 @@ class TtsState {
33 final String? playingMessageId;
34 final String? playingConversationId;
35 final bool isPreview;
36 + final Duration position;
37 + final Duration duration;
38 + final bool canSeek;
39
40 const TtsState({
41 this.isSpeaking = false,
@@ -42,6 +47,9 @@ class TtsState {
47 this.playingMessageId,
48 this.playingConversationId,
49 this.isPreview = false,
50 + this.position = Duration.zero,
51 + this.duration = Duration.zero,
52 + this.canSeek = false,
53 });
54
55 TtsState copyWith({
@@ -54,7 +62,11 @@ class TtsState {
62 String? playingMessageId,
63 String? playingConversationId,
64 bool? isPreview,
65 + Duration? position,
66 + Duration? duration,
67 + bool? canSeek,
68 bool clearPlayingTarget = false,
69 + bool resetProgress = false,
70 }) {
71 return TtsState(
72 isSpeaking: isSpeaking ?? this.isSpeaking,
@@ -69,6 +81,9 @@ class TtsState {
81 ? null
82 : (playingConversationId ?? this.playingConversationId),
83 isPreview: isPreview ?? this.isPreview,
84 + position: resetProgress ? Duration.zero : (position ?? this.position),
85 + duration: resetProgress ? Duration.zero : (duration ?? this.duration),
86 + canSeek: canSeek ?? this.canSeek,
87 );
88 }
89 }
@@ -107,6 +122,9 @@ class TtsNotifier extends Notifier<TtsState> {
122 final Map<int, AudioSource> _playlistBuffer = {};
123 int _nextPlaylistIndexToAdd = 0;
124 StreamSubscription<int?>? _currentIndexSubscription;
125 + StreamSubscription<Duration>? _positionSubscription;
126 + StreamSubscription<Duration?>? _durationSubscription;
127 + Timer? _systemProgressTimer;
128
129 // Progress tracking
130 final Map<int, Duration> _chunkDurations = {};
@@ -114,6 +132,11 @@ class TtsNotifier extends Notifier<TtsState> {
132 Duration _chunkOffset = Duration.zero;
133 final StreamController<void> _onChunkChanged = StreamController<void>.broadcast();
134
135 + // System TTS approximate seek
136 + String? _systemFullText;
137 + Duration _systemSkippedOffset = Duration.zero;
138 + DateTime? _systemSpeakStart;
139 +
140 AudioPlayer get player => _player;
141 Stream<Duration> get onPositionChanged => _player.positionStream;
142 Duration get totalDuration => _totalDuration;
@@ -141,11 +164,21 @@ class TtsNotifier extends Notifier<TtsState> {
164 _currentIndexSubscription = _player.currentIndexStream.listen((index) {
165 if (index != null && index != _currentChunkIndex) {
166 _currentChunkIndex = index;
167 + _updateChunkOffset();
168 _onChunkChanged.add(null);
169 _enqueueNextChunks();
170 + _updatePlaybackProgress();
171 }
172 });
173
174 + _positionSubscription = _player.positionStream.listen((_) {
175 + _updatePlaybackProgress();
176 + });
177 +
178 + _durationSubscription = _player.durationStream.listen((_) {
179 + _updatePlaybackProgress();
180 + });
181 +
182 _setupWorkerListener();
183
184 ref.onDispose(() {
@@ -155,6 +188,9 @@ class TtsNotifier extends Notifier<TtsState> {
188 _mainReceivePort.close();
189 _playerCompleteSubscription?.cancel();
190 _currentIndexSubscription?.cancel();
191 + _positionSubscription?.cancel();
192 + _durationSubscription?.cancel();
193 + _systemProgressTimer?.cancel();
194 _onChunkChanged.close();
195 if (!_isPlayerDisposed) {
196 _isPlayerDisposed = true;
@@ -187,6 +223,7 @@ class TtsNotifier extends Notifier<TtsState> {
223 _synthesizedChunks[message.chunkIndex!] = message.wavBytes!;
224 _computeChunkDuration(message.chunkIndex!, message.wavBytes!);
225 await _addChunkToPlaylist(message.chunkIndex!, message.wavBytes!);
226 + _updatePlaybackProgress();
227 } else {
228 Log.error(
229 'Worker error for chunk ${message.chunkIndex}: ${message.error}',
@@ -230,12 +267,106 @@ class TtsNotifier extends Notifier<TtsState> {
267 for (final d in _chunkDurations.values) {
268 _totalDuration += d;
269 }
270 + _updatePlaybackProgress();
271 + }
272 +
273 + void _updateChunkOffset() {
274 + var offset = Duration.zero;
275 + for (var i = 0; i < _currentChunkIndex; i++) {
276 + offset += _chunkDurations[i] ?? Duration.zero;
277 + }
278 + _chunkOffset = offset;
279 + }
280 +
281 + Duration _averageChunkDuration() {
282 + if (_chunkDurations.isEmpty) return const Duration(seconds: 3);
283 + final totalMs = _chunkDurations.values.fold<int>(
284 + 0,
285 + (sum, d) => sum + d.inMilliseconds,
286 + );
287 + return Duration(milliseconds: totalMs ~/ _chunkDurations.length);
288 + }
289 +
290 + Duration _estimatedTotalDuration() {
291 + if (_chunks.isEmpty) return Duration.zero;
292 + if (_chunkDurations.length >= _chunks.length) return _totalDuration;
293 + final avg = _averageChunkDuration();
294 + return Duration(
295 + milliseconds: avg.inMilliseconds * _chunks.length,
296 + );
297 + }
298 +
299 + Duration _computeGlobalPosition() {
300 + if (state.activeEngine == EngineId.system) {
301 + return _systemEstimatedPosition();
302 + }
303 + _updateChunkOffset();
304 + return _chunkOffset + _player.position;
305 + }
306 +
307 + void _updatePlaybackProgress() {
308 + if (!state.isSpeaking || state.isPreview) return;
309 +
310 + if (state.activeEngine == EngineId.system) {
311 + final duration = _systemEstimatedDuration();
312 + state = state.copyWith(
313 + position: _systemEstimatedPosition(),
314 + duration: duration,
315 + canSeek: duration > Duration.zero,
316 + );
317 + return;
318 + }
319 +
320 + final position = _computeGlobalPosition();
321 + final duration = _estimatedTotalDuration();
322 + state = state.copyWith(
323 + position: position,
324 + duration: duration,
325 + canSeek: !state.isInitializing && duration > Duration.zero,
326 + );
327 + }
328 +
329 + Duration _systemEstimatedDuration() {
330 + if (_systemFullText == null) return Duration.zero;
331 + final cps = 12.0 * ref.read(settingsProvider).ttsSpeed;
332 + return Duration(
333 + milliseconds: (_systemFullText!.length / cps * 1000).round(),
334 + );
335 + }
336 +
337 + Duration _systemEstimatedPosition() {
338 + if (_systemSpeakStart == null) return _systemSkippedOffset;
339 + final pos =
340 + _systemSkippedOffset + DateTime.now().difference(_systemSpeakStart!);
341 + final total = _systemEstimatedDuration();
342 + return pos > total ? total : pos;
343 + }
344 +
345 + void _startSystemProgressTimer() {
346 + _systemProgressTimer?.cancel();
347 + _systemProgressTimer = Timer.periodic(
348 + const Duration(milliseconds: 250),
349 + (_) => _updatePlaybackProgress(),
350 + );
351 + }
352 +
353 + void _stopSystemProgressTimer() {
354 + _systemProgressTimer?.cancel();
355 + _systemProgressTimer = null;
356 + }
357 +
358 + void _resetSystemSeekState() {
359 + _systemFullText = null;
360 + _systemSkippedOffset = Duration.zero;
361 + _systemSpeakStart = null;
362 + _stopSystemProgressTimer();
363 }
364
365 void _resetProgress() {
366 _chunkDurations.clear();
367 _totalDuration = Duration.zero;
368 _chunkOffset = Duration.zero;
369 + _resetSystemSeekState();
370 }
371
372 void _deleteChunkFile(int sessionId, int chunkIndex) {
@@ -266,6 +397,8 @@ class TtsNotifier extends Notifier<TtsState> {
397 playingContent: null,
398 isPreview: false,
399 clearPlayingTarget: true,
400 + resetProgress: true,
401 + canSeek: false,
402 );
403 }
404
@@ -445,6 +578,10 @@ class TtsNotifier extends Notifier<TtsState> {
578
579 if (engineId == EngineId.system) {
580 _flutterTts ??= FlutterTts();
581 + _resetSystemSeekState();
582 + _systemFullText = text;
583 + _systemSkippedOffset = Duration.zero;
584 + _systemSpeakStart = DateTime.now();
585 // Clear any previous handlers to prevent stale callbacks
586 _flutterTts!.setCompletionHandler(() {});
587 _flutterTts!.setErrorHandler((_) {});
@@ -458,7 +595,10 @@ class TtsNotifier extends Notifier<TtsState> {
595 playingMessageId: isPreview ? null : messageId,
596 playingConversationId: isPreview ? null : conversationId,
597 isPreview: isPreview,
598 + canSeek: !isPreview,
599 + duration: _systemEstimatedDuration(),
600 );
601 + _startSystemProgressTimer();
602 try {
603 await _flutterTts!.setLanguage('en-US');
604 await _flutterTts!.setSpeechRate(
@@ -466,40 +606,52 @@ class TtsNotifier extends Notifier<TtsState> {
606 );
607 _flutterTts!.setCompletionHandler(() {
608 if (!_isStopping) {
609 + _resetSystemSeekState();
610 state = state.copyWith(
611 isSpeaking: false,
612 playingContent: null,
613 clearPlayingTarget: true,
614 + resetProgress: true,
615 + canSeek: false,
616 );
617 }
618 });
619 _flutterTts!.setErrorHandler((msg) {
620 if (!_isStopping) {
621 + _resetSystemSeekState();
622 state = state.copyWith(
623 isSpeaking: false,
624 playingContent: null,
625 error: msg.toString(),
626 clearPlayingTarget: true,
627 + resetProgress: true,
628 + canSeek: false,
629 );
630 }
631 });
632 _flutterTts!.setCancelHandler(() {
633 if (!_isStopping) {
634 + _resetSystemSeekState();
635 state = state.copyWith(
636 isSpeaking: false,
637 playingContent: null,
638 clearPlayingTarget: true,
639 + resetProgress: true,
640 + canSeek: false,
641 );
642 }
643 });
644 await _flutterTts!.awaitSpeakCompletion(true);
645 await _flutterTts!.speak(text);
646 } catch (e) {
647 + _resetSystemSeekState();
648 state = state.copyWith(
649 isSpeaking: false,
650 error: 'System TTS unavailable',
651 playingContent: null,
652 clearPlayingTarget: true,
653 + resetProgress: true,
654 + canSeek: false,
655 );
656 rethrow;
657 }
@@ -542,7 +694,11 @@ class TtsNotifier extends Notifier<TtsState> {
694
695 try {
696 await _initEngine(engineId, voiceId: resolvedVoiceId);
545 - state = state.copyWith(isInitializing: false);
697 + state = state.copyWith(
698 + isInitializing: false,
699 + canSeek: true,
700 + duration: _estimatedTotalDuration(),
701 + );
702 _enqueueNextChunks();
703 } catch (e, st) {
704 if (_isPlayerDisposed) return;
@@ -591,6 +747,143 @@ class TtsNotifier extends Notifier<TtsState> {
747 }
748 }
749
750 + void _enqueueChunk(int chunkIndex) {
751 + if (_workerSendPort == null || _currentEngine == null) return;
752 + if (chunkIndex >= _chunks.length) return;
753 +
754 + final settings = ref.read(settingsProvider);
755 + final availableVoices = voicesForEngine(_currentEngine!);
756 + final voiceId =
757 + _currentVoice ??
758 + settings.ttsVoiceId ??
759 + (availableVoices.isNotEmpty ? availableVoices.first.id : null);
760 +
761 + while (_lastEnqueuedChunk < chunkIndex) {
762 + _lastEnqueuedChunk++;
763 + final index = _lastEnqueuedChunk;
764 + Log.debug('Enqueuing synthesis for chunk $index');
765 + _workerSendPort!.send(
766 + TtsSynthesizeRequest(
767 + text: _chunks[index],
768 + voiceId: voiceId ?? '',
769 + speed: settings.ttsSpeed,
770 + chunkIndex: index,
771 + sessionId: _currentSessionId,
772 + ),
773 + );
774 + }
775 + }
776 +
777 + Future<void> _ensurePlaylistThrough(int targetIndex) async {
778 + if (targetIndex < 0 || targetIndex >= _chunks.length) return;
779 + _enqueueChunk(targetIndex);
780 +
781 + final deadline = DateTime.now().add(const Duration(seconds: 20));
782 + while (_nextPlaylistIndexToAdd <= targetIndex &&
783 + DateTime.now().isBefore(deadline)) {
784 + await Future.delayed(const Duration(milliseconds: 50));
785 + if (_lastEnqueuedChunk < targetIndex) {
786 + _enqueueChunk(targetIndex);
787 + }
788 + }
789 + }
790 +
791 + int _chunkIndexForPosition(Duration target) {
792 + var accumulated = Duration.zero;
793 + for (var i = 0; i < _chunks.length; i++) {
794 + final chunkDuration = _chunkDurations[i] ?? _averageChunkDuration();
795 + if (accumulated + chunkDuration > target) return i;
796 + accumulated += chunkDuration;
797 + }
798 + return _chunks.isEmpty ? 0 : _chunks.length - 1;
799 + }
800 +
801 + Future<void> seekTo(Duration target) async {
802 + if (!state.isSpeaking || state.isPreview) return;
803 +
804 + if (state.activeEngine == EngineId.system) {
805 + await _systemSeekTo(target);
806 + return;
807 + }
808 +
809 + final maxDuration = _estimatedTotalDuration();
810 + if (maxDuration <= Duration.zero) return;
811 +
812 + var clamped = target;
813 + if (clamped.isNegative) clamped = Duration.zero;
814 + if (clamped > maxDuration) clamped = maxDuration;
815 +
816 + final targetChunk = _chunkIndexForPosition(clamped);
817 + await _ensurePlaylistThrough(targetChunk);
818 + await _player.seek(clamped);
819 + _currentChunkIndex = targetChunk;
820 + _updateChunkOffset();
821 + _updatePlaybackProgress();
822 + }
823 +
824 + Future<void> _systemSeekTo(Duration target) async {
825 + final full = _systemFullText;
826 + if (full == null || _flutterTts == null) return;
827 +
828 + final total = _systemEstimatedDuration();
829 + var clamped = target;
830 + if (clamped.isNegative) clamped = Duration.zero;
831 + if (clamped > total) clamped = total;
832 +
833 + final settings = ref.read(settingsProvider);
834 + final cps = 12.0 * settings.ttsSpeed;
835 + final charIndex =
836 + (clamped.inMilliseconds / 1000 * cps).round().clamp(0, full.length);
837 +
838 + var start = charIndex;
839 + if (start > 0 && start < full.length) {
840 + while (start > 0 && full[start - 1] != ' ') {
841 + start--;
842 + }
843 + }
844 +
845 + final remainder = full.substring(start.clamp(0, full.length));
846 + if (remainder.trim().isEmpty) {
847 + _resetSystemSeekState();
848 + state = state.copyWith(
849 + isSpeaking: false,
850 + playingContent: null,
851 + clearPlayingTarget: true,
852 + resetProgress: true,
853 + canSeek: false,
854 + );
855 + return;
856 + }
857 +
858 + await _flutterTts!.stop();
859 + _systemSkippedOffset = Duration(
860 + milliseconds: (start / cps * 1000).round(),
861 + );
862 + _systemSpeakStart = DateTime.now();
863 +
864 + await _flutterTts!.setSpeechRate(
865 + (settings.ttsSpeed * 0.5).clamp(0.0, 1.0),
866 + );
867 + await _flutterTts!.speak(remainder);
868 + _updatePlaybackProgress();
869 + }
870 +
871 + Future<void> skipForward() async {
872 + if (!state.canSeek && !state.isSpeaking) return;
873 + final skip = Duration(
874 + seconds: ref.read(settingsProvider).ttsSkipSeconds,
875 + );
876 + await seekTo(_computeGlobalPosition() + skip);
877 + }
878 +
879 + Future<void> skipBackward() async {
880 + if (!state.canSeek && !state.isSpeaking) return;
881 + final skip = Duration(
882 + seconds: ref.read(settingsProvider).ttsSkipSeconds,
883 + );
884 + await seekTo(_computeGlobalPosition() - skip);
885 + }
886 +
887 void _enqueueNextChunks() {
888 if (_workerSendPort == null || _currentEngine == null) return;
889
@@ -657,7 +950,44 @@ class TtsNotifier extends Notifier<TtsState> {
950 playingContent: null,
951 isPreview: false,
952 clearPlayingTarget: true,
953 + resetProgress: true,
954 + canSeek: false,
955 + );
956 + }
957 +
958 + Future<bool> downloadCurrentAudio() async {
959 + if (state.activeEngine == EngineId.system || state.isPreview) {
960 + return false;
961 + }
962 + if (!state.isSpeaking && !state.isPaused) return false;
963 +
964 + final wavFiles = <Uint8List>[];
965 + for (var i = 0; i < _chunks.length; i++) {
966 + final cached = _synthesizedChunks[i];
967 + if (cached != null && cached.isNotEmpty) {
968 + wavFiles.add(cached);
969 + continue;
970 + }
971 + final tempFile = File(
972 + '${Directory.systemTemp.path}/tts_chunk_${_currentSessionId}_$i.wav',
973 + );
974 + if (tempFile.existsSync()) {
975 + wavFiles.add(await tempFile.readAsBytes());
976 + }
977 + }
978 +
979 + if (wavFiles.isEmpty) return false;
980 +
981 + final merged = mergeWavFiles(wavFiles);
982 + final timestamp = DateTime.now().millisecondsSinceEpoch;
983 + final saved = await FilePicker.saveFile(
984 + dialogTitle: 'Save TTS audio',
985 + fileName: 'localmind_tts_$timestamp.wav',
986 + type: FileType.custom,
987 + allowedExtensions: const ['wav'],
988 + bytes: merged,
989 );
990 + return saved != null;
991 }
992
993 Future<void> previewVoice(Voice voice) async {
lib/features/tts/utils/wav_merge.dart new
+46
@@ -0,0 +1,46 @@
1 +import 'dart:typed_data';
2 +
3 +/// Merges multiple PCM WAV files (same format) into a single WAV.
4 +Uint8List mergeWavFiles(List<Uint8List> wavFiles) {
5 + if (wavFiles.isEmpty) return Uint8List(0);
6 + if (wavFiles.length == 1) return wavFiles.first;
7 +
8 + final first = wavFiles.first;
9 + if (first.length < 44) return first;
10 +
11 + final pcmBuilder = BytesBuilder(copy: false);
12 + for (final wav in wavFiles) {
13 + if (wav.length > 44) {
14 + pcmBuilder.add(wav.sublist(44));
15 + }
16 + }
17 + final pcmData = pcmBuilder.toBytes();
18 + final totalSize = 36 + pcmData.length;
19 +
20 + final merged = ByteData(44);
21 + merged.setUint8(0, 0x52); // R
22 + merged.setUint8(1, 0x49); // I
23 + merged.setUint8(2, 0x46); // F
24 + merged.setUint8(3, 0x46); // F
25 + merged.setUint32(4, totalSize, Endian.little);
26 + merged.setUint8(8, 0x57); // W
27 + merged.setUint8(9, 0x41); // A
28 + merged.setUint8(10, 0x56); // V
29 + merged.setUint8(11, 0x45); // E
30 +
31 + // Copy fmt chunk from first file (bytes 12-35)
32 + for (var i = 12; i < 36; i++) {
33 + merged.setUint8(i, first[i]);
34 + }
35 +
36 + merged.setUint8(36, 0x64); // d
37 + merged.setUint8(37, 0x61); // a
38 + merged.setUint8(38, 0x74); // t
39 + merged.setUint8(39, 0x61); // a
40 + merged.setUint32(40, pcmData.length, Endian.little);
41 +
42 + return Uint8List.fromList([
43 + ...merged.buffer.asUint8List(),
44 + ...pcmData,
45 + ]);
46 +}
lib/features/tts/views/components/tts_player_bar.dart
+161 -17
@@ -2,6 +2,8 @@ import 'dart:math' as math;
2 import 'package:flutter/material.dart';
3 import 'package:flutter_riverpod/flutter_riverpod.dart';
4 import 'package:go_router/go_router.dart';
5 +import 'package:localmind/core/models/enums.dart';
6 +import 'package:localmind/core/providers/app_providers.dart';
7 import 'package:localmind/core/routes/app_routes.dart';
8 import 'package:localmind/l10n/app_localizations.dart';
9 import '../../providers/tts_providers.dart' as tts;
@@ -23,6 +25,18 @@ class TtsPlayerBar extends ConsumerWidget {
25 return '${content.substring(0, 47)}...';
26 }
27
28 + String _formatDuration(Duration d) {
29 + if (d.inHours > 0) {
30 + final h = d.inHours.toString();
31 + final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
32 + final s = (d.inSeconds % 60).toString().padLeft(2, '0');
33 + return '$h:$m:$s';
34 + }
35 + final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
36 + final s = (d.inSeconds % 60).toString().padLeft(2, '0');
37 + return '$m:$s';
38 + }
39 +
40 Future<void> _scrollToPlayingMessage(
41 BuildContext context,
42 WidgetRef ref,
@@ -86,6 +100,12 @@ class TtsPlayerBar extends ConsumerWidget {
100
101 final preview = _truncateContent(ttsState.playingContent);
102 final canJumpToMessage = ttsState.playingMessageId != null;
103 + final skipSeconds = ref.watch(settingsProvider).ttsSkipSeconds;
104 + final hasTimeline = ttsState.duration > Duration.zero;
105 + final progress = hasTimeline
106 + ? (ttsState.position.inMilliseconds / ttsState.duration.inMilliseconds)
107 + .clamp(0.0, 1.0)
108 + : null;
109
110 return AnimatedSize(
111 duration: const Duration(milliseconds: 200),
@@ -173,31 +193,36 @@ class TtsPlayerBar extends ConsumerWidget {
193 ),
194 ),
195 ),
176 - if (!ttsState.isInitializing)
177 - Padding(
178 - padding: const EdgeInsets.only(top: 4),
179 - child: ClipRRect(
180 - borderRadius: BorderRadius.circular(2),
181 - child: LinearProgressIndicator(
182 - backgroundColor:
183 - theme.colorScheme.secondary,
184 - valueColor: AlwaysStoppedAnimation<Color>(
185 - theme.colorScheme.primary,
186 - ),
187 - minHeight: 3,
188 - ),
189 - ),
190 - ),
196 ],
197 ),
198 ),
199 ),
200 ),
201 ),
197 - const SizedBox(width: 12),
202 + const SizedBox(width: 8),
203 if (!ttsState.isInitializing)
204 _WaveformIndicator(isPlaying: !ttsState.isPaused),
200 - const SizedBox(width: 12),
205 + if (!ttsState.isInitializing &&
206 + ttsState.activeEngine != EngineId.system)
207 + IconButton(
208 + icon: const Icon(Icons.download_outlined, size: 20),
209 + tooltip: l10n.download_tts_audio,
210 + visualDensity: VisualDensity.compact,
211 + onPressed: () async {
212 + final ok = await notifier.downloadCurrentAudio();
213 + if (!context.mounted) return;
214 + ScaffoldMessenger.of(context).showSnackBar(
215 + SnackBar(
216 + content: Text(
217 + ok
218 + ? l10n.tts_download_success
219 + : l10n.tts_download_no_audio,
220 + ),
221 + ),
222 + );
223 + },
224 + ),
225 + const SizedBox(width: 4),
226 GestureDetector(
227 onTap: () => notifier.stop(),
228 child: Tooltip(
@@ -218,6 +243,35 @@ class TtsPlayerBar extends ConsumerWidget {
243 ),
244 ],
245 ),
246 + if (!ttsState.isInitializing && (ttsState.canSeek || hasTimeline)) ...[
247 + const SizedBox(height: 8),
248 + Row(
249 + children: [
250 + IconButton(
251 + icon: const Icon(Icons.fast_rewind_rounded, size: 22),
252 + tooltip: '-${skipSeconds}s',
253 + visualDensity: VisualDensity.compact,
254 + onPressed: () => notifier.skipBackward(),
255 + ),
256 + Expanded(
257 + child: _TtsSeekSlider(
258 + progress: progress ?? 0,
259 + position: ttsState.position,
260 + duration: ttsState.duration,
261 + enabled: hasTimeline && ttsState.canSeek,
262 + onSeek: notifier.seekTo,
263 + formatDuration: _formatDuration,
264 + ),
265 + ),
266 + IconButton(
267 + icon: const Icon(Icons.fast_forward_rounded, size: 22),
268 + tooltip: '+${skipSeconds}s',
269 + visualDensity: VisualDensity.compact,
270 + onPressed: () => notifier.skipForward(),
271 + ),
272 + ],
273 + ),
274 + ],
275 ],
276 ),
277 ),
@@ -225,6 +279,96 @@ class TtsPlayerBar extends ConsumerWidget {
279 }
280 }
281
282 +class _TtsSeekSlider extends StatefulWidget {
283 + const _TtsSeekSlider({
284 + required this.progress,
285 + required this.position,
286 + required this.duration,
287 + required this.enabled,
288 + required this.onSeek,
289 + required this.formatDuration,
290 + });
291 +
292 + final double progress;
293 + final Duration position;
294 + final Duration duration;
295 + final bool enabled;
296 + final Future<void> Function(Duration) onSeek;
297 + final String Function(Duration) formatDuration;
298 +
299 + @override
300 + State<_TtsSeekSlider> createState() => _TtsSeekSliderState();
301 +}
302 +
303 +class _TtsSeekSliderState extends State<_TtsSeekSlider> {
304 + double? _dragValue;
305 +
306 + @override
307 + Widget build(BuildContext context) {
308 + final theme = Theme.of(context);
309 + final displayProgress = _dragValue ?? widget.progress;
310 +
311 + return Column(
312 + children: [
313 + SliderTheme(
314 + data: SliderTheme.of(context).copyWith(
315 + trackHeight: 3,
316 + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
317 + overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
318 + ),
319 + child: Slider(
320 + value: displayProgress.clamp(0.0, 1.0),
321 + onChanged: widget.enabled
322 + ? (v) => setState(() => _dragValue = v)
323 + : null,
324 + onChangeEnd: widget.enabled
325 + ? (v) async {
326 + setState(() => _dragValue = null);
327 + await widget.onSeek(
328 + Duration(
329 + milliseconds:
330 + (v * widget.duration.inMilliseconds).round(),
331 + ),
332 + );
333 + }
334 + : null,
335 + ),
336 + ),
337 + Padding(
338 + padding: const EdgeInsets.symmetric(horizontal: 8),
339 + child: Row(
340 + mainAxisAlignment: MainAxisAlignment.spaceBetween,
341 + children: [
342 + Text(
343 + widget.formatDuration(
344 + _dragValue != null
345 + ? Duration(
346 + milliseconds: (_dragValue! *
347 + widget.duration.inMilliseconds)
348 + .round(),
349 + )
350 + : widget.position,
351 + ),
352 + style: TextStyle(
353 + fontSize: 10,
354 + color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
355 + ),
356 + ),
357 + Text(
358 + widget.formatDuration(widget.duration),
359 + style: TextStyle(
360 + fontSize: 10,
361 + color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
362 + ),
363 + ),
364 + ],
365 + ),
366 + ),
367 + ],
368 + );
369 + }
370 +}
371 +
372 class _WaveformIndicator extends StatefulWidget {
373 final bool isPlaying;
374
lib/l10n/app_en.arb
+75 -2
@@ -824,7 +824,7 @@
824 "@stop_generation_tooltip": {
825 "description": "Tooltip for stop button"
826 },
827 - "attach_images_tooltip": "Attach images",
827 + "attach_images_tooltip": "Attach images or text",
828 "@attach_images_tooltip": {
829 "description": "Tooltip for attach button"
830 },
@@ -2593,9 +2593,82 @@
2593 "export_conversation": "Export conversation",
2594 "tts_process_markdown": "Process markdown for speech",
2595 "tts_process_markdown_desc": "Strip formatting like **bold** before reading aloud",
2596 + "tts_skip_seconds": "Skip interval",
2597 + "tts_skip_seconds_desc": "Forward and rewind jump size during playback",
2598 + "tts_skip_seconds_value": "{seconds}s",
2599 + "@tts_skip_seconds_value": {
2600 + "placeholders": {
2601 + "seconds": {
2602 + "type": "int"
2603 + }
2604 + }
2605 + },
2606 "preview_system_prompts": "Preview system prompts",
2607 "welcome_message_1": "What can I help you with today?",
2608 "welcome_message_2": "Ask me anything — I'm ready when you are.",
2609 "welcome_message_3": "Start a conversation below.",
2600 - "welcome_message_4": "Need ideas? Try one of the quick prompts."
2610 + "welcome_message_4": "Need ideas? Try one of the quick prompts.",
2611 + "temporary_chat": "Temporary chat",
2612 + "temporary_chat_desc": "Chats aren't saved to history.",
2613 + "temporary_chat_banner": "Temporary chat — not saved to history",
2614 + "temporary_chat_save_warning_title": "Save message in temporary chat?",
2615 + "temporary_chat_save_warning_body": "This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.",
2616 + "save_to_history": "Save to history",
2617 + "share_conversation": "Share conversation",
2618 + "download_tts_audio": "Download audio",
2619 + "tts_download_unavailable": "Download is only available for Piper and Kitten TTS",
2620 + "tts_download_no_audio": "No audio available to download yet",
2621 + "tts_download_success": "Audio saved",
2622 + "return_to_chat": "Return to chat",
2623 + "return_to_temp_chat": "Return to temporary chat",
2624 + "insert_saved_message": "Insert saved message",
2625 + "insert_saved_message_desc": "Choose a saved message to add to your input",
2626 + "model_info": "Model info",
2627 + "model_name": "Model name",
2628 + "model_identifier": "Identifier",
2629 + "not_available": "Not available",
2630 + "save_message_folders": "Save message",
2631 + "remove_from_saved": "Remove from saved",
2632 + "message_already_saved": "Saved",
2633 + "stream_ttft": "Time to first token",
2634 + "stream_tokens_per_sec": "Tokens per second",
2635 + "stream_stop_reason": "Stop reason",
2636 + "stream_input_tokens": "Input tokens",
2637 + "stream_output_tokens": "Output tokens",
2638 + "stream_generation_time": "Generation time",
2639 + "attach_image": "Image",
2640 + "attach_text_document": "Text document",
2641 + "add_attachment": "Add attachment",
2642 + "photo_permission_denied": "Photo access is required to attach images",
2643 + "characters_label": "Characters",
2644 + "exit_temporary_chat_title": "Exit temporary chat?",
2645 + "exit_temporary_chat_body": "This will discard the current temporary chat and return to a new chat.",
2646 + "saved_message_temp_snap_unavailable": "This message was saved from a temporary chat and can't be opened in its original conversation.",
2647 + "filter_title": "Filter",
2648 + "filter_pinned": "Pinned",
2649 + "filter_archived": "Archived",
2650 + "filter_temp_chats": "Temporary chats",
2651 + "filter_user_messages": "User messages",
2652 + "filter_assistant_messages": "Assistant messages",
2653 + "archive_chat": "Archive",
2654 + "unarchive_chat": "Unarchive",
2655 + "conversation_message_count": "{count, plural, one{1 message} other{{count} messages}}",
2656 + "@conversation_message_count": {
2657 + "placeholders": {
2658 + "count": {
2659 + "type": "int"
2660 + }
2661 + }
2662 + },
2663 + "conversation_character_count": "{count} chars",
2664 + "@conversation_character_count": {
2665 + "placeholders": {
2666 + "count": {
2667 + "type": "int"
2668 + }
2669 + }
2670 + },
2671 + "generate_title_with_ai": "Generate with AI",
2672 + "generating_title": "Generating...",
2673 + "generate_title_failed": "Could not generate a title"
2674 }
lib/l10n/app_localizations.dart
+318 -1
@@ -12,6 +12,7 @@ import 'app_localizations_es.dart';
12 import 'app_localizations_hi.dart';
13 import 'app_localizations_it.dart';
14 import 'app_localizations_ja.dart';
15 +import 'app_localizations_ru.dart';
16 import 'app_localizations_zh.dart';
17
18 // ignore_for_file: type=lint
@@ -107,6 +108,7 @@ abstract class AppLocalizations {
108 Locale('hi'),
109 Locale('it'),
110 Locale('ja'),
111 + Locale('ru'),
112 Locale('zh'),
113 Locale('zh', 'TW'),
114 ];
@@ -1224,7 +1226,7 @@ abstract class AppLocalizations {
1226 /// Tooltip for attach button
1227 ///
1228 /// In en, this message translates to:
1227 - /// **'Attach images'**
1229 + /// **'Attach images or text'**
1230 String get attach_images_tooltip;
1231
1232 /// Tooltip for start listening button
@@ -4077,6 +4079,24 @@ abstract class AppLocalizations {
4079 /// **'Strip formatting like **bold** before reading aloud'**
4080 String get tts_process_markdown_desc;
4081
4082 + /// No description provided for @tts_skip_seconds.
4083 + ///
4084 + /// In en, this message translates to:
4085 + /// **'Skip interval'**
4086 + String get tts_skip_seconds;
4087 +
4088 + /// No description provided for @tts_skip_seconds_desc.
4089 + ///
4090 + /// In en, this message translates to:
4091 + /// **'Forward and rewind jump size during playback'**
4092 + String get tts_skip_seconds_desc;
4093 +
4094 + /// No description provided for @tts_skip_seconds_value.
4095 + ///
4096 + /// In en, this message translates to:
4097 + /// **'{seconds}s'**
4098 + String tts_skip_seconds_value(int seconds);
4099 +
4100 /// No description provided for @preview_system_prompts.
4101 ///
4102 /// In en, this message translates to:
@@ -4106,6 +4126,300 @@ abstract class AppLocalizations {
4126 /// In en, this message translates to:
4127 /// **'Need ideas? Try one of the quick prompts.'**
4128 String get welcome_message_4;
4129 +
4130 + /// No description provided for @temporary_chat.
4131 + ///
4132 + /// In en, this message translates to:
4133 + /// **'Temporary chat'**
4134 + String get temporary_chat;
4135 +
4136 + /// No description provided for @temporary_chat_desc.
4137 + ///
4138 + /// In en, this message translates to:
4139 + /// **'Chats aren\'t saved to history.'**
4140 + String get temporary_chat_desc;
4141 +
4142 + /// No description provided for @temporary_chat_banner.
4143 + ///
4144 + /// In en, this message translates to:
4145 + /// **'Temporary chat — not saved to history'**
4146 + String get temporary_chat_banner;
4147 +
4148 + /// No description provided for @temporary_chat_save_warning_title.
4149 + ///
4150 + /// In en, this message translates to:
4151 + /// **'Save message in temporary chat?'**
4152 + String get temporary_chat_save_warning_title;
4153 +
4154 + /// No description provided for @temporary_chat_save_warning_body.
4155 + ///
4156 + /// In en, this message translates to:
4157 + /// **'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.'**
4158 + String get temporary_chat_save_warning_body;
4159 +
4160 + /// No description provided for @save_to_history.
4161 + ///
4162 + /// In en, this message translates to:
4163 + /// **'Save to history'**
4164 + String get save_to_history;
4165 +
4166 + /// No description provided for @share_conversation.
4167 + ///
4168 + /// In en, this message translates to:
4169 + /// **'Share conversation'**
4170 + String get share_conversation;
4171 +
4172 + /// No description provided for @download_tts_audio.
4173 + ///
4174 + /// In en, this message translates to:
4175 + /// **'Download audio'**
4176 + String get download_tts_audio;
4177 +
4178 + /// No description provided for @tts_download_unavailable.
4179 + ///
4180 + /// In en, this message translates to:
4181 + /// **'Download is only available for Piper and Kitten TTS'**
4182 + String get tts_download_unavailable;
4183 +
4184 + /// No description provided for @tts_download_no_audio.
4185 + ///
4186 + /// In en, this message translates to:
4187 + /// **'No audio available to download yet'**
4188 + String get tts_download_no_audio;
4189 +
4190 + /// No description provided for @tts_download_success.
4191 + ///
4192 + /// In en, this message translates to:
4193 + /// **'Audio saved'**
4194 + String get tts_download_success;
4195 +
4196 + /// No description provided for @return_to_chat.
4197 + ///
4198 + /// In en, this message translates to:
4199 + /// **'Return to chat'**
4200 + String get return_to_chat;
4201 +
4202 + /// No description provided for @return_to_temp_chat.
4203 + ///
4204 + /// In en, this message translates to:
4205 + /// **'Return to temporary chat'**
4206 + String get return_to_temp_chat;
4207 +
4208 + /// No description provided for @insert_saved_message.
4209 + ///
4210 + /// In en, this message translates to:
4211 + /// **'Insert saved message'**
4212 + String get insert_saved_message;
4213 +
4214 + /// No description provided for @insert_saved_message_desc.
4215 + ///
4216 + /// In en, this message translates to:
4217 + /// **'Choose a saved message to add to your input'**
4218 + String get insert_saved_message_desc;
4219 +
4220 + /// No description provided for @model_info.
4221 + ///
4222 + /// In en, this message translates to:
4223 + /// **'Model info'**
4224 + String get model_info;
4225 +
4226 + /// No description provided for @model_name.
4227 + ///
4228 + /// In en, this message translates to:
4229 + /// **'Model name'**
4230 + String get model_name;
4231 +
4232 + /// No description provided for @model_identifier.
4233 + ///
4234 + /// In en, this message translates to:
4235 + /// **'Identifier'**
4236 + String get model_identifier;
4237 +
4238 + /// No description provided for @not_available.
4239 + ///
4240 + /// In en, this message translates to:
4241 + /// **'Not available'**
4242 + String get not_available;
4243 +
4244 + /// No description provided for @save_message_folders.
4245 + ///
4246 + /// In en, this message translates to:
4247 + /// **'Save message'**
4248 + String get save_message_folders;
4249 +
4250 + /// No description provided for @remove_from_saved.
4251 + ///
4252 + /// In en, this message translates to:
4253 + /// **'Remove from saved'**
4254 + String get remove_from_saved;
4255 +
4256 + /// No description provided for @message_already_saved.
4257 + ///
4258 + /// In en, this message translates to:
4259 + /// **'Saved'**
4260 + String get message_already_saved;
4261 +
4262 + /// No description provided for @stream_ttft.
4263 + ///
4264 + /// In en, this message translates to:
4265 + /// **'Time to first token'**
4266 + String get stream_ttft;
4267 +
4268 + /// No description provided for @stream_tokens_per_sec.
4269 + ///
4270 + /// In en, this message translates to:
4271 + /// **'Tokens per second'**
4272 + String get stream_tokens_per_sec;
4273 +
4274 + /// No description provided for @stream_stop_reason.
4275 + ///
4276 + /// In en, this message translates to:
4277 + /// **'Stop reason'**
4278 + String get stream_stop_reason;
4279 +
4280 + /// No description provided for @stream_input_tokens.
4281 + ///
4282 + /// In en, this message translates to:
4283 + /// **'Input tokens'**
4284 + String get stream_input_tokens;
4285 +
4286 + /// No description provided for @stream_output_tokens.
4287 + ///
4288 + /// In en, this message translates to:
4289 + /// **'Output tokens'**
4290 + String get stream_output_tokens;
4291 +
4292 + /// No description provided for @stream_generation_time.
4293 + ///
4294 + /// In en, this message translates to:
4295 + /// **'Generation time'**
4296 + String get stream_generation_time;
4297 +
4298 + /// No description provided for @attach_image.
4299 + ///
4300 + /// In en, this message translates to:
4301 + /// **'Image'**
4302 + String get attach_image;
4303 +
4304 + /// No description provided for @attach_text_document.
4305 + ///
4306 + /// In en, this message translates to:
4307 + /// **'Text document'**
4308 + String get attach_text_document;
4309 +
4310 + /// No description provided for @add_attachment.
4311 + ///
4312 + /// In en, this message translates to:
4313 + /// **'Add attachment'**
4314 + String get add_attachment;
4315 +
4316 + /// No description provided for @photo_permission_denied.
4317 + ///
4318 + /// In en, this message translates to:
4319 + /// **'Photo access is required to attach images'**
4320 + String get photo_permission_denied;
4321 +
4322 + /// No description provided for @characters_label.
4323 + ///
4324 + /// In en, this message translates to:
4325 + /// **'Characters'**
4326 + String get characters_label;
4327 +
4328 + /// No description provided for @exit_temporary_chat_title.
4329 + ///
4330 + /// In en, this message translates to:
4331 + /// **'Exit temporary chat?'**
4332 + String get exit_temporary_chat_title;
4333 +
4334 + /// No description provided for @exit_temporary_chat_body.
4335 + ///
4336 + /// In en, this message translates to:
4337 + /// **'This will discard the current temporary chat and return to a new chat.'**
4338 + String get exit_temporary_chat_body;
4339 +
4340 + /// No description provided for @saved_message_temp_snap_unavailable.
4341 + ///
4342 + /// In en, this message translates to:
4343 + /// **'This message was saved from a temporary chat and can\'t be opened in its original conversation.'**
4344 + String get saved_message_temp_snap_unavailable;
4345 +
4346 + /// No description provided for @filter_title.
4347 + ///
4348 + /// In en, this message translates to:
4349 + /// **'Filter'**
4350 + String get filter_title;
4351 +
4352 + /// No description provided for @filter_pinned.
4353 + ///
4354 + /// In en, this message translates to:
4355 + /// **'Pinned'**
4356 + String get filter_pinned;
4357 +
4358 + /// No description provided for @filter_archived.
4359 + ///
4360 + /// In en, this message translates to:
4361 + /// **'Archived'**
4362 + String get filter_archived;
4363 +
4364 + /// No description provided for @filter_temp_chats.
4365 + ///
4366 + /// In en, this message translates to:
4367 + /// **'Temporary chats'**
4368 + String get filter_temp_chats;
4369 +
4370 + /// No description provided for @filter_user_messages.
4371 + ///
4372 + /// In en, this message translates to:
4373 + /// **'User messages'**
4374 + String get filter_user_messages;
4375 +
4376 + /// No description provided for @filter_assistant_messages.
4377 + ///
4378 + /// In en, this message translates to:
4379 + /// **'Assistant messages'**
4380 + String get filter_assistant_messages;
4381 +
4382 + /// No description provided for @archive_chat.
4383 + ///
4384 + /// In en, this message translates to:
4385 + /// **'Archive'**
4386 + String get archive_chat;
4387 +
4388 + /// No description provided for @unarchive_chat.
4389 + ///
4390 + /// In en, this message translates to:
4391 + /// **'Unarchive'**
4392 + String get unarchive_chat;
4393 +
4394 + /// No description provided for @conversation_message_count.
4395 + ///
4396 + /// In en, this message translates to:
4397 + /// **'{count, plural, one{1 message} other{{count} messages}}'**
4398 + String conversation_message_count(int count);
4399 +
4400 + /// No description provided for @conversation_character_count.
4401 + ///
4402 + /// In en, this message translates to:
4403 + /// **'{count} chars'**
4404 + String conversation_character_count(int count);
4405 +
4406 + /// No description provided for @generate_title_with_ai.
4407 + ///
4408 + /// In en, this message translates to:
4409 + /// **'Generate with AI'**
4410 + String get generate_title_with_ai;
4411 +
4412 + /// No description provided for @generating_title.
4413 + ///
4414 + /// In en, this message translates to:
4415 + /// **'Generating...'**
4416 + String get generating_title;
4417 +
4418 + /// No description provided for @generate_title_failed.
4419 + ///
4420 + /// In en, this message translates to:
4421 + /// **'Could not generate a title'**
4422 + String get generate_title_failed;
4423 }
4424
4425 class _AppLocalizationsDelegate
@@ -4126,6 +4440,7 @@ class _AppLocalizationsDelegate
4440 'hi',
4441 'it',
4442 'ja',
4443 + 'ru',
4444 'zh',
4445 ].contains(locale.languageCode);
4446
@@ -4162,6 +4477,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
4477 return AppLocalizationsIt();
4478 case 'ja':
4479 return AppLocalizationsJa();
4480 + case 'ru':
4481 + return AppLocalizationsRu();
4482 case 'zh':
4483 return AppLocalizationsZh();
4484 }
lib/l10n/app_localizations_ar.dart
+176
@@ -2147,6 +2147,18 @@ class AppLocalizationsAr extends AppLocalizations {
2147 String get tts_process_markdown_desc =>
2148 'Strip formatting like **bold** before reading aloud';
2149
2150 + @override
2151 + String get tts_skip_seconds => 'Skip interval';
2152 +
2153 + @override
2154 + String get tts_skip_seconds_desc =>
2155 + 'Forward and rewind jump size during playback';
2156 +
2157 + @override
2158 + String tts_skip_seconds_value(int seconds) {
2159 + return '${seconds}s';
2160 + }
2161 +
2162 @override
2163 String get preview_system_prompts => 'Preview system prompts';
2164
@@ -2161,4 +2173,168 @@ class AppLocalizationsAr extends AppLocalizations {
2173
2174 @override
2175 String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2176 +
2177 + @override
2178 + String get temporary_chat => 'Temporary chat';
2179 +
2180 + @override
2181 + String get temporary_chat_desc => 'Chats aren\'t saved to history.';
2182 +
2183 + @override
2184 + String get temporary_chat_banner => 'Temporary chat — not saved to history';
2185 +
2186 + @override
2187 + String get temporary_chat_save_warning_title =>
2188 + 'Save message in temporary chat?';
2189 +
2190 + @override
2191 + String get temporary_chat_save_warning_body =>
2192 + 'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.';
2193 +
2194 + @override
2195 + String get save_to_history => 'Save to history';
2196 +
2197 + @override
2198 + String get share_conversation => 'Share conversation';
2199 +
2200 + @override
2201 + String get download_tts_audio => 'Download audio';
2202 +
2203 + @override
2204 + String get tts_download_unavailable =>
2205 + 'Download is only available for Piper and Kitten TTS';
2206 +
2207 + @override
2208 + String get tts_download_no_audio => 'No audio available to download yet';
2209 +
2210 + @override
2211 + String get tts_download_success => 'Audio saved';
2212 +
2213 + @override
2214 + String get return_to_chat => 'Return to chat';
2215 +
2216 + @override
2217 + String get return_to_temp_chat => 'Return to temporary chat';
2218 +
2219 + @override
2220 + String get insert_saved_message => 'Insert saved message';
2221 +
2222 + @override
2223 + String get insert_saved_message_desc =>
2224 + 'Choose a saved message to add to your input';
2225 +
2226 + @override
2227 + String get model_info => 'Model info';
2228 +
2229 + @override
2230 + String get model_name => 'Model name';
2231 +
2232 + @override
2233 + String get model_identifier => 'Identifier';
2234 +
2235 + @override
2236 + String get not_available => 'Not available';
2237 +
2238 + @override
2239 + String get save_message_folders => 'Save message';
2240 +
2241 + @override
2242 + String get remove_from_saved => 'Remove from saved';
2243 +
2244 + @override
2245 + String get message_already_saved => 'Saved';
2246 +
2247 + @override
2248 + String get stream_ttft => 'Time to first token';
2249 +
2250 + @override
2251 + String get stream_tokens_per_sec => 'Tokens per second';
2252 +
2253 + @override
2254 + String get stream_stop_reason => 'Stop reason';
2255 +
2256 + @override
2257 + String get stream_input_tokens => 'Input tokens';
2258 +
2259 + @override
2260 + String get stream_output_tokens => 'Output tokens';
2261 +
2262 + @override
2263 + String get stream_generation_time => 'Generation time';
2264 +
2265 + @override
2266 + String get attach_image => 'Image';
2267 +
2268 + @override
2269 + String get attach_text_document => 'Text document';
2270 +
2271 + @override
2272 + String get add_attachment => 'Add attachment';
2273 +
2274 + @override
2275 + String get photo_permission_denied =>
2276 + 'Photo access is required to attach images';
2277 +
2278 + @override
2279 + String get characters_label => 'Characters';
2280 +
2281 + @override
2282 + String get exit_temporary_chat_title => 'Exit temporary chat?';
2283 +
2284 + @override
2285 + String get exit_temporary_chat_body =>
2286 + 'This will discard the current temporary chat and return to a new chat.';
2287 +
2288 + @override
2289 + String get saved_message_temp_snap_unavailable =>
2290 + 'This message was saved from a temporary chat and can\'t be opened in its original conversation.';
2291 +
2292 + @override
2293 + String get filter_title => 'Filter';
2294 +
2295 + @override
2296 + String get filter_pinned => 'Pinned';
2297 +
2298 + @override
2299 + String get filter_archived => 'Archived';
2300 +
2301 + @override
2302 + String get filter_temp_chats => 'Temporary chats';
2303 +
2304 + @override
2305 + String get filter_user_messages => 'User messages';
2306 +
2307 + @override
2308 + String get filter_assistant_messages => 'Assistant messages';
2309 +
2310 + @override
2311 + String get archive_chat => 'Archive';
2312 +
2313 + @override
2314 + String get unarchive_chat => 'Unarchive';
2315 +
2316 + @override
2317 + String conversation_message_count(int count) {
2318 + String _temp0 = intl.Intl.pluralLogic(
2319 + count,
2320 + locale: localeName,
2321 + other: '$count messages',
2322 + one: '1 message',
2323 + );
2324 + return '$_temp0';
2325 + }
2326 +
2327 + @override
2328 + String conversation_character_count(int count) {
2329 + return '$count chars';
2330 + }
2331 +
2332 + @override
2333 + String get generate_title_with_ai => 'Generate with AI';
2334 +
2335 + @override
2336 + String get generating_title => 'Generating...';
2337 +
2338 + @override
2339 + String get generate_title_failed => 'Could not generate a title';
2340 }
lib/l10n/app_localizations_bn.dart
+176
@@ -2160,6 +2160,18 @@ class AppLocalizationsBn extends AppLocalizations {
2160 String get tts_process_markdown_desc =>
2161 'Strip formatting like **bold** before reading aloud';
2162
2163 + @override
2164 + String get tts_skip_seconds => 'Skip interval';
2165 +
2166 + @override
2167 + String get tts_skip_seconds_desc =>
2168 + 'Forward and rewind jump size during playback';
2169 +
2170 + @override
2171 + String tts_skip_seconds_value(int seconds) {
2172 + return '${seconds}s';
2173 + }
2174 +
2175 @override
2176 String get preview_system_prompts => 'Preview system prompts';
2177
@@ -2174,4 +2186,168 @@ class AppLocalizationsBn extends AppLocalizations {
2186
2187 @override
2188 String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2189 +
2190 + @override
2191 + String get temporary_chat => 'Temporary chat';
2192 +
2193 + @override
2194 + String get temporary_chat_desc => 'Chats aren\'t saved to history.';
2195 +
2196 + @override
2197 + String get temporary_chat_banner => 'Temporary chat — not saved to history';
2198 +
2199 + @override
2200 + String get temporary_chat_save_warning_title =>
2201 + 'Save message in temporary chat?';
2202 +
2203 + @override
2204 + String get temporary_chat_save_warning_body =>
2205 + 'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.';
2206 +
2207 + @override
2208 + String get save_to_history => 'Save to history';
2209 +
2210 + @override
2211 + String get share_conversation => 'Share conversation';
2212 +
2213 + @override
2214 + String get download_tts_audio => 'Download audio';
2215 +
2216 + @override
2217 + String get tts_download_unavailable =>
2218 + 'Download is only available for Piper and Kitten TTS';
2219 +
2220 + @override
2221 + String get tts_download_no_audio => 'No audio available to download yet';
2222 +
2223 + @override
2224 + String get tts_download_success => 'Audio saved';
2225 +
2226 + @override
2227 + String get return_to_chat => 'Return to chat';
2228 +
2229 + @override
2230 + String get return_to_temp_chat => 'Return to temporary chat';
2231 +
2232 + @override
2233 + String get insert_saved_message => 'Insert saved message';
2234 +
2235 + @override
2236 + String get insert_saved_message_desc =>
2237 + 'Choose a saved message to add to your input';
2238 +
2239 + @override
2240 + String get model_info => 'Model info';
2241 +
2242 + @override
2243 + String get model_name => 'Model name';
2244 +
2245 + @override
2246 + String get model_identifier => 'Identifier';
2247 +
2248 + @override
2249 + String get not_available => 'Not available';
2250 +
2251 + @override
2252 + String get save_message_folders => 'Save message';
2253 +
2254 + @override
2255 + String get remove_from_saved => 'Remove from saved';
2256 +
2257 + @override
2258 + String get message_already_saved => 'Saved';
2259 +
2260 + @override
2261 + String get stream_ttft => 'Time to first token';
2262 +
2263 + @override
2264 + String get stream_tokens_per_sec => 'Tokens per second';
2265 +
2266 + @override
2267 + String get stream_stop_reason => 'Stop reason';
2268 +
2269 + @override
2270 + String get stream_input_tokens => 'Input tokens';
2271 +
2272 + @override
2273 + String get stream_output_tokens => 'Output tokens';
2274 +
2275 + @override
2276 + String get stream_generation_time => 'Generation time';
2277 +
2278 + @override
2279 + String get attach_image => 'Image';
2280 +
2281 + @override
2282 + String get attach_text_document => 'Text document';
2283 +
2284 + @override
2285 + String get add_attachment => 'Add attachment';
2286 +
2287 + @override
2288 + String get photo_permission_denied =>
2289 + 'Photo access is required to attach images';
2290 +
2291 + @override
2292 + String get characters_label => 'Characters';
2293 +
2294 + @override
2295 + String get exit_temporary_chat_title => 'Exit temporary chat?';
2296 +
2297 + @override
2298 + String get exit_temporary_chat_body =>
2299 + 'This will discard the current temporary chat and return to a new chat.';
2300 +
2301 + @override
2302 + String get saved_message_temp_snap_unavailable =>
2303 + 'This message was saved from a temporary chat and can\'t be opened in its original conversation.';
2304 +
2305 + @override
2306 + String get filter_title => 'Filter';
2307 +
2308 + @override
2309 + String get filter_pinned => 'Pinned';
2310 +
2311 + @override
2312 + String get filter_archived => 'Archived';
2313 +
2314 + @override
2315 + String get filter_temp_chats => 'Temporary chats';
2316 +
2317 + @override
2318 + String get filter_user_messages => 'User messages';
2319 +
2320 + @override
2321 + String get filter_assistant_messages => 'Assistant messages';
2322 +
2323 + @override
2324 + String get archive_chat => 'Archive';
2325 +
2326 + @override
2327 + String get unarchive_chat => 'Unarchive';
2328 +
2329 + @override
2330 + String conversation_message_count(int count) {
2331 + String _temp0 = intl.Intl.pluralLogic(
2332 + count,
2333 + locale: localeName,
2334 + other: '$count messages',
2335 + one: '1 message',
2336 + );
2337 + return '$_temp0';
2338 + }
2339 +
2340 + @override
2341 + String conversation_character_count(int count) {
2342 + return '$count chars';
2343 + }
2344 +
2345 + @override
2346 + String get generate_title_with_ai => 'Generate with AI';
2347 +
2348 + @override
2349 + String get generating_title => 'Generating...';
2350 +
2351 + @override
2352 + String get generate_title_failed => 'Could not generate a title';
2353 }
lib/l10n/app_localizations_en.dart
+177 -1
@@ -594,7 +594,7 @@ class AppLocalizationsEn extends AppLocalizations {
594 String get stop_generation_tooltip => 'Stop generation';
595
596 @override
597 - String get attach_images_tooltip => 'Attach images';
597 + String get attach_images_tooltip => 'Attach images or text';
598
599 @override
600 String get start_listening_tooltip => 'Start listening';
@@ -2152,6 +2152,18 @@ class AppLocalizationsEn extends AppLocalizations {
2152 String get tts_process_markdown_desc =>
2153 'Strip formatting like **bold** before reading aloud';
2154
2155 + @override
2156 + String get tts_skip_seconds => 'Skip interval';
2157 +
2158 + @override
2159 + String get tts_skip_seconds_desc =>
2160 + 'Forward and rewind jump size during playback';
2161 +
2162 + @override
2163 + String tts_skip_seconds_value(int seconds) {
2164 + return '${seconds}s';
2165 + }
2166 +
2167 @override
2168 String get preview_system_prompts => 'Preview system prompts';
2169
@@ -2166,4 +2178,168 @@ class AppLocalizationsEn extends AppLocalizations {
2178
2179 @override
2180 String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2181 +
2182 + @override
2183 + String get temporary_chat => 'Temporary chat';
2184 +
2185 + @override
2186 + String get temporary_chat_desc => 'Chats aren\'t saved to history.';
2187 +
2188 + @override
2189 + String get temporary_chat_banner => 'Temporary chat — not saved to history';
2190 +
2191 + @override
2192 + String get temporary_chat_save_warning_title =>
2193 + 'Save message in temporary chat?';
2194 +
2195 + @override
2196 + String get temporary_chat_save_warning_body =>
2197 + 'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.';
2198 +
2199 + @override
2200 + String get save_to_history => 'Save to history';
2201 +
2202 + @override
2203 + String get share_conversation => 'Share conversation';
2204 +
2205 + @override
2206 + String get download_tts_audio => 'Download audio';
2207 +
2208 + @override
2209 + String get tts_download_unavailable =>
2210 + 'Download is only available for Piper and Kitten TTS';
2211 +
2212 + @override
2213 + String get tts_download_no_audio => 'No audio available to download yet';
2214 +
2215 + @override
2216 + String get tts_download_success => 'Audio saved';
2217 +
2218 + @override
2219 + String get return_to_chat => 'Return to chat';
2220 +
2221 + @override
2222 + String get return_to_temp_chat => 'Return to temporary chat';
2223 +
2224 + @override
2225 + String get insert_saved_message => 'Insert saved message';
2226 +
2227 + @override
2228 + String get insert_saved_message_desc =>
2229 + 'Choose a saved message to add to your input';
2230 +
2231 + @override
2232 + String get model_info => 'Model info';
2233 +
2234 + @override
2235 + String get model_name => 'Model name';
2236 +
2237 + @override
2238 + String get model_identifier => 'Identifier';
2239 +
2240 + @override
2241 + String get not_available => 'Not available';
2242 +
2243 + @override
2244 + String get save_message_folders => 'Save message';
2245 +
2246 + @override
2247 + String get remove_from_saved => 'Remove from saved';
2248 +
2249 + @override
2250 + String get message_already_saved => 'Saved';
2251 +
2252 + @override
2253 + String get stream_ttft => 'Time to first token';
2254 +
2255 + @override
2256 + String get stream_tokens_per_sec => 'Tokens per second';
2257 +
2258 + @override
2259 + String get stream_stop_reason => 'Stop reason';
2260 +
2261 + @override
2262 + String get stream_input_tokens => 'Input tokens';
2263 +
2264 + @override
2265 + String get stream_output_tokens => 'Output tokens';
2266 +
2267 + @override
2268 + String get stream_generation_time => 'Generation time';
2269 +
2270 + @override
2271 + String get attach_image => 'Image';
2272 +
2273 + @override
2274 + String get attach_text_document => 'Text document';
2275 +
2276 + @override
2277 + String get add_attachment => 'Add attachment';
2278 +
2279 + @override
2280 + String get photo_permission_denied =>
2281 + 'Photo access is required to attach images';
2282 +
2283 + @override
2284 + String get characters_label => 'Characters';
2285 +
2286 + @override
2287 + String get exit_temporary_chat_title => 'Exit temporary chat?';
2288 +
2289 + @override
2290 + String get exit_temporary_chat_body =>
2291 + 'This will discard the current temporary chat and return to a new chat.';
2292 +
2293 + @override
2294 + String get saved_message_temp_snap_unavailable =>
2295 + 'This message was saved from a temporary chat and can\'t be opened in its original conversation.';
2296 +
2297 + @override
2298 + String get filter_title => 'Filter';
2299 +
2300 + @override
2301 + String get filter_pinned => 'Pinned';
2302 +
2303 + @override
2304 + String get filter_archived => 'Archived';
2305 +
2306 + @override
2307 + String get filter_temp_chats => 'Temporary chats';
2308 +
2309 + @override
2310 + String get filter_user_messages => 'User messages';
2311 +
2312 + @override
2313 + String get filter_assistant_messages => 'Assistant messages';
2314 +
2315 + @override
2316 + String get archive_chat => 'Archive';
2317 +
2318 + @override
2319 + String get unarchive_chat => 'Unarchive';
2320 +
2321 + @override
2322 + String conversation_message_count(int count) {
2323 + String _temp0 = intl.Intl.pluralLogic(
2324 + count,
2325 + locale: localeName,
2326 + other: '$count messages',
2327 + one: '1 message',
2328 + );
2329 + return '$_temp0';
2330 + }
2331 +
2332 + @override
2333 + String conversation_character_count(int count) {
2334 + return '$count chars';
2335 + }
2336 +
2337 + @override
2338 + String get generate_title_with_ai => 'Generate with AI';
2339 +
2340 + @override
2341 + String get generating_title => 'Generating...';
2342 +
2343 + @override
2344 + String get generate_title_failed => 'Could not generate a title';
2345 }
lib/l10n/app_localizations_es.dart
+176
@@ -2167,6 +2167,18 @@ class AppLocalizationsEs extends AppLocalizations {
2167 String get tts_process_markdown_desc =>
2168 'Strip formatting like **bold** before reading aloud';
2169
2170 + @override
2171 + String get tts_skip_seconds => 'Skip interval';
2172 +
2173 + @override
2174 + String get tts_skip_seconds_desc =>
2175 + 'Forward and rewind jump size during playback';
2176 +
2177 + @override
2178 + String tts_skip_seconds_value(int seconds) {
2179 + return '${seconds}s';
2180 + }
2181 +
2182 @override
2183 String get preview_system_prompts => 'Preview system prompts';
2184
@@ -2181,4 +2193,168 @@ class AppLocalizationsEs extends AppLocalizations {
2193
2194 @override
2195 String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2196 +
2197 + @override
2198 + String get temporary_chat => 'Temporary chat';
2199 +
2200 + @override
2201 + String get temporary_chat_desc => 'Chats aren\'t saved to history.';
2202 +
2203 + @override
2204 + String get temporary_chat_banner => 'Temporary chat — not saved to history';
2205 +
2206 + @override
2207 + String get temporary_chat_save_warning_title =>
2208 + 'Save message in temporary chat?';
2209 +
2210 + @override
2211 + String get temporary_chat_save_warning_body =>
2212 + 'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.';
2213 +
2214 + @override
2215 + String get save_to_history => 'Save to history';
2216 +
2217 + @override
2218 + String get share_conversation => 'Share conversation';
2219 +
2220 + @override
2221 + String get download_tts_audio => 'Download audio';
2222 +
2223 + @override
2224 + String get tts_download_unavailable =>
2225 + 'Download is only available for Piper and Kitten TTS';
2226 +
2227 + @override
2228 + String get tts_download_no_audio => 'No audio available to download yet';
2229 +
2230 + @override
2231 + String get tts_download_success => 'Audio saved';
2232 +
2233 + @override
2234 + String get return_to_chat => 'Return to chat';
2235 +
2236 + @override
2237 + String get return_to_temp_chat => 'Return to temporary chat';
2238 +
2239 + @override
2240 + String get insert_saved_message => 'Insert saved message';
2241 +
2242 + @override
2243 + String get insert_saved_message_desc =>
2244 + 'Choose a saved message to add to your input';
2245 +
2246 + @override
2247 + String get model_info => 'Model info';
2248 +
2249 + @override
2250 + String get model_name => 'Model name';
2251 +
2252 + @override
2253 + String get model_identifier => 'Identifier';
2254 +
2255 + @override
2256 + String get not_available => 'Not available';
2257 +
2258 + @override
2259 + String get save_message_folders => 'Save message';
2260 +
2261 + @override
2262 + String get remove_from_saved => 'Remove from saved';
2263 +
2264 + @override
2265 + String get message_already_saved => 'Saved';
2266 +
2267 + @override
2268 + String get stream_ttft => 'Time to first token';
2269 +
2270 + @override
2271 + String get stream_tokens_per_sec => 'Tokens per second';
2272 +
2273 + @override
2274 + String get stream_stop_reason => 'Stop reason';
2275 +
2276 + @override
2277 + String get stream_input_tokens => 'Input tokens';
2278 +
2279 + @override
2280 + String get stream_output_tokens => 'Output tokens';
2281 +
2282 + @override
2283 + String get stream_generation_time => 'Generation time';
2284 +
2285 + @override
2286 + String get attach_image => 'Image';
2287 +
2288 + @override
2289 + String get attach_text_document => 'Text document';
2290 +
2291 + @override
2292 + String get add_attachment => 'Add attachment';
2293 +
2294 + @override
2295 + String get photo_permission_denied =>
2296 + 'Photo access is required to attach images';
2297 +
2298 + @override
2299 + String get characters_label => 'Characters';
2300 +
2301 + @override
2302 + String get exit_temporary_chat_title => 'Exit temporary chat?';
2303 +
2304 + @override
2305 + String get exit_temporary_chat_body =>
2306 + 'This will discard the current temporary chat and return to a new chat.';
2307 +
2308 + @override
2309 + String get saved_message_temp_snap_unavailable =>
2310 + 'This message was saved from a temporary chat and can\'t be opened in its original conversation.';
2311 +
2312 + @override
2313 + String get filter_title => 'Filter';
2314 +
2315 + @override
2316 + String get filter_pinned => 'Pinned';
2317 +
2318 + @override
2319 + String get filter_archived => 'Archived';
2320 +
2321 + @override
2322 + String get filter_temp_chats => 'Temporary chats';
2323 +
2324 + @override
2325 + String get filter_user_messages => 'User messages';
2326 +
2327 + @override
2328 + String get filter_assistant_messages => 'Assistant messages';
2329 +
2330 + @override
2331 + String get archive_chat => 'Archive';
2332 +
2333 + @override
2334 + String get unarchive_chat => 'Unarchive';
2335 +
2336 + @override
2337 + String conversation_message_count(int count) {
2338 + String _temp0 = intl.Intl.pluralLogic(
2339 + count,
2340 + locale: localeName,
2341 + other: '$count messages',
2342 + one: '1 message',
2343 + );
2344 + return '$_temp0';
2345 + }
2346 +
2347 + @override
2348 + String conversation_character_count(int count) {
2349 + return '$count chars';
2350 + }
2351 +
2352 + @override
2353 + String get generate_title_with_ai => 'Generate with AI';
2354 +
2355 + @override
2356 + String get generating_title => 'Generating...';
2357 +
2358 + @override
2359 + String get generate_title_failed => 'Could not generate a title';
2360 }
lib/l10n/app_localizations_hi.dart
+176
@@ -2156,6 +2156,18 @@ class AppLocalizationsHi extends AppLocalizations {
2156 String get tts_process_markdown_desc =>
2157 'Strip formatting like **bold** before reading aloud';
2158
2159 + @override
2160 + String get tts_skip_seconds => 'Skip interval';
2161 +
2162 + @override
2163 + String get tts_skip_seconds_desc =>
2164 + 'Forward and rewind jump size during playback';
2165 +
2166 + @override
2167 + String tts_skip_seconds_value(int seconds) {
2168 + return '${seconds}s';
2169 + }
2170 +
2171 @override
2172 String get preview_system_prompts => 'Preview system prompts';
2173
@@ -2170,4 +2182,168 @@ class AppLocalizationsHi extends AppLocalizations {
2182
2183 @override
2184 String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2185 +
2186 + @override
2187 + String get temporary_chat => 'Temporary chat';
2188 +
2189 + @override
2190 + String get temporary_chat_desc => 'Chats aren\'t saved to history.';
2191 +
2192 + @override
2193 + String get temporary_chat_banner => 'Temporary chat — not saved to history';
2194 +
2195 + @override
2196 + String get temporary_chat_save_warning_title =>
2197 + 'Save message in temporary chat?';
2198 +
2199 + @override
2200 + String get temporary_chat_save_warning_body =>
2201 + 'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.';
2202 +
2203 + @override
2204 + String get save_to_history => 'Save to history';
2205 +
2206 + @override
2207 + String get share_conversation => 'Share conversation';
2208 +
2209 + @override
2210 + String get download_tts_audio => 'Download audio';
2211 +
2212 + @override
2213 + String get tts_download_unavailable =>
2214 + 'Download is only available for Piper and Kitten TTS';
2215 +
2216 + @override
2217 + String get tts_download_no_audio => 'No audio available to download yet';
2218 +
2219 + @override
2220 + String get tts_download_success => 'Audio saved';
2221 +
2222 + @override
2223 + String get return_to_chat => 'Return to chat';
2224 +
2225 + @override
2226 + String get return_to_temp_chat => 'Return to temporary chat';
2227 +
2228 + @override
2229 + String get insert_saved_message => 'Insert saved message';
2230 +
2231 + @override
2232 + String get insert_saved_message_desc =>
2233 + 'Choose a saved message to add to your input';
2234 +
2235 + @override
2236 + String get model_info => 'Model info';
2237 +
2238 + @override
2239 + String get model_name => 'Model name';
2240 +
2241 + @override
2242 + String get model_identifier => 'Identifier';
2243 +
2244 + @override
2245 + String get not_available => 'Not available';
2246 +
2247 + @override
2248 + String get save_message_folders => 'Save message';
2249 +
2250 + @override
2251 + String get remove_from_saved => 'Remove from saved';
2252 +
2253 + @override
2254 + String get message_already_saved => 'Saved';
2255 +
2256 + @override
2257 + String get stream_ttft => 'Time to first token';
2258 +
2259 + @override
2260 + String get stream_tokens_per_sec => 'Tokens per second';
2261 +
2262 + @override
2263 + String get stream_stop_reason => 'Stop reason';
2264 +
2265 + @override
2266 + String get stream_input_tokens => 'Input tokens';
2267 +
2268 + @override
2269 + String get stream_output_tokens => 'Output tokens';
2270 +
2271 + @override
2272 + String get stream_generation_time => 'Generation time';
2273 +
2274 + @override
2275 + String get attach_image => 'Image';
2276 +
2277 + @override
2278 + String get attach_text_document => 'Text document';
2279 +
2280 + @override
2281 + String get add_attachment => 'Add attachment';
2282 +
2283 + @override
2284 + String get photo_permission_denied =>
2285 + 'Photo access is required to attach images';
2286 +
2287 + @override
2288 + String get characters_label => 'Characters';
2289 +
2290 + @override
2291 + String get exit_temporary_chat_title => 'Exit temporary chat?';
2292 +
2293 + @override
2294 + String get exit_temporary_chat_body =>
2295 + 'This will discard the current temporary chat and return to a new chat.';
2296 +
2297 + @override
2298 + String get saved_message_temp_snap_unavailable =>
2299 + 'This message was saved from a temporary chat and can\'t be opened in its original conversation.';
2300 +
2301 + @override
2302 + String get filter_title => 'Filter';
2303 +
2304 + @override
2305 + String get filter_pinned => 'Pinned';
2306 +
2307 + @override
2308 + String get filter_archived => 'Archived';
2309 +
2310 + @override
2311 + String get filter_temp_chats => 'Temporary chats';
2312 +
2313 + @override
2314 + String get filter_user_messages => 'User messages';
2315 +
2316 + @override
2317 + String get filter_assistant_messages => 'Assistant messages';
2318 +
2319 + @override
2320 + String get archive_chat => 'Archive';
2321 +
2322 + @override
2323 + String get unarchive_chat => 'Unarchive';
2324 +
2325 + @override
2326 + String conversation_message_count(int count) {
2327 + String _temp0 = intl.Intl.pluralLogic(
2328 + count,
2329 + locale: localeName,
2330 + other: '$count messages',
2331 + one: '1 message',
2332 + );
2333 + return '$_temp0';
2334 + }
2335 +
2336 + @override
2337 + String conversation_character_count(int count) {
2338 + return '$count chars';
2339 + }
2340 +
2341 + @override
2342 + String get generate_title_with_ai => 'Generate with AI';
2343 +
2344 + @override
2345 + String get generating_title => 'Generating...';
2346 +
2347 + @override
2348 + String get generate_title_failed => 'Could not generate a title';
2349 }
lib/l10n/app_localizations_it.dart
+176
@@ -2167,6 +2167,18 @@ class AppLocalizationsIt extends AppLocalizations {
2167 String get tts_process_markdown_desc =>
2168 'Strip formatting like **bold** before reading aloud';
2169
2170 + @override
2171 + String get tts_skip_seconds => 'Skip interval';
2172 +
2173 + @override
2174 + String get tts_skip_seconds_desc =>
2175 + 'Forward and rewind jump size during playback';
2176 +
2177 + @override
2178 + String tts_skip_seconds_value(int seconds) {
2179 + return '${seconds}s';
2180 + }
2181 +
2182 @override
2183 String get preview_system_prompts => 'Preview system prompts';
2184
@@ -2181,4 +2193,168 @@ class AppLocalizationsIt extends AppLocalizations {
2193
2194 @override
2195 String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2196 +
2197 + @override
2198 + String get temporary_chat => 'Temporary chat';
2199 +
2200 + @override
2201 + String get temporary_chat_desc => 'Chats aren\'t saved to history.';
2202 +
2203 + @override
2204 + String get temporary_chat_banner => 'Temporary chat — not saved to history';
2205 +
2206 + @override
2207 + String get temporary_chat_save_warning_title =>
2208 + 'Save message in temporary chat?';
2209 +
2210 + @override
2211 + String get temporary_chat_save_warning_body =>
2212 + 'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.';
2213 +
2214 + @override
2215 + String get save_to_history => 'Save to history';
2216 +
2217 + @override
2218 + String get share_conversation => 'Share conversation';
2219 +
2220 + @override
2221 + String get download_tts_audio => 'Download audio';
2222 +
2223 + @override
2224 + String get tts_download_unavailable =>
2225 + 'Download is only available for Piper and Kitten TTS';
2226 +
2227 + @override
2228 + String get tts_download_no_audio => 'No audio available to download yet';
2229 +
2230 + @override
2231 + String get tts_download_success => 'Audio saved';
2232 +
2233 + @override
2234 + String get return_to_chat => 'Return to chat';
2235 +
2236 + @override
2237 + String get return_to_temp_chat => 'Return to temporary chat';
2238 +
2239 + @override
2240 + String get insert_saved_message => 'Insert saved message';
2241 +
2242 + @override
2243 + String get insert_saved_message_desc =>
2244 + 'Choose a saved message to add to your input';
2245 +
2246 + @override
2247 + String get model_info => 'Model info';
2248 +
2249 + @override
2250 + String get model_name => 'Model name';
2251 +
2252 + @override
2253 + String get model_identifier => 'Identifier';
2254 +
2255 + @override
2256 + String get not_available => 'Not available';
2257 +
2258 + @override
2259 + String get save_message_folders => 'Save message';
2260 +
2261 + @override
2262 + String get remove_from_saved => 'Remove from saved';
2263 +
2264 + @override
2265 + String get message_already_saved => 'Saved';
2266 +
2267 + @override
2268 + String get stream_ttft => 'Time to first token';
2269 +
2270 + @override
2271 + String get stream_tokens_per_sec => 'Tokens per second';
2272 +
2273 + @override
2274 + String get stream_stop_reason => 'Stop reason';
2275 +
2276 + @override
2277 + String get stream_input_tokens => 'Input tokens';
2278 +
2279 + @override
2280 + String get stream_output_tokens => 'Output tokens';
2281 +
2282 + @override
2283 + String get stream_generation_time => 'Generation time';
2284 +
2285 + @override
2286 + String get attach_image => 'Image';
2287 +
2288 + @override
2289 + String get attach_text_document => 'Text document';
2290 +
2291 + @override
2292 + String get add_attachment => 'Add attachment';
2293 +
2294 + @override
2295 + String get photo_permission_denied =>
2296 + 'Photo access is required to attach images';
2297 +
2298 + @override
2299 + String get characters_label => 'Characters';
2300 +
2301 + @override
2302 + String get exit_temporary_chat_title => 'Exit temporary chat?';
2303 +
2304 + @override
2305 + String get exit_temporary_chat_body =>
2306 + 'This will discard the current temporary chat and return to a new chat.';
2307 +
2308 + @override
2309 + String get saved_message_temp_snap_unavailable =>
2310 + 'This message was saved from a temporary chat and can\'t be opened in its original conversation.';
2311 +
2312 + @override
2313 + String get filter_title => 'Filter';
2314 +
2315 + @override
2316 + String get filter_pinned => 'Pinned';
2317 +
2318 + @override
2319 + String get filter_archived => 'Archived';
2320 +
2321 + @override
2322 + String get filter_temp_chats => 'Temporary chats';
2323 +
2324 + @override
2325 + String get filter_user_messages => 'User messages';
2326 +
2327 + @override
2328 + String get filter_assistant_messages => 'Assistant messages';
2329 +
2330 + @override
2331 + String get archive_chat => 'Archive';
2332 +
2333 + @override
2334 + String get unarchive_chat => 'Unarchive';
2335 +
2336 + @override
2337 + String conversation_message_count(int count) {
2338 + String _temp0 = intl.Intl.pluralLogic(
2339 + count,
2340 + locale: localeName,
2341 + other: '$count messages',
2342 + one: '1 message',
2343 + );
2344 + return '$_temp0';
2345 + }
2346 +
2347 + @override
2348 + String conversation_character_count(int count) {
2349 + return '$count chars';
2350 + }
2351 +
2352 + @override
2353 + String get generate_title_with_ai => 'Generate with AI';
2354 +
2355 + @override
2356 + String get generating_title => 'Generating...';
2357 +
2358 + @override
2359 + String get generate_title_failed => 'Could not generate a title';
2360 }
lib/l10n/app_localizations_ja.dart
+176
@@ -2129,6 +2129,18 @@ class AppLocalizationsJa extends AppLocalizations {
2129 String get tts_process_markdown_desc =>
2130 'Strip formatting like **bold** before reading aloud';
2131
2132 + @override
2133 + String get tts_skip_seconds => 'Skip interval';
2134 +
2135 + @override
2136 + String get tts_skip_seconds_desc =>
2137 + 'Forward and rewind jump size during playback';
2138 +
2139 + @override
2140 + String tts_skip_seconds_value(int seconds) {
2141 + return '${seconds}s';
2142 + }
2143 +
2144 @override
2145 String get preview_system_prompts => 'Preview system prompts';
2146
@@ -2143,4 +2155,168 @@ class AppLocalizationsJa extends AppLocalizations {
2155
2156 @override
2157 String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2158 +
2159 + @override
2160 + String get temporary_chat => 'Temporary chat';
2161 +
2162 + @override
2163 + String get temporary_chat_desc => 'Chats aren\'t saved to history.';
2164 +
2165 + @override
2166 + String get temporary_chat_banner => 'Temporary chat — not saved to history';
2167 +
2168 + @override
2169 + String get temporary_chat_save_warning_title =>
2170 + 'Save message in temporary chat?';
2171 +
2172 + @override
2173 + String get temporary_chat_save_warning_body =>
2174 + 'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.';
2175 +
2176 + @override
2177 + String get save_to_history => 'Save to history';
2178 +
2179 + @override
2180 + String get share_conversation => 'Share conversation';
2181 +
2182 + @override
2183 + String get download_tts_audio => 'Download audio';
2184 +
2185 + @override
2186 + String get tts_download_unavailable =>
2187 + 'Download is only available for Piper and Kitten TTS';
2188 +
2189 + @override
2190 + String get tts_download_no_audio => 'No audio available to download yet';
2191 +
2192 + @override
2193 + String get tts_download_success => 'Audio saved';
2194 +
2195 + @override
2196 + String get return_to_chat => 'Return to chat';
2197 +
2198 + @override
2199 + String get return_to_temp_chat => 'Return to temporary chat';
2200 +
2201 + @override
2202 + String get insert_saved_message => 'Insert saved message';
2203 +
2204 + @override
2205 + String get insert_saved_message_desc =>
2206 + 'Choose a saved message to add to your input';
2207 +
2208 + @override
2209 + String get model_info => 'Model info';
2210 +
2211 + @override
2212 + String get model_name => 'Model name';
2213 +
2214 + @override
2215 + String get model_identifier => 'Identifier';
2216 +
2217 + @override
2218 + String get not_available => 'Not available';
2219 +
2220 + @override
2221 + String get save_message_folders => 'Save message';
2222 +
2223 + @override
2224 + String get remove_from_saved => 'Remove from saved';
2225 +
2226 + @override
2227 + String get message_already_saved => 'Saved';
2228 +
2229 + @override
2230 + String get stream_ttft => 'Time to first token';
2231 +
2232 + @override
2233 + String get stream_tokens_per_sec => 'Tokens per second';
2234 +
2235 + @override
2236 + String get stream_stop_reason => 'Stop reason';
2237 +
2238 + @override
2239 + String get stream_input_tokens => 'Input tokens';
2240 +
2241 + @override
2242 + String get stream_output_tokens => 'Output tokens';
2243 +
2244 + @override
2245 + String get stream_generation_time => 'Generation time';
2246 +
2247 + @override
2248 + String get attach_image => 'Image';
2249 +
2250 + @override
2251 + String get attach_text_document => 'Text document';
2252 +
2253 + @override
2254 + String get add_attachment => 'Add attachment';
2255 +
2256 + @override
2257 + String get photo_permission_denied =>
2258 + 'Photo access is required to attach images';
2259 +
2260 + @override
2261 + String get characters_label => 'Characters';
2262 +
2263 + @override
2264 + String get exit_temporary_chat_title => 'Exit temporary chat?';
2265 +
2266 + @override
2267 + String get exit_temporary_chat_body =>
2268 + 'This will discard the current temporary chat and return to a new chat.';
2269 +
2270 + @override
2271 + String get saved_message_temp_snap_unavailable =>
2272 + 'This message was saved from a temporary chat and can\'t be opened in its original conversation.';
2273 +
2274 + @override
2275 + String get filter_title => 'Filter';
2276 +
2277 + @override
2278 + String get filter_pinned => 'Pinned';
2279 +
2280 + @override
2281 + String get filter_archived => 'Archived';
2282 +
2283 + @override
2284 + String get filter_temp_chats => 'Temporary chats';
2285 +
2286 + @override
2287 + String get filter_user_messages => 'User messages';
2288 +
2289 + @override
2290 + String get filter_assistant_messages => 'Assistant messages';
2291 +
2292 + @override
2293 + String get archive_chat => 'Archive';
2294 +
2295 + @override
2296 + String get unarchive_chat => 'Unarchive';
2297 +
2298 + @override
2299 + String conversation_message_count(int count) {
2300 + String _temp0 = intl.Intl.pluralLogic(
2301 + count,
2302 + locale: localeName,
2303 + other: '$count messages',
2304 + one: '1 message',
2305 + );
2306 + return '$_temp0';
2307 + }
2308 +
2309 + @override
2310 + String conversation_character_count(int count) {
2311 + return '$count chars';
2312 + }
2313 +
2314 + @override
2315 + String get generate_title_with_ai => 'Generate with AI';
2316 +
2317 + @override
2318 + String get generating_title => 'Generating...';
2319 +
2320 + @override
2321 + String get generate_title_failed => 'Could not generate a title';
2322 }
lib/l10n/app_localizations_ru.dart new
+2363
@@ -0,0 +1,2363 @@
1 +// ignore: unused_import
2 +import 'package:intl/intl.dart' as intl;
3 +import 'app_localizations.dart';
4 +
5 +// ignore_for_file: type=lint
6 +
7 +/// The translations for Russian (`ru`).
8 +class AppLocalizationsRu extends AppLocalizations {
9 + AppLocalizationsRu([String locale = 'ru']) : super(locale);
10 +
11 + @override
12 + String get app_name => 'LocalMind';
13 +
14 + @override
15 + String get app_tagline => 'Ваш ИИ. Ваше устройство. Ваши правила.';
16 +
17 + @override
18 + String get app_version => '1.0.0';
19 +
20 + @override
21 + String get cancel => 'Отмена';
22 +
23 + @override
24 + String get confirm => 'Подтверждать';
25 +
26 + @override
27 + String get delete => 'Удалить';
28 +
29 + @override
30 + String get save => 'Сохранять';
31 +
32 + @override
33 + String get retry => 'Повторить попытку';
34 +
35 + @override
36 + String get close => 'Закрывать';
37 +
38 + @override
39 + String get done => 'Сделанный';
40 +
41 + @override
42 + String get continue_action => 'Продолжать';
43 +
44 + @override
45 + String get skip => 'Пропускать';
46 +
47 + @override
48 + String get install => 'Установить';
49 +
50 + @override
51 + String get download => 'Скачать';
52 +
53 + @override
54 + String get resume => 'Резюме';
55 +
56 + @override
57 + String get pause => 'Пауза';
58 +
59 + @override
60 + String get stop => 'Останавливаться';
61 +
62 + @override
63 + String get edit => 'Редактировать';
64 +
65 + @override
66 + String get preview => 'Предварительный просмотр';
67 +
68 + @override
69 + String get unload => 'Разгрузить';
70 +
71 + @override
72 + String get load => 'Нагрузка';
73 +
74 + @override
75 + String get rename => 'Переименовать';
76 +
77 + @override
78 + String get pin => 'Приколоть';
79 +
80 + @override
81 + String get unpin => 'Открепить';
82 +
83 + @override
84 + String get share => 'Делиться';
85 +
86 + @override
87 + String get copy => 'Копировать';
88 +
89 + @override
90 + String get copied => 'Скопировано!';
91 +
92 + @override
93 + String get copied_to_clipboard => 'Скопировано в буфер обмена';
94 +
95 + @override
96 + String get select => 'Выбирать';
97 +
98 + @override
99 + String get active => 'Активный';
100 +
101 + @override
102 + String get all => 'Все';
103 +
104 + @override
105 + String get none => 'Никто';
106 +
107 + @override
108 + String get none_selected => 'Ничего не выбрано';
109 +
110 + @override
111 + String get online => 'Онлайн';
112 +
113 + @override
114 + String get offline => 'Оффлайн';
115 +
116 + @override
117 + String get error => 'Ошибка';
118 +
119 + @override
120 + String get unknown_error => 'Неизвестная ошибка';
121 +
122 + @override
123 + String get not_now => 'Не сейчас';
124 +
125 + @override
126 + String get enable => 'Давать возможность';
127 +
128 + @override
129 + String get proceed_anyway => 'Продолжить в любом случае';
130 +
131 + @override
132 + String get test_connection => 'Тестовое соединение';
133 +
134 + @override
135 + String get testing => 'Тестирование...';
136 +
137 + @override
138 + String get connection_successful => 'Подключение успешно!';
139 +
140 + @override
141 + String get connection_failed =>
142 + 'Соединение не удалось. Проверьте свои настройки.';
143 +
144 + @override
145 + String get save_continue => 'Сохранить и продолжить';
146 +
147 + @override
148 + String get save_changes => 'Сохранить изменения';
149 +
150 + @override
151 + String get finish_setup => 'Завершить настройку';
152 +
153 + @override
154 + String get start_new_chat => 'Начать новый чат';
155 +
156 + @override
157 + String get cannot_undo => 'Это действие невозможно отменить.';
158 +
159 + @override
160 + String get ram_warning => 'Предупреждение об оперативной памяти';
161 +
162 + @override
163 + String get recommended => 'РЕКОМЕНДУЕТСЯ';
164 +
165 + @override
166 + String get may_be_large => 'Может быть слишком большим для этого устройства';
167 +
168 + @override
169 + String get calculating => 'Расчет...';
170 +
171 + @override
172 + String get download_failed => 'Загрузка не удалась';
173 +
174 + @override
175 + String get downloaded => 'Скачано';
176 +
177 + @override
178 + String get not_downloaded => 'Не загружено';
179 +
180 + @override
181 + String get installed => 'Установлено';
182 +
183 + @override
184 + String get not_installed => 'Не установлено';
185 +
186 + @override
187 + String get loading => 'Загрузка...';
188 +
189 + @override
190 + String get thinking => 'мышление';
191 +
192 + @override
193 + String get processing => 'Обработка...';
194 +
195 + @override
196 + String get initializing => 'Инициализация...';
197 +
198 + @override
199 + String get ready => 'Готовый';
200 +
201 + @override
202 + String get preparing_app => 'Подготовка приложения...';
203 +
204 + @override
205 + String get initializing_services => 'Инициализация служб...';
206 +
207 + @override
208 + String get configuring_server => 'Настройка сервера...';
209 +
210 + @override
211 + String get startup_failed => 'Запуск не удался';
212 +
213 + @override
214 + String get something_went_wrong => 'Что-то пошло не так';
215 +
216 + @override
217 + String get delete_model_title => 'Удалить модель';
218 +
219 + @override
220 + String delete_model_body(String name) {
221 + return 'Вы уверены, что хотите удалить $name?';
222 + }
223 +
224 + @override
225 + String delete_model_body_with_size(String name, String size) {
226 + return 'Вы уверены, что хотите удалить $name? Это освободит примерно $size места.\n\nПри необходимости вы сможете загрузить эту модель позже.';
227 + }
228 +
229 + @override
230 + String get delete_voice_title => 'Удалить голос';
231 +
232 + @override
233 + String delete_voice_body(String name, String size) {
234 + return 'Вы уверены, что хотите удалить $name? Это освободит примерно $size места.\n\nПри необходимости вы сможете скачать этот голос позже.';
235 + }
236 +
237 + @override
238 + String get delete_server_title => 'Удалить сервер';
239 +
240 + @override
241 + String delete_server_body(String name) {
242 + return 'Вы уверены, что хотите удалить \"$name\"? Это невозможно отменить.';
243 + }
244 +
245 + @override
246 + String get delete_conversation_title => 'Удалить разговор?';
247 +
248 + @override
249 + String delete_conversation_body(String title) {
250 + return 'Вы уверены, что хотите удалить \"$title\"? Это невозможно отменить.';
251 + }
252 +
253 + @override
254 + String get delete_message_title => 'Удалить сообщение?';
255 +
256 + @override
257 + String delete_persona_title(String name) {
258 + return 'Удалить \"$name\"?';
259 + }
260 +
261 + @override
262 + String get delete_persona_body => 'Это невозможно отменить.';
263 +
264 + @override
265 + String get clear_conversation_title => 'Ясный разговор?';
266 +
267 + @override
268 + String get clear_conversation_body =>
269 + 'Это приведет к удалению всех сообщений в этом разговоре.';
270 +
271 + @override
272 + String get clear => 'Прозрачный';
273 +
274 + @override
275 + String label_completed(String label) {
276 + return '$label завершено';
277 + }
278 +
279 + @override
280 + String error_with_message(String error) {
281 + return 'Ошибка: $error';
282 + }
283 +
284 + @override
285 + String preview_failed(String error) {
286 + return 'Не удалось выполнить предварительный просмотр: $error';
287 + }
288 +
289 + @override
290 + String loading_model(String modelId) {
291 + return 'Загрузка $modelId...';
292 + }
293 +
294 + @override
295 + String model_loaded(String modelId, String backend) {
296 + return 'Модель загружена: $modelId ($backend)';
297 + }
298 +
299 + @override
300 + String get no_model_loaded =>
301 + 'Ни одна модель не загружена. Нажмите «Управление моделями на устройстве», чтобы скачать и загрузить модель.';
302 +
303 + @override
304 + String loading_model_error(String error) {
305 + return 'Ошибка: $error';
306 + }
307 +
308 + @override
309 + String get delete_conversation => 'Удалить разговор?';
310 +
311 + @override
312 + String get nav_history => 'История';
313 +
314 + @override
315 + String get nav_servers => 'Серверы';
316 +
317 + @override
318 + String get nav_local_models => 'Локальные модели';
319 +
320 + @override
321 + String get nav_tts => 'Текст в речь';
322 +
323 + @override
324 + String get nav_personas => 'Персоны';
325 +
326 + @override
327 + String get nav_settings => 'Настройки';
328 +
329 + @override
330 + String get nav_new_chat => 'Новый чат';
331 +
332 + @override
333 + String get search_hint => 'Поиск разговоров...';
334 +
335 + @override
336 + String get no_server_selected => 'Сервер не выбран';
337 +
338 + @override
339 + String get switch_server => 'Переключить сервер';
340 +
341 + @override
342 + String get switch_server_subtitle => 'Выберите сервер для подключения';
343 +
344 + @override
345 + String get manage_servers => 'Управление серверами';
346 +
347 + @override
348 + String get open_source => 'Открытый исходный код';
349 +
350 + @override
351 + String get open_source_desc =>
352 + 'LocalMind имеет открытый исходный код. Следите за нашим прогрессом или вносите свой вклад на GitHub.';
353 +
354 + @override
355 + String get star_on_github => 'Звезда на GitHub';
356 +
357 + @override
358 + String get add_more => 'Добавить еще';
359 +
360 + @override
361 + String get on_github => 'на GitHub';
362 +
363 + @override
364 + String get could_not_open_github => 'Не удалось открыть GitHub.';
365 +
366 + @override
367 + String get settings_title => 'Настройки';
368 +
369 + @override
370 + String get settings_appearance => 'Появление';
371 +
372 + @override
373 + String get settings_language => 'Язык';
374 +
375 + @override
376 + String get language_system_default => 'Системные настройки по умолчанию';
377 +
378 + @override
379 + String get settings_tts => 'Преобразование текста в речь';
380 +
381 + @override
382 + String get settings_behavior => 'Поведение';
383 +
384 + @override
385 + String get settings_on_device => 'Вывод на устройстве';
386 +
387 + @override
388 + String get settings_default_server => 'Сервер по умолчанию';
389 +
390 + @override
391 + String get settings_default_persona => 'Персона по умолчанию';
392 +
393 + @override
394 + String get settings_privacy => 'Конфиденциальность';
395 +
396 + @override
397 + String get settings_data_management => 'Управление данными';
398 +
399 + @override
400 + String get settings_about => 'О';
401 +
402 + @override
403 + String get theme => 'Тема';
404 +
405 + @override
406 + String get theme_system => 'Система';
407 +
408 + @override
409 + String get theme_light => 'Свет';
410 +
411 + @override
412 + String get theme_dark => 'Темный';
413 +
414 + @override
415 + String get theme_claude => 'Клод';
416 +
417 + @override
418 + String get font_size => 'Размер шрифта';
419 +
420 + @override
421 + String get font_size_desc => 'Настройте размер текста в чате.';
422 +
423 + @override
424 + String get font_preview =>
425 + 'Быстрая бурая лиса перепрыгивает через ленивую собаку.';
426 +
427 + @override
428 + String get code_theme_dark => 'Кодовая тема (темная)';
429 +
430 + @override
431 + String get code_theme_light => 'Тема кода (светлая)';
432 +
433 + @override
434 + String get code_theme_desc =>
435 + 'Выберите тему подсветки синтаксиса для блоков кода.';
436 +
437 + @override
438 + String get tts_engine => 'Двигатель ТТС';
439 +
440 + @override
441 + String get tts_engine_system => 'Система ТТС';
442 +
443 + @override
444 + String get tts_engine_kitten => 'Котенок ТТС';
445 +
446 + @override
447 + String get voice => 'Голос';
448 +
449 + @override
450 + String get voice_female => 'Женский';
451 +
452 + @override
453 + String get voice_male => 'Мужской';
454 +
455 + @override
456 + String get voice_other => 'Другой';
457 +
458 + @override
459 + String get tts_speed => 'Скорость TTS';
460 +
461 + @override
462 + String get tts_speed_desc => 'Отрегулируйте скорость воспроизведения.';
463 +
464 + @override
465 + String get manage_tts_models => 'Управление моделями TTS';
466 +
467 + @override
468 + String get manage_on_device_models => 'Управление моделями на устройстве';
469 +
470 + @override
471 + String get enable_smart_reply => 'Умные ответы на устройстве';
472 +
473 + @override
474 + String get streaming_responses => 'Потоковая передача ответов';
475 +
476 + @override
477 + String get auto_generate_titles => 'Автоматическое создание заголовков';
478 +
479 + @override
480 + String get send_on_enter => 'Отправить при вводе';
481 +
482 + @override
483 + String get show_system_messages => 'Показать системные сообщения';
484 +
485 + @override
486 + String get haptic_feedback => 'Тактильная обратная связь';
487 +
488 + @override
489 + String get enable_mcp => 'Включить MCP';
490 +
491 + @override
492 + String get new_chat_mcp_default => 'Новый чат MCP по умолчанию';
493 +
494 + @override
495 + String get show_data_indicator => 'Показать индикатор данных';
496 +
497 + @override
498 + String get privacy_info => '«LocalMind никогда не видит ваши данные»';
499 +
500 + @override
501 + String get delete_all_conversations => 'Удалить все разговоры';
502 +
503 + @override
504 + String get reset_settings_defaults =>
505 + 'Сброс настроек к значениям по умолчанию';
506 +
507 + @override
508 + String get chat_title => 'LocalMind';
509 +
510 + @override
511 + String get chat_parameters_tooltip => 'Параметры чата';
512 +
513 + @override
514 + String get change_persona => 'Изменить личность';
515 +
516 + @override
517 + String get set_persona => 'Установить Персону';
518 +
519 + @override
520 + String get remove_persona => 'Удалить персону';
521 +
522 + @override
523 + String get clear_conversation => 'Чистый разговор';
524 +
525 + @override
526 + String get connection_error => 'Ошибка подключения. Проверьте свой сервер.';
527 +
528 + @override
529 + String get disconnected => 'Отключился от сервера.';
530 +
531 + @override
532 + String get configure => 'Настроить';
533 +
534 + @override
535 + String get select_model => 'Выберите модель';
536 +
537 + @override
538 + String get select_persona => 'Выберите Персону';
539 +
540 + @override
541 + String get start_conversation => 'Начать разговор';
542 +
543 + @override
544 + String get recent_chats => 'Недавние чаты';
545 +
546 + @override
547 + String get see_all => 'Посмотреть все';
548 +
549 + @override
550 + String get quick_write => 'Помогите мне написать функцию';
551 +
552 + @override
553 + String get quick_explain => 'Объясните этот код';
554 +
555 + @override
556 + String get quick_debug => 'Отладьте это для меня';
557 +
558 + @override
559 + String get quick_async => 'Как использовать async/await?';
560 +
561 + @override
562 + String get history_missing_title => 'История отсутствует';
563 +
564 + @override
565 + String get history_missing_desc =>
566 + 'Либо сообщения в этом чате были удалены, либо запись истории повреждена.';
567 +
568 + @override
569 + String get technical_details => 'Технические детали';
570 +
571 + @override
572 + String get last_error => 'Последняя ошибка:';
573 +
574 + @override
575 + String get copy_info => 'Копировать информацию';
576 +
577 + @override
578 + String get conversation_id => 'Идентификатор беседы';
579 +
580 + @override
581 + String get created_at => 'Создано в';
582 +
583 + @override
584 + String get expected_messages => 'Ожидаемые сообщения';
585 +
586 + @override
587 + String get debug_dialog_desc =>
588 + 'Диагностическая информация, помогающая выявить проблемы синхронизации.';
589 +
590 + @override
591 + String get chat_input_hint => 'Спроси что-нибудь';
592 +
593 + @override
594 + String get send_message_tooltip => 'Отправить сообщение';
595 +
596 + @override
597 + String get stop_generation_tooltip => 'Остановить генерацию';
598 +
599 + @override
600 + String get attach_images_tooltip => 'Прикрепите изображения или текст';
601 +
602 + @override
603 + String get start_listening_tooltip => 'Начать слушать';
604 +
605 + @override
606 + String get stop_listening_tooltip => 'Хватит слушать';
607 +
608 + @override
609 + String tool_label(String toolCallId) {
610 + return 'Инструмент: $toolCallId';
611 + }
612 +
613 + @override
614 + String get tool_unknown => 'Инструмент: Неизвестно';
615 +
616 + @override
617 + String get message_options => 'Параметры сообщения';
618 +
619 + @override
620 + String get copy_markdown => 'Скопировать как Markdown';
621 +
622 + @override
623 + String get copied_markdown => 'Скопировано как Markdown';
624 +
625 + @override
626 + String get read_aloud => 'Читать вслух';
627 +
628 + @override
629 + String get stop_reading => 'Хватит читать';
630 +
631 + @override
632 + String get more => 'Более';
633 +
634 + @override
635 + String character_count(int length) {
636 + return '$length символов';
637 + }
638 +
639 + @override
640 + String get edit_message => 'Редактировать сообщение';
641 +
642 + @override
643 + String get edit_message_desc =>
644 + 'При сохранении ответ помощника ниже будет удален и восстановлен.';
645 +
646 + @override
647 + String get save_regenerate => 'Сохранить и восстановить';
648 +
649 + @override
650 + String get chat_settings_title => 'Настройки чата';
651 +
652 + @override
653 + String get reset_defaults => 'Сбросить настройки по умолчанию';
654 +
655 + @override
656 + String get parameters_tab => 'Параметры';
657 +
658 + @override
659 + String get mcp_tab => 'МКП';
660 +
661 + @override
662 + String get temperature => 'Температура';
663 +
664 + @override
665 + String get temperature_desc =>
666 + 'Управляет случайностью: выше = креативно, ниже = сосредоточено.';
667 +
668 + @override
669 + String get top_p => 'Топ П';
670 +
671 + @override
672 + String get top_p_desc => 'Порог выборки ядра';
673 +
674 + @override
675 + String get max_tokens => 'Макс. жетонов';
676 +
677 + @override
678 + String get max_tokens_desc => 'Предел ответа';
679 +
680 + @override
681 + String get context_length => 'Длина контекста';
682 +
683 + @override
684 + String get context_length_desc => 'Окно истории';
685 +
686 + @override
687 + String get mcp_disabled_warning =>
688 + 'MCP отключен глобально. Включите его в настройках, чтобы использовать эти функции.';
689 +
690 + @override
691 + String get mcp_enable_chat => 'Включите MCP для этого чата';
692 +
693 + @override
694 + String get auto_execute_tools => 'Инструменты автоматического выполнения';
695 +
696 + @override
697 + String get beta_label => 'Бета';
698 +
699 + @override
700 + String get experimental_label => 'Экспериментальный';
701 +
702 + @override
703 + String get add_ephemeral_mcp => 'Добавить эфемерный сервер MCP';
704 +
705 + @override
706 + String get mcp_label_placeholder => 'Этикетка';
707 +
708 + @override
709 + String get mcp_url_placeholder => 'URL-адрес (https://...)';
710 +
711 + @override
712 + String get active_integrations => 'Активная интеграция';
713 +
714 + @override
715 + String get enable_notifications => 'Включить уведомления';
716 +
717 + @override
718 + String get enable_notifications_desc =>
719 + 'Получайте уведомления, когда модели завершают загрузку.';
720 +
721 + @override
722 + String get chat_history_title => 'История чата';
723 +
724 + @override
725 + String get conversation_just_now => 'Прямо сейчас';
726 +
727 + @override
728 + String conversation_minutes_ago(int minutes) {
729 + return '$minutes мин назад';
730 + }
731 +
732 + @override
733 + String conversation_hours_ago(int hours) {
734 + return '$hoursч назад';
735 + }
736 +
737 + @override
738 + String get conversation_yesterday => 'Вчера';
739 +
740 + @override
741 + String conversation_days_ago(int days) {
742 + return '$daysд назад';
743 + }
744 +
745 + @override
746 + String conversation_date(int month, int day, int year) {
747 + return '$month/$day/$year';
748 + }
749 +
750 + @override
751 + String get options_tooltip => 'Параметры';
752 +
753 + @override
754 + String get no_results_found => 'Результаты не найдены';
755 +
756 + @override
757 + String get no_conversations_yet => 'Пока нет разговоров';
758 +
759 + @override
760 + String get try_different_search => 'Попробуйте другой поисковый запрос';
761 +
762 + @override
763 + String get start_new_conversation => 'Начать новый разговор';
764 +
765 + @override
766 + String get rename_conversation => 'Переименовать беседу';
767 +
768 + @override
769 + String get enter_new_title => 'Введите новое название';
770 +
771 + @override
772 + String get pinned_section => 'ПРИКРЕПЛЕНО';
773 +
774 + @override
775 + String get today_section => 'СЕГОДНЯ';
776 +
777 + @override
778 + String get yesterday_section => 'ВЧЕРА';
779 +
780 + @override
781 + String get previous_7_days => 'ПРЕДЫДУЩИЕ 7 ДНЕЙ';
782 +
783 + @override
784 + String get previous_30_days => 'ПРЕДЫДУЩИЕ 30 ДНЕЙ';
785 +
786 + @override
787 + String get older_section => 'СТАРШЕ';
788 +
789 + @override
790 + String get onboarding_choose_language => 'Выберите язык';
791 +
792 + @override
793 + String get onboarding_choose_language_desc =>
794 + 'Выберите предпочитаемый язык. Вы можете изменить это в любое время в настройках.';
795 +
796 + @override
797 + String get onboarding_localmind => 'МЕСТНЫЙ РАЗУМ';
798 +
799 + @override
800 + String get onboarding_connect_server => 'Подключите свой\nСервер';
801 +
802 + @override
803 + String get onboarding_connect_desc =>
804 + 'Подключитесь к LM Studio, Ollama или\nOpenRouter для запуска вашего частного ИИ\nопыт.';
805 +
806 + @override
807 + String get openai_compatible_api => 'OpenAI-совместимый API';
808 +
809 + @override
810 + String get https_requires_ssl => 'HTTPS требует SSL';
811 +
812 + @override
813 + String get most_local_setups_use_http =>
814 + 'Большинство локальных установок используют http://';
815 +
816 + @override
817 + String get onboarding_welcome => 'Добро пожаловать в LocalMind';
818 +
819 + @override
820 + String get server_type_on_device => 'На устройстве';
821 +
822 + @override
823 + String get server_type_lm_studio => 'ЛМ Студия';
824 +
825 + @override
826 + String get server_type_ollama => 'Оллама';
827 +
828 + @override
829 + String get server_type_openrouter => 'OpenRouter';
830 +
831 + @override
832 + String get server_type_openrouter_sub => 'ЕДИНОЕ ОБЛАКО';
833 +
834 + @override
835 + String get ready_continue => 'ГОТОВЫ ПРОДОЛЖИТЬ';
836 +
837 + @override
838 + String get waiting_selection => 'ОЖИДАНИЕ ВЫБОРА';
839 +
840 + @override
841 + String get setup_connection => 'Настройка соединения';
842 +
843 + @override
844 + String setup_connection_desc(String server) {
845 + return 'Настройте свой сервер $server, чтобы начать общение.';
846 + }
847 +
848 + @override
849 + String get server_name => 'Имя сервера';
850 +
851 + @override
852 + String get name_required => 'Требуется имя';
853 +
854 + @override
855 + String get name_max_50 => 'Макс. 50 символов.';
856 +
857 + @override
858 + String get host_label => 'Хост/IP-адрес';
859 +
860 + @override
861 + String get host_required => 'Требуется хост';
862 +
863 + @override
864 + String get port_label => 'Порт';
865 +
866 + @override
867 + String get port_required => 'Требуется порт';
868 +
869 + @override
870 + String get port_invalid => 'Должно быть число';
871 +
872 + @override
873 + String get port_range => 'Введите действительный порт (1-65535).';
874 +
875 + @override
876 + String get api_key_required => 'API-ключ *';
877 +
878 + @override
879 + String get api_key_optional => 'Ключ API (необязательно)';
880 +
881 + @override
882 + String get api_key_required_openrouter =>
883 + 'Ключ API, необходимый для OpenRouter';
884 +
885 + @override
886 + String get api_key_format => 'Ключи API OpenRouter начинаются с sk-';
887 +
888 + @override
889 + String get my_server_hint => 'Мой сервер';
890 +
891 + @override
892 + String get name_length_validation =>
893 + 'Имя должно содержать не более 50 символов.';
894 +
895 + @override
896 + String get host_valid => 'Введите действительное имя хоста или IP-адрес.';
897 +
898 + @override
899 + String get api_key_hint_openrouter => 'ск-...';
900 +
901 + @override
902 + String get api_key_hint_generic => 'Для аутентифицированных серверов';
903 +
904 + @override
905 + String get update_server => 'Обновление сервера';
906 +
907 + @override
908 + String get save_server => 'Сохранить сервер';
909 +
910 + @override
911 + String get server_updated => 'Сервер обновлен';
912 +
913 + @override
914 + String get server_added => 'Сервер добавлен';
915 +
916 + @override
917 + String get download_model_title => 'Скачать модель';
918 +
919 + @override
920 + String get download_model_desc =>
921 + 'Выберите модель для загрузки.\nОн будет работать локально на вашем устройстве.';
922 +
923 + @override
924 + String get on_device_android_only =>
925 + 'Вывод на устройстве в настоящее время доступен только на Android.';
926 +
927 + @override
928 + String get total_ram => 'Общий объем оперативной памяти';
929 +
930 + @override
931 + String get available => 'Доступный';
932 +
933 + @override
934 + String ram_min_required(String fileSize) {
935 + return '$fileSize ГБ ОЗУ мин.';
936 + }
937 +
938 + @override
939 + String download_progress(String percent, String speed) {
940 + return '$percent% • $speed';
941 + }
942 +
943 + @override
944 + String eta_label(String eta) {
945 + return 'Оставшееся время: $eta';
946 + }
947 +
948 + @override
949 + String paused_progress(String percent) {
950 + return 'Приостановлено – $percent %';
951 + }
952 +
953 + @override
954 + String ram_warning_body_download(String ram, String totalMemory) {
955 + return 'Для этой модели требуется не менее $ram ГБ ОЗУ, но на вашем устройстве имеется $totalMemory. Оно может работать неправильно или привести к сбою приложения.';
956 + }
957 +
958 + @override
959 + String ram_warning_body_load(String availableRAM, String ram) {
960 + return 'На вашем устройстве доступно ОЗУ $availableRAM, но для этой модели рекомендуется не менее $ram ГБ. Загрузка может привести к сбою или вызвать нестабильность.';
961 + }
962 +
963 + @override
964 + String get choose_theme => 'Выбрать тему';
965 +
966 + @override
967 + String get choose_theme_desc =>
968 + 'Персонализируйте внешний вид приложения. Вы всегда можете изменить это позже в настройках.';
969 +
970 + @override
971 + String get theme_card_system => 'Система';
972 +
973 + @override
974 + String get theme_card_system_sub =>
975 + 'Соответствует настройкам вашего устройства';
976 +
977 + @override
978 + String get theme_card_light => 'Свет';
979 +
980 + @override
981 + String get theme_card_light_sub => 'Чистый и светлый';
982 +
983 + @override
984 + String get theme_card_dark => 'Темный';
985 +
986 + @override
987 + String get theme_card_dark_sub => 'Легко для глаз';
988 +
989 + @override
990 + String get theme_card_claude => 'Клод';
991 +
992 + @override
993 + String get theme_card_claude_sub => 'Теплая тема персикового оттенка.';
994 +
995 + @override
996 + String get stay_updated => 'Оставайтесь в курсе';
997 +
998 + @override
999 + String get stay_updated_desc =>
1000 + 'Получайте уведомления, когда ваши модели ИИ завершают загрузку или завершаются длительные задачи.';
1001 +
1002 + @override
1003 + String get notification_benefit_downloads => 'Ход загрузки модели';
1004 +
1005 + @override
1006 + String get notification_benefit_completions => 'Завершения генерации';
1007 +
1008 + @override
1009 + String get notification_benefit_background => 'Статус фоновых задач';
1010 +
1011 + @override
1012 + String get allow_notifications => 'Разрешить уведомления';
1013 +
1014 + @override
1015 + String get servers_title => 'Серверы';
1016 +
1017 + @override
1018 + String get no_servers_yet => 'Серверов пока нет';
1019 +
1020 + @override
1021 + String get no_servers_desc =>
1022 + 'Добавьте свой первый сервер, чтобы начать общаться с моделями ИИ.';
1023 +
1024 + @override
1025 + String get add_server => 'Добавить сервер';
1026 +
1027 + @override
1028 + String switched_to_server(String name) {
1029 + return 'Переключено на $name';
1030 + }
1031 +
1032 + @override
1033 + String get edit_server => 'Редактировать сервер';
1034 +
1035 + @override
1036 + String get add_server_title => 'Добавить сервер';
1037 +
1038 + @override
1039 + String get server_type_label => 'Тип сервера';
1040 +
1041 + @override
1042 + String get server_icon_label => 'Значок сервера';
1043 +
1044 + @override
1045 + String get default_icon => 'Значок по умолчанию';
1046 +
1047 + @override
1048 + String get server_type_lm_studio_display => 'ЛМ Студия';
1049 +
1050 + @override
1051 + String get server_type_openai_display => 'Совместимость с OpenAI';
1052 +
1053 + @override
1054 + String get server_type_ollama_display => 'Оллама';
1055 +
1056 + @override
1057 + String get server_type_openrouter_display => 'OpenRouter';
1058 +
1059 + @override
1060 + String get server_type_on_device_display => 'На устройстве';
1061 +
1062 + @override
1063 + String get server_address_openrouter => 'openrouter.ai';
1064 +
1065 + @override
1066 + String get server_address_on_device => 'Локальный вывод';
1067 +
1068 + @override
1069 + String server_address_format(String host, String port) {
1070 + return '$host:$port';
1071 + }
1072 +
1073 + @override
1074 + String get default_badge => 'По умолчанию';
1075 +
1076 + @override
1077 + String get set_as_default => 'Установить по умолчанию';
1078 +
1079 + @override
1080 + String get select_icon => 'Выберите значок';
1081 +
1082 + @override
1083 + String get select_icon_desc => 'Выберите значок для своего сервера';
1084 +
1085 + @override
1086 + String get search_icons_hint => 'Иконки поиска...';
1087 +
1088 + @override
1089 + String get server_icon_stack => 'Серверный стек';
1090 +
1091 + @override
1092 + String get server_icon_stack2 => 'Серверный стек 02';
1093 +
1094 + @override
1095 + String get server_icon_stack3 => 'Серверный стек 03';
1096 +
1097 + @override
1098 + String get server_icon_cloud => 'Облако';
1099 +
1100 + @override
1101 + String get server_icon_cloud_server => 'Облачный сервер';
1102 +
1103 + @override
1104 + String get server_icon_mcp => 'MCP-сервер';
1105 +
1106 + @override
1107 + String get server_icon_database => 'База данных';
1108 +
1109 + @override
1110 + String get server_icon_database1 => 'База данных 01';
1111 +
1112 + @override
1113 + String get server_icon_database2 => 'База данных 02';
1114 +
1115 + @override
1116 + String get server_icon_cpu => 'Процессор';
1117 +
1118 + @override
1119 + String get server_icon_chip => 'Чип';
1120 +
1121 + @override
1122 + String get server_icon_chip2 => 'Чип 02';
1123 +
1124 + @override
1125 + String get server_icon_computer => 'Компьютер';
1126 +
1127 + @override
1128 + String get server_icon_laptop => 'Ноутбук';
1129 +
1130 + @override
1131 + String get server_icon_terminal => 'Компьютерный терминал';
1132 +
1133 + @override
1134 + String get server_icon_code => 'Код';
1135 +
1136 + @override
1137 + String get server_icon_ai_brain => 'ИИ Мозг';
1138 +
1139 + @override
1140 + String get server_icon_ai_brain2 => 'ИИ Мозг 02';
1141 +
1142 + @override
1143 + String get server_icon_ai_cloud => 'Облако ИИ';
1144 +
1145 + @override
1146 + String get server_icon_ai_network => 'Сеть искусственного интеллекта';
1147 +
1148 + @override
1149 + String get server_icon_ai_chat => 'AI-чат';
1150 +
1151 + @override
1152 + String get server_icon_cellular => 'Сотовая сеть';
1153 +
1154 + @override
1155 + String get server_icon_plug1 => 'Вилка 01';
1156 +
1157 + @override
1158 + String get server_icon_plug2 => 'Вилка 02';
1159 +
1160 + @override
1161 + String get server_icon_bot => 'Бот';
1162 +
1163 + @override
1164 + String get server_icon_bot2 => 'Бот 02';
1165 +
1166 + @override
1167 + String get server_icon_robotic => 'Роботизированный';
1168 +
1169 + @override
1170 + String get server_icon_rocket => 'Ракета';
1171 +
1172 + @override
1173 + String get server_icon_star => 'Звезда';
1174 +
1175 + @override
1176 + String get server_icon_settings1 => 'Настройки 01';
1177 +
1178 + @override
1179 + String get server_icon_settings2 => 'Настройки 02';
1180 +
1181 + @override
1182 + String get server_icon_home1 => 'Дом 01';
1183 +
1184 + @override
1185 + String get server_icon_home2 => 'Дом 02';
1186 +
1187 + @override
1188 + String get server_icon_folder1 => 'Папка 01';
1189 +
1190 + @override
1191 + String get server_icon_folder2 => 'Папка 02';
1192 +
1193 + @override
1194 + String get server_icon_file1 => 'Файл 01';
1195 +
1196 + @override
1197 + String get server_icon_lock => 'Замок';
1198 +
1199 + @override
1200 + String get server_icon_key => 'Ключ 01';
1201 +
1202 + @override
1203 + String get server_icon_link => 'Ссылка 01';
1204 +
1205 + @override
1206 + String get server_icon_globe => 'Глобус';
1207 +
1208 + @override
1209 + String get server_icon_api => 'API';
1210 +
1211 + @override
1212 + String get server_icon_arrow_right => 'Стрелка вправо 01';
1213 +
1214 + @override
1215 + String get server_icon_check => 'Проверить круг';
1216 +
1217 + @override
1218 + String get server_icon_alert => 'Круг оповещения';
1219 +
1220 + @override
1221 + String get server_icon_info => 'Информационный круг';
1222 +
1223 + @override
1224 + String get server_icon_zap => 'Зап';
1225 +
1226 + @override
1227 + String get server_icon_cloud_upload => 'Загрузка в облако';
1228 +
1229 + @override
1230 + String get server_icon_cloud_download => 'Облачная загрузка';
1231 +
1232 + @override
1233 + String get server_icon_refresh => 'Обновить';
1234 +
1235 + @override
1236 + String get server_icon_hard_drive => 'Жесткий диск';
1237 +
1238 + @override
1239 + String get server_icon_drive => 'Водить машину';
1240 +
1241 + @override
1242 + String get personas_title => 'Персоны';
1243 +
1244 + @override
1245 + String get persona_category_general => 'Общий';
1246 +
1247 + @override
1248 + String get persona_category_coding => 'Кодирование';
1249 +
1250 + @override
1251 + String get persona_category_education => 'Образование';
1252 +
1253 + @override
1254 + String get persona_category_creative => 'Креатив';
1255 +
1256 + @override
1257 + String get persona_builtin_section => 'ВСТРОЕННЫЙ';
1258 +
1259 + @override
1260 + String get persona_my_section => 'МОИ ПЕРСОНАЛЫ';
1261 +
1262 + @override
1263 + String get clone_edit => 'Клонировать и редактировать';
1264 +
1265 + @override
1266 + String get builtin_badge => 'Встроенный';
1267 +
1268 + @override
1269 + String get no_personas_found => 'Персоны не найдены';
1270 +
1271 + @override
1272 + String get no_personas_desc =>
1273 + 'Создайте свою первую личность, чтобы настроить поведение ИИ.';
1274 +
1275 + @override
1276 + String get edit_persona => 'Изменить личность';
1277 +
1278 + @override
1279 + String get create_persona => 'Создать Персону';
1280 +
1281 + @override
1282 + String get create_persona_button => 'Создавать';
1283 +
1284 + @override
1285 + String get emoji_label => 'Эмодзи';
1286 +
1287 + @override
1288 + String get name_label => 'Имя';
1289 +
1290 + @override
1291 + String get my_persona_hint => 'Моя Персона';
1292 +
1293 + @override
1294 + String get category_label => 'Категория';
1295 +
1296 + @override
1297 + String get description_optional => 'Описание (необязательно)';
1298 +
1299 + @override
1300 + String get description_hint => 'Что делает эта личность...';
1301 +
1302 + @override
1303 + String get system_prompt => 'Системная подсказка';
1304 +
1305 + @override
1306 + String character_count_max(int currentLen) {
1307 + return '$currentLen/4000';
1308 + }
1309 +
1310 + @override
1311 + String get no_prompt_placeholder => 'Пока нет подсказки...';
1312 +
1313 + @override
1314 + String get prompt_hint => 'Вы полезный помощник...';
1315 +
1316 + @override
1317 + String get prompt_required => 'Требуется системное приглашение';
1318 +
1319 + @override
1320 + String get prompt_max_chars => 'Макс. 4000 символов.';
1321 +
1322 + @override
1323 + String get advanced_settings => 'Расширенные настройки';
1324 +
1325 + @override
1326 + String get temperature_label => 'Температура (0,0-2,0)';
1327 +
1328 + @override
1329 + String get top_p_label => 'Топ П (0,0-1,0)';
1330 +
1331 + @override
1332 + String get temp_hint => '0,7';
1333 +
1334 + @override
1335 + String get top_p_hint => '0,9';
1336 +
1337 + @override
1338 + String get range_0_2 => '0,0-2,0';
1339 +
1340 + @override
1341 + String get range_0_1 => '0,0-1,0';
1342 +
1343 + @override
1344 + String get persona_updated => 'Персона обновлена';
1345 +
1346 + @override
1347 + String get persona_created => 'Персона создана';
1348 +
1349 + @override
1350 + String get tts_models_title => 'Модели преобразования текста в речь';
1351 +
1352 + @override
1353 + String get always_available => 'Всегда доступен';
1354 +
1355 + @override
1356 + String get tts_system_desc =>
1357 + 'Использует встроенный в ваше устройство механизм преобразования текста в речь.\nНикаких загрузок не требуется. Выбор голоса использует системные настройки вашего устройства.';
1358 +
1359 + @override
1360 + String get downloading_status => 'Загрузка...';
1361 +
1362 + @override
1363 + String tts_kitten_desc(String size) {
1364 + return 'Молниеносный нейронный TTS с 8 выразительными голосами.\nТребуется загрузка $size.';
1365 + }
1366 +
1367 + @override
1368 + String tts_piper_desc(String size) {
1369 + return 'Быстрые офлайн-голоса Пайпер с двумя выразительными голосами.\nТребуется загрузка $size на голос.';
1370 + }
1371 +
1372 + @override
1373 + String engine_spec(String sizeMb, String ramMb, int voiceCount) {
1374 + return '$sizeMb МБ · $ramMb МБ ОЗУ · $voiceCount голосов';
1375 + }
1376 +
1377 + @override
1378 + String get on_device_models_title => 'Модели на устройстве';
1379 +
1380 + @override
1381 + String get settings_huggingface_token =>
1382 + 'Жетон «Обнимающее лицо» (необязательно)';
1383 +
1384 + @override
1385 + String get settings_huggingface_token_desc =>
1386 + 'Требуется только для закрытых моделей (например, Gemma). Получите токен на сайтеhuggingface.co/settings/tokens.';
1387 +
1388 + @override
1389 + String get settings_huggingface_token_set => 'Токен сохранен.';
1390 +
1391 + @override
1392 + String get settings_huggingface_token_cleared => 'Токен очищен';
1393 +
1394 + @override
1395 + String get model_requires_huggingface_token =>
1396 + 'Требуется жетон «Обнимающее лицо».';
1397 +
1398 + @override
1399 + String get model_missing_huggingface_token =>
1400 + 'Данная модель изготовлена ​​на Hugging Face. Добавьте токен в «Настройки» → «Вывод на устройстве», чтобы загрузить его.';
1401 +
1402 + @override
1403 + String get set_huggingface_token => 'Установить токен';
1404 +
1405 + @override
1406 + String get clear_huggingface_token => 'Прозрачный';
1407 +
1408 + @override
1409 + String get edit_huggingface_token_dialog_title =>
1410 + 'Токен доступа «Обнимающее лицо»';
1411 +
1412 + @override
1413 + String get huggingface_token_dialog_hint => 'хф_…';
1414 +
1415 + @override
1416 + String get server_type_ollama_desc =>
1417 + 'Локальный ИИ-движок. Ключ API не требуется.';
1418 +
1419 + @override
1420 + String get server_type_on_device_desc =>
1421 + 'Работает на вашем телефоне. Некоторым моделям нужен жетон «Обнимающее лицо».';
1422 +
1423 + @override
1424 + String get server_type_lm_studio_desc =>
1425 + 'Локальный API-сервер. Ключ API не требуется.';
1426 +
1427 + @override
1428 + String get available_models => 'Доступные модели';
1429 +
1430 + @override
1431 + String get device_memory => 'Память устройства';
1432 +
1433 + @override
1434 + String get ram_usage => 'Использование оперативной памяти';
1435 +
1436 + @override
1437 + String get memory_healthy => 'Здоровый';
1438 +
1439 + @override
1440 + String get memory_critical => 'Критический';
1441 +
1442 + @override
1443 + String get memory_low => 'Низкий';
1444 +
1445 + @override
1446 + String ram_used(String percent) {
1447 + return '$percent % использовано';
1448 + }
1449 +
1450 + @override
1451 + String get available_ram => 'Доступная оперативная память';
1452 +
1453 + @override
1454 + String get total_capacity => 'Общая емкость';
1455 +
1456 + @override
1457 + String get loaded_status => 'Загружено';
1458 +
1459 + @override
1460 + String get inference_backend => 'Серверная часть вывода';
1461 +
1462 + @override
1463 + String get backend_ios_notice => 'В iOS доступен только серверный процессор.';
1464 +
1465 + @override
1466 + String get backend_cpu_desc =>
1467 + 'Работает на всех устройствах. Наиболее совместим.';
1468 +
1469 + @override
1470 + String get backend_gpu_desc =>
1471 + 'Ускорение OpenCL. Быстрее на поддерживаемых устройствах.';
1472 +
1473 + @override
1474 + String get backend_npu_desc =>
1475 + 'Поставщик НПУ (Qualcomm/MediaTek). Самый быстрый вывод.';
1476 +
1477 + @override
1478 + String get select_model_title => 'Выберите модель';
1479 +
1480 + @override
1481 + String get refresh_models => 'Обновить модели';
1482 +
1483 + @override
1484 + String get search_models_hint => 'Поиск моделей...';
1485 +
1486 + @override
1487 + String get no_server_connected => 'Сервер не подключен';
1488 +
1489 + @override
1490 + String get add_server_first =>
1491 + 'Сначала добавьте сервер, чтобы увидеть доступные модели.';
1492 +
1493 + @override
1494 + String get failed_load_models => 'Не удалось загрузить модели.';
1495 +
1496 + @override
1497 + String get no_models_available => 'Нет доступных моделей';
1498 +
1499 + @override
1500 + String no_models_match(String searchQuery) {
1501 + return 'Нет моделей, соответствующих запросу \"$searchQuery\".';
1502 + }
1503 +
1504 + @override
1505 + String model_load_failed(String error) {
1506 + return 'Не удалось загрузить модель: $error';
1507 + }
1508 +
1509 + @override
1510 + String model_unloaded_ollama(String name) {
1511 + return 'Выгрузка запрошена для $name. Если Оллама доступен, модель выпускается немедленно.';
1512 + }
1513 +
1514 + @override
1515 + String model_unloaded_success(String name) {
1516 + return '$name успешно выгружено';
1517 + }
1518 +
1519 + @override
1520 + String model_unload_failed(String error) {
1521 + return 'Не удалось выгрузить модель: $error';
1522 + }
1523 +
1524 + @override
1525 + String get unload_from_server => 'Выгрузить с сервера';
1526 +
1527 + @override
1528 + String context_chip(String ctx) {
1529 + return '$ctx ctx';
1530 + }
1531 +
1532 + @override
1533 + String get unload_all_models => 'Unload all';
1534 +
1535 + @override
1536 + String loaded_models_count(int count) {
1537 + return '$count loaded';
1538 + }
1539 +
1540 + @override
1541 + String get all_models_unloaded => 'All models unloaded';
1542 +
1543 + @override
1544 + String get branch_chat => 'Branch chat';
1545 +
1546 + @override
1547 + String get branch_chat_desc => 'Start a new conversation from this message';
1548 +
1549 + @override
1550 + String get edit_assistant_message_desc => 'Edit the assistant response text.';
1551 +
1552 + @override
1553 + String switch_to_model(String modelName) {
1554 + return 'Switch to $modelName';
1555 + }
1556 +
1557 + @override
1558 + String download_notification_title(String modelName) {
1559 + return 'Загрузка $modelName...';
1560 + }
1561 +
1562 + @override
1563 + String get download_complete_notification => 'Загрузка завершена!';
1564 +
1565 + @override
1566 + String download_complete_body(String modelName) {
1567 + return 'Модель $modelName успешно загружена.';
1568 + }
1569 +
1570 + @override
1571 + String download_failed_notification(String error) {
1572 + return 'Загрузка не удалась: $error';
1573 + }
1574 +
1575 + @override
1576 + String download_failed_body(String modelName) {
1577 + return 'Не удалось загрузить $modelName.';
1578 + }
1579 +
1580 + @override
1581 + String get engine_name_system => 'Система ТТС';
1582 +
1583 + @override
1584 + String get engine_tagline_system => 'Встроенный движок устройства';
1585 +
1586 + @override
1587 + String get engine_name_kitten => 'Котенок ТТС';
1588 +
1589 + @override
1590 + String get engine_tagline_kitten => 'Высокоскоростная нейронная TTS';
1591 +
1592 + @override
1593 + String get engine_name_sherpa => 'Шерпа ОННКС ВИТС';
1594 +
1595 + @override
1596 + String get engine_tagline_sherpa => 'Оффлайн голоса Пайпер';
1597 +
1598 + @override
1599 + String get voice_jasper => 'Джаспер';
1600 +
1601 + @override
1602 + String get voice_bella => 'Белла';
1603 +
1604 + @override
1605 + String get voice_bruno => 'Бруно';
1606 +
1607 + @override
1608 + String get voice_luna => 'Луна';
1609 +
1610 + @override
1611 + String get voice_hugo => 'Хьюго';
1612 +
1613 + @override
1614 + String get voice_rosie => 'Рози';
1615 +
1616 + @override
1617 + String get voice_leo => 'Лео';
1618 +
1619 + @override
1620 + String get voice_kiki => 'Кики';
1621 +
1622 + @override
1623 + String get voice_lessac => 'Лессак (США)';
1624 +
1625 + @override
1626 + String get voice_ryan => 'Райан (США)';
1627 +
1628 + @override
1629 + String get model_qwen_3 => 'Квен 3 0.6Б';
1630 +
1631 + @override
1632 + String get model_qwen_3_desc =>
1633 + 'Самая маленькая модель чата общего назначения. Быстрые ответы, низкое использование памяти.';
1634 +
1635 + @override
1636 + String get model_license_apache => 'Апач-2.0';
1637 +
1638 + @override
1639 + String get model_qwen_25 => 'Квен 2.5 1.5B Инструктировать';
1640 +
1641 + @override
1642 + String get model_qwen_25_desc =>
1643 + 'Сбалансированное качество и размер. Подходит для общего разговора.';
1644 +
1645 + @override
1646 + String get model_deepseek => 'DeepSeek R1 Дистилл Qwen 1.5B';
1647 +
1648 + @override
1649 + String get model_deepseek_desc =>
1650 + 'Модель рассуждения и цепочки мыслей. Лучше всего подходит для логических задач.';
1651 +
1652 + @override
1653 + String get model_license_mit => 'Массачусетский технологический институт';
1654 +
1655 + @override
1656 + String get model_gemma => 'Джемма 4 E2B Инструктирует';
1657 +
1658 + @override
1659 + String get model_gemma_desc =>
1660 + 'Флагманская модель Google. Высочайшее качество, требует больше оперативной памяти.';
1661 +
1662 + @override
1663 + String export_header(String date) {
1664 + return '*Экспортировано из LocalMind — $date*';
1665 + }
1666 +
1667 + @override
1668 + String get export_role_user => '## 👤 Пользователь';
1669 +
1670 + @override
1671 + String get export_role_assistant => '## 🤖 Помощник';
1672 +
1673 + @override
1674 + String get export_role_system => '## ⚙️ Система';
1675 +
1676 + @override
1677 + String get export_role_tool => '## 🔧 Инструмент';
1678 +
1679 + @override
1680 + String get export_text_user => '[ПОЛЬЗОВАТЕЛЬ]';
1681 +
1682 + @override
1683 + String get export_text_assistant => '[ПОМОЩНИК]';
1684 +
1685 + @override
1686 + String get export_text_system => '[СИСТЕМА]';
1687 +
1688 + @override
1689 + String get export_text_tool => '[ИНСТРУМЕНТ]';
1690 +
1691 + @override
1692 + String get export_label_user => 'ПОЛЬЗОВАТЕЛЬ';
1693 +
1694 + @override
1695 + String get export_label_assistant => 'ПОМОЩНИК';
1696 +
1697 + @override
1698 + String get export_label_system => 'СИСТЕМА';
1699 +
1700 + @override
1701 + String get export_label_tool => 'ИНСТРУМЕНТ';
1702 +
1703 + @override
1704 + String get select_model_hint => 'Выберите модель, чтобы начать общение';
1705 +
1706 + @override
1707 + String get test_notification_title => 'Уведомление о тестировании';
1708 +
1709 + @override
1710 + String get test_notification_body =>
1711 + 'Это тестовое уведомление о ходе загрузки модели.';
1712 +
1713 + @override
1714 + String get tts_supports_background =>
1715 + 'Поддерживает фоновое воспроизведение как собственный звук';
1716 +
1717 + @override
1718 + String get tts_other_services_background_note =>
1719 + 'Примечание. Другие службы TTS поддерживают фоновое воспроизведение как собственный звук.';
1720 +
1721 + @override
1722 + String get gguf_imported_models_title => 'Импортированные модели GGUF';
1723 +
1724 + @override
1725 + String get gguf_imported_models_empty_subtitle =>
1726 + 'Импортируйте GGUF со своего устройства или добавьте его из Hugging Face. Импортированные модели запускаются локально с помощью llama.cpp.';
1727 +
1728 + @override
1729 + String get gguf_imported_models_ready =>
1730 + 'импортированные модели готовы для локального вывода.';
1731 +
1732 + @override
1733 + String get gguf_curated_models_subtitle =>
1734 + 'Рекомендуемые модели на устройстве, которые вы можете загружать и управлять ими внутри LocalMind.';
1735 +
1736 + @override
1737 + String get gguf_only_supported =>
1738 + 'Для этого импорта поддерживаются только модели GGUF.';
1739 +
1740 + @override
1741 + String get gguf_imported_from_local_file =>
1742 + 'импортировано из локального файла.';
1743 +
1744 + @override
1745 + String get gguf_import_failed => 'Не удалось импортировать модель GGUF.';
1746 +
1747 + @override
1748 + String get gguf_imported_from_huggingface => 'импортировано из Hugging Face.';
1749 +
1750 + @override
1751 + String get gguf_import_canceled => 'Импорт GGUF отменен.';
1752 +
1753 + @override
1754 + String get gguf_enter_huggingface_url =>
1755 + 'Введите URL-адрес GGUF «Обнимающее лицо».';
1756 +
1757 + @override
1758 + String get gguf_only_official_huggingface_urls =>
1759 + 'Поддерживаются только официальные URL-адреса Hugging Face GGUF.';
1760 +
1761 + @override
1762 + String get gguf_use_https_url =>
1763 + 'Используйте URL-адрес обнимающего лица HTTPS для импорта GGUF.';
1764 +
1765 + @override
1766 + String get gguf_url_must_point_to_file =>
1767 + 'URL-адрес «Обнимающего лица» должен указывать непосредственно на файл .gguf.';
1768 +
1769 + @override
1770 + String get gguf_unable_to_detect_file_name =>
1771 + 'Невозможно определить имя файла GGUF.';
1772 +
1773 + @override
1774 + String get gguf_download_empty =>
1775 + 'Загруженный файл GGUF был пуст или отсутствовал.';
1776 +
1777 + @override
1778 + String get gguf_selected_file_missing =>
1779 + 'Выбранный файл модели не существует.';
1780 +
1781 + @override
1782 + String get gguf_import_action => 'Импортировать GGUF';
1783 +
1784 + @override
1785 + String get gguf_overview_title => 'Принесите свои собственные модели GGUF';
1786 +
1787 + @override
1788 + String get gguf_overview_subtitle =>
1789 + 'Импортируйте .gguf из локального хранилища или загрузите его прямо с Hugging Face. Импортированные модели остаются на этом устройстве и загружаются с помощью llama.cpp.';
1790 +
1791 + @override
1792 + String get gguf_imported_count_label => 'импортированный';
1793 +
1794 + @override
1795 + String get gguf_local_files_label => 'локальные файлы';
1796 +
1797 + @override
1798 + String get gguf_huggingface_label => 'Обнимающее лицо';
1799 +
1800 + @override
1801 + String get gguf_import_local_title => 'Импортировать локальный GGUF';
1802 +
1803 + @override
1804 + String get gguf_import_local_subtitle =>
1805 + 'Скопируйте файл .gguf с этого устройства.';
1806 +
1807 + @override
1808 + String get gguf_import_huggingface_title => 'Импорт из «Обнимающего лица»';
1809 +
1810 + @override
1811 + String get gguf_import_huggingface_subtitle =>
1812 + 'Вставьте URL-адрес GGUF или путь к репозиторию.';
1813 +
1814 + @override
1815 + String get gguf_no_imported_title => 'Импортированных моделей GGUF пока нет.';
1816 +
1817 + @override
1818 + String get gguf_no_imported_subtitle =>
1819 + 'Вы можете перенести свой собственный файл GGUF из хранилища устройства или вставить URL-адрес Hugging Face или путь к репозиторию, указывающий на файл .gguf.';
1820 +
1821 + @override
1822 + String get gguf_import_huggingface_dialog_title =>
1823 + 'Импортировать GGUF из Hugging Face';
1824 +
1825 + @override
1826 + String get gguf_import_huggingface_dialog_subtitle =>
1827 + 'Вставьте прямой URL-адрес GGUF или путь репозитория Hugging Face, например `owner/repo/blob/main/model.gguf`. Ссылки на большие двоичные объекты преобразуются автоматически.';
1828 +
1829 + @override
1830 + String get gguf_url_or_repo_path => 'URL-адрес GGUF или путь к репозиторию';
1831 +
1832 + @override
1833 + String get paste => 'Вставить';
1834 +
1835 + @override
1836 + String get gguf_browse => 'Просмотр GGUF';
1837 +
1838 + @override
1839 + String get gguf_huggingface_token_ready => 'Жетон «Обнимающее лицо» готов.';
1840 +
1841 + @override
1842 + String get gguf_huggingface_token_optional =>
1843 + 'Токен необязателен, но рекомендуется';
1844 +
1845 + @override
1846 + String get gguf_huggingface_token_ready_desc =>
1847 + 'Ваш сохраненный токен будет автоматически использоваться для закрытых или частных репозиториев.';
1848 +
1849 + @override
1850 + String get gguf_huggingface_token_optional_desc =>
1851 + 'Требуется жетон «Обнимающее лицо». Добавьте его в настройках, если этот GGUF является закрытым или частным.';
1852 +
1853 + @override
1854 + String get gguf_downloading => 'Загрузка GGUF';
1855 +
1856 + @override
1857 + String get gguf_preparing => 'Подготовка';
1858 +
1859 + @override
1860 + String get gguf_preparing_download => 'Подготовка загрузки...';
1861 +
1862 + @override
1863 + String get gguf_cancel_import => 'Отменить импорт';
1864 +
1865 + @override
1866 + String get clipboard_empty => 'Буфер обмена пуст.';
1867 +
1868 + @override
1869 + String get could_not_open_huggingface =>
1870 + 'Не удалось открыть «Обнимающее лицо».';
1871 +
1872 + @override
1873 + String get gguf_paste_url_error =>
1874 + 'Вставьте URL-адрес GGUF Hugging Face или путь к репозиторию.';
1875 +
1876 + @override
1877 + String get gguf_blob_link => 'Ссылка на BLOB-объект';
1878 +
1879 + @override
1880 + String get gguf_repository_label => 'Репозиторий';
1881 +
1882 + @override
1883 + String get gguf_detected_path_label => 'Обнаруженный путь';
1884 +
1885 + @override
1886 + String get gguf_imported_section_label => 'Импортированный GGUF';
1887 +
1888 + @override
1889 + String get gguf_already_available => 'Уже доступно на этом устройстве';
1890 +
1891 + @override
1892 + String get gguf_curated_models_short => 'Кураторские модели на устройстве';
1893 +
1894 + @override
1895 + String get execute_tool_title => 'Выполнить инструмент';
1896 +
1897 + @override
1898 + String get execute_tool_request_desc =>
1899 + 'Модель запрашивает выполнение следующего инструмента:';
1900 +
1901 + @override
1902 + String get reject => 'Отклонять';
1903 +
1904 + @override
1905 + String get approve => 'Утвердить';
1906 +
1907 + @override
1908 + String get server_type_help =>
1909 + 'Прежде чем заполнять данные подключения, выберите провайдера.';
1910 +
1911 + @override
1912 + String get server_identity_title => 'Личность';
1913 +
1914 + @override
1915 + String get server_identity_desc =>
1916 + 'Назовите этот сервер и выберите, как он будет отображаться в списке.';
1917 +
1918 + @override
1919 + String get server_connection_title => 'Связь';
1920 +
1921 + @override
1922 + String get server_connection_desc =>
1923 + 'Используйте адрес и порт, предоставленные вашим сервером.';
1924 +
1925 + @override
1926 + String get server_authentication_title => 'Аутентификация';
1927 +
1928 + @override
1929 + String get server_authentication_required_desc =>
1930 + 'OpenRouter требует ключ API перед тестированием.';
1931 +
1932 + @override
1933 + String get server_authentication_optional_desc =>
1934 + 'Оставьте ключ API пустым, если этот сервер не требует его.';
1935 +
1936 + @override
1937 + String get mcp_tools_title => 'Инструменты MCP';
1938 +
1939 + @override
1940 + String get available_tools => 'Доступные инструменты';
1941 +
1942 + @override
1943 + String get unable_load_tools => 'Невозможно загрузить инструменты';
1944 +
1945 + @override
1946 + String get no_tools_registered => 'Инструменты не зарегистрированы';
1947 +
1948 + @override
1949 + String get no_tools_registered_desc =>
1950 + 'Включите пример сервера MCP или добавьте интеграцию MCP из настроек чата.';
1951 +
1952 + @override
1953 + String get example_mcp_server_title => 'Пример MCP-сервера';
1954 +
1955 + @override
1956 + String get example_mcp_server_desc =>
1957 + 'Регистрирует example.echo и example.word_count через того же поставщика инструментов MCP, который используется внешними серверами.';
1958 +
1959 + @override
1960 + String get disable_example_server => 'Отключить пример сервера';
1961 +
1962 + @override
1963 + String get enable_example_server => 'Включить пример сервера';
1964 +
1965 + @override
1966 + String get built_in_label => 'Встроенный';
1967 +
1968 + @override
1969 + String get highlights_label => 'Основные моменты';
1970 +
1971 + @override
1972 + String get built_with_label => 'Построен с';
1973 +
1974 + @override
1975 + String get local_label => 'Местный';
1976 +
1977 + @override
1978 + String get gguf_format_label => 'ГГУФ';
1979 +
1980 + @override
1981 + String get tool_status_requested => 'Запрошено';
1982 +
1983 + @override
1984 + String get tool_status_approved => 'Одобренный';
1985 +
1986 + @override
1987 + String get tool_status_rejected => 'Отклоненный';
1988 +
1989 + @override
1990 + String get tool_status_running => 'Бег';
1991 +
1992 + @override
1993 + String get tool_status_done => 'Сделанный';
1994 +
1995 + @override
1996 + String get tool_status_failed => 'Неуспешный';
1997 +
1998 + @override
1999 + String get model_favorite_toggle => 'Toggle favorite';
2000 +
2001 + @override
2002 + String get model_note_label => 'Note';
2003 +
2004 + @override
2005 + String get model_note_hint => 'Add a note about this model…';
2006 +
2007 + @override
2008 + String get unload_models_before_load =>
2009 + 'Unload all models before loading a new one';
2010 +
2011 + @override
2012 + String get export_all_data => 'Export all data';
2013 +
2014 + @override
2015 + String get import_all_data => 'Import all data';
2016 +
2017 + @override
2018 + String get export_data_success => 'Backup exported successfully';
2019 +
2020 + @override
2021 + String get import_data_success => 'Backup imported successfully';
2022 +
2023 + @override
2024 + String import_data_failed(String error) {
2025 + return 'Failed to import backup: $error';
2026 + }
2027 +
2028 + @override
2029 + String get import_data_confirm =>
2030 + 'Import conversations and custom personas from this backup? Existing items with the same IDs will be updated.';
2031 +
2032 + @override
2033 + String get import_settings_confirm =>
2034 + 'Replace current settings with the imported backup?';
2035 +
2036 + @override
2037 + String get export_conversations => 'Export conversations';
2038 +
2039 + @override
2040 + String get import_conversations => 'Import conversations';
2041 +
2042 + @override
2043 + String get export_personas => 'Export personas';
2044 +
2045 + @override
2046 + String get import_personas => 'Import personas';
2047 +
2048 + @override
2049 + String get export_settings => 'Export settings';
2050 +
2051 + @override
2052 + String get import_settings => 'Import settings';
2053 +
2054 + @override
2055 + String get export_all_zip => 'Export all (ZIP)';
2056 +
2057 + @override
2058 + String get import_all_zip => 'Import all (ZIP)';
2059 +
2060 + @override
2061 + String get duplicate_chat => 'Duplicate chat';
2062 +
2063 + @override
2064 + String get duplicate_chat_success => 'Chat duplicated';
2065 +
2066 + @override
2067 + String get move_to_folder => 'Move to folder';
2068 +
2069 + @override
2070 + String get remove_from_folder => 'Remove from folder';
2071 +
2072 + @override
2073 + String get create_folder => 'Create folder';
2074 +
2075 + @override
2076 + String get new_folder => 'New folder';
2077 +
2078 + @override
2079 + String get folder_name_hint => 'Folder name';
2080 +
2081 + @override
2082 + String get all_chats => 'All';
2083 +
2084 + @override
2085 + String get unfiled_chats => 'Unfiled';
2086 +
2087 + @override
2088 + String get create => 'Create';
2089 +
2090 + @override
2091 + String get server_path_prefix_label => 'API path prefix';
2092 +
2093 + @override
2094 + String get server_path_prefix_hint => '/your-secret-token';
2095 +
2096 + @override
2097 + String get search_message_contents => 'Search message contents';
2098 +
2099 + @override
2100 + String get message_search_results => 'Message matches';
2101 +
2102 + @override
2103 + String get saved_messages_title => 'Saved Messages';
2104 +
2105 + @override
2106 + String get nav_saved_messages => 'Saved Messages';
2107 +
2108 + @override
2109 + String get saved_messages_empty =>
2110 + 'No saved messages yet. Bookmark a message from its options menu.';
2111 +
2112 + @override
2113 + String get save_message => 'Save message';
2114 +
2115 + @override
2116 + String get message_saved => 'Message saved';
2117 +
2118 + @override
2119 + String token_count(int count) {
2120 + return '$count tokens';
2121 + }
2122 +
2123 + @override
2124 + String estimated_token_count(int count) {
2125 + return '~$count tokens (estimated)';
2126 + }
2127 +
2128 + @override
2129 + String get test_tts_section_title => 'Test voice';
2130 +
2131 + @override
2132 + String get test_tts_hint => 'Enter text to hear the current TTS engine…';
2133 +
2134 + @override
2135 + String get test_speak_button => 'Speak';
2136 +
2137 + @override
2138 + String get scroll_to_bottom => 'Scroll to bottom';
2139 +
2140 + @override
2141 + String get generate_ai_response => 'Generate AI response';
2142 +
2143 + @override
2144 + String get no_response => 'No response';
2145 +
2146 + @override
2147 + String get export => 'Export';
2148 +
2149 + @override
2150 + String get import => 'Import';
2151 +
2152 + @override
2153 + String get conversations_label => 'Conversations';
2154 +
2155 + @override
2156 + String get personas_label => 'Personas';
2157 +
2158 + @override
2159 + String get settings_label => 'Settings';
2160 +
2161 + @override
2162 + String get export_conversation => 'Export conversation';
2163 +
2164 + @override
2165 + String get tts_process_markdown => 'Process markdown for speech';
2166 +
2167 + @override
2168 + String get tts_process_markdown_desc =>
2169 + 'Strip formatting like **bold** before reading aloud';
2170 +
2171 + @override
2172 + String get tts_skip_seconds => 'Skip interval';
2173 +
2174 + @override
2175 + String get tts_skip_seconds_desc =>
2176 + 'Forward and rewind jump size during playback';
2177 +
2178 + @override
2179 + String tts_skip_seconds_value(int seconds) {
2180 + return '${seconds}s';
2181 + }
2182 +
2183 + @override
2184 + String get preview_system_prompts => 'Preview system prompts';
2185 +
2186 + @override
2187 + String get welcome_message_1 => 'What can I help you with today?';
2188 +
2189 + @override
2190 + String get welcome_message_2 => 'Ask me anything — I\'m ready when you are.';
2191 +
2192 + @override
2193 + String get welcome_message_3 => 'Start a conversation below.';
2194 +
2195 + @override
2196 + String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2197 +
2198 + @override
2199 + String get temporary_chat => 'Temporary chat';
2200 +
2201 + @override
2202 + String get temporary_chat_desc => 'Chats aren\'t saved to history.';
2203 +
2204 + @override
2205 + String get temporary_chat_banner => 'Temporary chat — not saved to history';
2206 +
2207 + @override
2208 + String get temporary_chat_save_warning_title =>
2209 + 'Save message in temporary chat?';
2210 +
2211 + @override
2212 + String get temporary_chat_save_warning_body =>
2213 + 'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.';
2214 +
2215 + @override
2216 + String get save_to_history => 'Save to history';
2217 +
2218 + @override
2219 + String get share_conversation => 'Share conversation';
2220 +
2221 + @override
2222 + String get download_tts_audio => 'Download audio';
2223 +
2224 + @override
2225 + String get tts_download_unavailable =>
2226 + 'Download is only available for Piper and Kitten TTS';
2227 +
2228 + @override
2229 + String get tts_download_no_audio => 'No audio available to download yet';
2230 +
2231 + @override
2232 + String get tts_download_success => 'Audio saved';
2233 +
2234 + @override
2235 + String get return_to_chat => 'Return to chat';
2236 +
2237 + @override
2238 + String get return_to_temp_chat => 'Return to temporary chat';
2239 +
2240 + @override
2241 + String get insert_saved_message => 'Insert saved message';
2242 +
2243 + @override
2244 + String get insert_saved_message_desc =>
2245 + 'Choose a saved message to add to your input';
2246 +
2247 + @override
2248 + String get model_info => 'Model info';
2249 +
2250 + @override
2251 + String get model_name => 'Model name';
2252 +
2253 + @override
2254 + String get model_identifier => 'Identifier';
2255 +
2256 + @override
2257 + String get not_available => 'Not available';
2258 +
2259 + @override
2260 + String get save_message_folders => 'Save message';
2261 +
2262 + @override
2263 + String get remove_from_saved => 'Remove from saved';
2264 +
2265 + @override
2266 + String get message_already_saved => 'Saved';
2267 +
2268 + @override
2269 + String get stream_ttft => 'Time to first token';
2270 +
2271 + @override
2272 + String get stream_tokens_per_sec => 'Tokens per second';
2273 +
2274 + @override
2275 + String get stream_stop_reason => 'Stop reason';
2276 +
2277 + @override
2278 + String get stream_input_tokens => 'Input tokens';
2279 +
2280 + @override
2281 + String get stream_output_tokens => 'Output tokens';
2282 +
2283 + @override
2284 + String get stream_generation_time => 'Generation time';
2285 +
2286 + @override
2287 + String get attach_image => 'Image';
2288 +
2289 + @override
2290 + String get attach_text_document => 'Text document';
2291 +
2292 + @override
2293 + String get add_attachment => 'Add attachment';
2294 +
2295 + @override
2296 + String get photo_permission_denied =>
2297 + 'Photo access is required to attach images';
2298 +
2299 + @override
2300 + String get characters_label => 'Characters';
2301 +
2302 + @override
2303 + String get exit_temporary_chat_title => 'Выйти из временного чата?';
2304 +
2305 + @override
2306 + String get exit_temporary_chat_body =>
2307 + 'Текущий временный чат будет удалён, и вы вернётесь к новому чату.';
2308 +
2309 + @override
2310 + String get saved_message_temp_snap_unavailable =>
2311 + 'Это сообщение сохранено из временного чата и не может быть открыто в исходной беседе.';
2312 +
2313 + @override
2314 + String get filter_title => 'Фильтр';
2315 +
2316 + @override
2317 + String get filter_pinned => 'Закреплённые';
2318 +
2319 + @override
2320 + String get filter_archived => 'Архив';
2321 +
2322 + @override
2323 + String get filter_temp_chats => 'Временные чаты';
2324 +
2325 + @override
2326 + String get filter_user_messages => 'Сообщения пользователя';
2327 +
2328 + @override
2329 + String get filter_assistant_messages => 'Сообщения ассистента';
2330 +
2331 + @override
2332 + String get archive_chat => 'Архивировать';
2333 +
2334 + @override
2335 + String get unarchive_chat => 'Разархивировать';
2336 +
2337 + @override
2338 + String conversation_message_count(int count) {
2339 + String _temp0 = intl.Intl.pluralLogic(
2340 + count,
2341 + locale: localeName,
2342 + other: '$count сообщений',
2343 + many: '$count сообщений',
2344 + few: '$count сообщения',
2345 + one: '1 сообщение',
2346 + );
2347 + return '$_temp0';
2348 + }
2349 +
2350 + @override
2351 + String conversation_character_count(int count) {
2352 + return '$count симв.';
2353 + }
2354 +
2355 + @override
2356 + String get generate_title_with_ai => 'Сгенерировать с ИИ';
2357 +
2358 + @override
2359 + String get generating_title => 'Генерация...';
2360 +
2361 + @override
2362 + String get generate_title_failed => 'Не удалось сгенерировать заголовок';
2363 +}
lib/l10n/app_localizations_zh.dart
+176
@@ -2122,6 +2122,18 @@ class AppLocalizationsZh extends AppLocalizations {
2122 String get tts_process_markdown_desc =>
2123 'Strip formatting like **bold** before reading aloud';
2124
2125 + @override
2126 + String get tts_skip_seconds => 'Skip interval';
2127 +
2128 + @override
2129 + String get tts_skip_seconds_desc =>
2130 + 'Forward and rewind jump size during playback';
2131 +
2132 + @override
2133 + String tts_skip_seconds_value(int seconds) {
2134 + return '${seconds}s';
2135 + }
2136 +
2137 @override
2138 String get preview_system_prompts => 'Preview system prompts';
2139
@@ -2136,6 +2148,170 @@ class AppLocalizationsZh extends AppLocalizations {
2148
2149 @override
2150 String get welcome_message_4 => 'Need ideas? Try one of the quick prompts.';
2151 +
2152 + @override
2153 + String get temporary_chat => 'Temporary chat';
2154 +
2155 + @override
2156 + String get temporary_chat_desc => 'Chats aren\'t saved to history.';
2157 +
2158 + @override
2159 + String get temporary_chat_banner => 'Temporary chat — not saved to history';
2160 +
2161 + @override
2162 + String get temporary_chat_save_warning_title =>
2163 + 'Save message in temporary chat?';
2164 +
2165 + @override
2166 + String get temporary_chat_save_warning_body =>
2167 + 'This chat is temporary and hidden from history. The saved message will still appear in Saved Messages.';
2168 +
2169 + @override
2170 + String get save_to_history => 'Save to history';
2171 +
2172 + @override
2173 + String get share_conversation => 'Share conversation';
2174 +
2175 + @override
2176 + String get download_tts_audio => 'Download audio';
2177 +
2178 + @override
2179 + String get tts_download_unavailable =>
2180 + 'Download is only available for Piper and Kitten TTS';
2181 +
2182 + @override
2183 + String get tts_download_no_audio => 'No audio available to download yet';
2184 +
2185 + @override
2186 + String get tts_download_success => 'Audio saved';
2187 +
2188 + @override
2189 + String get return_to_chat => 'Return to chat';
2190 +
2191 + @override
2192 + String get return_to_temp_chat => 'Return to temporary chat';
2193 +
2194 + @override
2195 + String get insert_saved_message => 'Insert saved message';
2196 +
2197 + @override
2198 + String get insert_saved_message_desc =>
2199 + 'Choose a saved message to add to your input';
2200 +
2201 + @override
2202 + String get model_info => 'Model info';
2203 +
2204 + @override
2205 + String get model_name => 'Model name';
2206 +
2207 + @override
2208 + String get model_identifier => 'Identifier';
2209 +
2210 + @override
2211 + String get not_available => 'Not available';
2212 +
2213 + @override
2214 + String get save_message_folders => 'Save message';
2215 +
2216 + @override
2217 + String get remove_from_saved => 'Remove from saved';
2218 +
2219 + @override
2220 + String get message_already_saved => 'Saved';
2221 +
2222 + @override
2223 + String get stream_ttft => 'Time to first token';
2224 +
2225 + @override
2226 + String get stream_tokens_per_sec => 'Tokens per second';
2227 +
2228 + @override
2229 + String get stream_stop_reason => 'Stop reason';
2230 +
2231 + @override
2232 + String get stream_input_tokens => 'Input tokens';
2233 +
2234 + @override
2235 + String get stream_output_tokens => 'Output tokens';
2236 +
2237 + @override
2238 + String get stream_generation_time => 'Generation time';
2239 +
2240 + @override
2241 + String get attach_image => 'Image';
2242 +
2243 + @override
2244 + String get attach_text_document => 'Text document';
2245 +
2246 + @override
2247 + String get add_attachment => 'Add attachment';
2248 +
2249 + @override
2250 + String get photo_permission_denied =>
2251 + 'Photo access is required to attach images';
2252 +
2253 + @override
2254 + String get characters_label => 'Characters';
2255 +
2256 + @override
2257 + String get exit_temporary_chat_title => 'Exit temporary chat?';
2258 +
2259 + @override
2260 + String get exit_temporary_chat_body =>
2261 + 'This will discard the current temporary chat and return to a new chat.';
2262 +
2263 + @override
2264 + String get saved_message_temp_snap_unavailable =>
2265 + 'This message was saved from a temporary chat and can\'t be opened in its original conversation.';
2266 +
2267 + @override
2268 + String get filter_title => 'Filter';
2269 +
2270 + @override
2271 + String get filter_pinned => 'Pinned';
2272 +
2273 + @override
2274 + String get filter_archived => 'Archived';
2275 +
2276 + @override
2277 + String get filter_temp_chats => 'Temporary chats';
2278 +
2279 + @override
2280 + String get filter_user_messages => 'User messages';
2281 +
2282 + @override
2283 + String get filter_assistant_messages => 'Assistant messages';
2284 +
2285 + @override
2286 + String get archive_chat => 'Archive';
2287 +
2288 + @override
2289 + String get unarchive_chat => 'Unarchive';
2290 +
2291 + @override
2292 + String conversation_message_count(int count) {
2293 + String _temp0 = intl.Intl.pluralLogic(
2294 + count,
2295 + locale: localeName,
2296 + other: '$count messages',
2297 + one: '1 message',
2298 + );
2299 + return '$_temp0';
2300 + }
2301 +
2302 + @override
2303 + String conversation_character_count(int count) {
2304 + return '$count chars';
2305 + }
2306 +
2307 + @override
2308 + String get generate_title_with_ai => 'Generate with AI';
2309 +
2310 + @override
2311 + String get generating_title => 'Generating...';
2312 +
2313 + @override
2314 + String get generate_title_failed => 'Could not generate a title';
2315 }
2316
2317 /// The translations for Chinese, as used in Taiwan (`zh_TW`).
lib/l10n/app_ru.arb new
+619
@@ -0,0 +1,619 @@
1 +{
2 + "@@locale": "ru",
3 + "active": "Активный",
4 + "active_integrations": "Активная интеграция",
5 + "add_ephemeral_mcp": "Добавить эфемерный сервер MCP",
6 + "add_server": "Добавить сервер",
7 + "add_server_first": "Сначала добавьте сервер, чтобы увидеть доступные модели.",
8 + "add_server_title": "Добавить сервер",
9 + "advanced_settings": "Расширенные настройки",
10 + "all": "Все",
11 + "allow_notifications": "Разрешить уведомления",
12 + "always_available": "Всегда доступен",
13 + "api_key_format": "Ключи API OpenRouter начинаются с sk-",
14 + "api_key_hint_generic": "Для аутентифицированных серверов",
15 + "api_key_hint_openrouter": "ск-...",
16 + "api_key_optional": "Ключ API (необязательно)",
17 + "api_key_required": "API-ключ *",
18 + "api_key_required_openrouter": "Ключ API, необходимый для OpenRouter",
19 + "app_name": "LocalMind",
20 + "app_tagline": "Ваш ИИ. Ваше устройство. Ваши правила.",
21 + "app_version": "1.0.0",
22 + "attach_images_tooltip": "Прикрепите изображения или текст",
23 + "auto_execute_tools": "Инструменты автоматического выполнения",
24 + "auto_generate_titles": "Автоматическое создание заголовков",
25 + "available": "Доступный",
26 + "beta_label": "Бета",
27 + "experimental_label": "Экспериментальный",
28 + "available_models": "Доступные модели",
29 + "available_ram": "Доступная оперативная память",
30 + "backend_cpu_desc": "Работает на всех устройствах. Наиболее совместим.",
31 + "backend_gpu_desc": "Ускорение OpenCL. Быстрее на поддерживаемых устройствах.",
32 + "backend_ios_notice": "В iOS доступен только серверный процессор.",
33 + "backend_npu_desc": "Поставщик НПУ (Qualcomm/MediaTek). Самый быстрый вывод.",
34 + "builtin_badge": "Встроенный",
35 + "calculating": "Расчет...",
36 + "cancel": "Отмена",
37 + "cannot_undo": "Это действие невозможно отменить.",
38 + "category_label": "Категория",
39 + "change_persona": "Изменить личность",
40 + "character_count": "{length} символов",
41 + "character_count_max": "{currentLen}/4000",
42 + "chat_history_title": "История чата",
43 + "chat_input_hint": "Спроси что-нибудь",
44 + "chat_parameters_tooltip": "Параметры чата",
45 + "chat_settings_title": "Настройки чата",
46 + "chat_title": "LocalMind",
47 + "choose_theme": "Выбрать тему",
48 + "choose_theme_desc": "Персонализируйте внешний вид приложения. Вы всегда можете изменить это позже в настройках.",
49 + "clear": "Прозрачный",
50 + "clear_conversation": "Чистый разговор",
51 + "clear_conversation_body": "Это приведет к удалению всех сообщений в этом разговоре.",
52 + "clear_conversation_title": "Ясный разговор?",
53 + "clone_edit": "Клонировать и редактировать",
54 + "close": "Закрывать",
55 + "code_theme_dark": "Кодовая тема (темная)",
56 + "code_theme_desc": "Выберите тему подсветки синтаксиса для блоков кода.",
57 + "code_theme_light": "Тема кода (светлая)",
58 + "configure": "Настроить",
59 + "configuring_server": "Настройка сервера...",
60 + "confirm": "Подтверждать",
61 + "connection_error": "Ошибка подключения. Проверьте свой сервер.",
62 + "connection_failed": "Соединение не удалось. Проверьте свои настройки.",
63 + "connection_successful": "Подключение успешно!",
64 + "context_chip": "{ctx} ctx",
65 + "context_length": "Длина контекста",
66 + "context_length_desc": "Окно истории",
67 + "continue_action": "Продолжать",
68 + "conversation_date": "{month}/{day}/{year}",
69 + "conversation_days_ago": "{days}д назад",
70 + "conversation_hours_ago": "{hours}ч назад",
71 + "conversation_id": "Идентификатор беседы",
72 + "conversation_just_now": "Прямо сейчас",
73 + "conversation_minutes_ago": "{minutes} мин назад",
74 + "conversation_yesterday": "Вчера",
75 + "copied": "Скопировано!",
76 + "copied_markdown": "Скопировано как Markdown",
77 + "copied_to_clipboard": "Скопировано в буфер обмена",
78 + "copy": "Копировать",
79 + "copy_info": "Копировать информацию",
80 + "copy_markdown": "Скопировать как Markdown",
81 + "create_persona": "Создать Персону",
82 + "create_persona_button": "Создавать",
83 + "created_at": "Создано в",
84 + "debug_dialog_desc": "Диагностическая информация, помогающая выявить проблемы синхронизации.",
85 + "default_badge": "По умолчанию",
86 + "default_icon": "Значок по умолчанию",
87 + "delete": "Удалить",
88 + "delete_all_conversations": "Удалить все разговоры",
89 + "delete_conversation": "Удалить разговор?",
90 + "delete_conversation_body": "Вы уверены, что хотите удалить \"{title}\"? Это невозможно отменить.",
91 + "delete_conversation_title": "Удалить разговор?",
92 + "delete_message_title": "Удалить сообщение?",
93 + "delete_model_body": "Вы уверены, что хотите удалить {name}?",
94 + "delete_model_body_with_size": "Вы уверены, что хотите удалить {name}? Это освободит примерно {size} места.\n\nПри необходимости вы сможете загрузить эту модель позже.",
95 + "delete_model_title": "Удалить модель",
96 + "delete_persona_body": "Это невозможно отменить.",
97 + "delete_persona_title": "Удалить \"{name}\"?",
98 + "delete_server_body": "Вы уверены, что хотите удалить \"{name}\"? Это невозможно отменить.",
99 + "delete_server_title": "Удалить сервер",
100 + "delete_voice_body": "Вы уверены, что хотите удалить {name}? Это освободит примерно {size} места.\n\nПри необходимости вы сможете скачать этот голос позже.",
101 + "delete_voice_title": "Удалить голос",
102 + "description_hint": "Что делает эта личность...",
103 + "description_optional": "Описание (необязательно)",
104 + "device_memory": "Память устройства",
105 + "disconnected": "Отключился от сервера.",
106 + "done": "Сделанный",
107 + "download": "Скачать",
108 + "download_complete_body": "Модель {modelName} успешно загружена.",
109 + "download_complete_notification": "Загрузка завершена!",
110 + "download_failed": "Загрузка не удалась",
111 + "download_failed_body": "Не удалось загрузить {modelName}.",
112 + "download_failed_notification": "Загрузка не удалась: {error}",
113 + "download_model_desc": "Выберите модель для загрузки.\nОн будет работать локально на вашем устройстве.",
114 + "download_model_title": "Скачать модель",
115 + "download_notification_title": "Загрузка {modelName}...",
116 + "download_progress": "{percent}% • {speed}",
117 + "downloaded": "Скачано",
118 + "downloading_status": "Загрузка...",
119 + "edit": "Редактировать",
120 + "edit_message": "Редактировать сообщение",
121 + "edit_message_desc": "При сохранении ответ помощника ниже будет удален и восстановлен.",
122 + "edit_persona": "Изменить личность",
123 + "edit_server": "Редактировать сервер",
124 + "emoji_label": "Эмодзи",
125 + "enable": "Давать возможность",
126 + "enable_mcp": "Включить MCP",
127 + "enable_notifications": "Включить уведомления",
128 + "enable_notifications_desc": "Получайте уведомления, когда модели завершают загрузку.",
129 + "engine_name_kitten": "Котенок ТТС",
130 + "engine_name_sherpa": "Шерпа ОННКС ВИТС",
131 + "engine_name_system": "Система ТТС",
132 + "engine_spec": "{sizeMb} МБ · {ramMb} МБ ОЗУ · {voiceCount} голосов",
133 + "engine_tagline_kitten": "Высокоскоростная нейронная TTS",
134 + "engine_tagline_sherpa": "Оффлайн голоса Пайпер",
135 + "engine_tagline_system": "Встроенный движок устройства",
136 + "enter_new_title": "Введите новое название",
137 + "error": "Ошибка",
138 + "error_with_message": "Ошибка: {error}",
139 + "eta_label": "Оставшееся время: {eta}",
140 + "expected_messages": "Ожидаемые сообщения",
141 + "export_header": "*Экспортировано из LocalMind — {date}*",
142 + "export_label_assistant": "ПОМОЩНИК",
143 + "export_label_system": "СИСТЕМА",
144 + "export_label_tool": "ИНСТРУМЕНТ",
145 + "export_label_user": "ПОЛЬЗОВАТЕЛЬ",
146 + "export_role_assistant": "## 🤖 Помощник",
147 + "export_role_system": "## ⚙️ Система",
148 + "export_role_tool": "## 🔧 Инструмент",
149 + "export_role_user": "## 👤 Пользователь",
150 + "export_text_assistant": "[ПОМОЩНИК]",
151 + "export_text_system": "[СИСТЕМА]",
152 + "export_text_tool": "[ИНСТРУМЕНТ]",
153 + "export_text_user": "[ПОЛЬЗОВАТЕЛЬ]",
154 + "failed_load_models": "Не удалось загрузить модели.",
155 + "finish_setup": "Завершить настройку",
156 + "font_preview": "Быстрая бурая лиса перепрыгивает через ленивую собаку.",
157 + "font_size": "Размер шрифта",
158 + "font_size_desc": "Настройте размер текста в чате.",
159 + "haptic_feedback": "Тактильная обратная связь",
160 + "history_missing_desc": "Либо сообщения в этом чате были удалены, либо запись истории повреждена.",
161 + "history_missing_title": "История отсутствует",
162 + "host_label": "Хост/IP-адрес",
163 + "host_required": "Требуется хост",
164 + "host_valid": "Введите действительное имя хоста или IP-адрес.",
165 + "inference_backend": "Серверная часть вывода",
166 + "initializing": "Инициализация...",
167 + "initializing_services": "Инициализация служб...",
168 + "install": "Установить",
169 + "installed": "Установлено",
170 + "label_completed": "{label} завершено",
171 + "language_system_default": "Системные настройки по умолчанию",
172 + "last_error": "Последняя ошибка:",
173 + "load": "Нагрузка",
174 + "loaded_status": "Загружено",
175 + "loading": "Загрузка...",
176 + "loading_model": "Загрузка {modelId}...",
177 + "loading_model_error": "Ошибка: {error}",
178 + "manage_on_device_models": "Управление моделями на устройстве",
179 + "enable_smart_reply": "Умные ответы на устройстве",
180 + "manage_servers": "Управление серверами",
181 + "manage_tts_models": "Управление моделями TTS",
182 + "max_tokens": "Макс. жетонов",
183 + "max_tokens_desc": "Предел ответа",
184 + "may_be_large": "Может быть слишком большим для этого устройства",
185 + "mcp_disabled_warning": "MCP отключен глобально. Включите его в настройках, чтобы использовать эти функции.",
186 + "mcp_enable_chat": "Включите MCP для этого чата",
187 + "mcp_label_placeholder": "Этикетка",
188 + "mcp_tab": "МКП",
189 + "mcp_url_placeholder": "URL-адрес (https://...)",
190 + "memory_critical": "Критический",
191 + "memory_healthy": "Здоровый",
192 + "memory_low": "Низкий",
193 + "message_options": "Параметры сообщения",
194 + "model_deepseek": "DeepSeek R1 Дистилл Qwen 1.5B",
195 + "model_deepseek_desc": "Модель рассуждения и цепочки мыслей. Лучше всего подходит для логических задач.",
196 + "model_gemma": "Джемма 4 E2B Инструктирует",
197 + "model_gemma_desc": "Флагманская модель Google. Высочайшее качество, требует больше оперативной памяти.",
198 + "model_license_apache": "Апач-2.0",
199 + "model_license_mit": "Массачусетский технологический институт",
200 + "model_load_failed": "Не удалось загрузить модель: {error}",
201 + "model_loaded": "Модель загружена: {modelId} ({backend})",
202 + "model_qwen_25": "Квен 2.5 1.5B Инструктировать",
203 + "model_qwen_25_desc": "Сбалансированное качество и размер. Подходит для общего разговора.",
204 + "model_qwen_3": "Квен 3 0.6Б",
205 + "model_qwen_3_desc": "Самая маленькая модель чата общего назначения. Быстрые ответы, низкое использование памяти.",
206 + "model_unload_failed": "Не удалось выгрузить модель: {error}",
207 + "model_unloaded_ollama": "Выгрузка запрошена для {name}. Если Оллама доступен, модель выпускается немедленно.",
208 + "model_unloaded_success": "{name} успешно выгружено",
209 + "more": "Более",
210 + "my_persona_hint": "Моя Персона",
211 + "my_server_hint": "Мой сервер",
212 + "name_label": "Имя",
213 + "name_length_validation": "Имя должно содержать не более 50 символов.",
214 + "name_max_50": "Макс. 50 символов.",
215 + "name_required": "Требуется имя",
216 + "nav_history": "История",
217 + "nav_local_models": "Локальные модели",
218 + "nav_new_chat": "Новый чат",
219 + "nav_personas": "Персоны",
220 + "nav_servers": "Серверы",
221 + "nav_settings": "Настройки",
222 + "nav_tts": "Текст в речь",
223 + "new_chat_mcp_default": "Новый чат MCP по умолчанию",
224 + "no_conversations_yet": "Пока нет разговоров",
225 + "no_model_loaded": "Ни одна модель не загружена. Нажмите «Управление моделями на устройстве», чтобы скачать и загрузить модель.",
226 + "no_models_available": "Нет доступных моделей",
227 + "no_models_match": "Нет моделей, соответствующих запросу \"{searchQuery}\".",
228 + "no_personas_desc": "Создайте свою первую личность, чтобы настроить поведение ИИ.",
229 + "no_personas_found": "Персоны не найдены",
230 + "no_prompt_placeholder": "Пока нет подсказки...",
231 + "no_results_found": "Результаты не найдены",
232 + "no_server_connected": "Сервер не подключен",
233 + "no_server_selected": "Сервер не выбран",
234 + "no_servers_desc": "Добавьте свой первый сервер, чтобы начать общаться с моделями ИИ.",
235 + "no_servers_yet": "Серверов пока нет",
236 + "none": "Никто",
237 + "none_selected": "Ничего не выбрано",
238 + "not_downloaded": "Не загружено",
239 + "not_installed": "Не установлено",
240 + "not_now": "Не сейчас",
241 + "notification_benefit_background": "Статус фоновых задач",
242 + "notification_benefit_completions": "Завершения генерации",
243 + "notification_benefit_downloads": "Ход загрузки модели",
244 + "offline": "Оффлайн",
245 + "older_section": "СТАРШЕ",
246 + "on_device_android_only": "Вывод на устройстве в настоящее время доступен только на Android.",
247 + "on_device_models_title": "Модели на устройстве",
248 + "onboarding_choose_language": "Выберите язык",
249 + "onboarding_choose_language_desc": "Выберите предпочитаемый язык. Вы можете изменить это в любое время в настройках.",
250 + "onboarding_connect_desc": "Подключитесь к LM Studio, Ollama или\nOpenRouter для запуска вашего частного ИИ\nопыт.",
251 + "onboarding_connect_server": "Подключите свой\nСервер",
252 + "onboarding_localmind": "МЕСТНЫЙ РАЗУМ",
253 + "onboarding_welcome": "Добро пожаловать в LocalMind",
254 + "online": "Онлайн",
255 + "open_source": "Открытый исходный код",
256 + "open_source_desc": "LocalMind имеет открытый исходный код. Следите за нашим прогрессом или вносите свой вклад на GitHub.",
257 + "add_more": "Добавить еще",
258 + "on_github": "на GitHub",
259 + "could_not_open_github": "Не удалось открыть GitHub.",
260 + "openai_compatible_api": "OpenAI-совместимый API",
261 + "https_requires_ssl": "HTTPS требует SSL",
262 + "most_local_setups_use_http": "Большинство локальных установок используют http://",
263 + "options_tooltip": "Параметры",
264 + "parameters_tab": "Параметры",
265 + "pause": "Пауза",
266 + "paused_progress": "Приостановлено – {percent} %",
267 + "persona_builtin_section": "ВСТРОЕННЫЙ",
268 + "persona_category_coding": "Кодирование",
269 + "persona_category_creative": "Креатив",
270 + "persona_category_education": "Образование",
271 + "persona_category_general": "Общий",
272 + "persona_created": "Персона создана",
273 + "persona_my_section": "МОИ ПЕРСОНАЛЫ",
274 + "persona_updated": "Персона обновлена",
275 + "personas_title": "Персоны",
276 + "pin": "Приколоть",
277 + "pinned_section": "ПРИКРЕПЛЕНО",
278 + "port_invalid": "Должно быть число",
279 + "port_label": "Порт",
280 + "port_range": "Введите действительный порт (1-65535).",
281 + "port_required": "Требуется порт",
282 + "preparing_app": "Подготовка приложения...",
283 + "preview": "Предварительный просмотр",
284 + "preview_failed": "Не удалось выполнить предварительный просмотр: {error}",
285 + "previous_30_days": "ПРЕДЫДУЩИЕ 30 ДНЕЙ",
286 + "previous_7_days": "ПРЕДЫДУЩИЕ 7 ДНЕЙ",
287 + "privacy_info": "«LocalMind никогда не видит ваши данные»",
288 + "proceed_anyway": "Продолжить в любом случае",
289 + "processing": "Обработка...",
290 + "prompt_hint": "Вы полезный помощник...",
291 + "prompt_max_chars": "Макс. 4000 символов.",
292 + "prompt_required": "Требуется системное приглашение",
293 + "quick_async": "Как использовать async/await?",
294 + "quick_debug": "Отладьте это для меня",
295 + "quick_explain": "Объясните этот код",
296 + "quick_write": "Помогите мне написать функцию",
297 + "ram_min_required": "{fileSize} ГБ ОЗУ мин.",
298 + "ram_usage": "Использование оперативной памяти",
299 + "ram_used": "{percent} % использовано",
300 + "ram_warning": "Предупреждение об оперативной памяти",
301 + "ram_warning_body_download": "Для этой модели требуется не менее {ram} ГБ ОЗУ, но на вашем устройстве имеется {totalMemory}. Оно может работать неправильно или привести к сбою приложения.",
302 + "ram_warning_body_load": "На вашем устройстве доступно ОЗУ {availableRAM}, но для этой модели рекомендуется не менее {ram} ГБ. Загрузка может привести к сбою или вызвать нестабильность.",
303 + "range_0_1": "0,0-1,0",
304 + "range_0_2": "0,0-2,0",
305 + "read_aloud": "Читать вслух",
306 + "ready": "Готовый",
307 + "ready_continue": "ГОТОВЫ ПРОДОЛЖИТЬ",
308 + "recent_chats": "Недавние чаты",
309 + "recommended": "РЕКОМЕНДУЕТСЯ",
310 + "refresh_models": "Обновить модели",
311 + "remove_persona": "Удалить персону",
312 + "rename": "Переименовать",
313 + "rename_conversation": "Переименовать беседу",
314 + "reset_defaults": "Сбросить настройки по умолчанию",
315 + "reset_settings_defaults": "Сброс настроек к значениям по умолчанию",
316 + "resume": "Резюме",
317 + "retry": "Повторить попытку",
318 + "save": "Сохранять",
319 + "save_changes": "Сохранить изменения",
320 + "save_continue": "Сохранить и продолжить",
321 + "save_regenerate": "Сохранить и восстановить",
322 + "save_server": "Сохранить сервер",
323 + "search_hint": "Поиск разговоров...",
324 + "search_icons_hint": "Иконки поиска...",
325 + "search_models_hint": "Поиск моделей...",
326 + "see_all": "Посмотреть все",
327 + "select": "Выбирать",
328 + "select_icon": "Выберите значок",
329 + "select_icon_desc": "Выберите значок для своего сервера",
330 + "select_model": "Выберите модель",
331 + "select_model_hint": "Выберите модель, чтобы начать общение",
332 + "select_model_title": "Выберите модель",
333 + "select_persona": "Выберите Персону",
334 + "send_message_tooltip": "Отправить сообщение",
335 + "send_on_enter": "Отправить при вводе",
336 + "server_added": "Сервер добавлен",
337 + "server_address_format": "{host}:{port}",
338 + "server_address_on_device": "Локальный вывод",
339 + "server_address_openrouter": "openrouter.ai",
340 + "server_icon_ai_brain": "ИИ Мозг",
341 + "server_icon_ai_brain2": "ИИ Мозг 02",
342 + "server_icon_ai_chat": "AI-чат",
343 + "server_icon_ai_cloud": "Облако ИИ",
344 + "server_icon_ai_network": "Сеть искусственного интеллекта",
345 + "server_icon_alert": "Круг оповещения",
346 + "server_icon_api": "API",
347 + "server_icon_arrow_right": "Стрелка вправо 01",
348 + "server_icon_bot": "Бот",
349 + "server_icon_bot2": "Бот 02",
350 + "server_icon_cellular": "Сотовая сеть",
351 + "server_icon_check": "Проверить круг",
352 + "server_icon_chip": "Чип",
353 + "server_icon_chip2": "Чип 02",
354 + "server_icon_cloud": "Облако",
355 + "server_icon_cloud_download": "Облачная загрузка",
356 + "server_icon_cloud_server": "Облачный сервер",
357 + "server_icon_cloud_upload": "Загрузка в облако",
358 + "server_icon_code": "Код",
359 + "server_icon_computer": "Компьютер",
360 + "server_icon_cpu": "Процессор",
361 + "server_icon_database": "База данных",
362 + "server_icon_database1": "База данных 01",
363 + "server_icon_database2": "База данных 02",
364 + "server_icon_drive": "Водить машину",
365 + "server_icon_file1": "Файл 01",
366 + "server_icon_folder1": "Папка 01",
367 + "server_icon_folder2": "Папка 02",
368 + "server_icon_globe": "Глобус",
369 + "server_icon_hard_drive": "Жесткий диск",
370 + "server_icon_home1": "Дом 01",
371 + "server_icon_home2": "Дом 02",
372 + "server_icon_info": "Информационный круг",
373 + "server_icon_key": "Ключ 01",
374 + "server_icon_label": "Значок сервера",
375 + "server_icon_laptop": "Ноутбук",
376 + "server_icon_link": "Ссылка 01",
377 + "server_icon_lock": "Замок",
378 + "server_icon_mcp": "MCP-сервер",
379 + "server_icon_plug1": "Вилка 01",
380 + "server_icon_plug2": "Вилка 02",
381 + "server_icon_refresh": "Обновить",
382 + "server_icon_robotic": "Роботизированный",
383 + "server_icon_rocket": "Ракета",
384 + "server_icon_settings1": "Настройки 01",
385 + "server_icon_settings2": "Настройки 02",
386 + "server_icon_stack": "Серверный стек",
387 + "server_icon_stack2": "Серверный стек 02",
388 + "server_icon_stack3": "Серверный стек 03",
389 + "server_icon_star": "Звезда",
390 + "server_icon_terminal": "Компьютерный терминал",
391 + "server_icon_zap": "Зап",
392 + "server_name": "Имя сервера",
393 + "server_type_label": "Тип сервера",
394 + "server_type_lm_studio": "ЛМ Студия",
395 + "server_type_lm_studio_display": "ЛМ Студия",
396 + "server_type_ollama": "Оллама",
397 + "server_type_ollama_display": "Оллама",
398 + "server_type_on_device": "На устройстве",
399 + "server_type_on_device_display": "На устройстве",
400 + "server_type_openai_display": "Совместимость с OpenAI",
401 + "server_type_openrouter": "OpenRouter",
402 + "server_type_openrouter_display": "OpenRouter",
403 + "server_type_openrouter_sub": "ЕДИНОЕ ОБЛАКО",
404 + "server_updated": "Сервер обновлен",
405 + "servers_title": "Серверы",
406 + "set_as_default": "Установить по умолчанию",
407 + "set_persona": "Установить Персону",
408 + "settings_about": "О",
409 + "settings_appearance": "Появление",
410 + "settings_behavior": "Поведение",
411 + "settings_data_management": "Управление данными",
412 + "settings_default_persona": "Персона по умолчанию",
413 + "settings_default_server": "Сервер по умолчанию",
414 + "settings_language": "Язык",
415 + "settings_on_device": "Вывод на устройстве",
416 + "settings_privacy": "Конфиденциальность",
417 + "settings_title": "Настройки",
418 + "settings_tts": "Преобразование текста в речь",
419 + "setup_connection": "Настройка соединения",
420 + "setup_connection_desc": "Настройте свой сервер {server}, чтобы начать общение.",
421 + "share": "Делиться",
422 + "show_data_indicator": "Показать индикатор данных",
423 + "show_system_messages": "Показать системные сообщения",
424 + "skip": "Пропускать",
425 + "something_went_wrong": "Что-то пошло не так",
426 + "star_on_github": "Звезда на GitHub",
427 + "start_conversation": "Начать разговор",
428 + "start_new_chat": "Начать новый чат",
429 + "start_new_conversation": "Начать новый разговор",
430 + "startup_failed": "Запуск не удался",
431 + "stay_updated": "Оставайтесь в курсе",
432 + "stay_updated_desc": "Получайте уведомления, когда ваши модели ИИ завершают загрузку или завершаются длительные задачи.",
433 + "stop": "Останавливаться",
434 + "stop_generation_tooltip": "Остановить генерацию",
435 + "stop_reading": "Хватит читать",
436 + "streaming_responses": "Потоковая передача ответов",
437 + "switch_server": "Переключить сервер",
438 + "switch_server_subtitle": "Выберите сервер для подключения",
439 + "switched_to_server": "Переключено на {name}",
440 + "system_prompt": "Системная подсказка",
441 + "technical_details": "Технические детали",
442 + "temp_hint": "0,7",
443 + "temperature": "Температура",
444 + "temperature_desc": "Управляет случайностью: выше = креативно, ниже = сосредоточено.",
445 + "temperature_label": "Температура (0,0-2,0)",
446 + "test_connection": "Тестовое соединение",
447 + "test_notification_body": "Это тестовое уведомление о ходе загрузки модели.",
448 + "test_notification_title": "Уведомление о тестировании",
449 + "testing": "Тестирование...",
450 + "theme": "Тема",
451 + "theme_card_claude": "Клод",
452 + "theme_card_claude_sub": "Теплая тема персикового оттенка.",
453 + "theme_card_dark": "Темный",
454 + "theme_card_dark_sub": "Легко для глаз",
455 + "theme_card_light": "Свет",
456 + "theme_card_light_sub": "Чистый и светлый",
457 + "theme_card_system": "Система",
458 + "theme_card_system_sub": "Соответствует настройкам вашего устройства",
459 + "theme_claude": "Клод",
460 + "theme_dark": "Темный",
461 + "theme_light": "Свет",
462 + "theme_system": "Система",
463 + "thinking": "мышление",
464 + "today_section": "СЕГОДНЯ",
465 + "tool_label": "Инструмент: {toolCallId}",
466 + "tool_unknown": "Инструмент: Неизвестно",
467 + "top_p": "Топ П",
468 + "top_p_desc": "Порог выборки ядра",
469 + "top_p_hint": "0,9",
470 + "top_p_label": "Топ П (0,0-1,0)",
471 + "total_capacity": "Общая емкость",
472 + "total_ram": "Общий объем оперативной памяти",
473 + "try_different_search": "Попробуйте другой поисковый запрос",
474 + "tts_engine": "Двигатель ТТС",
475 + "tts_engine_kitten": "Котенок ТТС",
476 + "tts_engine_system": "Система ТТС",
477 + "tts_kitten_desc": "Молниеносный нейронный TTS с 8 выразительными голосами.\nТребуется загрузка {size}.",
478 + "tts_models_title": "Модели преобразования текста в речь",
479 + "tts_piper_desc": "Быстрые офлайн-голоса Пайпер с двумя выразительными голосами.\nТребуется загрузка {size} на голос.",
480 + "tts_speed": "Скорость TTS",
481 + "tts_speed_desc": "Отрегулируйте скорость воспроизведения.",
482 + "tts_system_desc": "Использует встроенный в ваше устройство механизм преобразования текста в речь.\nНикаких загрузок не требуется. Выбор голоса использует системные настройки вашего устройства.",
483 + "unknown_error": "Неизвестная ошибка",
484 + "unload": "Разгрузить",
485 + "unload_from_server": "Выгрузить с сервера",
486 + "unpin": "Открепить",
487 + "update_server": "Обновление сервера",
488 + "voice": "Голос",
489 + "voice_bella": "Белла",
490 + "voice_bruno": "Бруно",
491 + "voice_female": "Женский",
492 + "voice_hugo": "Хьюго",
493 + "voice_jasper": "Джаспер",
494 + "voice_kiki": "Кики",
495 + "voice_leo": "Лео",
496 + "voice_lessac": "Лессак (США)",
497 + "voice_luna": "Луна",
498 + "voice_male": "Мужской",
499 + "voice_other": "Другой",
500 + "voice_rosie": "Рози",
501 + "voice_ryan": "Райан (США)",
502 + "waiting_selection": "ОЖИДАНИЕ ВЫБОРА",
503 + "yesterday_section": "ВЧЕРА",
504 + "tts_supports_background": "Поддерживает фоновое воспроизведение как собственный звук",
505 + "tts_other_services_background_note": "Примечание. Другие службы TTS поддерживают фоновое воспроизведение как собственный звук.",
506 + "start_listening_tooltip": "Начать слушать",
507 + "stop_listening_tooltip": "Хватит слушать",
508 + "settings_huggingface_token": "Жетон «Обнимающее лицо» (необязательно)",
509 + "settings_huggingface_token_desc": "Требуется только для закрытых моделей (например, Gemma). Получите токен на сайтеhuggingface.co/settings/tokens.",
510 + "settings_huggingface_token_set": "Токен сохранен.",
511 + "settings_huggingface_token_cleared": "Токен очищен",
512 + "model_requires_huggingface_token": "Требуется жетон «Обнимающее лицо».",
513 + "model_missing_huggingface_token": "Данная модель изготовлена ​​на Hugging Face. Добавьте токен в «Настройки» → «Вывод на устройстве», чтобы загрузить его.",
514 + "set_huggingface_token": "Установить токен",
515 + "clear_huggingface_token": "Прозрачный",
516 + "edit_huggingface_token_dialog_title": "Токен доступа «Обнимающее лицо»",
517 + "huggingface_token_dialog_hint": "хф_…",
518 + "server_type_ollama_desc": "Локальный ИИ-движок. Ключ API не требуется.",
519 + "server_type_on_device_desc": "Работает на вашем телефоне. Некоторым моделям нужен жетон «Обнимающее лицо».",
520 + "server_type_lm_studio_desc": "Локальный API-сервер. Ключ API не требуется.",
521 + "gguf_imported_models_title": "Импортированные модели GGUF",
522 + "gguf_imported_models_empty_subtitle": "Импортируйте GGUF со своего устройства или добавьте его из Hugging Face. Импортированные модели запускаются локально с помощью llama.cpp.",
523 + "gguf_imported_models_ready": "импортированные модели готовы для локального вывода.",
524 + "gguf_curated_models_subtitle": "Рекомендуемые модели на устройстве, которые вы можете загружать и управлять ими внутри LocalMind.",
525 + "gguf_only_supported": "Для этого импорта поддерживаются только модели GGUF.",
526 + "gguf_imported_from_local_file": "импортировано из локального файла.",
527 + "gguf_import_failed": "Не удалось импортировать модель GGUF.",
528 + "gguf_imported_from_huggingface": "импортировано из Hugging Face.",
529 + "gguf_import_canceled": "Импорт GGUF отменен.",
530 + "gguf_enter_huggingface_url": "Введите URL-адрес GGUF «Обнимающее лицо».",
531 + "gguf_only_official_huggingface_urls": "Поддерживаются только официальные URL-адреса Hugging Face GGUF.",
532 + "gguf_use_https_url": "Используйте URL-адрес обнимающего лица HTTPS для импорта GGUF.",
533 + "gguf_url_must_point_to_file": "URL-адрес «Обнимающего лица» должен указывать непосредственно на файл .gguf.",
534 + "gguf_unable_to_detect_file_name": "Невозможно определить имя файла GGUF.",
535 + "gguf_download_empty": "Загруженный файл GGUF был пуст или отсутствовал.",
536 + "gguf_selected_file_missing": "Выбранный файл модели не существует.",
537 + "gguf_import_action": "Импортировать GGUF",
538 + "gguf_overview_title": "Принесите свои собственные модели GGUF",
539 + "gguf_overview_subtitle": "Импортируйте .gguf из локального хранилища или загрузите его прямо с Hugging Face. Импортированные модели остаются на этом устройстве и загружаются с помощью llama.cpp.",
540 + "gguf_imported_count_label": "импортированный",
541 + "gguf_local_files_label": "локальные файлы",
542 + "gguf_huggingface_label": "Обнимающее лицо",
543 + "gguf_import_local_title": "Импортировать локальный GGUF",
544 + "gguf_import_local_subtitle": "Скопируйте файл .gguf с этого устройства.",
545 + "gguf_import_huggingface_title": "Импорт из «Обнимающего лица»",
546 + "gguf_import_huggingface_subtitle": "Вставьте URL-адрес GGUF или путь к репозиторию.",
547 + "gguf_no_imported_title": "Импортированных моделей GGUF пока нет.",
548 + "gguf_no_imported_subtitle": "Вы можете перенести свой собственный файл GGUF из хранилища устройства или вставить URL-адрес Hugging Face или путь к репозиторию, указывающий на файл .gguf.",
549 + "gguf_import_huggingface_dialog_title": "Импортировать GGUF из Hugging Face",
550 + "gguf_import_huggingface_dialog_subtitle": "Вставьте прямой URL-адрес GGUF или путь репозитория Hugging Face, например `owner/repo/blob/main/model.gguf`. Ссылки на большие двоичные объекты преобразуются автоматически.",
551 + "gguf_url_or_repo_path": "URL-адрес GGUF или путь к репозиторию",
552 + "paste": "Вставить",
553 + "gguf_browse": "Просмотр GGUF",
554 + "gguf_huggingface_token_ready": "Жетон «Обнимающее лицо» готов.",
555 + "gguf_huggingface_token_optional": "Токен необязателен, но рекомендуется",
556 + "gguf_huggingface_token_ready_desc": "Ваш сохраненный токен будет автоматически использоваться для закрытых или частных репозиториев.",
557 + "gguf_huggingface_token_optional_desc": "Требуется жетон «Обнимающее лицо». Добавьте его в настройках, если этот GGUF является закрытым или частным.",
558 + "gguf_downloading": "Загрузка GGUF",
559 + "gguf_preparing": "Подготовка",
560 + "gguf_preparing_download": "Подготовка загрузки...",
561 + "gguf_cancel_import": "Отменить импорт",
562 + "clipboard_empty": "Буфер обмена пуст.",
563 + "could_not_open_huggingface": "Не удалось открыть «Обнимающее лицо».",
564 + "gguf_paste_url_error": "Вставьте URL-адрес GGUF Hugging Face или путь к репозиторию.",
565 + "gguf_blob_link": "Ссылка на BLOB-объект",
566 + "gguf_repository_label": "Репозиторий",
567 + "gguf_detected_path_label": "Обнаруженный путь",
568 + "gguf_imported_section_label": "Импортированный GGUF",
569 + "gguf_already_available": "Уже доступно на этом устройстве",
570 + "gguf_curated_models_short": "Кураторские модели на устройстве",
571 + "execute_tool_title": "Выполнить инструмент",
572 + "execute_tool_request_desc": "Модель запрашивает выполнение следующего инструмента:",
573 + "reject": "Отклонять",
574 + "approve": "Утвердить",
575 + "server_type_help": "Прежде чем заполнять данные подключения, выберите провайдера.",
576 + "server_identity_title": "Личность",
577 + "server_identity_desc": "Назовите этот сервер и выберите, как он будет отображаться в списке.",
578 + "server_connection_title": "Связь",
579 + "server_connection_desc": "Используйте адрес и порт, предоставленные вашим сервером.",
580 + "server_authentication_title": "Аутентификация",
581 + "server_authentication_required_desc": "OpenRouter требует ключ API перед тестированием.",
582 + "server_authentication_optional_desc": "Оставьте ключ API пустым, если этот сервер не требует его.",
583 + "mcp_tools_title": "Инструменты MCP",
584 + "available_tools": "Доступные инструменты",
585 + "unable_load_tools": "Невозможно загрузить инструменты",
586 + "no_tools_registered": "Инструменты не зарегистрированы",
587 + "no_tools_registered_desc": "Включите пример сервера MCP или добавьте интеграцию MCP из настроек чата.",
588 + "example_mcp_server_title": "Пример MCP-сервера",
589 + "example_mcp_server_desc": "Регистрирует example.echo и example.word_count через того же поставщика инструментов MCP, который используется внешними серверами.",
590 + "disable_example_server": "Отключить пример сервера",
591 + "enable_example_server": "Включить пример сервера",
592 + "built_in_label": "Встроенный",
593 + "highlights_label": "Основные моменты",
594 + "built_with_label": "Построен с",
595 + "local_label": "Местный",
596 + "gguf_format_label": "ГГУФ",
597 + "tool_status_requested": "Запрошено",
598 + "tool_status_approved": "Одобренный",
599 + "tool_status_rejected": "Отклоненный",
600 + "tool_status_running": "Бег",
601 + "tool_status_done": "Сделанный",
602 + "tool_status_failed": "Неуспешный",
603 + "exit_temporary_chat_title": "Выйти из временного чата?",
604 + "exit_temporary_chat_body": "Текущий временный чат будет удалён, и вы вернётесь к новому чату.",
605 + "saved_message_temp_snap_unavailable": "Это сообщение сохранено из временного чата и не может быть открыто в исходной беседе.",
606 + "filter_title": "Фильтр",
607 + "filter_pinned": "Закреплённые",
608 + "filter_archived": "Архив",
609 + "filter_temp_chats": "Временные чаты",
610 + "filter_user_messages": "Сообщения пользователя",
611 + "filter_assistant_messages": "Сообщения ассистента",
612 + "archive_chat": "Архивировать",
613 + "unarchive_chat": "Разархивировать",
614 + "conversation_message_count": "{count, plural, one{1 сообщение} few{{count} сообщения} many{{count} сообщений} other{{count} сообщений}}",
615 + "conversation_character_count": "{count} симв.",
616 + "generate_title_with_ai": "Сгенерировать с ИИ",
617 + "generating_title": "Генерация...",
618 + "generate_title_failed": "Не удалось сгенерировать заголовок"
619 +}
lib/objectbox-model.json
+37 -2
@@ -5,7 +5,7 @@
5 "entities": [
6 {
7 "id": "1:6060443518060866028",
8 - "lastPropertyId": "20:6746128880040296628",
8 + "lastPropertyId": "23:3122805731877931734",
9 "name": "ConversationEntity",
10 "properties": [
11 {
@@ -110,13 +110,28 @@
110 "id": "20:6746128880040296628",
111 "name": "folderId",
112 "type": 9
113 + },
114 + {
115 + "id": "21:4372897463643655414",
116 + "name": "isTemporary",
117 + "type": 1
118 + },
119 + {
120 + "id": "22:2980396196878246454",
121 + "name": "isArchived",
122 + "type": 1
123 + },
124 + {
125 + "id": "23:3122805731877931734",
126 + "name": "characterCount",
127 + "type": 6
128 }
129 ],
130 "relations": []
131 },
132 {
133 "id": "2:196881068437544233",
119 - "lastPropertyId": "24:5960797950716320436",
134 + "lastPropertyId": "28:8162956617579414275",
135 "name": "MessageEntity",
136 "properties": [
137 {
@@ -246,6 +261,26 @@
261 "id": "24:5960797950716320436",
262 "name": "parentMessageId",
263 "type": 9
264 + },
265 + {
266 + "id": "25:1552629421552033386",
267 + "name": "inputTokenCount",
268 + "type": 6
269 + },
270 + {
271 + "id": "26:1965067209419994290",
272 + "name": "tokensPerSecond",
273 + "type": 8
274 + },
275 + {
276 + "id": "27:7785830711210478899",
277 + "name": "ttftMs",
278 + "type": 6
279 + },
280 + {
281 + "id": "28:8162956617579414275",
282 + "name": "stopReason",
283 + "type": 9
284 }
285 ],
286 "relations": []
lib/objectbox.g.dart
+134 -4
@@ -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(20, 6746128880040296628),
25 + lastPropertyId: const obx_int.IdUid(23, 3122805731877931734),
26 flags: 0,
27 properties: <obx_int.ModelProperty>[
28 obx_int.ModelProperty(
@@ -146,6 +146,24 @@ final _entities = <obx_int.ModelEntity>[
146 type: 9,
147 flags: 0,
148 ),
149 + obx_int.ModelProperty(
150 + id: const obx_int.IdUid(21, 4372897463643655414),
151 + name: 'isTemporary',
152 + type: 1,
153 + flags: 0,
154 + ),
155 + obx_int.ModelProperty(
156 + id: const obx_int.IdUid(22, 2980396196878246454),
157 + name: 'isArchived',
158 + type: 1,
159 + flags: 0,
160 + ),
161 + obx_int.ModelProperty(
162 + id: const obx_int.IdUid(23, 3122805731877931734),
163 + name: 'characterCount',
164 + type: 6,
165 + flags: 0,
166 + ),
167 ],
168 relations: <obx_int.ModelRelation>[],
169 backlinks: <obx_int.ModelBacklink>[
@@ -159,7 +177,7 @@ final _entities = <obx_int.ModelEntity>[
177 obx_int.ModelEntity(
178 id: const obx_int.IdUid(2, 196881068437544233),
179 name: 'MessageEntity',
162 - lastPropertyId: const obx_int.IdUid(24, 5960797950716320436),
180 + lastPropertyId: const obx_int.IdUid(28, 8162956617579414275),
181 flags: 0,
182 properties: <obx_int.ModelProperty>[
183 obx_int.ModelProperty(
@@ -311,6 +329,30 @@ final _entities = <obx_int.ModelEntity>[
329 type: 9,
330 flags: 0,
331 ),
332 + obx_int.ModelProperty(
333 + id: const obx_int.IdUid(25, 1552629421552033386),
334 + name: 'inputTokenCount',
335 + type: 6,
336 + flags: 0,
337 + ),
338 + obx_int.ModelProperty(
339 + id: const obx_int.IdUid(26, 1965067209419994290),
340 + name: 'tokensPerSecond',
341 + type: 8,
342 + flags: 0,
343 + ),
344 + obx_int.ModelProperty(
345 + id: const obx_int.IdUid(27, 7785830711210478899),
346 + name: 'ttftMs',
347 + type: 6,
348 + flags: 0,
349 + ),
350 + obx_int.ModelProperty(
351 + id: const obx_int.IdUid(28, 8162956617579414275),
352 + name: 'stopReason',
353 + type: 9,
354 + flags: 0,
355 + ),
356 ],
357 relations: <obx_int.ModelRelation>[],
358 backlinks: <obx_int.ModelBacklink>[],
@@ -737,7 +779,7 @@ obx_int.ModelDefinition getObjectBoxModel() {
779 final folderIdOffset = object.folderId == null
780 ? null
781 : fbb.writeString(object.folderId!);
740 - fbb.startTable(21);
782 + fbb.startTable(24);
783 fbb.addInt64(0, object.internalId);
784 fbb.addOffset(1, idOffset);
785 fbb.addOffset(2, titleOffset);
@@ -758,6 +800,9 @@ obx_int.ModelDefinition getObjectBoxModel() {
800 fbb.addOffset(17, smartRepliesJsonOffset);
801 fbb.addOffset(18, smartRepliesLastMessageIdOffset);
802 fbb.addOffset(19, folderIdOffset);
803 + fbb.addBool(20, object.isTemporary);
804 + fbb.addBool(21, object.isArchived);
805 + fbb.addInt64(22, object.characterCount);
806 fbb.finish(fbb.endTable());
807 return object.internalId;
808 },
@@ -843,6 +888,24 @@ obx_int.ModelDefinition getObjectBoxModel() {
888 final folderIdParam = const fb.StringReader(
889 asciiOptimization: true,
890 ).vTableGetNullable(buffer, rootOffset, 42);
891 + final isTemporaryParam = const fb.BoolReader().vTableGet(
892 + buffer,
893 + rootOffset,
894 + 44,
895 + false,
896 + );
897 + final isArchivedParam = const fb.BoolReader().vTableGet(
898 + buffer,
899 + rootOffset,
900 + 46,
901 + false,
902 + );
903 + final characterCountParam = const fb.Int64Reader().vTableGet(
904 + buffer,
905 + rootOffset,
906 + 48,
907 + 0,
908 + );
909 final object = ConversationEntity(
910 internalId: internalIdParam,
911 id: idParam,
@@ -864,6 +927,9 @@ obx_int.ModelDefinition getObjectBoxModel() {
927 smartRepliesJson: smartRepliesJsonParam,
928 smartRepliesLastMessageId: smartRepliesLastMessageIdParam,
929 folderId: folderIdParam,
930 + isTemporary: isTemporaryParam,
931 + isArchived: isArchivedParam,
932 + characterCount: characterCountParam,
933 );
934 obx_int.InternalToManyAccess.setRelInfo<ConversationEntity>(
935 object.messages,
@@ -919,7 +985,10 @@ obx_int.ModelDefinition getObjectBoxModel() {
985 final parentMessageIdOffset = object.parentMessageId == null
986 ? null
987 : fbb.writeString(object.parentMessageId!);
922 - fbb.startTable(25);
988 + final stopReasonOffset = object.stopReason == null
989 + ? null
990 + : fbb.writeString(object.stopReason!);
991 + fbb.startTable(29);
992 fbb.addInt64(0, object.internalId);
993 fbb.addOffset(1, idOffset);
994 fbb.addInt64(2, object.conversation.targetId);
@@ -944,6 +1013,10 @@ obx_int.ModelDefinition getObjectBoxModel() {
1013 fbb.addInt64(21, object.threadOrder);
1014 fbb.addBool(22, object.isActiveVariant);
1015 fbb.addOffset(23, parentMessageIdOffset);
1016 + fbb.addInt64(24, object.inputTokenCount);
1017 + fbb.addFloat64(25, object.tokensPerSecond);
1018 + fbb.addInt64(26, object.ttftMs);
1019 + fbb.addOffset(27, stopReasonOffset);
1020 fbb.finish(fbb.endTable());
1021 return object.internalId;
1022 },
@@ -988,6 +1061,24 @@ obx_int.ModelDefinition getObjectBoxModel() {
1061 rootOffset,
1062 22,
1063 );
1064 + final inputTokenCountParam = const fb.Int64Reader().vTableGetNullable(
1065 + buffer,
1066 + rootOffset,
1067 + 52,
1068 + );
1069 + final tokensPerSecondParam = const fb.Float64Reader().vTableGetNullable(
1070 + buffer,
1071 + rootOffset,
1072 + 54,
1073 + );
1074 + final ttftMsParam = const fb.Int64Reader().vTableGetNullable(
1075 + buffer,
1076 + rootOffset,
1077 + 56,
1078 + );
1079 + final stopReasonParam = const fb.StringReader(
1080 + asciiOptimization: true,
1081 + ).vTableGetNullable(buffer, rootOffset, 58);
1082 final errorMessageParam = const fb.StringReader(
1083 asciiOptimization: true,
1084 ).vTableGetNullable(buffer, rootOffset, 24);
@@ -1054,6 +1145,10 @@ obx_int.ModelDefinition getObjectBoxModel() {
1145 statusIndex: statusIndexParam,
1146 modelId: modelIdParam,
1147 tokenCount: tokenCountParam,
1148 + inputTokenCount: inputTokenCountParam,
1149 + tokensPerSecond: tokensPerSecondParam,
1150 + ttftMs: ttftMsParam,
1151 + stopReason: stopReasonParam,
1152 errorMessage: errorMessageParam,
1153 attachmentPathsJson: attachmentPathsJsonParam,
1154 generationTimeMs: generationTimeMsParam,
@@ -1598,6 +1693,21 @@ class ConversationEntity_ {
1693 _entities[0].properties[19],
1694 );
1695
1696 + /// See [ConversationEntity.isTemporary].
1697 + static final isTemporary = obx.QueryBooleanProperty<ConversationEntity>(
1698 + _entities[0].properties[20],
1699 + );
1700 +
1701 + /// See [ConversationEntity.isArchived].
1702 + static final isArchived = obx.QueryBooleanProperty<ConversationEntity>(
1703 + _entities[0].properties[21],
1704 + );
1705 +
1706 + /// See [ConversationEntity.characterCount].
1707 + static final characterCount = obx.QueryIntegerProperty<ConversationEntity>(
1708 + _entities[0].properties[22],
1709 + );
1710 +
1711 /// see [ConversationEntity.messages]
1712 static final messages =
1713 obx.QueryBacklinkToMany<MessageEntity, ConversationEntity>(
@@ -1727,6 +1837,26 @@ class MessageEntity_ {
1837 static final parentMessageId = obx.QueryStringProperty<MessageEntity>(
1838 _entities[1].properties[23],
1839 );
1840 +
1841 + /// See [MessageEntity.inputTokenCount].
1842 + static final inputTokenCount = obx.QueryIntegerProperty<MessageEntity>(
1843 + _entities[1].properties[24],
1844 + );
1845 +
1846 + /// See [MessageEntity.tokensPerSecond].
1847 + static final tokensPerSecond = obx.QueryDoubleProperty<MessageEntity>(
1848 + _entities[1].properties[25],
1849 + );
1850 +
1851 + /// See [MessageEntity.ttftMs].
1852 + static final ttftMs = obx.QueryIntegerProperty<MessageEntity>(
1853 + _entities[1].properties[26],
1854 + );
1855 +
1856 + /// See [MessageEntity.stopReason].
1857 + static final stopReason = obx.QueryStringProperty<MessageEntity>(
1858 + _entities[1].properties[27],
1859 + );
1860 }
1861
1862 /// [PersonaEntity] entity fields to define ObjectBox queries.
pubspec.lock
+160
@@ -433,6 +433,38 @@ packages:
433 url: "https://pub.dev"
434 source: hosted
435 version: "12.0.0-beta.5"
436 + file_selector_linux:
437 + dependency: transitive
438 + description:
439 + name: file_selector_linux
440 + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0"
441 + url: "https://pub.dev"
442 + source: hosted
443 + version: "0.9.4"
444 + file_selector_macos:
445 + dependency: transitive
446 + description:
447 + name: file_selector_macos
448 + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a"
449 + url: "https://pub.dev"
450 + source: hosted
451 + version: "0.9.5"
452 + file_selector_platform_interface:
453 + dependency: transitive
454 + description:
455 + name: file_selector_platform_interface
456 + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85"
457 + url: "https://pub.dev"
458 + source: hosted
459 + version: "2.7.0"
460 + file_selector_windows:
461 + dependency: transitive
462 + description:
463 + name: file_selector_windows
464 + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd"
465 + url: "https://pub.dev"
466 + source: hosted
467 + version: "0.9.3+5"
468 fixnum:
469 dependency: transitive
470 description:
@@ -749,6 +781,70 @@ packages:
781 url: "https://pub.dev"
782 source: hosted
783 version: "1.1.7"
784 + image_picker:
785 + dependency: "direct main"
786 + description:
787 + name: image_picker
788 + sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac"
789 + url: "https://pub.dev"
790 + source: hosted
791 + version: "1.2.2"
792 + image_picker_android:
793 + dependency: transitive
794 + description:
795 + name: image_picker_android
796 + sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a"
797 + url: "https://pub.dev"
798 + source: hosted
799 + version: "0.8.13+19"
800 + image_picker_for_web:
801 + dependency: transitive
802 + description:
803 + name: image_picker_for_web
804 + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214"
805 + url: "https://pub.dev"
806 + source: hosted
807 + version: "3.1.1"
808 + image_picker_ios:
809 + dependency: transitive
810 + description:
811 + name: image_picker_ios
812 + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
813 + url: "https://pub.dev"
814 + source: hosted
815 + version: "0.8.13+6"
816 + image_picker_linux:
817 + dependency: transitive
818 + description:
819 + name: image_picker_linux
820 + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4"
821 + url: "https://pub.dev"
822 + source: hosted
823 + version: "0.2.2"
824 + image_picker_macos:
825 + dependency: transitive
826 + description:
827 + name: image_picker_macos
828 + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91"
829 + url: "https://pub.dev"
830 + source: hosted
831 + version: "0.2.2+1"
832 + image_picker_platform_interface:
833 + dependency: transitive
834 + description:
835 + name: image_picker_platform_interface
836 + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c"
837 + url: "https://pub.dev"
838 + source: hosted
839 + version: "2.11.1"
840 + image_picker_windows:
841 + dependency: transitive
842 + description:
843 + name: image_picker_windows
844 + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae
845 + url: "https://pub.dev"
846 + source: hosted
847 + version: "0.2.2"
848 in_app_review:
849 dependency: "direct main"
850 description:
@@ -1117,6 +1213,54 @@ packages:
1213 url: "https://pub.dev"
1214 source: hosted
1215 version: "2.3.0"
1216 + permission_handler:
1217 + dependency: "direct main"
1218 + description:
1219 + name: permission_handler
1220 + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
1221 + url: "https://pub.dev"
1222 + source: hosted
1223 + version: "11.4.0"
1224 + permission_handler_android:
1225 + dependency: transitive
1226 + description:
1227 + name: permission_handler_android
1228 + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
1229 + url: "https://pub.dev"
1230 + source: hosted
1231 + version: "12.1.0"
1232 + permission_handler_apple:
1233 + dependency: transitive
1234 + description:
1235 + name: permission_handler_apple
1236 + sha256: "79dfa1df734798aa3cfdad166d3a3698c206d8813de13516ea1071b5d7e2f420"
1237 + url: "https://pub.dev"
1238 + source: hosted
1239 + version: "9.4.10"
1240 + permission_handler_html:
1241 + dependency: transitive
1242 + description:
1243 + name: permission_handler_html
1244 + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
1245 + url: "https://pub.dev"
1246 + source: hosted
1247 + version: "0.1.3+5"
1248 + permission_handler_platform_interface:
1249 + dependency: transitive
1250 + description:
1251 + name: permission_handler_platform_interface
1252 + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
1253 + url: "https://pub.dev"
1254 + source: hosted
1255 + version: "4.3.0"
1256 + permission_handler_windows:
1257 + dependency: transitive
1258 + description:
1259 + name: permission_handler_windows
1260 + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
1261 + url: "https://pub.dev"
1262 + source: hosted
1263 + version: "0.2.1"
1264 petitparser:
1265 dependency: transitive
1266 description:
@@ -1229,6 +1373,22 @@ packages:
1373 url: "https://pub.dev"
1374 source: hosted
1375 version: "0.54.0"
1376 + share_plus:
1377 + dependency: "direct main"
1378 + description:
1379 + name: share_plus
1380 + sha256: "9eee8283462d91a7a1c8bdb67d08874abd75a2f8fae3bc0ca033035e375fb3d8"
1381 + url: "https://pub.dev"
1382 + source: hosted
1383 + version: "13.2.0"
1384 + share_plus_platform_interface:
1385 + dependency: transitive
1386 + description:
1387 + name: share_plus_platform_interface
1388 + sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9"
1389 + url: "https://pub.dev"
1390 + source: hosted
1391 + version: "7.1.0"
1392 shared_preferences:
1393 dependency: "direct main"
1394 description:
pubspec.yaml
+3
@@ -78,6 +78,9 @@ dependencies:
78 loading_animation_widget: ^1.3.0
79 gpt_markdown: ^1.1.7
80 speech_to_text: ^7.4.0
81 + share_plus: ^13.2.0
82 + image_picker: ^1.1.2
83 + permission_handler: ^11.4.0
84
85 dev_dependencies:
86 flutter_test: