11 files changed
+221
-88
android/app/build.gradle.kts
-1
@@ -3,7 +3,6 @@ import java.io.FileInputStream
3
4
plugins {
5
id("com.android.application")
6
- id("kotlin-android")
6
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
7
id("dev.flutter.flutter-gradle-plugin")
8
}
lib/core/services/crash_report_service.dart
+19
-3
@@ -1,9 +1,10 @@
1
import 'dart:async';
2
import 'dart:io' show Platform;
3
4
-import 'package:characters/characters.dart';
4
import 'package:device_info_plus/device_info_plus.dart';
5
import 'package:flutter/foundation.dart';
6
+import 'package:flutter/scheduler.dart';
7
+import 'package:flutter/widgets.dart';
8
import 'package:package_info_plus/package_info_plus.dart';
9
10
/// Immutable payload describing a captured crash.
@@ -254,14 +255,29 @@ class CrashReportService {
255
}
256
257
if (!isDuplicate) {
257
- _currentCrash.value = report;
258
+ _setCurrentCrash(report);
259
}
260
return report;
261
}
262
263
/// Clear the current crash so the app can re-render normally.
264
void clearCrash() {
264
- _currentCrash.value = null;
265
+ _setCurrentCrash(null);
266
+ }
267
+
268
+ void _setCurrentCrash(CrashReport? report) {
269
+ try {
270
+ final binding = WidgetsBinding.instance;
271
+ if (binding.schedulerPhase == SchedulerPhase.persistentCallbacks) {
272
+ scheduleMicrotask(() {
273
+ _currentCrash.value = report;
274
+ });
275
+ return;
276
+ }
277
+ } catch (_) {
278
+ // Binding is not initialized (e.g. non-UI unit tests).
279
+ }
280
+ _currentCrash.value = report;
281
}
282
283
/// Construct a prefilled "new issue" URL pointing at the GitHub repo.
lib/core/utils/bpe_decoder.dart
+27
-4
@@ -16,6 +16,7 @@ class SimpleStringSink implements Sink<String> {
16
class BpeDecoder {
17
late final ByteConversionSink _utf8Sink;
18
String _decoded = '';
19
+ bool _hasError = false;
20
21
BpeDecoder() {
22
_utf8Sink = utf8.decoder.startChunkedConversion(
@@ -27,6 +28,18 @@ class BpeDecoder {
28
29
/// Decodes a chunk of a BPE-encoded unicode string and returns the newly decoded text segment.
30
String decodeChunk(String bpeChunk) {
31
+ if (bpeChunk.isEmpty) return '';
32
+ if (_hasError) return bpeChunk;
33
+
34
+ // Check if bpeChunk contains characters with code point > 255 that are NOT in _bpeToByte.
35
+ // If so, the input is ALREADY a decoded standard string (e.g. Turkish 'İ' = 352).
36
+ for (var i = 0; i < bpeChunk.length; i++) {
37
+ final code = bpeChunk.codeUnitAt(i);
38
+ if (code > 255 && !_bpeToByte.containsKey(code)) {
39
+ return bpeChunk;
40
+ }
41
+ }
42
+
43
final bytes = <int>[];
44
for (var i = 0; i < bpeChunk.length; i++) {
45
final code = bpeChunk.codeUnitAt(i);
@@ -37,15 +50,25 @@ class BpeDecoder {
50
}
51
52
final previousLength = _decoded.length;
40
- _utf8Sink.add(bytes);
41
- return _decoded.substring(previousLength);
53
+ try {
54
+ _utf8Sink.add(bytes);
55
+ return _decoded.substring(previousLength);
56
+ } catch (_) {
57
+ _hasError = true;
58
+ return bpeChunk;
59
+ }
60
}
61
62
/// Flushes any remaining bytes in the decoder buffer and returns the remaining decoded text.
63
String flush() {
64
+ if (_hasError) return '';
65
final previousLength = _decoded.length;
47
- _utf8Sink.close();
48
- return _decoded.substring(previousLength);
66
+ try {
67
+ _utf8Sink.close();
68
+ return _decoded.substring(previousLength);
69
+ } catch (_) {
70
+ return '';
71
+ }
72
}
73
}
74
lib/core/widgets/crash_error_widget.dart
+22
-18
@@ -21,30 +21,34 @@ class CrashErrorWidget extends StatelessWidget {
21
final stackLines = crash.stackTrace.toString().split('\n');
22
final previewLines = stackLines.take(40).join('\n');
23
24
- return Material(
25
- color: theme.colorScheme.surface,
26
- child: SafeArea(
27
- child: Padding(
28
- padding: const EdgeInsets.all(20),
29
- child: Center(
24
+ return Scaffold(
25
+ backgroundColor: theme.colorScheme.surface,
26
+ body: SafeArea(
27
+ child: Center(
28
+ child: SingleChildScrollView(
29
+ physics: const BouncingScrollPhysics(),
30
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
31
child: ConstrainedBox(
32
constraints: const BoxConstraints(maxWidth: 560),
33
child: Column(
34
+ mainAxisSize: MainAxisSize.min,
35
mainAxisAlignment: MainAxisAlignment.center,
36
crossAxisAlignment: CrossAxisAlignment.stretch,
37
children: [
36
- Container(
37
- width: 72,
38
- height: 72,
39
- alignment: Alignment.center,
40
- decoration: BoxDecoration(
41
- color: theme.colorScheme.errorContainer,
42
- borderRadius: BorderRadius.circular(20),
43
- ),
44
- child: HugeIcon(
45
- icon: HugeIcons.strokeRoundedAlertCircle,
46
- size: 36,
47
- color: theme.colorScheme.onErrorContainer,
38
+ Center(
39
+ child: Container(
40
+ width: 72,
41
+ height: 72,
42
+ alignment: Alignment.center,
43
+ decoration: BoxDecoration(
44
+ color: theme.colorScheme.errorContainer,
45
+ borderRadius: BorderRadius.circular(20),
46
+ ),
47
+ child: HugeIcon(
48
+ icon: HugeIcons.strokeRoundedAlertCircle,
49
+ size: 36,
50
+ color: theme.colorScheme.onErrorContainer,
51
+ ),
52
),
53
),
54
const SizedBox(height: 16),
lib/features/chat/providers/chat_notifier.dart
+7
@@ -11,6 +11,7 @@ import 'package:localmind/core/providers/review_prompt_providers.dart';
11
import 'package:localmind/core/providers/service_providers.dart';
12
import 'package:localmind/core/providers/chat_background_service_provider.dart';
13
import 'package:localmind/core/providers/storage_providers.dart';
14
+import 'package:localmind/core/services/crash_report_service.dart';
15
import 'package:localmind/core/services/message_save_service.dart';
16
import 'package:localmind/core/storage/entities.dart';
17
import 'package:localmind/features/conversations/data/models/conversation.dart';
@@ -997,6 +998,12 @@ class ChatNotifier extends Notifier<ChatState> {
998
isProcessing: false,
999
);
1000
_latestStreamingMessage = streamingAssistantMessage;
1001
+ if (streamingAssistantMessage.errorMessage != null) {
1002
+ CrashReportService.instance.capture(
1003
+ Exception(streamingAssistantMessage.errorMessage!),
1004
+ StackTrace.current,
1005
+ );
1006
+ }
1007
1008
if (isCurrentContext) {
1009
_replaceMessageInState(
lib/features/on_device/data/on_device_chat_service.dart
+58
-35
@@ -4,6 +4,7 @@ import 'package:flutter_gemma/flutter_gemma.dart' as gemma;
4
5
import '../../../core/logger/app_logger.dart';
6
import '../../../core/models/enums.dart';
7
+import '../../../core/services/crash_report_service.dart';
8
import '../../../core/utils/bpe_decoder.dart';
9
import '../../chat/data/chat_service.dart';
10
import '../../chat/data/models/chat_parameters.dart';
@@ -157,43 +158,60 @@ class OnDeviceChatService implements ChatService {
158
(response) {
159
if (_isCancelled) return;
160
160
- if (response is gemma.TextResponse) {
161
- if (response.token.isNotEmpty) {
162
- final content = textDecoder != null
163
- ? textDecoder.decodeChunk(response.token)
164
- : response.token;
165
- if (content.isNotEmpty) {
166
- _streamController?.add(
167
- ChatResponse(
168
- type: ChatResponseType.message,
169
- content: content,
161
+ try {
162
+ if (response is gemma.TextResponse) {
163
+ if (response.token.isNotEmpty) {
164
+ final content = textDecoder != null
165
+ ? textDecoder.decodeChunk(response.token)
166
+ : response.token;
167
+ if (content.isNotEmpty) {
168
+ _streamController?.add(
169
+ ChatResponse(
170
+ type: ChatResponseType.message,
171
+ content: content,
172
+ ),
173
+ );
174
+ }
175
+ }
176
+ } else if (response is gemma.FunctionCallResponse) {
177
+ _streamController?.add(
178
+ ChatResponse(
179
+ type: ChatResponseType.toolCall,
180
+ toolCall: ToolCallData(
181
+ tool: response.name,
182
+ arguments: response.args,
183
),
171
- );
184
+ ),
185
+ );
186
+ } else if (response is gemma.ThinkingResponse) {
187
+ if (response.content.isNotEmpty) {
188
+ final reasoningContent = reasoningDecoder != null
189
+ ? reasoningDecoder.decodeChunk(response.content)
190
+ : response.content;
191
+ if (reasoningContent.isNotEmpty) {
192
+ _streamController?.add(
193
+ ChatResponse(
194
+ type: ChatResponseType.reasoning,
195
+ reasoningContent: reasoningContent,
196
+ ),
197
+ );
198
+ }
199
}
200
}
174
- } else if (response is gemma.FunctionCallResponse) {
175
- _streamController?.add(
176
- ChatResponse(
177
- type: ChatResponseType.toolCall,
178
- toolCall: ToolCallData(
179
- tool: response.name,
180
- arguments: response.args,
201
+ } catch (error, stackTrace) {
202
+ Log.error('OnDevice stream handling error: $error\n$stackTrace');
203
+ CrashReportService.instance.capture(error, stackTrace);
204
+ if (!_isCancelled && !(_streamController?.isClosed ?? true)) {
205
+ _streamController?.add(
206
+ ChatResponse(
207
+ type: ChatResponseType.error,
208
+ content: 'Inference error: ${error.toString()}',
209
),
182
- ),
183
- );
184
- } else if (response is gemma.ThinkingResponse) {
185
- if (response.content.isNotEmpty) {
186
- final reasoningContent = reasoningDecoder != null
187
- ? reasoningDecoder.decodeChunk(response.content)
188
- : response.content;
189
- if (reasoningContent.isNotEmpty) {
190
- _streamController?.add(
191
- ChatResponse(
192
- type: ChatResponseType.reasoning,
193
- reasoningContent: reasoningContent,
194
- ),
195
- );
196
- }
210
+ );
211
+ _streamController?.add(
212
+ const ChatResponse(type: ChatResponseType.done),
213
+ );
214
+ _streamController?.close();
215
}
216
}
217
},
@@ -225,8 +243,12 @@ class OnDeviceChatService implements ChatService {
243
_currentSubscription = null;
244
if (!completer.isCompleted) completer.complete();
245
},
228
- onError: (error) {
246
+ onError: (error, stackTrace) {
247
Log.error('OnDevice stream error: $error');
248
+ CrashReportService.instance.capture(
249
+ error,
250
+ stackTrace is StackTrace ? stackTrace : StackTrace.current,
251
+ );
252
if (!_isCancelled && !(_streamController?.isClosed ?? true)) {
253
_streamController?.add(
254
ChatResponse(
@@ -250,8 +272,9 @@ class OnDeviceChatService implements ChatService {
272
);
273
274
await completer.future;
253
- } catch (e) {
275
+ } catch (e, stackTrace) {
276
Log.error('OnDevice inference error: $e');
277
+ CrashReportService.instance.capture(e, stackTrace);
278
if (!(_streamController?.isClosed ?? true)) {
279
_streamController?.add(
280
ChatResponse(
lib/features/on_device/data/on_device_gemma_service.dart
-1
@@ -1,7 +1,6 @@
1
import 'dart:io';
2
3
import 'package:flutter_gemma/flutter_gemma.dart';
4
-import 'package:flutter_gemma/core/domain/model_source.dart';
4
import 'package:flutter_gemma_litertlm/flutter_gemma_litertlm.dart';
5
import 'package:path_provider/path_provider.dart';
6
lib/main.dart
+4
-1
@@ -68,7 +68,10 @@ class CrashFallbackApp extends StatelessWidget {
68
valueListenable: CrashReportService.instance.currentCrash,
69
builder: (context, crash, _) {
70
if (crash != null) {
71
- return CrashErrorWidget(crash: crash);
71
+ return MaterialApp(
72
+ debugShowCheckedModeBanner: false,
73
+ home: CrashErrorWidget(crash: crash),
74
+ );
75
}
76
return const BootstrapHost();
77
},
pubspec.lock
+54
-22
@@ -165,10 +165,10 @@ packages:
165
dependency: transitive
166
description:
167
name: background_downloader
168
- sha256: aceacec2b2a72ec3a8862ab5895fcbbc71ab33765f3619d57963f3110dd268e3
168
+ sha256: "2ea4d638b12690d58ba5f797453f9f9e322bc2848f1f38cfe67aa56ece1ad072"
169
url: "https://pub.dev"
170
source: hosted
171
- version: "9.5.5"
171
+ version: "9.5.7"
172
boolean_selector:
173
dependency: transitive
174
description:
@@ -285,10 +285,10 @@ packages:
285
dependency: transitive
286
description:
287
name: code_assets
288
- sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
288
+ sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
289
url: "https://pub.dev"
290
source: hosted
291
- version: "1.0.0"
291
+ version: "1.2.1"
292
collection:
293
dependency: transitive
294
description:
@@ -369,6 +369,14 @@ packages:
369
url: "https://pub.dev"
370
source: hosted
371
version: "0.7.14"
372
+ decimal:
373
+ dependency: transitive
374
+ description:
375
+ name: decimal
376
+ sha256: "2c3c8b74f2948066d3f42585477aec9cfc48fefd7a723a4d4274a6c71a5c0df7"
377
+ url: "https://pub.dev"
378
+ source: hosted
379
+ version: "3.2.6"
380
device_info_plus:
381
dependency: "direct main"
382
description:
@@ -409,6 +417,14 @@ packages:
417
url: "https://pub.dev"
418
source: hosted
419
version: "2.1.2"
420
+ email_validator:
421
+ dependency: transitive
422
+ description:
423
+ name: email_validator
424
+ sha256: b19aa5d92fdd76fbc65112060c94d45ba855105a28bb6e462de7ff03b12fa1fb
425
+ url: "https://pub.dev"
426
+ source: hosted
427
+ version: "3.0.0"
428
extended_image:
429
dependency: transitive
430
description:
@@ -538,18 +554,18 @@ packages:
554
dependency: "direct main"
555
description:
556
name: flutter_gemma
541
- sha256: a45e48e04193080ebe37fe65a9adb33d8473feceb8e1f51659f7bee8f8c95e04
557
+ sha256: "00093a86d841581f588b96b46994d33eb64213d47e9aa19489b72e4dde92692d"
558
url: "https://pub.dev"
559
source: hosted
544
- version: "1.0.0"
560
+ version: "1.5.2"
561
flutter_gemma_litertlm:
562
dependency: "direct main"
563
description:
564
name: flutter_gemma_litertlm
549
- sha256: "6b801403f1f682b699dbf31b485bddba2692296a894b7f23f1c78f171fc62fa3"
565
+ sha256: "34f5d0e83d9aaa9077f43d41a0d4f2c2f92a7c93e7b44db3967a5342640f812b"
566
url: "https://pub.dev"
567
source: hosted
552
- version: "1.0.0"
568
+ version: "1.3.1"
569
flutter_lints:
570
dependency: "direct dev"
571
description:
@@ -717,6 +733,14 @@ packages:
733
url: "https://pub.dev"
734
source: hosted
735
version: "4.0.0"
736
+ genai_primitives:
737
+ dependency: transitive
738
+ description:
739
+ name: genai_primitives
740
+ sha256: "5a1e8c7ae9caf01aa609f59c54de47b73f51bf1ae95e0f679b7c7ab6948c4d9d"
741
+ url: "https://pub.dev"
742
+ source: hosted
743
+ version: "0.2.4"
744
glob:
745
dependency: transitive
746
description:
@@ -769,10 +793,10 @@ packages:
793
dependency: transitive
794
description:
795
name: hooks
772
- sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
796
+ sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
797
url: "https://pub.dev"
798
source: hosted
775
- version: "1.0.3"
799
+ version: "2.0.2"
800
http:
801
dependency: transitive
802
description:
@@ -949,6 +973,14 @@ packages:
973
url: "https://pub.dev"
974
source: hosted
975
version: "4.12.0"
976
+ json_schema_builder:
977
+ dependency: transitive
978
+ description:
979
+ name: json_schema_builder
980
+ sha256: e46b1a2957590d2c811f47b22079710a273ebf9c8240a9e1440b759efb8ded5f
981
+ url: "https://pub.dev"
982
+ source: hosted
983
+ version: "0.1.6"
984
just_audio:
985
dependency: "direct main"
986
description:
@@ -1025,10 +1057,10 @@ packages:
1057
dependency: "direct main"
1058
description:
1059
name: llamadart
1028
- sha256: "814bbfc71e3629163372a62421e00b2ba695987fcd6ac811a075fe0fed00e524"
1060
+ sha256: c49692aff4b0bb2a402bdd3754a1ea021f3bd6892213949666b643e30d548058
1061
url: "https://pub.dev"
1062
source: hosted
1031
- version: "0.8.4"
1063
+ version: "0.8.18"
1064
llamadart_llama_cpp_flutter:
1065
dependency: "direct main"
1066
description:
@@ -1109,14 +1141,6 @@ packages:
1141
url: "https://pub.dev"
1142
source: hosted
1143
version: "3.1.0"
1112
- native_toolchain_c:
1113
- dependency: transitive
1114
- description:
1115
- name: native_toolchain_c
1116
- sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
1117
- url: "https://pub.dev"
1118
- source: hosted
1119
- version: "0.17.6"
1144
nested:
1145
dependency: transitive
1146
description:
@@ -1161,10 +1185,10 @@ packages:
1185
dependency: transitive
1186
description:
1187
name: objective_c
1164
- sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
1188
+ sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
1189
url: "https://pub.dev"
1190
source: hosted
1167
- version: "9.3.0"
1191
+ version: "9.5.0"
1192
os_detect:
1193
dependency: transitive
1194
description:
@@ -1381,6 +1405,14 @@ packages:
1405
url: "https://pub.dev"
1406
source: hosted
1407
version: "1.5.0"
1408
+ rational:
1409
+ dependency: transitive
1410
+ description:
1411
+ name: rational
1412
+ sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336
1413
+ url: "https://pub.dev"
1414
+ source: hosted
1415
+ version: "2.2.3"
1416
record_use:
1417
dependency: transitive
1418
description:
pubspec.yaml
+3
-3
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
16
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
# In Windows, build-name is used as the major, minor, and patch parts
18
# of the product and file versions while build-number is used as the build suffix.
19
-version: 1.6.4+69
19
+version: 1.6.5+70
20
21
environment:
22
sdk: ">=3.12.0 <4.0.0"
@@ -64,8 +64,8 @@ dependencies:
64
objectbox: ^5.3.1
65
objectbox_flutter_libs: ^5.3.1
66
path: ^1.9.1
67
- flutter_gemma: ^1.0.0
68
- flutter_gemma_litertlm: ^1.0.0
67
+ flutter_gemma: ^1.5.2
68
+ flutter_gemma_litertlm: ^1.3.1
69
llamadart: ^0.8.4
70
llamadart_llama_cpp_flutter: ^0.0.4
71
wakelock_plus: ^1.2.8
test/core/utils/bpe_decoder_test.dart
new
+27
@@ -0,0 +1,27 @@
1
+import 'package:flutter_test/flutter_test.dart';
2
+import 'package:localmind/core/utils/bpe_decoder.dart';
3
+
4
+void main() {
5
+ group('BpeDecoder', () {
6
+ test('handles standard UTF-8 decoded text with Turkish characters', () {
7
+ final decoder = BpeDecoder();
8
+ const input = 'İstersen, biraz Türkçe konuşalım!';
9
+ final result = decoder.decodeChunk(input);
10
+ expect(result, equals('İstersen, biraz Türkçe konuşalım!'));
11
+ });
12
+
13
+ test('handles empty chunks and flushes cleanly', () {
14
+ final decoder = BpeDecoder();
15
+ expect(decoder.decodeChunk(''), equals(''));
16
+ expect(decoder.flush(), equals(''));
17
+ });
18
+
19
+ test('handles chunked standard UTF-8 text', () {
20
+ final decoder = BpeDecoder();
21
+ final chunk1 = decoder.decodeChunk('İstersen, ');
22
+ final chunk2 = decoder.decodeChunk('biraz');
23
+ expect(chunk1, equals('İstersen, '));
24
+ expect(chunk2, equals('biraz'));
25
+ });
26
+ });
27
+}