19 files changed
+478
-174
android/app/src/main/AndroidManifest.xml
+10
@@ -1,6 +1,10 @@
1
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
2
xmlns:tools="http://schemas.android.com/tools">
3
<uses-permission android:name="android.permission.INTERNET" />
4
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
5
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
6
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
7
+ <uses-permission android:name="android.permission.WAKE_LOCK" />
8
9
<application
10
android:label="LocalMind"
@@ -34,6 +38,12 @@
38
<meta-data
39
android:name="flutterEmbedding"
40
android:value="2" />
41
+
42
+ <service
43
+ android:name=".ChatForegroundService"
44
+ android:enabled="true"
45
+ android:exported="false"
46
+ android:foregroundServiceType="dataSync" />
47
</application>
48
<!-- Required to query activities that can process text, see:
49
https://developer.android.com/training/package-visibility and
android/app/src/main/kotlin/pro/momin/localmind/ChatForegroundService.kt
new
+68
@@ -0,0 +1,68 @@
1
+package pro.momin.localmind
2
+
3
+import android.app.*
4
+import android.content.Context
5
+import android.content.Intent
6
+import android.os.Build
7
+import android.os.IBinder
8
+import androidx.core.app.NotificationCompat
9
+
10
+class ChatForegroundService : Service() {
11
+ companion object {
12
+ private const val CHANNEL_ID = "chat_generation_channel"
13
+ private const val NOTIFICATION_ID = 1001
14
+
15
+ fun startService(context: Context) {
16
+ val intent = Intent(context, ChatForegroundService::class.java)
17
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
18
+ context.startForegroundService(intent)
19
+ } else {
20
+ context.startService(intent)
21
+ }
22
+ }
23
+
24
+ fun stopService(context: Context) {
25
+ val intent = Intent(context, ChatForegroundService::class.java)
26
+ context.stopService(intent)
27
+ }
28
+ }
29
+
30
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
31
+ createNotificationChannel()
32
+
33
+ val notificationIntent = Intent(this, MainActivity::class.java)
34
+ val pendingIntent = PendingIntent.getActivity(
35
+ this, 0, notificationIntent,
36
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
37
+ )
38
+
39
+ val notification = NotificationCompat.Builder(this, CHANNEL_ID)
40
+ .setContentTitle("LocalMind")
41
+ .setContentText("Generating response…")
42
+ .setSmallIcon(android.R.drawable.stat_notify_chat)
43
+ .setOngoing(true)
44
+ .setContentIntent(pendingIntent)
45
+ .setPriority(NotificationCompat.PRIORITY_LOW)
46
+ .build()
47
+
48
+ startForeground(NOTIFICATION_ID, notification)
49
+
50
+ return START_STICKY
51
+ }
52
+
53
+ override fun onBind(intent: Intent?): IBinder? = null
54
+
55
+ private fun createNotificationChannel() {
56
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
57
+ val serviceChannel = NotificationChannel(
58
+ CHANNEL_ID,
59
+ "Chat Generation Service Channel",
60
+ NotificationManager.IMPORTANCE_LOW
61
+ ).apply {
62
+ description = "Shows a notification when AI is generating a response in the background"
63
+ }
64
+ val manager = getSystemService(NotificationManager::class.java)
65
+ manager.createNotificationChannel(serviceChannel)
66
+ }
67
+ }
68
+}
android/app/src/main/kotlin/pro/momin/localmind/MainActivity.kt
+25
-1
@@ -1,5 +1,29 @@
1
package pro.momin.localmind
2
3
+import androidx.annotation.NonNull
4
import io.flutter.embedding.android.FlutterActivity
5
+import io.flutter.embedding.engine.FlutterEngine
6
+import io.flutter.plugin.common.MethodChannel
7
5
-class MainActivity : FlutterActivity()
8
+class MainActivity : FlutterActivity() {
9
+ private val CHANNEL = "localmind/chat_background"
10
+
11
+ override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
12
+ super.configureFlutterEngine(flutterEngine)
13
+ MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
14
+ when (call.method) {
15
+ "startForeground" -> {
16
+ ChatForegroundService.startService(this)
17
+ result.success(null)
18
+ }
19
+ "stopForeground" -> {
20
+ ChatForegroundService.stopService(this)
21
+ result.success(null)
22
+ }
23
+ else -> {
24
+ result.notImplemented()
25
+ }
26
+ }
27
+ }
28
+ }
29
+}
ios/Runner/AppDelegate.swift
+43
-7
@@ -2,11 +2,47 @@ import Flutter
2
import UIKit
3
4
@main
5
-@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
6
- override func application(
7
- _ application: UIApplication,
8
- didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
9
- ) -> Bool {
10
- return super.application(application, didFinishLaunchingWithOptions: launchOptions)
11
- }
5
+@objc class AppDelegate: FlutterAppDelegate {
6
+ private var backgroundTask: UIBackgroundTaskIdentifier = .invalid
7
+
8
+ override func application(
9
+ _ application: UIApplication,
10
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
11
+ ) -> Bool {
12
+ let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
13
+ let channel = FlutterMethodChannel(name: "localmind/chat_background", binaryMessenger: controller.binaryMessenger)
14
+
15
+ channel.setMethodCallHandler({
16
+ (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
17
+ switch call.method {
18
+ case "startForeground":
19
+ // On iOS, we start a background task assertion
20
+ self.startBackgroundTask()
21
+ result(nil)
22
+ case "stopForeground":
23
+ // On iOS, we end the background task assertion
24
+ self.stopBackgroundTask()
25
+ result(nil)
26
+ default:
27
+ result(FlutterMethodNotImplemented)
28
+ }
29
+ })
30
+
31
+ GeneratedPluginRegistrant.register(with: self)
32
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
33
+ }
34
+
35
+ private func startBackgroundTask() {
36
+ stopBackgroundTask()
37
+ backgroundTask = UIApplication.shared.beginBackgroundTask { [weak self] in
38
+ self?.stopBackgroundTask()
39
+ }
40
+ }
41
+
42
+ private func stopBackgroundTask() {
43
+ if backgroundTask != .invalid {
44
+ UIApplication.shared.endBackgroundTask(backgroundTask)
45
+ backgroundTask = .invalid
46
+ }
47
+ }
48
}
ios/Runner/Info.plist
+1
@@ -77,6 +77,7 @@
77
<array>
78
<string>fetch</string>
79
<string>processing</string>
80
+ <string>audio</string>
81
</array>
82
</dict>
83
</plist>
lib/core/providers/chat_background_service_provider.dart
new
+6
@@ -0,0 +1,6 @@
1
+import 'package:flutter_riverpod/flutter_riverpod.dart';
2
+import '../services/chat_background_service.dart';
3
+
4
+final chatBackgroundServiceProvider = Provider<ChatBackgroundService>((ref) {
5
+ return ChatBackgroundService();
6
+});
lib/core/providers/highlighter_provider.dart
new
+40
@@ -0,0 +1,40 @@
1
+import 'package:flutter_riverpod/flutter_riverpod.dart';
2
+import 'package:syntax_highlight/syntax_highlight.dart';
3
+
4
+const _languages = [
5
+ 'css',
6
+ 'dart',
7
+ 'go',
8
+ 'html',
9
+ 'java',
10
+ 'javascript',
11
+ 'json',
12
+ 'kotlin',
13
+ 'python',
14
+ 'rust',
15
+ 'sql',
16
+ 'swift',
17
+ 'typescript',
18
+ 'yaml',
19
+];
20
+
21
+final highlighterThemesProvider = FutureProvider<HighlighterThemes>((ref) {
22
+ return HighlighterThemes.load();
23
+});
24
+
25
+class HighlighterThemes {
26
+ final HighlighterTheme light;
27
+ final HighlighterTheme dark;
28
+
29
+ HighlighterThemes({required this.light, required this.dark});
30
+
31
+ static Future<HighlighterThemes> load() async {
32
+ final light = await HighlighterTheme.loadLightTheme();
33
+ final dark = await HighlighterTheme.loadDarkTheme();
34
+ return HighlighterThemes(light: light, dark: dark);
35
+ }
36
+}
37
+
38
+Future<void> initializeHighlighter() async {
39
+ await Highlighter.initialize(_languages);
40
+}
lib/core/services/chat_background_service.dart
new
+32
@@ -0,0 +1,32 @@
1
+import 'package:flutter/services.dart';
2
+import 'package:wakelock_plus/wakelock_plus.dart';
3
+import '../logger/app_logger.dart';
4
+
5
+class ChatBackgroundService {
6
+ static const _channel = MethodChannel('localmind/chat_background');
7
+ bool _isActive = false;
8
+
9
+ Future<void> start() async {
10
+ if (_isActive) return;
11
+ try {
12
+ Log.info('Starting background chat service');
13
+ await _channel.invokeMethod('startForeground');
14
+ await WakelockPlus.enable();
15
+ _isActive = true;
16
+ } catch (e) {
17
+ Log.error('Failed to start background chat service: $e');
18
+ }
19
+ }
20
+
21
+ Future<void> stop() async {
22
+ if (!_isActive) return;
23
+ try {
24
+ Log.info('Stopping background chat service');
25
+ await _channel.invokeMethod('stopForeground');
26
+ await WakelockPlus.disable();
27
+ _isActive = false;
28
+ } catch (e) {
29
+ Log.error('Failed to stop background chat service: $e');
30
+ }
31
+ }
32
+}
lib/features/chat/providers/chat_providers.dart
+9
@@ -22,6 +22,7 @@ import '../../conversations/data/models/conversation.dart';
22
import '../data/chat_service.dart';
23
import '../data/models/chat_parameters.dart';
24
import '../data/models/message.dart';
25
+import '../../../core/providers/chat_background_service_provider.dart';
26
27
final selectedModelProvider =
28
NotifierProvider<SelectedModelNotifier, ModelInfo?>(() {
@@ -550,6 +551,9 @@ class ChatNotifier extends Notifier<ChatState> {
551
await _saveMessage(userMessage);
552
await _saveMessage(assistantMessage);
553
554
+ // Start background service to prevent suspension
555
+ ref.read(chatBackgroundServiceProvider).start();
556
+
557
_resetCheckpointMetrics();
558
_updateSavedMetrics(assistantMessage);
559
@@ -709,6 +713,7 @@ class ChatNotifier extends Notifier<ChatState> {
713
}
714
},
715
onDone: () async {
716
+ ref.read(chatBackgroundServiceProvider).stop();
717
final streamConvId = assistantMessage.conversationId;
718
final isCurrentContext = _currentConversationId == streamConvId;
719
final streamingMessage = streamingAssistantMessage;
@@ -811,6 +816,8 @@ class ChatNotifier extends Notifier<ChatState> {
816
if (errorMessage != null) {
817
await _saveMessage(errorMessage);
818
819
+ ref.read(chatBackgroundServiceProvider).stop();
820
+
821
final streamConvId = assistantMessage.conversationId;
822
if (_currentConversationId == streamConvId) {
823
final messageIndex = state.messages.indexWhere(
@@ -846,6 +853,7 @@ class ChatNotifier extends Notifier<ChatState> {
853
errorMessage: e.toString(),
854
clearStreaming: true,
855
);
856
+ ref.read(chatBackgroundServiceProvider).stop();
857
}
858
}
859
@@ -959,6 +967,7 @@ class ChatNotifier extends Notifier<ChatState> {
967
_streamSubscription?.cancel();
968
_streamSubscription = null;
969
ref.read(chatServiceProvider)?.cancelStream();
970
+ ref.read(chatBackgroundServiceProvider).stop();
971
972
_chunkCount = 0;
973
_lastCheckpointTime = null;
lib/features/chat/views/chat_screen.dart
+1
-5
@@ -230,11 +230,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
230
recentConversations: ref.watch(
231
conv.recentConversationsProvider,
232
),
233
- onSeeAll: () {
234
- if (Scaffold.maybeOf(context)?.hasDrawer ?? false) {
235
- Scaffold.of(context).openDrawer();
236
- }
237
- },
233
+ onSeeAll: () => context.push(AppRoutes.chatHistory),
234
selectedModel: selectedModel,
235
onModelTap: () => _showModelPicker(context),
236
selectedPersona: ref.watch(selectedPersonaProvider),
lib/features/chat/views/components/code_block.dart
+87
-80
@@ -1,9 +1,8 @@
1
import 'package:flutter/material.dart';
2
import 'package:flutter/services.dart';
3
import 'package:flutter_riverpod/flutter_riverpod.dart';
4
-import 'package:flutter_syntax_view/flutter_syntax_view.dart';
5
-import '../../../../core/providers/app_providers.dart';
6
-import '../../../settings/data/models/app_settings.dart';
4
+import 'package:syntax_highlight/syntax_highlight.dart';
5
+import '../../../../core/providers/highlighter_provider.dart';
6
7
class CodeBlock extends ConsumerWidget {
8
const CodeBlock({super.key, required this.code, this.language});
@@ -11,69 +10,61 @@ class CodeBlock extends ConsumerWidget {
10
final String code;
11
final String? language;
12
14
- Syntax? _mapLanguage(String? lang) {
15
- if (lang == null || lang.isEmpty) return null;
13
+ String _mapLanguage(String? lang) {
14
+ if (lang == null || lang.isEmpty) return '';
15
17
- switch (lang.toLowerCase()) {
16
+ final lower = lang.toLowerCase();
17
+ switch (lower) {
18
case 'dart':
19
case 'flutter':
20
- return Syntax.DART;
20
+ return 'dart';
21
case 'c':
22
- return Syntax.C;
22
+ return 'c';
23
case 'cpp':
24
case 'c++':
25
case 'cxx':
26
- return Syntax.CPP;
26
+ return 'cpp';
27
+ case 'csharp':
28
+ case 'cs':
29
+ return 'csharp';
30
+ case 'go':
31
+ case 'golang':
32
+ return 'go';
33
case 'java':
28
- return Syntax.JAVA;
34
+ return 'java';
35
case 'javascript':
36
case 'js':
31
- return Syntax.JAVASCRIPT;
37
+ return 'javascript';
38
+ case 'typescript':
39
+ case 'ts':
40
+ return 'typescript';
41
case 'kotlin':
42
case 'kt':
34
- return Syntax.KOTLIN;
43
+ return 'kotlin';
44
case 'lua':
36
- return Syntax.LUA;
45
+ return 'lua';
46
case 'python':
47
case 'py':
39
- return Syntax.PYTHON;
48
+ return 'python';
49
case 'rust':
50
case 'rs':
42
- return Syntax.RUST;
51
+ return 'rust';
52
case 'swift':
44
- return Syntax.SWIFT;
53
+ return 'swift';
54
case 'yaml':
55
case 'yml':
47
- return Syntax.YAML;
56
+ return 'yaml';
57
+ case 'html':
58
+ case 'htm':
59
+ return 'html';
60
+ case 'css':
61
+ return 'css';
62
+ case 'json':
63
+ return 'json';
64
+ case 'sql':
65
+ return 'sql';
66
default:
49
- return null;
50
- }
51
- }
52
-
53
- SyntaxTheme _getSyntaxTheme(SyntaxThemeName themeName) {
54
- switch (themeName) {
55
- case SyntaxThemeName.vscodeDark:
56
- return SyntaxTheme.vscodeDark();
57
- case SyntaxThemeName.vscodeLight:
58
- return SyntaxTheme.vscodeLight();
59
- case SyntaxThemeName.dracula:
60
- return SyntaxTheme.dracula();
61
- case SyntaxThemeName.monokaiSublime:
62
- return SyntaxTheme.monokaiSublime();
63
- case SyntaxThemeName.ayuLight:
64
- return SyntaxTheme.ayuLight();
65
- case SyntaxThemeName.ayuDark:
66
- return SyntaxTheme.ayuDark();
67
- case SyntaxThemeName.gravityLight:
68
- return SyntaxTheme.gravityLight();
69
- case SyntaxThemeName.gravityDark:
70
- return SyntaxTheme.gravityDark();
71
- case SyntaxThemeName.obsidian:
72
- return SyntaxTheme.obsidian();
73
- case SyntaxThemeName.oceanSunset:
74
- return SyntaxTheme.oceanSunset();
75
- case SyntaxThemeName.standard:
76
- return SyntaxTheme.standard();
67
+ return lower;
68
}
69
}
70
@@ -86,9 +77,9 @@ class CodeBlock extends ConsumerWidget {
77
Widget build(BuildContext context, WidgetRef ref) {
78
final theme = Theme.of(context);
79
final isDark = theme.brightness == Brightness.dark;
89
- final settings = ref.watch(settingsProvider);
90
- final themeName = isDark ? settings.codeThemeDark : settings.codeThemeLight;
91
- final codeTheme = _getSyntaxTheme(themeName);
80
+ final themes = ref.watch(highlighterThemesProvider);
81
+
82
+ final languageName = _mapLanguage(language);
83
84
final backgroundColor = isDark
85
? const Color(0xFF1E1E1E)
@@ -102,13 +93,6 @@ class CodeBlock extends ConsumerWidget {
93
? const Color(0xFF3A3A3A)
94
: const Color(0xFFE0E0E0);
95
105
- final textColor = isDark
106
- ? const Color(0xFF9CDCFE)
107
- : const Color(0xFF001080);
108
-
109
- final syntax = _mapLanguage(language);
110
- final syntaxTheme = codeTheme.copyWith(backgroundColor: Colors.transparent);
111
-
96
return Container(
97
decoration: BoxDecoration(
98
color: backgroundColor,
@@ -144,31 +128,54 @@ class CodeBlock extends ConsumerWidget {
128
],
129
),
130
),
147
- SingleChildScrollView(
148
- scrollDirection: Axis.horizontal,
149
- padding: const EdgeInsets.all(12),
150
- child: syntax != null
151
- ? SelectableText.rich(
152
- TextSpan(
153
- style: TextStyle(
154
- fontFamily: 'monospace',
155
- fontSize: 13,
156
- height: 1.5,
157
- ),
158
- children: <TextSpan>[
159
- getSyntax(syntax, syntaxTheme).format(code),
160
- ],
161
- ),
162
- )
163
- : SelectableText(
164
- code,
165
- style: TextStyle(
166
- fontFamily: 'monospace',
167
- fontSize: 13,
168
- height: 1.5,
169
- color: textColor,
170
- ),
171
- ),
131
+ themes.when(
132
+ data: (themesData) {
133
+ final loadedTheme = isDark ? themesData.dark : themesData.light;
134
+ final highlighter = Highlighter(
135
+ language: languageName.isNotEmpty ? languageName : 'dart',
136
+ theme: loadedTheme,
137
+ );
138
+ return SingleChildScrollView(
139
+ scrollDirection: Axis.horizontal,
140
+ padding: const EdgeInsets.all(12),
141
+ child: SelectableText.rich(
142
+ TextSpan(
143
+ style: const TextStyle(
144
+ fontFamily: 'monospace',
145
+ fontSize: 13,
146
+ height: 1.5,
147
+ ),
148
+ children: <TextSpan>[
149
+ highlighter.highlight(code),
150
+ ],
151
+ ),
152
+ ),
153
+ );
154
+ },
155
+ loading: () => SingleChildScrollView(
156
+ scrollDirection: Axis.horizontal,
157
+ padding: const EdgeInsets.all(12),
158
+ child: SelectableText(
159
+ code,
160
+ style: const TextStyle(
161
+ fontFamily: 'monospace',
162
+ fontSize: 13,
163
+ height: 1.5,
164
+ ),
165
+ ),
166
+ ),
167
+ error: (e, _) => SingleChildScrollView(
168
+ scrollDirection: Axis.horizontal,
169
+ padding: const EdgeInsets.all(12),
170
+ child: SelectableText(
171
+ code,
172
+ style: const TextStyle(
173
+ fontFamily: 'monospace',
174
+ fontSize: 13,
175
+ height: 1.5,
176
+ ),
177
+ ),
178
+ ),
179
),
180
],
181
),
lib/features/conversations/views/components/conversation_tile.dart
+16
@@ -1,4 +1,5 @@
1
import 'package:flutter/material.dart';
2
+import 'package:hugeicons/hugeicons.dart';
3
import '../../data/models/conversation.dart';
4
5
class ConversationTile extends StatelessWidget {
@@ -126,6 +127,21 @@ class ConversationTile extends StatelessWidget {
127
: const Color(0xFF999999),
128
),
129
),
130
+ const SizedBox(width: 8),
131
+ IconButton(
132
+ onPressed: () => _showContextMenu(context, isDark),
133
+ icon: HugeIcon(
134
+ icon: HugeIcons.strokeRoundedMoreVertical,
135
+ size: 18,
136
+ color: isDark
137
+ ? const Color(0xFF888888)
138
+ : const Color(0xFF666666),
139
+ ),
140
+ padding: EdgeInsets.zero,
141
+ constraints: const BoxConstraints(),
142
+ visualDensity: VisualDensity.compact,
143
+ tooltip: 'Options',
144
+ ),
145
],
146
),
147
),
lib/features/on_device/data/on_device_chat_service.dart
+86
-28
@@ -15,6 +15,7 @@ class OnDeviceChatService implements ChatService {
15
final OnDeviceEngineService _engineService;
16
StreamSubscription<LiteLmMessage>? _currentSubscription;
17
StreamController<ChatResponse>? _streamController;
18
+ LiteLmConversation? _activeConversation;
19
bool _isCancelled = false;
20
21
OnDeviceChatService(this._engineService);
@@ -28,6 +29,8 @@ class OnDeviceChatService implements ChatService {
29
List<McpIntegration>? integrations,
30
String? previousResponseId,
31
}) {
32
+ // Cancel any previous inference before starting a new one
33
+ cancelStream();
34
_isCancelled = false;
35
_streamController = StreamController<ChatResponse>();
36
@@ -68,10 +71,13 @@ class OnDeviceChatService implements ChatService {
71
samplerConfig: samplerConfig,
72
);
73
74
+ // Store a reference so cancelStream can dispose it
75
+ _activeConversation = conversation;
76
+
77
final liteMessages = _convertMessages(messages);
78
79
if (_isCancelled) {
74
- await conversation.dispose();
80
+ _disposeConversation(conversation);
81
_streamController?.add(const ChatResponse(type: ChatResponseType.done));
82
await _streamController?.close();
83
return;
@@ -87,6 +93,7 @@ class OnDeviceChatService implements ChatService {
93
content: 'No user message found',
94
),
95
);
96
+ _disposeConversation(conversation);
97
_streamController?.add(const ChatResponse(type: ChatResponseType.done));
98
await _streamController?.close();
99
return;
@@ -94,39 +101,71 @@ class OnDeviceChatService implements ChatService {
101
102
final stream = conversation.sendMessageStream(lastUserMessage.text ?? '');
103
97
- final buffer = StringBuffer();
98
- await for (final delta in stream) {
99
- if (_isCancelled) {
100
- await conversation.dispose();
101
- _streamController?.add(
102
- const ChatResponse(type: ChatResponseType.done),
103
- );
104
- await _streamController?.close();
105
- return;
106
- }
107
-
108
- if (delta.text.isNotEmpty) {
109
- buffer.write(delta.text);
110
- _streamController?.add(
111
- ChatResponse(type: ChatResponseType.message, content: delta.text),
112
- );
113
- }
114
-
115
- if (delta.toolCalls.isNotEmpty) {
116
- for (final tc in delta.toolCalls) {
104
+ // Use .listen() instead of `await for` so the subscription can be
105
+ // cancelled externally without waiting for the next native token.
106
+ final completer = Completer<void>();
107
+
108
+ _currentSubscription = stream.listen(
109
+ (delta) {
110
+ if (_isCancelled) return;
111
+
112
+ if (delta.text.isNotEmpty) {
113
_streamController?.add(
114
ChatResponse(
119
- type: ChatResponseType.toolCall,
120
- toolCall: ToolCallData(tool: tc.name, arguments: tc.arguments),
115
+ type: ChatResponseType.message,
116
+ content: delta.text,
117
),
118
);
119
}
124
- }
125
- }
120
127
- _streamController?.add(const ChatResponse(type: ChatResponseType.done));
128
- await conversation.dispose();
129
- await _streamController?.close();
121
+ if (delta.toolCalls.isNotEmpty) {
122
+ for (final tc in delta.toolCalls) {
123
+ _streamController?.add(
124
+ ChatResponse(
125
+ type: ChatResponseType.toolCall,
126
+ toolCall:
127
+ ToolCallData(tool: tc.name, arguments: tc.arguments),
128
+ ),
129
+ );
130
+ }
131
+ }
132
+ },
133
+ onDone: () {
134
+ if (!_isCancelled) {
135
+ _streamController?.add(
136
+ const ChatResponse(type: ChatResponseType.done),
137
+ );
138
+ _disposeConversation(conversation);
139
+ _streamController?.close();
140
+ }
141
+ _activeConversation = null;
142
+ _currentSubscription = null;
143
+ if (!completer.isCompleted) completer.complete();
144
+ },
145
+ onError: (error) {
146
+ Log.error('OnDevice stream error: $error');
147
+ if (!_isCancelled &&
148
+ !(_streamController?.isClosed ?? true)) {
149
+ _streamController?.add(
150
+ ChatResponse(
151
+ type: ChatResponseType.error,
152
+ content: 'Inference error: ${error.toString()}',
153
+ ),
154
+ );
155
+ _streamController?.add(
156
+ const ChatResponse(type: ChatResponseType.done),
157
+ );
158
+ _streamController?.close();
159
+ }
160
+ _disposeConversation(conversation);
161
+ _activeConversation = null;
162
+ _currentSubscription = null;
163
+ if (!completer.isCompleted) completer.complete();
164
+ },
165
+ cancelOnError: true,
166
+ );
167
+
168
+ await completer.future;
169
} catch (e) {
170
Log.error('OnDevice inference error: $e');
171
if (!(_streamController?.isClosed ?? true)) {
@@ -142,6 +181,13 @@ class OnDeviceChatService implements ChatService {
181
}
182
}
183
184
+ /// Dispose a conversation without blocking — fire and forget.
185
+ void _disposeConversation(LiteLmConversation conversation) {
186
+ conversation.dispose().catchError((e) {
187
+ Log.error('Error disposing conversation: $e');
188
+ });
189
+ }
190
+
191
List<({String role, String? text})> _convertMessages(List<Message> messages) {
192
return messages
193
.where(
@@ -157,8 +203,20 @@ class OnDeviceChatService implements ChatService {
203
@override
204
void cancelStream() {
205
_isCancelled = true;
206
+
207
+ // Cancel the native stream subscription first — this stops the
208
+ // EventChannel from delivering more tokens and unblocks the loop.
209
_currentSubscription?.cancel();
210
_currentSubscription = null;
211
+
212
+ // Dispose the active conversation (fire-and-forget to avoid blocking
213
+ // the UI thread if the native side is mid-inference).
214
+ final conversation = _activeConversation;
215
+ _activeConversation = null;
216
+ if (conversation != null) {
217
+ _disposeConversation(conversation);
218
+ }
219
+
220
if (!(_streamController?.isClosed ?? true)) {
221
_streamController?.add(const ChatResponse(type: ChatResponseType.done));
222
_streamController?.close();
lib/features/settings/data/models/app_settings.dart
+4
-13
@@ -5,17 +5,8 @@ import 'package:flutter/material.dart';
5
import '../../../../core/models/enums.dart';
6
7
enum SyntaxThemeName {
8
- vscodeDark,
9
- vscodeLight,
10
- dracula,
11
- monokaiSublime,
12
- ayuLight,
13
- ayuDark,
14
- gravityLight,
15
- gravityDark,
16
- obsidian,
17
- oceanSunset,
18
- standard,
8
+ light,
9
+ dark,
10
}
11
12
class AppSettings {
@@ -56,8 +47,8 @@ class AppSettings {
47
this.defaultPersonaId,
48
this.hasCompletedOnboarding = false,
49
this.mcpEnabled = true,
59
- this.codeThemeDark = SyntaxThemeName.vscodeDark,
60
- this.codeThemeLight = SyntaxThemeName.vscodeLight,
50
+ this.codeThemeDark = SyntaxThemeName.dark,
51
+ this.codeThemeLight = SyntaxThemeName.light,
52
this.preferredBackend = LiteLmBackendType.cpu,
53
});
54
lib/features/settings/screens/settings_screen.dart
+4
-22
@@ -762,28 +762,10 @@ class _CodeThemeDropdown extends StatelessWidget {
762
763
String _getDisplayName(SyntaxThemeName theme) {
764
switch (theme) {
765
- case SyntaxThemeName.vscodeDark:
766
- return 'VS Code Dark';
767
- case SyntaxThemeName.vscodeLight:
768
- return 'VS Code Light';
769
- case SyntaxThemeName.dracula:
770
- return 'Dracula';
771
- case SyntaxThemeName.monokaiSublime:
772
- return 'Monokai';
773
- case SyntaxThemeName.ayuLight:
774
- return 'Ayu Light';
775
- case SyntaxThemeName.ayuDark:
776
- return 'Ayu Dark';
777
- case SyntaxThemeName.gravityLight:
778
- return 'Gravity Light';
779
- case SyntaxThemeName.gravityDark:
780
- return 'Gravity Dark';
781
- case SyntaxThemeName.obsidian:
782
- return 'Obsidian';
783
- case SyntaxThemeName.oceanSunset:
784
- return 'Ocean Sunset';
785
- case SyntaxThemeName.standard:
786
- return 'Standard';
765
+ case SyntaxThemeName.light:
766
+ return 'Light';
767
+ case SyntaxThemeName.dark:
768
+ return 'Dark';
769
}
770
}
771
lib/features/sidebar/sidebar_widget.dart
+22
-8
@@ -21,7 +21,7 @@ class SidebarWidget extends ConsumerWidget {
21
Widget build(BuildContext context, WidgetRef ref) {
22
final theme = Theme.of(context);
23
final location = GoRouterState.of(context).uri.toString();
24
-
24
+
25
final isServers = location.startsWith(AppRoutes.servers);
26
final isPersonas = location.startsWith(AppRoutes.personas);
27
final themeMode = ref.watch(themeModeProvider);
@@ -37,7 +37,7 @@ class SidebarWidget extends ConsumerWidget {
37
child: Column(
38
children: [
39
const ConversationDrawerHeader(),
40
-
40
+
41
// New Chat Button
42
Padding(
43
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -54,17 +54,28 @@ class SidebarWidget extends ConsumerWidget {
54
child: const Text('New Chat'),
55
),
56
),
57
-
57
+
58
const SidebarSearchButton(),
59
const SizedBox(height: 8),
60
const Divider(height: 1, indent: 16, endIndent: 16),
61
const SizedBox(height: 12),
62
-
62
+
63
// Primary Navigation
64
Expanded(
65
child: SingleChildScrollView(
66
child: Column(
67
children: [
68
+ DrawerNavItem(
69
+ iconData: HugeIcons.strokeRoundedChatting01,
70
+ label: 'History',
71
+ isSelected: isServers,
72
+ onTap: () {
73
+ if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
74
+ Navigator.pop(context);
75
+ }
76
+ context.go(AppRoutes.chatHistory);
77
+ },
78
+ ),
79
DrawerNavItem(
80
iconData: HugeIcons.strokeRoundedServerStack01,
81
label: 'Servers',
@@ -106,16 +117,19 @@ class SidebarWidget extends ConsumerWidget {
117
label: 'Appearance: ${_getThemeLabel(themeMode)}',
118
isSelected: false,
119
onTap: () {
109
- final nextMode = AppThemeType.values[
110
- (themeMode.index + 1) % AppThemeType.values.length];
111
- ref.read(themeModeProvider.notifier).setThemeMode(nextMode);
120
+ final nextMode =
121
+ AppThemeType.values[(themeMode.index + 1) %
122
+ AppThemeType.values.length];
123
+ ref
124
+ .read(themeModeProvider.notifier)
125
+ .setThemeMode(nextMode);
126
},
127
),
128
],
129
),
130
),
131
),
118
-
132
+
133
// Bottom Section
134
const GitHubRepoCard(),
135
const Divider(height: 1),
lib/main.dart
+3
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
3
import 'package:shared_preferences/shared_preferences.dart';
4
5
import 'app.dart';
6
+import 'core/providers/highlighter_provider.dart';
7
import 'core/providers/storage_providers.dart';
8
import 'core/storage/objectbox_store.dart';
9
import 'features/on_device/providers/on_device_providers.dart';
@@ -10,6 +11,8 @@ import 'features/on_device/providers/on_device_providers.dart';
11
Future<void> main() async {
12
WidgetsFlutterBinding.ensureInitialized();
13
14
+ await initializeHighlighter();
15
+
16
final prefs = await SharedPreferences.getInstance();
17
final database = await ObjectBoxStore.create();
18
pubspec.lock
+17
-8
@@ -137,6 +137,14 @@ packages:
137
url: "https://pub.dev"
138
source: hosted
139
version: "0.2.0"
140
+ clipboard:
141
+ dependency: transitive
142
+ description:
143
+ name: clipboard
144
+ sha256: c0ba5e7214035b824549473fa134870bc544fff0d7b89170d4756c0ac2c0239d
145
+ url: "https://pub.dev"
146
+ source: hosted
147
+ version: "3.0.14"
148
clock:
149
dependency: transitive
150
description:
@@ -451,14 +459,6 @@ packages:
459
url: "https://pub.dev"
460
source: hosted
461
version: "2.2.4"
454
- flutter_syntax_view:
455
- dependency: "direct main"
456
- description:
457
- name: flutter_syntax_view
458
- sha256: c5017bbedfdcf538daba765e16541fcb26434071655ca00cea7cbc205a70246a
459
- url: "https://pub.dev"
460
- source: hosted
461
- version: "4.1.7"
462
flutter_test:
463
dependency: "direct dev"
464
description: flutter
@@ -1098,6 +1098,15 @@ packages:
1098
url: "https://pub.dev"
1099
source: hosted
1100
version: "1.4.1"
1101
+ syntax_highlight:
1102
+ dependency: "direct main"
1103
+ description:
1104
+ path: "."
1105
+ ref: HEAD
1106
+ resolved-ref: "52dc4dd9a95658e2abefd7dc527543c71836faeb"
1107
+ url: "https://github.com/abdulmominsakib/syntax_highlight.git"
1108
+ source: git
1109
+ version: "0.5.0"
1110
term_glyph:
1111
dependency: transitive
1112
description:
pubspec.yaml
+4
-2
@@ -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.1.0+12
19
+version: 1.2.0+15
20
21
environment:
22
sdk: ^3.11.0
@@ -47,7 +47,9 @@ dependencies:
47
haptic_feedback: ^0.6.4+3
48
hugeicons: ^1.1.5
49
google_fonts: ^6.2.1
50
- flutter_syntax_view: 4.1.7
50
+ syntax_highlight:
51
+ git:
52
+ url: https://github.com/abdulmominsakib/syntax_highlight.git
53
animations: 2.1.2
54
logger: ^2.7.0
55
shared_preferences: ^2.5.5