@tduncan / Cortana / commits / a6708b4

refactor: upgrade flutter_gemma, improve BPE decoding robustness, and integrate crash reporting for streaming inference errors.

abdulmominsakib committed Aug 8, 2026 at 23:22 UTC a6708b4afeb47bd8007b8d14e8486c6ad18b778f
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 +}