@tduncan / Cortana / commits / db9e1f2

feat: MCP is out of Beta and enhance tool calling integration for OpenAI and Ollama services

abdulmominsakib committed Jun 6, 2026 at 19:06 UTC db9e1f21a628537b3b4bbd2ed1d1e9d921cdfb2e
17 files changed +1209 -395
ios/Runner.xcodeproj/project.pbxproj
+48 -48
@@ -10,13 +10,13 @@
10 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
11 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
12 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
13 - 4D35725BDD0DD5019A931DB2 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A032784C9BB81D3315A79076 /* Pods_RunnerTests.framework */; };
13 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
14 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
15 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
16 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
17 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
19 - AD5D2C9F4682DFDBC0E7E5C1 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9B309EF20AB394872F65BD5E /* Pods_Runner.framework */; };
18 + C4C0F84E5F9C916D8FE832A1 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9FCBB4563C2D0AFFC8624EB4 /* Pods_RunnerTests.framework */; };
19 + FAEA42D7984B92E2DBCE269D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC43B112C6A72A8AE769B41D /* Pods_Runner.framework */; };
20 /* End PBXBuildFile section */
21
22 /* Begin PBXContainerItemProxy section */
@@ -43,18 +43,20 @@
43 /* End PBXCopyFilesBuildPhase section */
44
45 /* Begin PBXFileReference section */
46 + 106F6EFA508F20C096747279 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
47 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
48 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
48 - 2E58D4AE73D0962B3323FB68 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
49 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
50 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
51 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
52 + 58428C72F3809A49D626379A /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
53 + 73AFEF529F048553B062B4E3 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
54 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
55 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
56 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
57 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
58 7CC868A42FD2D68B0007F1A7 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
57 - 86D4F01C5ED86C95435B62E4 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
59 + 950B01045D49DA64F6A7C4FA /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
60 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
61 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
62 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -62,12 +64,10 @@
64 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
65 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
66 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
65 - 9B309EF20AB394872F65BD5E /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
66 - A032784C9BB81D3315A79076 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
67 - B01A7AB948A43BF5CF62AC78 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
68 - D7F09850547E66B715D2AE1C /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
69 - EB56E4A51F0E4D7E0193C01F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
70 - F63A76ABB8D4E03F11C4084E /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
67 + 9FCBB4563C2D0AFFC8624EB4 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
68 + C6D853C36CD61AA682DF6E81 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
69 + F3270ED4CE365DCA44881F78 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
70 + FC43B112C6A72A8AE769B41D /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
71 /* End PBXFileReference section */
72
73 /* Begin PBXFrameworksBuildPhase section */
@@ -75,7 +75,7 @@
75 isa = PBXFrameworksBuildPhase;
76 buildActionMask = 2147483647;
77 files = (
78 - AD5D2C9F4682DFDBC0E7E5C1 /* Pods_Runner.framework in Frameworks */,
78 + FAEA42D7984B92E2DBCE269D /* Pods_Runner.framework in Frameworks */,
79 );
80 runOnlyForDeploymentPostprocessing = 0;
81 };
@@ -83,18 +83,18 @@
83 isa = PBXFrameworksBuildPhase;
84 buildActionMask = 2147483647;
85 files = (
86 - 4D35725BDD0DD5019A931DB2 /* Pods_RunnerTests.framework in Frameworks */,
86 + C4C0F84E5F9C916D8FE832A1 /* Pods_RunnerTests.framework in Frameworks */,
87 );
88 runOnlyForDeploymentPostprocessing = 0;
89 };
90 /* End PBXFrameworksBuildPhase section */
91
92 /* Begin PBXGroup section */
93 - 1149248C99B08ECDAC23FF9D /* Frameworks */ = {
93 + 0A7EDC9E473CF51F03159619 /* Frameworks */ = {
94 isa = PBXGroup;
95 children = (
96 - 9B309EF20AB394872F65BD5E /* Pods_Runner.framework */,
97 - A032784C9BB81D3315A79076 /* Pods_RunnerTests.framework */,
96 + FC43B112C6A72A8AE769B41D /* Pods_Runner.framework */,
97 + 9FCBB4563C2D0AFFC8624EB4 /* Pods_RunnerTests.framework */,
98 );
99 name = Frameworks;
100 sourceTree = "<group>";
@@ -126,7 +126,7 @@
126 97C146EF1CF9000F007C117D /* Products */,
127 331C8082294A63A400263BE5 /* RunnerTests */,
128 B5F0F4932A555741EAC185FD /* Pods */,
129 - 1149248C99B08ECDAC23FF9D /* Frameworks */,
129 + 0A7EDC9E473CF51F03159619 /* Frameworks */,
130 );
131 sourceTree = "<group>";
132 };
@@ -159,12 +159,12 @@
159 B5F0F4932A555741EAC185FD /* Pods */ = {
160 isa = PBXGroup;
161 children = (
162 - EB56E4A51F0E4D7E0193C01F /* Pods-Runner.debug.xcconfig */,
163 - 2E58D4AE73D0962B3323FB68 /* Pods-Runner.release.xcconfig */,
164 - B01A7AB948A43BF5CF62AC78 /* Pods-Runner.profile.xcconfig */,
165 - F63A76ABB8D4E03F11C4084E /* Pods-RunnerTests.debug.xcconfig */,
166 - D7F09850547E66B715D2AE1C /* Pods-RunnerTests.release.xcconfig */,
167 - 86D4F01C5ED86C95435B62E4 /* Pods-RunnerTests.profile.xcconfig */,
162 + F3270ED4CE365DCA44881F78 /* Pods-Runner.debug.xcconfig */,
163 + C6D853C36CD61AA682DF6E81 /* Pods-Runner.release.xcconfig */,
164 + 73AFEF529F048553B062B4E3 /* Pods-Runner.profile.xcconfig */,
165 + 106F6EFA508F20C096747279 /* Pods-RunnerTests.debug.xcconfig */,
166 + 58428C72F3809A49D626379A /* Pods-RunnerTests.release.xcconfig */,
167 + 950B01045D49DA64F6A7C4FA /* Pods-RunnerTests.profile.xcconfig */,
168 );
169 path = Pods;
170 sourceTree = "<group>";
@@ -176,7 +176,7 @@
176 isa = PBXNativeTarget;
177 buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
178 buildPhases = (
179 - D2249D2FD0465B2C76420590 /* [CP] Check Pods Manifest.lock */,
179 + DDFE1FA6C8EABD4B904564F6 /* [CP] Check Pods Manifest.lock */,
180 331C807D294A63A400263BE5 /* Sources */,
181 331C807F294A63A400263BE5 /* Resources */,
182 9AA24F4969F6FB2D761614D7 /* Frameworks */,
@@ -195,15 +195,15 @@
195 isa = PBXNativeTarget;
196 buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
197 buildPhases = (
198 - FE21A8B6035F35A6E6A38E92 /* [CP] Check Pods Manifest.lock */,
198 + CAD44E4886D762F8D9220F47 /* [CP] Check Pods Manifest.lock */,
199 9740EEB61CF901F6004384FC /* Run Script */,
200 97C146EA1CF9000F007C117D /* Sources */,
201 97C146EB1CF9000F007C117D /* Frameworks */,
202 97C146EC1CF9000F007C117D /* Resources */,
203 9705A1C41CF9048500538489 /* Embed Frameworks */,
204 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
205 - 1E56B4EBA88B451F7B385BA9 /* [CP] Embed Pods Frameworks */,
206 - D0F9B4752B25C3F455EAAB34 /* [CP] Copy Pods Resources */,
205 + 231076B88C0FA832CC283DB1 /* [CP] Embed Pods Frameworks */,
206 + 94ED4B90A2C8AEA5F8005A0F /* [CP] Copy Pods Resources */,
207 );
208 buildRules = (
209 );
@@ -275,7 +275,7 @@
275 /* End PBXResourcesBuildPhase section */
276
277 /* Begin PBXShellScriptBuildPhase section */
278 - 1E56B4EBA88B451F7B385BA9 /* [CP] Embed Pods Frameworks */ = {
278 + 231076B88C0FA832CC283DB1 /* [CP] Embed Pods Frameworks */ = {
279 isa = PBXShellScriptBuildPhase;
280 buildActionMask = 2147483647;
281 files = (
@@ -308,39 +308,39 @@
308 shellPath = /bin/sh;
309 shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
310 };
311 - 9740EEB61CF901F6004384FC /* Run Script */ = {
311 + 94ED4B90A2C8AEA5F8005A0F /* [CP] Copy Pods Resources */ = {
312 isa = PBXShellScriptBuildPhase;
313 - alwaysOutOfDate = 1;
313 buildActionMask = 2147483647;
314 files = (
315 );
317 - inputPaths = (
316 + inputFileListPaths = (
317 + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
318 );
319 - name = "Run Script";
320 - outputPaths = (
319 + name = "[CP] Copy Pods Resources";
320 + outputFileListPaths = (
321 + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
322 );
323 runOnlyForDeploymentPostprocessing = 0;
324 shellPath = /bin/sh;
324 - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
325 + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
326 + showEnvVarsInLog = 0;
327 };
326 - D0F9B4752B25C3F455EAAB34 /* [CP] Copy Pods Resources */ = {
328 + 9740EEB61CF901F6004384FC /* Run Script */ = {
329 isa = PBXShellScriptBuildPhase;
330 + alwaysOutOfDate = 1;
331 buildActionMask = 2147483647;
332 files = (
333 );
331 - inputFileListPaths = (
332 - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
334 + inputPaths = (
335 );
334 - name = "[CP] Copy Pods Resources";
335 - outputFileListPaths = (
336 - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
336 + name = "Run Script";
337 + outputPaths = (
338 );
339 runOnlyForDeploymentPostprocessing = 0;
340 shellPath = /bin/sh;
340 - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
341 - showEnvVarsInLog = 0;
341 + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
342 };
343 - D2249D2FD0465B2C76420590 /* [CP] Check Pods Manifest.lock */ = {
343 + CAD44E4886D762F8D9220F47 /* [CP] Check Pods Manifest.lock */ = {
344 isa = PBXShellScriptBuildPhase;
345 buildActionMask = 2147483647;
346 files = (
@@ -355,14 +355,14 @@
355 outputFileListPaths = (
356 );
357 outputPaths = (
358 - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
358 + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
359 );
360 runOnlyForDeploymentPostprocessing = 0;
361 shellPath = /bin/sh;
362 shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
363 showEnvVarsInLog = 0;
364 };
365 - FE21A8B6035F35A6E6A38E92 /* [CP] Check Pods Manifest.lock */ = {
365 + DDFE1FA6C8EABD4B904564F6 /* [CP] Check Pods Manifest.lock */ = {
366 isa = PBXShellScriptBuildPhase;
367 buildActionMask = 2147483647;
368 files = (
@@ -377,7 +377,7 @@
377 outputFileListPaths = (
378 );
379 outputPaths = (
380 - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
380 + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
381 );
382 runOnlyForDeploymentPostprocessing = 0;
383 shellPath = /bin/sh;
@@ -512,7 +512,7 @@
512 };
513 331C8088294A63A400263BE5 /* Debug */ = {
514 isa = XCBuildConfiguration;
515 - baseConfigurationReference = F63A76ABB8D4E03F11C4084E /* Pods-RunnerTests.debug.xcconfig */;
515 + baseConfigurationReference = 106F6EFA508F20C096747279 /* Pods-RunnerTests.debug.xcconfig */;
516 buildSettings = {
517 BUNDLE_LOADER = "$(TEST_HOST)";
518 CODE_SIGN_STYLE = Automatic;
@@ -530,7 +530,7 @@
530 };
531 331C8089294A63A400263BE5 /* Release */ = {
532 isa = XCBuildConfiguration;
533 - baseConfigurationReference = D7F09850547E66B715D2AE1C /* Pods-RunnerTests.release.xcconfig */;
533 + baseConfigurationReference = 58428C72F3809A49D626379A /* Pods-RunnerTests.release.xcconfig */;
534 buildSettings = {
535 BUNDLE_LOADER = "$(TEST_HOST)";
536 CODE_SIGN_STYLE = Automatic;
@@ -546,7 +546,7 @@
546 };
547 331C808A294A63A400263BE5 /* Profile */ = {
548 isa = XCBuildConfiguration;
549 - baseConfigurationReference = 86D4F01C5ED86C95435B62E4 /* Pods-RunnerTests.profile.xcconfig */;
549 + baseConfigurationReference = 950B01045D49DA64F6A7C4FA /* Pods-RunnerTests.profile.xcconfig */;
550 buildSettings = {
551 BUNDLE_LOADER = "$(TEST_HOST)";
552 CODE_SIGN_STYLE = Automatic;
lib/core/services/chat_background_service.dart
+7 -2
@@ -1,3 +1,4 @@
1 +import 'dart:io';
2 import 'package:flutter/services.dart';
3 import 'package:wakelock_plus/wakelock_plus.dart';
4 import '../logger/app_logger.dart';
@@ -10,7 +11,9 @@ class ChatBackgroundService {
11 if (_isActive) return;
12 try {
13 Log.info('Starting background chat service');
13 - await _channel.invokeMethod('startForeground');
14 + if (Platform.isAndroid) {
15 + await _channel.invokeMethod('startForeground');
16 + }
17 await WakelockPlus.enable();
18 _isActive = true;
19 } catch (e) {
@@ -22,7 +25,9 @@ class ChatBackgroundService {
25 if (!_isActive) return;
26 try {
27 Log.info('Stopping background chat service');
25 - await _channel.invokeMethod('stopForeground');
28 + if (Platform.isAndroid) {
29 + await _channel.invokeMethod('stopForeground');
30 + }
31 await WakelockPlus.disable();
32 _isActive = false;
33 } catch (e) {
lib/features/chat/data/chat_service.dart
+29 -2
@@ -7,6 +7,7 @@ import '../../servers/data/models/server.dart';
7 import 'models/message.dart';
8 import 'models/chat_parameters.dart';
9 import 'models/mcp_integration.dart';
10 +import 'tools/tool_definition.dart';
11 import '../../../core/models/enums.dart';
12 import '../../../core/logger/app_logger.dart';
13 import '../../on_device/data/on_device_gemma_service.dart';
@@ -23,6 +24,7 @@ abstract class ChatService {
24 required List<Message> messages,
25 required ChatParameters params,
26 List<McpIntegration>? integrations,
27 + List<ToolDefinition>? tools,
28 String? previousResponseId,
29 });
30
@@ -151,6 +153,7 @@ class LMStudioChatService implements ChatService {
153 required List<Message> messages,
154 required ChatParameters params,
155 List<McpIntegration>? integrations,
156 + List<ToolDefinition>? tools,
157 String? previousResponseId,
158 }) async* {
159 _cancelToken = CancelToken();
@@ -457,10 +460,11 @@ class OpenAICompatibleChatService implements ChatService {
460 required List<Message> messages,
461 required ChatParameters params,
462 List<McpIntegration>? integrations,
463 + List<ToolDefinition>? tools,
464 String? previousResponseId,
465 }) async* {
466 _cancelToken = CancelToken();
463 - final toolAdapter = OllamaToolAdapter();
467 + final toolAdapter = OpenAiToolAdapter();
468
469 final apiMessages = messages
470 .map((m) => {'role': _roleToString(m.role), 'content': m.content})
@@ -483,6 +487,13 @@ class OpenAICompatibleChatService implements ChatService {
487 'stream': true,
488 };
489
490 + if (tools != null && tools.isNotEmpty) {
491 + final toolsPayload = toolAdapter.buildToolDefinitionPayload(tools);
492 + if (toolsPayload.containsKey('tools')) {
493 + body['tools'] = toolsPayload['tools'];
494 + }
495 + }
496 +
497 Log.debug(
498 'OpenAICompatible: Sending request to ${server.chatEndpoint} with model: $modelId',
499 );
@@ -661,10 +672,11 @@ class OllamaChatService implements ChatService {
672 required List<Message> messages,
673 required ChatParameters params,
674 List<McpIntegration>? integrations,
675 + List<ToolDefinition>? tools,
676 String? previousResponseId,
677 }) async* {
678 _cancelToken = CancelToken();
667 - final toolAdapter = OpenAiToolAdapter();
679 + final toolAdapter = OllamaToolAdapter();
680
681 final body = {
682 'model': modelId,
@@ -679,6 +691,13 @@ class OllamaChatService implements ChatService {
691 },
692 };
693
694 + if (tools != null && tools.isNotEmpty) {
695 + final toolsPayload = toolAdapter.buildToolDefinitionPayload(tools);
696 + if (toolsPayload.containsKey('tools')) {
697 + body['tools'] = toolsPayload['tools'];
698 + }
699 + }
700 +
701 try {
702 final response = await _dio.post<ResponseBody>(
703 server.chatEndpoint,
@@ -768,6 +787,7 @@ class OpenRouterChatService implements ChatService {
787 required List<Message> messages,
788 required ChatParameters params,
789 List<McpIntegration>? integrations,
790 + List<ToolDefinition>? tools,
791 String? previousResponseId,
792 }) async* {
793 _cancelToken = CancelToken();
@@ -784,6 +804,13 @@ class OpenRouterChatService implements ChatService {
804 'stream': true,
805 };
806
807 + if (tools != null && tools.isNotEmpty) {
808 + final toolsPayload = toolAdapter.buildToolDefinitionPayload(tools);
809 + if (toolsPayload.containsKey('tools')) {
810 + body['tools'] = toolsPayload['tools'];
811 + }
812 + }
813 +
814 Log.debug(
815 'OpenRouter: Sending request to ${server.chatEndpoint} with model: $modelId',
816 );
lib/features/chat/data/mcp_client.dart
+265 -61
@@ -1,6 +1,8 @@
1 import 'dart:async';
2 +import 'dart:convert';
3
4 import 'package:dio/dio.dart';
5 +import 'package:localmind/core/logger/app_logger.dart';
6
7 class McpClient {
8 final String serverUrl;
@@ -9,37 +11,246 @@ class McpClient {
11 bool _initialized = false;
12 McpCapabilities? _capabilities;
13
14 + bool _useSse = false;
15 + StreamSubscription? _sseSubscription;
16 + String? _postUrl;
17 + final Map<String, Completer<Map<String, dynamic>>> _pendingRequests = {};
18 + int _requestIdCounter = 1;
19 +
20 McpClient({required this.serverUrl, this.headers, Dio? dio})
21 : _dio = dio ?? Dio();
22
23 bool get isInitialized => _initialized;
24 McpCapabilities? get capabilities => _capabilities;
25
26 + Future<void> _connectSse() async {
27 + try {
28 + final response = await _dio.get<ResponseBody>(
29 + serverUrl,
30 + options: Options(
31 + headers: {
32 + 'Accept': 'text/event-stream',
33 + 'Cache-Control': 'no-cache',
34 + ...?headers,
35 + },
36 + responseType: ResponseType.stream,
37 + ),
38 + );
39 +
40 + final contentType = response.headers.value('content-type') ?? '';
41 + if (!contentType.contains('text/event-stream') && !serverUrl.contains('sse')) {
42 + // Not an SSE stream endpoint, fallback to direct HTTP POST
43 + return;
44 + }
45 +
46 + _useSse = true;
47 + final handshakeCompleter = Completer<void>();
48 +
49 + final stream = response.data!.stream.cast<List<int>>().transform(utf8.decoder);
50 + String buffer = '';
51 + String currentEvent = 'message';
52 + String currentData = '';
53 +
54 + _sseSubscription = stream.listen(
55 + (chunk) {
56 + buffer += chunk;
57 + final lines = buffer.split('\n');
58 + buffer = lines.removeLast();
59 +
60 + for (final line in lines) {
61 + final trimmed = line.trim();
62 + if (trimmed.isEmpty) {
63 + if (currentData.isNotEmpty) {
64 + _handleSseMessage(currentEvent, currentData, handshakeCompleter);
65 + currentEvent = 'message';
66 + currentData = '';
67 + }
68 + continue;
69 + }
70 +
71 + if (trimmed.startsWith('event:')) {
72 + currentEvent = trimmed.substring(6).trim();
73 + } else if (trimmed.startsWith('data:')) {
74 + final val = trimmed.substring(5).trim();
75 + if (currentData.isNotEmpty) {
76 + currentData += '\n$val';
77 + } else {
78 + currentData = val;
79 + }
80 + }
81 + }
82 + },
83 + onError: (e) {
84 + if (!handshakeCompleter.isCompleted) {
85 + handshakeCompleter.completeError(e);
86 + }
87 + _handleSseDisconnect(e);
88 + },
89 + onDone: () {
90 + if (!handshakeCompleter.isCompleted) {
91 + handshakeCompleter.completeError(McpException('SSE stream closed before handshake completed'));
92 + }
93 + _handleSseDisconnect(null);
94 + },
95 + );
96 +
97 + // Wait for the endpoint event or timeout
98 + await handshakeCompleter.future.timeout(
99 + const Duration(seconds: 10),
100 + onTimeout: () {
101 + throw McpException('Handshake timeout waiting for SSE endpoint event');
102 + },
103 + );
104 + } catch (e) {
105 + await _closeSse();
106 + _useSse = false;
107 + rethrow;
108 + }
109 + }
110 +
111 + void _handleSseMessage(String event, String data, Completer<void> initCompleter) {
112 + if (event == 'endpoint') {
113 + try {
114 + final baseUri = Uri.parse(serverUrl);
115 + final resolvedUri = baseUri.resolve(data);
116 + _postUrl = resolvedUri.toString();
117 + Log.debug('MCP SSE Handshake completed. Message endpoint: $_postUrl');
118 + if (!initCompleter.isCompleted) {
119 + initCompleter.complete();
120 + }
121 + } catch (e) {
122 + if (!initCompleter.isCompleted) {
123 + initCompleter.completeError(e);
124 + }
125 + }
126 + } else {
127 + try {
128 + final json = jsonDecode(data);
129 + if (json is Map<String, dynamic>) {
130 + final id = json['id'];
131 + if (id != null) {
132 + final key = id.toString();
133 + final completer = _pendingRequests.remove(key);
134 + if (completer != null && !completer.isCompleted) {
135 + completer.complete(json);
136 + }
137 + }
138 + }
139 + } catch (_) {}
140 + }
141 + }
142 +
143 + void _handleSseDisconnect(dynamic error) {
144 + final ex = McpException(error != null ? 'SSE disconnected: $error' : 'SSE connection closed');
145 + for (final completer in _pendingRequests.values) {
146 + if (!completer.isCompleted) {
147 + completer.completeError(ex);
148 + }
149 + }
150 + _pendingRequests.clear();
151 + _initialized = false;
152 + }
153 +
154 + Future<void> _closeSse() async {
155 + await _sseSubscription?.cancel();
156 + _sseSubscription = null;
157 + _postUrl = null;
158 + }
159 +
160 + Future<Map<String, dynamic>> _sendJsonRpc(String method, Map<String, dynamic> params) async {
161 + final id = _requestIdCounter++;
162 + final payload = {
163 + 'jsonrpc': '2.0',
164 + 'id': id,
165 + 'method': method,
166 + 'params': params,
167 + };
168 +
169 + if (_useSse) {
170 + if (_postUrl == null) {
171 + throw McpException('SSE transport initialized but POST endpoint is missing');
172 + }
173 +
174 + final completer = Completer<Map<String, dynamic>>();
175 + _pendingRequests[id.toString()] = completer;
176 +
177 + try {
178 + await _dio.post(
179 + _postUrl!,
180 + data: payload,
181 + options: Options(
182 + headers: {'Content-Type': 'application/json', ...?headers},
183 + ),
184 + );
185 + } catch (e) {
186 + _pendingRequests.remove(id.toString());
187 + throw McpException('Failed to post message over SSE transport: $e');
188 + }
189 +
190 + final responseJson = await completer.future.timeout(
191 + const Duration(seconds: 30),
192 + onTimeout: () {
193 + _pendingRequests.remove(id.toString());
194 + throw McpException('Timeout waiting for JSON-RPC response over SSE transport');
195 + },
196 + );
197 +
198 + if (responseJson.containsKey('error')) {
199 + final error = responseJson['error'];
200 + final message = error is Map ? (error['message'] ?? 'Unknown error') : 'Unknown error';
201 + throw McpException('Server returned error: $message');
202 + }
203 +
204 + return responseJson;
205 + } else {
206 + try {
207 + final response = await _dio.post(
208 + serverUrl,
209 + data: payload,
210 + options: Options(
211 + headers: {'Content-Type': 'application/json', ...?headers},
212 + ),
213 + );
214 +
215 + final responseData = response.data;
216 + if (responseData is! Map<String, dynamic>) {
217 + throw McpException('Invalid JSON-RPC response payload format');
218 + }
219 +
220 + if (responseData.containsKey('error')) {
221 + final error = responseData['error'];
222 + final message = error is Map ? (error['message'] ?? 'Unknown error') : 'Unknown error';
223 + throw McpException('Server returned error: $message');
224 + }
225 +
226 + return responseData;
227 + } catch (e) {
228 + throw McpException('Failed to send JSON-RPC request over HTTP: $e');
229 + }
230 + }
231 + }
232 +
233 Future<McpCapabilities> initialize() async {
234 if (_initialized && _capabilities != null) {
235 return _capabilities!;
236 }
237
238 try {
24 - final response = await _dio.post(
25 - serverUrl,
26 - data: {
27 - 'jsonrpc': '2.0',
28 - 'id': 1,
29 - 'method': 'initialize',
30 - 'params': {
31 - 'protocolVersion': '2024-11-05',
32 - 'capabilities': {},
33 - 'clientInfo': {'name': 'localmind', 'version': '1.0.0'},
34 - },
35 - },
36 - options: Options(
37 - headers: {'Content-Type': 'application/json', ...?headers},
38 - ),
39 - );
239 + try {
240 + await _connectSse();
241 + } catch (e) {
242 + Log.debug('Failed to connect via SSE: $e. Falling back to direct HTTP POST.');
243 + _useSse = false;
244 + }
245 +
246 + final response = await _sendJsonRpc('initialize', {
247 + 'protocolVersion': '2024-11-05',
248 + 'capabilities': {},
249 + 'clientInfo': {'name': 'localmind', 'version': '1.0.0'},
250 + });
251
252 _capabilities = McpCapabilities.fromJson(
42 - response.data['result']['capabilities'] as Map<String, dynamic>,
253 + response['result']['capabilities'] as Map<String, dynamic>,
254 );
255 _initialized = true;
256
@@ -53,15 +264,31 @@ class McpClient {
264
265 Future<void> _sendInitializedNotification() async {
266 try {
56 - await _dio.post(
57 - serverUrl,
58 - data: {
59 - 'jsonrpc': '2.0',
60 - 'method': 'notifications/initialized',
61 - 'params': {},
62 - },
63 - options: Options(headers: {'Content-Type': 'application/json'}),
64 - );
267 + final payload = {
268 + 'jsonrpc': '2.0',
269 + 'method': 'notifications/initialized',
270 + 'params': {},
271 + };
272 +
273 + if (_useSse) {
274 + if (_postUrl != null) {
275 + await _dio.post(
276 + _postUrl!,
277 + data: payload,
278 + options: Options(
279 + headers: {'Content-Type': 'application/json', ...?headers},
280 + ),
281 + );
282 + }
283 + } else {
284 + await _dio.post(
285 + serverUrl,
286 + data: payload,
287 + options: Options(
288 + headers: {'Content-Type': 'application/json', ...?headers},
289 + ),
290 + );
291 + }
292 } catch (_) {}
293 }
294
@@ -71,12 +298,9 @@ class McpClient {
298 }
299
300 try {
74 - final response = await _dio.post(
75 - serverUrl,
76 - data: {'jsonrpc': '2.0', 'id': 2, 'method': 'tools/list', 'params': {}},
77 - );
301 + final response = await _sendJsonRpc('tools/list', {});
302
79 - final tools = response.data['result']['tools'] as List?;
303 + final tools = response['result']['tools'] as List?;
304 if (tools == null) return [];
305
306 return tools
@@ -93,17 +317,12 @@ class McpClient {
317 }
318
319 try {
96 - final response = await _dio.post(
97 - serverUrl,
98 - data: {
99 - 'jsonrpc': '2.0',
100 - 'id': 3,
101 - 'method': 'tools/call',
102 - 'params': {'name': name, 'arguments': arguments},
103 - },
104 - );
320 + final response = await _sendJsonRpc('tools/call', {
321 + 'name': name,
322 + 'arguments': arguments,
323 + });
324
106 - final result = response.data['result'];
325 + final result = response['result'];
326 if (result == null) {
327 throw McpException('Tool call returned no result');
328 }
@@ -126,17 +345,9 @@ class McpClient {
345 }
346
347 try {
129 - final response = await _dio.post(
130 - serverUrl,
131 - data: {
132 - 'jsonrpc': '2.0',
133 - 'id': 4,
134 - 'method': 'resources/list',
135 - 'params': {},
136 - },
137 - );
348 + final response = await _sendJsonRpc('resources/list', {});
349
139 - final resources = response.data['result']['resources'] as List?;
350 + final resources = response['result']['resources'] as List?;
351 if (resources == null) return [];
352
353 return resources
@@ -153,17 +364,9 @@ class McpClient {
364 }
365
366 try {
156 - final response = await _dio.post(
157 - serverUrl,
158 - data: {
159 - 'jsonrpc': '2.0',
160 - 'id': 5,
161 - 'method': 'resources/read',
162 - 'params': {'uri': uri},
163 - },
164 - );
367 + final response = await _sendJsonRpc('resources/read', {'uri': uri});
368
166 - final contents = response.data['result']['contents'] as List?;
369 + final contents = response['result']['contents'] as List?;
370 if (contents == null || contents.isEmpty) {
371 return '';
372 }
@@ -175,6 +378,7 @@ class McpClient {
378 }
379
380 Future<void> close() async {
381 + await _closeSse();
382 _initialized = false;
383 _capabilities = null;
384 }
lib/features/chat/data/models/mcp_integration.dart
-6
@@ -71,26 +71,22 @@ class McpIntegration {
71
72 class ChatMcpConfig {
73 final List<McpIntegration> integrations;
74 - final bool autoExecuteTools;
74 final Map<String, String> activeMcpServers;
75 final bool enabled;
76
77 const ChatMcpConfig({
78 this.integrations = const [],
80 - this.autoExecuteTools = true,
79 this.activeMcpServers = const {},
80 this.enabled = true,
81 });
82
83 ChatMcpConfig copyWith({
84 List<McpIntegration>? integrations,
87 - bool? autoExecuteTools,
85 Map<String, String>? activeMcpServers,
86 bool? enabled,
87 }) {
88 return ChatMcpConfig(
89 integrations: integrations ?? this.integrations,
93 - autoExecuteTools: autoExecuteTools ?? this.autoExecuteTools,
90 activeMcpServers: activeMcpServers ?? this.activeMcpServers,
91 enabled: enabled ?? this.enabled,
92 );
@@ -99,7 +95,6 @@ class ChatMcpConfig {
95 Map<String, dynamic> toJson() {
96 return {
97 'integrations': integrations.map((i) => i.toJson()).toList(),
102 - 'autoExecuteTools': autoExecuteTools,
98 'activeMcpServers': activeMcpServers,
99 };
100 }
@@ -111,7 +106,6 @@ class ChatMcpConfig {
106 ?.map((i) => McpIntegration.fromJson(i as Map<String, dynamic>))
107 .toList() ??
108 [],
114 - autoExecuteTools: json['autoExecuteTools'] as bool? ?? true,
109 activeMcpServers:
110 (json['activeMcpServers'] as Map?)?.cast<String, String>() ?? {},
111 enabled: json['enabled'] as bool? ?? true,
lib/features/chat/providers/chat_mcp_providers.dart
+23 -3
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
2 import '../../../core/providers/app_providers.dart';
3 import '../data/models/mcp_integration.dart';
4 import '../../conversations/providers/conversation_providers.dart';
5 +import 'tooling_providers.dart';
6
7 export '../data/models/mcp_integration.dart';
8
@@ -15,6 +16,17 @@ class ChatMcpConfigNotifier extends Notifier<ChatMcpConfig> {
16
17 void setConfig(ChatMcpConfig config) {
18 state = config;
19 + final manager = ref.read(mcpServerManagerProvider);
20 + manager.clear();
21 + for (final integration in config.integrations) {
22 + if (integration.serverLabel != null && integration.serverUrl != null) {
23 + manager.addServer(
24 + integration.serverLabel!,
25 + integration.serverUrl!,
26 + headers: integration.headers,
27 + ).catchError((_) {});
28 + }
29 + }
30 }
31
32 void setEnabled(bool enabled) {
@@ -39,6 +51,13 @@ class ChatMcpConfigNotifier extends Notifier<ChatMcpConfig> {
51 }
52 : state.activeMcpServers,
53 );
54 + if (integration.serverLabel != null && integration.serverUrl != null) {
55 + ref.read(mcpServerManagerProvider).addServer(
56 + integration.serverLabel!,
57 + integration.serverUrl!,
58 + headers: integration.headers,
59 + ).catchError((_) {});
60 + }
61 }
62
63 void removeIntegration(int index) {
@@ -53,11 +72,11 @@ class ChatMcpConfigNotifier extends Notifier<ChatMcpConfig> {
72 integrations: newIntegrations,
73 activeMcpServers: newServers,
74 );
75 + if (integration.serverLabel != null) {
76 + ref.read(mcpServerManagerProvider).removeServer(integration.serverLabel!);
77 + }
78 }
79
58 - void toggleAutoExecute() {
59 - state = state.copyWith(autoExecuteTools: !state.autoExecuteTools);
60 - }
80
81 void toggleEnabled() {
82 state = state.copyWith(enabled: !state.enabled);
@@ -66,6 +85,7 @@ class ChatMcpConfigNotifier extends Notifier<ChatMcpConfig> {
85 void clearAll() {
86 final settings = ref.read(settingsProvider);
87 state = ChatMcpConfig(enabled: settings.newChatMcpEnabled);
88 + ref.read(mcpServerManagerProvider).clear();
89 }
90 }
91
lib/features/chat/providers/chat_providers.dart
+24 -19
@@ -26,6 +26,7 @@ import '../data/chat_service.dart';
26 import '../data/models/chat_parameters.dart';
27 import '../data/models/message.dart' hide ToolCallData;
28 import '../data/smart_reply_service.dart';
29 +import '../data/tools/tool_definition.dart';
30 import '../data/tools/tool_execution_loop.dart';
31 import '../data/tools/adapters/tool_transport_adapter.dart' show ParsedToolCall;
32 import '../../../core/providers/chat_background_service_provider.dart';
@@ -734,6 +735,11 @@ class ChatNotifier extends Notifier<ChatState> {
735 ? mcpConfig.integrations
736 : null;
737
738 + final registry = ref.read(toolRegistryProvider);
739 + final tools = mcpConfig.enabled
740 + ? await registry.listTools()
741 + : const <ToolDefinition>[];
742 +
743 final collectedToolCalls = <ToolCallData>[];
744
745 bool stateNeedsUpdate = false;
@@ -761,6 +767,7 @@ class ChatNotifier extends Notifier<ChatState> {
767 messages: messagesForApi,
768 params: chatParams,
769 integrations: integrations,
770 + tools: tools,
771 )
772 .listen(
773 (response) async {
@@ -923,25 +930,23 @@ class ChatNotifier extends Notifier<ChatState> {
930 final loop = ToolExecutionLoop(
931 adapter: adapter,
932 registry: registry,
926 - onRequestApproval: mcpConfig.autoExecuteTools
927 - ? null
928 - : (call) async {
929 - final completer = Completer<bool>();
930 - state = state.copyWith(
931 - pendingToolApproval: PendingToolApproval(
932 - toolCall: call,
933 - completer: completer,
934 - ),
935 - );
936 -
937 - final result = await completer.future;
938 -
939 - state = state.copyWith(
940 - clearPendingApproval: true,
941 - );
942 -
943 - return result;
944 - },
933 + onRequestApproval: (call) async {
934 + final completer = Completer<bool>();
935 + state = state.copyWith(
936 + pendingToolApproval: PendingToolApproval(
937 + toolCall: call,
938 + completer: completer,
939 + ),
940 + );
941 +
942 + final result = await completer.future;
943 +
944 + state = state.copyWith(
945 + clearPendingApproval: true,
946 + );
947 +
948 + return result;
949 + },
950 );
951
952 final loopResult = await loop.run(
lib/features/chat/views/components/chat_bubble.dart
+445 -135
@@ -1,4 +1,5 @@
1 import 'dart:async';
2 +import 'dart:convert';
3 import 'dart:io';
4 import 'package:flutter/material.dart';
5 import 'package:gpt_markdown/gpt_markdown.dart';
@@ -691,75 +692,306 @@ class _ToolBubble extends StatelessWidget {
692 }
693 }
694
695 +class _AnimatedRunningIcon extends StatefulWidget {
696 + const _AnimatedRunningIcon({required this.color});
697 + final Color color;
698 +
699 + @override
700 + State<_AnimatedRunningIcon> createState() => _AnimatedRunningIconState();
701 +}
702 +
703 +class _AnimatedRunningIconState extends State<_AnimatedRunningIcon>
704 + with SingleTickerProviderStateMixin {
705 + late AnimationController _controller;
706 +
707 + @override
708 + void initState() {
709 + super.initState();
710 + _controller = AnimationController(
711 + vsync: this,
712 + duration: const Duration(seconds: 2),
713 + )..repeat();
714 + }
715 +
716 + @override
717 + void dispose() {
718 + _controller.dispose();
719 + super.dispose();
720 + }
721 +
722 + @override
723 + Widget build(BuildContext context) {
724 + return RotationTransition(
725 + turns: _controller,
726 + child: Icon(
727 + Icons.cached_rounded,
728 + size: 14,
729 + color: widget.color,
730 + ),
731 + );
732 + }
733 +}
734 +
735 +class _GroupedToolCall {
736 + final String baseId;
737 + final String toolName;
738 + final ToolProviderType providerType;
739 + final String? providerRef;
740 + final Map<String, dynamic>? arguments;
741 + final ToolEventStatus status;
742 + final String? result;
743 + final String? error;
744 + final int? durationMs;
745 + final DateTime timestamp;
746 +
747 + const _GroupedToolCall({
748 + required this.baseId,
749 + required this.toolName,
750 + required this.providerType,
751 + this.providerRef,
752 + this.arguments,
753 + required this.status,
754 + this.result,
755 + this.error,
756 + this.durationMs,
757 + required this.timestamp,
758 + });
759 +}
760 +
761 class _ToolTimeline extends StatelessWidget {
762 const _ToolTimeline({required this.events});
763 final List<ToolEvent> events;
764
765 + List<_GroupedToolCall> _groupEvents(List<ToolEvent> events) {
766 + final Map<String, _GroupedToolCall> groups = {};
767 + final List<String> orderedBaseIds = [];
768 +
769 + for (final event in events) {
770 + final dotIndex = event.eventId.lastIndexOf('.');
771 + final baseId = dotIndex != -1 ? event.eventId.substring(0, dotIndex) : event.eventId;
772 +
773 + if (!orderedBaseIds.contains(baseId)) {
774 + orderedBaseIds.add(baseId);
775 + }
776 +
777 + final existing = groups[baseId];
778 + if (existing == null) {
779 + groups[baseId] = _GroupedToolCall(
780 + baseId: baseId,
781 + toolName: event.toolName,
782 + providerType: event.providerType,
783 + providerRef: event.providerRef,
784 + arguments: event.arguments,
785 + status: event.status,
786 + result: event.result,
787 + error: event.error,
788 + durationMs: event.durationMs,
789 + timestamp: event.timestamp,
790 + );
791 + } else {
792 + groups[baseId] = _GroupedToolCall(
793 + baseId: baseId,
794 + toolName: event.toolName,
795 + providerType: event.providerType,
796 + providerRef: event.providerRef,
797 + arguments: event.arguments ?? existing.arguments,
798 + status: event.status,
799 + result: event.result ?? existing.result,
800 + error: event.error ?? existing.error,
801 + durationMs: event.durationMs ?? existing.durationMs,
802 + timestamp: event.timestamp,
803 + );
804 + }
805 + }
806 +
807 + return orderedBaseIds.map((id) => groups[id]!).toList();
808 + }
809 +
810 + String _formatArgs(Map<String, dynamic> args) {
811 + if (args.isEmpty) return '';
812 + try {
813 + final encoder = JsonEncoder.withIndent(' ');
814 + return encoder.convert(args);
815 + } catch (_) {
816 + return args.entries.map((e) => ' ${e.key}: ${e.value}').join('\n');
817 + }
818 + }
819 +
820 @override
821 Widget build(BuildContext context) {
822 final theme = Theme.of(context);
823 final isDark = theme.brightness == Brightness.dark;
824 + final groupedCalls = _groupEvents(events);
825 +
826 + if (groupedCalls.isEmpty) {
827 + return const SizedBox.shrink();
828 + }
829
830 return Container(
831 + margin: const EdgeInsets.only(top: 8, bottom: 4),
832 decoration: BoxDecoration(
705 - color: isDark ? const Color(0xFF1E293B) : const Color(0xFFF8FAFC),
706 - borderRadius: BorderRadius.circular(8),
833 + color: isDark
834 + ? const Color(0xFF1E1E2E).withValues(alpha: 0.6)
835 + : const Color(0xFFF5F7FF).withValues(alpha: 0.8),
836 + borderRadius: BorderRadius.circular(12),
837 border: Border.all(
708 - color: isDark ? const Color(0xFF334155) : const Color(0xFFE2E8F0),
838 + color: isDark
839 + ? const Color(0xFF313244).withValues(alpha: 0.8)
840 + : const Color(0xFFE0E5F5),
841 ),
842 + boxShadow: [
843 + BoxShadow(
844 + color: Colors.black.withValues(alpha: isDark ? 0.1 : 0.02),
845 + blurRadius: 8,
846 + offset: const Offset(0, 4),
847 + ),
848 + ],
849 ),
850 child: Column(
851 crossAxisAlignment: CrossAxisAlignment.start,
852 children: [
714 - Padding(
715 - padding: const EdgeInsets.fromLTRB(10, 8, 10, 4),
853 + // Header Bar
854 + Container(
855 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
856 + decoration: BoxDecoration(
857 + border: Border(
858 + bottom: BorderSide(
859 + color: isDark
860 + ? const Color(0xFF313244).withValues(alpha: 0.5)
861 + : const Color(0xFFE0E5F5).withValues(alpha: 0.5),
862 + ),
863 + ),
864 + ),
865 child: Row(
866 children: [
867 Icon(
719 - Icons.troubleshoot,
720 - size: 14,
868 + Icons.terminal_rounded,
869 + size: 16,
870 color: isDark
722 - ? const Color(0xFF94A3B8)
723 - : const Color(0xFF64748B),
871 + ? const Color(0xFFBAC2DE)
872 + : const Color(0xFF585B70),
873 ),
725 - const SizedBox(width: 4),
874 + const SizedBox(width: 8),
875 Text(
727 - 'Tools',
876 + 'SYSTEM ACTIONS',
877 style: TextStyle(
878 fontSize: 11,
730 - fontWeight: FontWeight.w600,
879 + fontWeight: FontWeight.bold,
880 + letterSpacing: 0.5,
881 color: isDark
732 - ? const Color(0xFF94A3B8)
733 - : const Color(0xFF64748B),
882 + ? const Color(0xFFBAC2DE)
883 + : const Color(0xFF585B70),
884 + ),
885 + ),
886 + const Spacer(),
887 + Container(
888 + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
889 + decoration: BoxDecoration(
890 + color: isDark ? const Color(0xFF313244) : const Color(0xFFE0E5F5),
891 + borderRadius: BorderRadius.circular(6),
892 + ),
893 + child: Text(
894 + '${groupedCalls.length} ${groupedCalls.length == 1 ? "action" : "actions"}',
895 + style: TextStyle(
896 + fontSize: 9,
897 + fontWeight: FontWeight.bold,
898 + color: isDark ? Colors.white70 : Colors.black54,
899 + ),
900 ),
901 ),
902 ],
903 ),
904 ),
739 - ...events.map((event) => _buildEventRow(context, event, isDark)),
905 + const SizedBox(height: 4),
906 + // Grouped tool calls
907 + Padding(
908 + padding: const EdgeInsets.symmetric(vertical: 4),
909 + child: ListView.separated(
910 + shrinkWrap: true,
911 + physics: const NeverScrollableScrollPhysics(),
912 + itemCount: groupedCalls.length,
913 + separatorBuilder: (context, index) => Padding(
914 + padding: const EdgeInsets.symmetric(vertical: 6),
915 + child: Divider(
916 + height: 1,
917 + thickness: 1,
918 + color: isDark
919 + ? const Color(0xFF313244).withValues(alpha: 0.3)
920 + : const Color(0xFFE0E5F5).withValues(alpha: 0.5),
921 + ),
922 + ),
923 + itemBuilder: (context, index) => _ToolRowWidget(
924 + call: groupedCalls[index],
925 + isDark: isDark,
926 + formatArgs: _formatArgs,
927 + ),
928 + ),
929 + ),
930 + const SizedBox(height: 4),
931 ],
932 ),
933 );
934 }
935 +}
936 +
937 +class _ToolRowWidget extends StatefulWidget {
938 + const _ToolRowWidget({
939 + required this.call,
940 + required this.isDark,
941 + required this.formatArgs,
942 + });
943 +
944 + final _GroupedToolCall call;
945 + final bool isDark;
946 + final String Function(Map<String, dynamic>) formatArgs;
947 +
948 + @override
949 + State<_ToolRowWidget> createState() => _ToolRowWidgetState();
950 +}
951 +
952 +class _ToolRowWidgetState extends State<_ToolRowWidget> {
953 + late bool _isExpanded;
954 +
955 + @override
956 + void initState() {
957 + super.initState();
958 + _isExpanded = widget.call.status == ToolEventStatus.running ||
959 + widget.call.status == ToolEventStatus.failed ||
960 + widget.call.status == ToolEventStatus.requested ||
961 + widget.call.status == ToolEventStatus.approved;
962 + }
963
745 - Widget _buildEventRow(BuildContext context, ToolEvent event, bool isDark) {
746 - final iconData = switch (event.status) {
747 - ToolEventStatus.requested => Icons.hourglass_empty,
748 - ToolEventStatus.approved => Icons.check_circle_outline,
749 - ToolEventStatus.rejected => Icons.cancel_outlined,
750 - ToolEventStatus.running => Icons.play_circle_outline,
751 - ToolEventStatus.completed => Icons.check_circle,
752 - ToolEventStatus.failed => Icons.error_outline,
753 - };
754 - final iconColor = switch (event.status) {
964 + @override
965 + void didUpdateWidget(_ToolRowWidget oldWidget) {
966 + super.didUpdateWidget(oldWidget);
967 + if (oldWidget.call.status != widget.call.status) {
968 + if (widget.call.status == ToolEventStatus.running ||
969 + widget.call.status == ToolEventStatus.failed ||
970 + widget.call.status == ToolEventStatus.requested ||
971 + widget.call.status == ToolEventStatus.approved) {
972 + setState(() {
973 + _isExpanded = true;
974 + });
975 + }
976 + }
977 + }
978 +
979 + @override
980 + Widget build(BuildContext context) {
981 + final call = widget.call;
982 + final isDark = widget.isDark;
983 + final isMcpTool = call.providerType == ToolProviderType.mcp;
984 +
985 + final iconColor = switch (call.status) {
986 ToolEventStatus.completed => const Color(0xFF22C55E),
987 ToolEventStatus.failed ||
988 ToolEventStatus.rejected => const Color(0xFFEF4444),
758 - ToolEventStatus.running ||
759 - ToolEventStatus.requested => const Color(0xFFF59E0B),
760 - ToolEventStatus.approved => const Color(0xFF3B82F6),
989 + ToolEventStatus.running => const Color(0xFF3B82F6),
990 + ToolEventStatus.requested ||
991 + ToolEventStatus.approved => const Color(0xFFF59E0B),
992 };
762 - final label = switch (event.status) {
993 +
994 + final statusLabel = switch (call.status) {
995 ToolEventStatus.requested => 'Requested',
996 ToolEventStatus.approved => 'Approved',
997 ToolEventStatus.rejected => 'Rejected',
@@ -767,148 +999,226 @@ class _ToolTimeline extends StatelessWidget {
999 ToolEventStatus.completed => 'Done',
1000 ToolEventStatus.failed => 'Failed',
1001 };
770 - final l10n = AppLocalizations.of(context)!;
771 - final isMcpTool = event.providerType == ToolProviderType.mcp;
1002 +
1003 + final showCodeBlock = (call.arguments != null && call.arguments!.isNotEmpty) ||
1004 + (call.result != null && call.result!.isNotEmpty) ||
1005 + (call.error != null && call.error!.isNotEmpty);
1006
1007 return Padding(
774 - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
775 - child: Row(
1008 + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
1009 + child: Column(
1010 crossAxisAlignment: CrossAxisAlignment.start,
1011 children: [
778 - Padding(
779 - padding: const EdgeInsets.only(top: 2),
780 - child: Icon(iconData, size: 14, color: iconColor),
781 - ),
782 - const SizedBox(width: 6),
783 - Expanded(
784 - child: Column(
785 - crossAxisAlignment: CrossAxisAlignment.start,
786 - children: [
787 - Row(
788 - children: [
789 - Flexible(
790 - child: Text(
791 - event.toolName,
792 - style: TextStyle(
793 - fontSize: 12,
794 - fontWeight: FontWeight.w600,
795 - color: isDark ? Colors.white : Colors.black87,
796 - fontFamily: 'monospace',
1012 + InkWell(
1013 + onTap: showCodeBlock
1014 + ? () => setState(() => _isExpanded = !_isExpanded)
1015 + : null,
1016 + borderRadius: BorderRadius.circular(6),
1017 + child: Padding(
1018 + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4),
1019 + child: Row(
1020 + crossAxisAlignment: CrossAxisAlignment.center,
1021 + children: [
1022 + if (call.status == ToolEventStatus.running)
1023 + _AnimatedRunningIcon(color: iconColor)
1024 + else
1025 + Icon(
1026 + switch (call.status) {
1027 + ToolEventStatus.requested => Icons.hourglass_empty_rounded,
1028 + ToolEventStatus.approved => Icons.check_circle_outline_rounded,
1029 + ToolEventStatus.rejected => Icons.block_flipped,
1030 + ToolEventStatus.running => Icons.cached_rounded,
1031 + ToolEventStatus.completed => Icons.check_circle_rounded,
1032 + ToolEventStatus.failed => Icons.error_rounded,
1033 + },
1034 + size: 14,
1035 + color: iconColor,
1036 + ),
1037 + const SizedBox(width: 8),
1038 + Expanded(
1039 + child: Row(
1040 + children: [
1041 + Flexible(
1042 + child: Text(
1043 + call.toolName,
1044 + style: TextStyle(
1045 + fontSize: 12,
1046 + fontWeight: FontWeight.bold,
1047 + color: isDark ? Colors.white : Colors.black87,
1048 + fontFamily: 'monospace',
1049 + ),
1050 + overflow: TextOverflow.ellipsis,
1051 + ),
1052 ),
798 - overflow: TextOverflow.ellipsis,
799 - ),
1053 + const SizedBox(width: 6),
1054 + if (isMcpTool) ...[
1055 + _InlineToolBadge(
1056 + label: 'MCP',
1057 + backgroundColor: Colors.purple.withValues(alpha: isDark ? 0.2 : 0.1),
1058 + textColor: isDark ? const Color(0xFFC084FC) : const Color(0xFF7E22CE),
1059 + ),
1060 + ] else if (call.providerType == ToolProviderType.lmStudioServer) ...[
1061 + _InlineToolBadge(
1062 + label: 'LM Studio',
1063 + backgroundColor: Colors.orange.withValues(alpha: isDark ? 0.2 : 0.1),
1064 + textColor: isDark ? const Color(0xFFF97316) : const Color(0xFFC2410C),
1065 + ),
1066 + ] else ...[
1067 + _InlineToolBadge(
1068 + label: 'LOCAL',
1069 + backgroundColor: Colors.blue.withValues(alpha: isDark ? 0.2 : 0.1),
1070 + textColor: isDark ? const Color(0xFF60A5FA) : const Color(0xFF1D4ED8),
1071 + ),
1072 + ],
1073 + const SizedBox(width: 6),
1074 + _InlineToolBadge(
1075 + label: statusLabel,
1076 + backgroundColor: iconColor.withValues(alpha: isDark ? 0.15 : 0.1),
1077 + textColor: iconColor,
1078 + ),
1079 + ],
1080 ),
801 - const SizedBox(width: 4),
802 - if (isMcpTool) ...[
803 - _InlineToolBadge(label: l10n.beta_label, isDark: isDark),
804 - const SizedBox(width: 4),
805 - _InlineToolBadge(
806 - label: l10n.experimental_label,
807 - isDark: isDark,
808 - ),
809 - const SizedBox(width: 4),
810 - ],
1081 + ),
1082 + if (call.durationMs != null) ...[
1083 + const SizedBox(width: 8),
1084 Text(
812 - label,
1085 + '${call.durationMs}ms',
1086 style: TextStyle(
1087 fontSize: 10,
815 - color: iconColor,
816 - fontWeight: FontWeight.w500,
1088 + color: isDark ? const Color(0xFF6B7280) : const Color(0xFF9CA3AF),
1089 ),
1090 ),
1091 ],
820 - ),
821 - if (event.arguments != null && event.arguments!.isNotEmpty)
822 - Padding(
823 - padding: const EdgeInsets.only(top: 2),
824 - child: Text(
825 - _formatArgs(event.arguments!),
826 - style: TextStyle(
827 - fontSize: 10,
828 - color: isDark
829 - ? const Color(0xFF94A3B8)
830 - : const Color(0xFF64748B),
831 - fontFamily: 'monospace',
832 - ),
833 - maxLines: 3,
834 - overflow: TextOverflow.ellipsis,
1092 + if (showCodeBlock) ...[
1093 + const SizedBox(width: 6),
1094 + Icon(
1095 + _isExpanded
1096 + ? Icons.keyboard_arrow_up_rounded
1097 + : Icons.keyboard_arrow_down_rounded,
1098 + size: 16,
1099 + color: isDark ? const Color(0xFF9CA3AF) : const Color(0xFF6B7280),
1100 ),
1101 + ],
1102 + ],
1103 + ),
1104 + ),
1105 + ),
1106 + if (showCodeBlock && _isExpanded)
1107 + Padding(
1108 + padding: const EdgeInsets.only(top: 6, left: 22),
1109 + child: Container(
1110 + width: double.infinity,
1111 + padding: const EdgeInsets.all(8),
1112 + decoration: BoxDecoration(
1113 + color: isDark ? const Color(0xFF0F172A) : const Color(0xFFF1F5F9),
1114 + borderRadius: BorderRadius.circular(8),
1115 + border: Border.all(
1116 + color: isDark ? const Color(0xFF1E293B) : const Color(0xFFE2E8F0),
1117 ),
837 - if (event.result != null && event.result!.isNotEmpty)
838 - Padding(
839 - padding: const EdgeInsets.only(top: 2),
840 - child: Text(
841 - '→ ${event.result}',
842 - style: TextStyle(
843 - fontSize: 10,
844 - color: isDark
845 - ? const Color(0xFF86EFAC)
846 - : const Color(0xFF166534),
847 - fontFamily: 'monospace',
1118 + ),
1119 + child: Column(
1120 + crossAxisAlignment: CrossAxisAlignment.start,
1121 + children: [
1122 + if (call.arguments != null && call.arguments!.isNotEmpty) ...[
1123 + Text(
1124 + 'Arguments:',
1125 + style: TextStyle(
1126 + fontSize: 9,
1127 + fontWeight: FontWeight.bold,
1128 + color: isDark ? const Color(0xFF38BDF8) : const Color(0xFF0284C7),
1129 + fontFamily: 'monospace',
1130 + ),
1131 ),
849 - maxLines: 2,
850 - overflow: TextOverflow.ellipsis,
851 - ),
852 - ),
853 - if (event.error != null && event.error!.isNotEmpty)
854 - Padding(
855 - padding: const EdgeInsets.only(top: 2),
856 - child: Text(
857 - '✕ ${event.error}',
858 - style: TextStyle(
859 - fontSize: 10,
860 - color: const Color(0xFFEF4444),
861 - fontFamily: 'monospace',
1132 + const SizedBox(height: 2),
1133 + Text(
1134 + widget.formatArgs(call.arguments!),
1135 + style: TextStyle(
1136 + fontSize: 10.5,
1137 + color: isDark ? const Color(0xFFCBD5E1) : const Color(0xFF475569),
1138 + fontFamily: 'monospace',
1139 + height: 1.3,
1140 + ),
1141 ),
863 - maxLines: 2,
864 - overflow: TextOverflow.ellipsis,
865 - ),
866 - ),
867 - if (event.durationMs != null)
868 - Padding(
869 - padding: const EdgeInsets.only(top: 1),
870 - child: Text(
871 - '${event.durationMs}ms',
872 - style: TextStyle(
873 - fontSize: 9,
874 - color: isDark
875 - ? const Color(0xFF64748B)
876 - : const Color(0xFF94A3B8),
1142 + ],
1143 + if (call.result != null && call.result!.isNotEmpty) ...[
1144 + if (call.arguments != null && call.arguments!.isNotEmpty)
1145 + const SizedBox(height: 8),
1146 + Text(
1147 + 'Output:',
1148 + style: TextStyle(
1149 + fontSize: 9,
1150 + fontWeight: FontWeight.bold,
1151 + color: isDark ? const Color(0xFF4ADE80) : const Color(0xFF16A34A),
1152 + fontFamily: 'monospace',
1153 + ),
1154 ),
878 - ),
879 - ),
880 - ],
1155 + const SizedBox(height: 2),
1156 + Text(
1157 + call.result!,
1158 + style: TextStyle(
1159 + fontSize: 10.5,
1160 + color: isDark ? const Color(0xFFF8FAFC) : const Color(0xFF0F172A),
1161 + fontFamily: 'monospace',
1162 + height: 1.3,
1163 + ),
1164 + ),
1165 + ],
1166 + if (call.error != null && call.error!.isNotEmpty) ...[
1167 + if (call.arguments != null && call.arguments!.isNotEmpty)
1168 + const SizedBox(height: 8),
1169 + Text(
1170 + 'Error:',
1171 + style: TextStyle(
1172 + fontSize: 9,
1173 + fontWeight: FontWeight.bold,
1174 + color: isDark ? const Color(0xFFF87171) : const Color(0xFFDC2626),
1175 + fontFamily: 'monospace',
1176 + ),
1177 + ),
1178 + const SizedBox(height: 2),
1179 + Text(
1180 + call.error!,
1181 + style: TextStyle(
1182 + fontSize: 10.5,
1183 + color: isDark ? const Color(0xFFFCA5A5) : const Color(0xFF991B1B),
1184 + fontFamily: 'monospace',
1185 + height: 1.3,
1186 + ),
1187 + ),
1188 + ],
1189 + ],
1190 + ),
1191 + ),
1192 ),
882 - ),
1193 ],
1194 ),
1195 );
1196 }
887 -
888 - String _formatArgs(Map<String, dynamic> args) {
889 - return args.entries.map((e) => '${e.key}: ${e.value}').join(', ');
890 - }
1197 }
1198
1199 class _InlineToolBadge extends StatelessWidget {
894 - const _InlineToolBadge({required this.label, required this.isDark});
1200 + const _InlineToolBadge({
1201 + required this.label,
1202 + required this.backgroundColor,
1203 + required this.textColor,
1204 + });
1205
1206 final String label;
897 - final bool isDark;
1207 + final Color backgroundColor;
1208 + final Color textColor;
1209
1210 @override
1211 Widget build(BuildContext context) {
1212 return Container(
1213 padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
1214 decoration: BoxDecoration(
904 - color: Colors.amber.withValues(alpha: isDark ? 0.18 : 0.14),
905 - borderRadius: BorderRadius.circular(999),
906 - border: Border.all(color: Colors.amber.withValues(alpha: 0.45)),
1215 + color: backgroundColor,
1216 + borderRadius: BorderRadius.circular(4),
1217 ),
1218 child: Text(
1219 label.toUpperCase(),
910 - style: const TextStyle(
911 - color: Color(0xFFB45309),
1220 + style: TextStyle(
1221 + color: textColor,
1222 fontSize: 8,
1223 fontWeight: FontWeight.w700,
1224 letterSpacing: 0.35,
lib/features/chat/views/components/chat_settings_sheet.dart
-18
@@ -151,8 +151,6 @@ class _ChatSettingsSheetState extends ConsumerState<ChatSettingsSheet> {
151 ),
152 const SizedBox(width: 8),
153 Text(l10n.mcp_tab),
154 - const SizedBox(width: 6),
155 - _McpBadge(label: l10n.beta_label, isDark: isDark),
154 ],
155 ),
156 ),
@@ -294,28 +292,12 @@ class _ChatSettingsSheetState extends ConsumerState<ChatSettingsSheet> {
292 crossAxisAlignment: WrapCrossAlignment.center,
293 children: [
294 Text(l10n.mcp_enable_chat),
297 - _McpBadge(label: l10n.beta_label, isDark: isDark),
295 _McpBadge(label: l10n.experimental_label, isDark: isDark),
296 ],
297 ),
298 ),
299 const SizedBox(height: 16),
300 if (mcpConfig.enabled && isGloballyEnabled) ...[
304 - ShadSwitch(
305 - value: mcpConfig.autoExecuteTools,
306 - onChanged: (v) =>
307 - ref.read(chatMcpConfigProvider.notifier).toggleAutoExecute(),
308 - label: Wrap(
309 - spacing: 6,
310 - runSpacing: 4,
311 - crossAxisAlignment: WrapCrossAlignment.center,
312 - children: [
313 - Text(l10n.auto_execute_tools),
314 - _McpBadge(label: l10n.experimental_label, isDark: isDark),
315 - ],
316 - ),
317 - ),
318 - const SizedBox(height: 24),
301 Text(l10n.add_ephemeral_mcp, style: theme.textTheme.list),
302 const SizedBox(height: 12),
303 Row(
lib/features/chat/views/components/reasoning_widget.dart
+165 -94
@@ -1,4 +1,5 @@
1 import 'package:flutter/material.dart';
2 +import 'package:gpt_markdown/gpt_markdown.dart';
3 import 'package:localmind/l10n/app_localizations.dart';
4
5 class ReasoningWidget extends StatefulWidget {
@@ -15,34 +16,54 @@ class ReasoningWidget extends StatefulWidget {
16 State<ReasoningWidget> createState() => _ReasoningWidgetState();
17 }
18
18 -class _ReasoningWidgetState extends State<ReasoningWidget> {
19 +class _ReasoningWidgetState extends State<ReasoningWidget> with SingleTickerProviderStateMixin {
20 late bool _isExpanded;
21 + late AnimationController _animationController;
22 + late Animation<double> _expandAnimation;
23
24 @override
25 void initState() {
26 super.initState();
27 _isExpanded = widget.isStreaming;
28 + _animationController = AnimationController(
29 + vsync: this,
30 + duration: const Duration(milliseconds: 250),
31 + );
32 + _expandAnimation = CurvedAnimation(
33 + parent: _animationController,
34 + curve: Curves.fastOutSlowIn,
35 + );
36 + if (_isExpanded) {
37 + _animationController.value = 1.0;
38 + }
39 + }
40 +
41 + @override
42 + void dispose() {
43 + _animationController.dispose();
44 + super.dispose();
45 }
46
47 @override
48 void didUpdateWidget(ReasoningWidget oldWidget) {
49 super.didUpdateWidget(oldWidget);
50 if (widget.isStreaming && !oldWidget.isStreaming) {
31 - _isExpanded = true;
51 + setState(() {
52 + _isExpanded = true;
53 + _animationController.forward();
54 + });
55 }
56 }
57
35 - String _getLastLines(String content, int maxLines) {
36 - var lineCount = 0;
37 - for (var i = content.length - 1; i >= 0; i--) {
38 - if (content.codeUnitAt(i) == 10) {
39 - lineCount++;
40 - if (lineCount >= maxLines) {
41 - return '... ${content.substring(i + 1).trim()}';
42 - }
58 + void _toggleExpand() {
59 + setState(() {
60 + _isExpanded = !_isExpanded;
61 + if (_isExpanded) {
62 + _animationController.forward();
63 + } else {
64 + _animationController.reverse();
65 }
44 - }
45 - return content.trim();
66 + });
67 }
68
69 @override
@@ -56,98 +77,148 @@ class _ReasoningWidgetState extends State<ReasoningWidget> {
77 return const SizedBox.shrink();
78 }
79
59 - final previewText = hasContent
60 - ? _getLastLines(widget.reasoningContent!, 4)
61 - : '';
62 - final visibleReasoningText = widget.isStreaming
63 - ? previewText
64 - : (_isExpanded ? widget.reasoningContent! : previewText);
65 -
66 - return GestureDetector(
67 - onTap: () => setState(() => _isExpanded = !_isExpanded),
68 - child: AnimatedContainer(
69 - duration: const Duration(milliseconds: 200),
70 - margin: const EdgeInsets.only(bottom: 8),
71 - padding: const EdgeInsets.all(12),
72 - decoration: BoxDecoration(
80 + return Container(
81 + margin: const EdgeInsets.only(bottom: 12, top: 4),
82 + decoration: BoxDecoration(
83 + color: isDark
84 + ? const Color(0xFF1E1E2E).withValues(alpha: 0.6)
85 + : const Color(0xFFF5F7FF).withValues(alpha: 0.8),
86 + borderRadius: BorderRadius.circular(12),
87 + border: Border.all(
88 color: isDark
74 - ? const Color(0xFF1F2937).withValues(alpha: 0.5)
75 - : const Color(0xFFEEF2FF).withValues(alpha: 0.5),
76 - borderRadius: BorderRadius.circular(8),
77 - border: Border.all(
78 - color: isDark ? const Color(0xFF374151) : const Color(0xFFE0E7FF),
79 - ),
89 + ? const Color(0xFF313244).withValues(alpha: 0.8)
90 + : const Color(0xFFE0E5F5),
91 + width: 1.0,
92 ),
81 - child: Column(
82 - crossAxisAlignment: CrossAxisAlignment.start,
83 - children: [
84 - Row(
85 - children: [
86 - if (widget.isStreaming)
87 - SizedBox(
88 - width: 12,
89 - height: 12,
90 - child: CircularProgressIndicator(
91 - strokeWidth: 2,
92 - color: theme.colorScheme.primary.withValues(alpha: 0.6),
93 - ),
94 - )
95 - else
96 - Icon(
97 - _isExpanded ? Icons.expand_less : Icons.expand_more,
98 - size: 16,
99 - color: isDark
100 - ? const Color(0xFF9CA3AF)
101 - : const Color(0xFF6B7280),
102 - ),
103 - const SizedBox(width: 8),
104 - Text(
105 - l10n.thinking,
106 - style: TextStyle(
107 - fontSize: 12,
108 - fontWeight: FontWeight.w500,
109 - color: isDark
110 - ? const Color(0xFF9CA3AF)
111 - : const Color(0xFF6B7280),
93 + boxShadow: [
94 + BoxShadow(
95 + color: Colors.black.withValues(alpha: isDark ? 0.15 : 0.03),
96 + blurRadius: 10,
97 + offset: const Offset(0, 4),
98 + ),
99 + ],
100 + ),
101 + child: ClipRRect(
102 + borderRadius: BorderRadius.circular(11),
103 + child: IntrinsicHeight(
104 + child: Row(
105 + crossAxisAlignment: CrossAxisAlignment.stretch,
106 + children: [
107 + // Accent glowing line
108 + Container(
109 + width: 4,
110 + decoration: BoxDecoration(
111 + gradient: LinearGradient(
112 + colors: [
113 + theme.colorScheme.primary,
114 + theme.colorScheme.secondary,
115 + ],
116 + begin: Alignment.topCenter,
117 + end: Alignment.bottomCenter,
118 ),
119 ),
114 - if (widget.isStreaming) ...[
115 - const SizedBox(width: 8),
116 - Text(
117 - _getThinkingIndicator(),
118 - style: TextStyle(
119 - fontSize: 11,
120 - color: isDark
121 - ? const Color(0xFF6B7280)
122 - : const Color(0xFF9CA3AF),
120 + ),
121 + Expanded(
122 + child: Column(
123 + mainAxisSize: MainAxisSize.min,
124 + crossAxisAlignment: CrossAxisAlignment.start,
125 + children: [
126 + // Header Row
127 + InkWell(
128 + onTap: _toggleExpand,
129 + borderRadius: const BorderRadius.only(
130 + topRight: Radius.circular(11),
131 + bottomRight: Radius.circular(11),
132 + ),
133 + child: Padding(
134 + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
135 + child: Row(
136 + children: [
137 + // Brain / thinking icon
138 + Icon(
139 + Icons.psychology_outlined,
140 + size: 18,
141 + color: isDark
142 + ? const Color(0xFFBAC2DE)
143 + : const Color(0xFF585B70),
144 + ),
145 + const SizedBox(width: 8),
146 + Text(
147 + l10n.thinking,
148 + style: TextStyle(
149 + fontSize: 13,
150 + fontWeight: FontWeight.w600,
151 + letterSpacing: 0.3,
152 + color: isDark
153 + ? const Color(0xFFBAC2DE)
154 + : const Color(0xFF585B70),
155 + ),
156 + ),
157 + const SizedBox(width: 8),
158 + if (widget.isStreaming) ...[
159 + SizedBox(
160 + width: 10,
161 + height: 10,
162 + child: CircularProgressIndicator(
163 + strokeWidth: 1.5,
164 + color: theme.colorScheme.primary,
165 + ),
166 + ),
167 + ],
168 + const Spacer(),
169 + // Expand Icon with rotation
170 + RotationTransition(
171 + turns: Tween(begin: 0.0, end: 0.5).animate(_expandAnimation),
172 + child: Icon(
173 + Icons.keyboard_arrow_down_rounded,
174 + size: 18,
175 + color: isDark
176 + ? const Color(0xFF9399B2)
177 + : const Color(0xFF7C7F93),
178 + ),
179 + ),
180 + ],
181 + ),
182 + ),
183 ),
124 - ),
125 - ],
126 - ],
127 - ),
128 - if (hasContent) ...[
129 - const SizedBox(height: 8),
130 - Text(
131 - visibleReasoningText,
132 - style: TextStyle(
133 - fontSize: 13,
134 - color: isDark
135 - ? const Color(0xFFD1D5DB)
136 - : const Color(0xFF4B5563),
137 - fontStyle: FontStyle.italic,
138 - height: 1.4,
184 + // Expandable content
185 + SizeTransition(
186 + sizeFactor: _expandAnimation,
187 + child: Padding(
188 + padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
189 + child: Column(
190 + crossAxisAlignment: CrossAxisAlignment.start,
191 + children: [
192 + GptMarkdownTheme(
193 + gptThemeData: GptMarkdownThemeData(
194 + brightness: isDark ? Brightness.dark : Brightness.light,
195 + highlightColor: isDark
196 + ? const Color(0xFF313244)
197 + : const Color(0xFFEFF1F5),
198 + linkColor: theme.colorScheme.primary,
199 + ),
200 + child: GptMarkdown(
201 + widget.reasoningContent ?? '',
202 + style: TextStyle(
203 + fontSize: 13,
204 + color: isDark
205 + ? const Color(0xFFCDD6F4).withValues(alpha: 0.95)
206 + : const Color(0xFF4C4F69).withValues(alpha: 0.95),
207 + height: 1.5,
208 + ),
209 + ),
210 + ),
211 + ],
212 + ),
213 + ),
214 + ),
215 + ],
216 ),
217 ),
218 ],
142 - ],
219 + ),
220 ),
221 ),
222 );
223 }
147 -
148 - final String _thinkingText = '';
149 - String _getThinkingIndicator() {
150 - if (!widget.isStreaming) return '';
151 - return _thinkingText;
152 - }
224 }
lib/features/on_device/data/on_device_chat_service.dart
+2
@@ -9,6 +9,7 @@ import '../../chat/data/chat_service.dart';
9 import '../../chat/data/models/chat_parameters.dart';
10 import '../../chat/data/models/mcp_integration.dart';
11 import '../../chat/data/models/message.dart' hide ToolCallData;
12 +import '../../chat/data/tools/tool_definition.dart';
13 import '../../servers/data/models/server.dart';
14 import 'on_device_gemma_service.dart';
15
@@ -30,6 +31,7 @@ class OnDeviceChatService implements ChatService {
31 required List<Message> messages,
32 required ChatParameters params,
33 List<McpIntegration>? integrations,
34 + List<ToolDefinition>? tools,
35 String? previousResponseId,
36 }) {
37 cancelStream();
lib/features/settings/views/settings_screen.dart
-2
@@ -162,7 +162,6 @@ class SettingsViews extends ConsumerWidget {
162 label: l10n.enable_mcp,
163 value: settings.mcpEnabled,
164 badges: [
165 - _FeatureBadge(label: l10n.beta_label),
165 _FeatureBadge(label: l10n.experimental_label),
166 ],
167 onChanged: (value) =>
@@ -172,7 +171,6 @@ class SettingsViews extends ConsumerWidget {
171 _ToggleSetting(
172 label: l10n.new_chat_mcp_default,
173 value: settings.newChatMcpEnabled,
175 - badges: [_FeatureBadge(label: l10n.beta_label)],
174 onChanged: (value) => ref
175 .read(settingsProvider.notifier)
176 .setNewChatMcpEnabled(value),
lib/features/sidebar/sidebar_widget.dart
-1
@@ -96,7 +96,6 @@ class SidebarWidget extends ConsumerWidget {
96 DrawerNavItem(
97 iconData: HugeIcons.strokeRoundedMcpServer,
98 label: 'MCP Tools',
99 - badgeText: 'Beta',
99 isSelected: isMcpTools,
100 onTap: () {
101 if (Scaffold.maybeOf(context)?.isDrawerOpen ?? false) {
lib/objectbox-model.json
+11 -1
@@ -111,7 +111,7 @@
111 },
112 {
113 "id": "2:196881068437544233",
114 - "lastPropertyId": "17:6197342254666211356",
114 + "lastPropertyId": "19:5573835801528383007",
115 "name": "MessageEntity",
116 "properties": [
117 {
@@ -206,6 +206,16 @@
206 "id": "17:6197342254666211356",
207 "name": "isProcessing",
208 "type": 1
209 + },
210 + {
211 + "id": "18:3862782083473953676",
212 + "name": "toolSessionId",
213 + "type": 9
214 + },
215 + {
216 + "id": "19:5573835801528383007",
217 + "name": "toolEventsJson",
218 + "type": 9
219 }
220 ],
221 "relations": []
lib/objectbox.g.dart
+40 -2
@@ -153,7 +153,7 @@ final _entities = <obx_int.ModelEntity>[
153 obx_int.ModelEntity(
154 id: const obx_int.IdUid(2, 196881068437544233),
155 name: 'MessageEntity',
156 - lastPropertyId: const obx_int.IdUid(17, 6197342254666211356),
156 + lastPropertyId: const obx_int.IdUid(19, 5573835801528383007),
157 flags: 0,
158 properties: <obx_int.ModelProperty>[
159 obx_int.ModelProperty(
@@ -263,6 +263,18 @@ final _entities = <obx_int.ModelEntity>[
263 type: 1,
264 flags: 0,
265 ),
266 + obx_int.ModelProperty(
267 + id: const obx_int.IdUid(18, 3862782083473953676),
268 + name: 'toolSessionId',
269 + type: 9,
270 + flags: 0,
271 + ),
272 + obx_int.ModelProperty(
273 + id: const obx_int.IdUid(19, 5573835801528383007),
274 + name: 'toolEventsJson',
275 + type: 9,
276 + flags: 0,
277 + ),
278 ],
279 relations: <obx_int.ModelRelation>[],
280 backlinks: <obx_int.ModelBacklink>[],
@@ -690,7 +702,13 @@ obx_int.ModelDefinition getObjectBoxModel() {
702 final toolCallIdOffset = object.toolCallId == null
703 ? null
704 : fbb.writeString(object.toolCallId!);
693 - fbb.startTable(18);
705 + final toolSessionIdOffset = object.toolSessionId == null
706 + ? null
707 + : fbb.writeString(object.toolSessionId!);
708 + final toolEventsJsonOffset = object.toolEventsJson == null
709 + ? null
710 + : fbb.writeString(object.toolEventsJson!);
711 + fbb.startTable(20);
712 fbb.addInt64(0, object.internalId);
713 fbb.addOffset(1, idOffset);
714 fbb.addInt64(2, object.conversation.targetId);
@@ -708,6 +726,8 @@ obx_int.ModelDefinition getObjectBoxModel() {
726 fbb.addOffset(14, toolCallsJsonOffset);
727 fbb.addOffset(15, toolCallIdOffset);
728 fbb.addBool(16, object.isProcessing);
729 + fbb.addOffset(17, toolSessionIdOffset);
730 + fbb.addOffset(18, toolEventsJsonOffset);
731 fbb.finish(fbb.endTable());
732 return object.internalId;
733 },
@@ -778,6 +798,12 @@ obx_int.ModelDefinition getObjectBoxModel() {
798 36,
799 false,
800 );
801 + final toolSessionIdParam = const fb.StringReader(
802 + asciiOptimization: true,
803 + ).vTableGetNullable(buffer, rootOffset, 38);
804 + final toolEventsJsonParam = const fb.StringReader(
805 + asciiOptimization: true,
806 + ).vTableGetNullable(buffer, rootOffset, 40);
807 final object = MessageEntity(
808 internalId: internalIdParam,
809 id: idParam,
@@ -795,6 +821,8 @@ obx_int.ModelDefinition getObjectBoxModel() {
821 toolCallsJson: toolCallsJsonParam,
822 toolCallId: toolCallIdParam,
823 isProcessing: isProcessingParam,
824 + toolSessionId: toolSessionIdParam,
825 + toolEventsJson: toolEventsJsonParam,
826 );
827 object.conversation.targetId = const fb.Int64Reader().vTableGet(
828 buffer,
@@ -1203,6 +1231,16 @@ class MessageEntity_ {
1231 static final isProcessing = obx.QueryBooleanProperty<MessageEntity>(
1232 _entities[1].properties[16],
1233 );
1234 +
1235 + /// See [MessageEntity.toolSessionId].
1236 + static final toolSessionId = obx.QueryStringProperty<MessageEntity>(
1237 + _entities[1].properties[17],
1238 + );
1239 +
1240 + /// See [MessageEntity.toolEventsJson].
1241 + static final toolEventsJson = obx.QueryStringProperty<MessageEntity>(
1242 + _entities[1].properties[18],
1243 + );
1244 }
1245
1246 /// [PersonaEntity] entity fields to define ObjectBox queries.
pubspec.yaml
+1 -1
@@ -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.3.6+38
19 +version: 1.3.7+39
20
21 environment:
22 sdk: ^3.11.0
test/features/chat/tools/chat_service_adapter_selection_test.dart
+149
@@ -1,9 +1,47 @@
1 +import 'dart:async';
2 +import 'dart:convert';
3 +import 'dart:typed_data';
4 +import 'package:dio/dio.dart';
5 import 'package:flutter_test/flutter_test.dart';
6 import 'package:localmind/core/models/enums.dart';
7 import 'package:localmind/features/chat/data/chat_service.dart';
8 +import 'package:localmind/features/chat/data/models/message.dart';
9 +import 'package:localmind/features/chat/data/models/chat_parameters.dart';
10 +import 'package:localmind/features/chat/data/tools/tool_definition.dart';
11 import 'package:localmind/features/chat/data/tools/adapters/ollama_tool_adapter.dart';
12 import 'package:localmind/features/chat/data/tools/adapters/openai_tool_adapter.dart';
13 import 'package:localmind/features/chat/data/tools/adapters/openrouter_tool_adapter.dart';
14 +import 'package:localmind/features/servers/data/models/server.dart';
15 +
16 +class StreamInterceptor extends Interceptor {
17 + final List<String> lines;
18 + StreamInterceptor(this.lines);
19 +
20 + @override
21 + void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
22 + final controller = StreamController<Uint8List>();
23 + handler.resolve(Response(
24 + requestOptions: options,
25 + data: ResponseBody(
26 + controller.stream,
27 + 200,
28 + headers: {
29 + Headers.contentTypeHeader: ['application/json'],
30 + },
31 + ),
32 + statusCode: 200,
33 + ));
34 +
35 + // Emit lines asynchronously
36 + Future.microtask(() async {
37 + for (final line in lines) {
38 + controller.add(Uint8List.fromList(utf8.encode('$line\n')));
39 + await Future.delayed(const Duration(milliseconds: 1));
40 + }
41 + await controller.close();
42 + });
43 + }
44 +}
45
46 void main() {
47 group('createAdapterForServerType', () {
@@ -22,4 +60,115 @@ void main() {
60 expect(adapter, isA<OllamaToolAdapter>());
61 });
62 });
63 +
64 + group('ChatService implementation tool call adapter integration', () {
65 + test('OpenAICompatibleChatService uses OpenAiToolAdapter and parses OpenAI stream tool calls', () async {
66 + final lines = [
67 + 'data: {"choices": [{"delta": {"tool_calls": [{"index": 0, "id": "call_openai_1", "type": "function", "function": {"name": "calc.add", "arguments": ""}}]}}]}',
68 + 'data: {"choices": [{"delta": {"tool_calls": [{"index": 0, "function": {"arguments": "{\\"a\\":1,\\"b\\":2}"}}]}}]}',
69 + 'data: [DONE]'
70 + ];
71 +
72 + final dio = Dio()..interceptors.add(StreamInterceptor(lines));
73 + final service = OpenAICompatibleChatService(dio);
74 +
75 + final server = Server(
76 + id: 'test-openai',
77 + name: 'Test OpenAI',
78 + type: ServerType.openAICompatible,
79 + host: 'localhost',
80 + port: 8080,
81 + createdAt: DateTime.now(),
82 + lastConnectedAt: DateTime.now(),
83 + );
84 +
85 + final responseStream = service.sendMessage(
86 + server: server,
87 + modelId: 'test-model',
88 + messages: [
89 + Message(
90 + id: 'msg_1',
91 + conversationId: 'conv_1',
92 + role: MessageRole.user,
93 + content: 'hello',
94 + createdAt: DateTime.now(),
95 + )
96 + ],
97 + params: ChatParameters.defaults(),
98 + tools: [
99 + const ToolDefinition(
100 + name: 'calc.add',
101 + description: 'Add two numbers',
102 + inputSchema: {'type': 'object'},
103 + providerType: ToolProviderType.builtIn,
104 + )
105 + ],
106 + );
107 +
108 + final responses = await responseStream.toList();
109 + final toolCalls = responses
110 + .where((r) => r.type == ChatResponseType.toolCall)
111 + .map((r) => r.toolCall)
112 + .toList();
113 +
114 + expect(toolCalls, isNotEmpty);
115 + expect(toolCalls.first!.tool, 'calc.add');
116 + expect(toolCalls.first!.arguments['a'], 1);
117 + expect(toolCalls.first!.arguments['b'], 2);
118 + });
119 +
120 + test('OllamaChatService uses OllamaToolAdapter and parses Ollama stream tool calls', () async {
121 + final lines = [
122 + '{"message": {"tool_calls": [{"id": "call_ollama_1", "function": {"name": "calc.multiply", "arguments": "{\\"a\\":5,\\"b\\":6}"}}]}}',
123 + '{"done": true}'
124 + ];
125 +
126 + final dio = Dio()..interceptors.add(StreamInterceptor(lines));
127 + final service = OllamaChatService(dio);
128 +
129 + final server = Server(
130 + id: 'test-ollama',
131 + name: 'Test Ollama',
132 + type: ServerType.ollama,
133 + host: 'localhost',
134 + port: 11434,
135 + createdAt: DateTime.now(),
136 + lastConnectedAt: DateTime.now(),
137 + );
138 +
139 + final responseStream = service.sendMessage(
140 + server: server,
141 + modelId: 'test-model',
142 + messages: [
143 + Message(
144 + id: 'msg_2',
145 + conversationId: 'conv_2',
146 + role: MessageRole.user,
147 + content: 'hello',
148 + createdAt: DateTime.now(),
149 + )
150 + ],
151 + params: ChatParameters.defaults(),
152 + tools: [
153 + const ToolDefinition(
154 + name: 'calc.multiply',
155 + description: 'Multiply two numbers',
156 + inputSchema: {'type': 'object'},
157 + providerType: ToolProviderType.builtIn,
158 + )
159 + ],
160 + );
161 +
162 + final responses = await responseStream.toList();
163 + final toolCalls = responses
164 + .where((r) => r.type == ChatResponseType.toolCall)
165 + .map((r) => r.toolCall)
166 + .toList();
167 +
168 + expect(toolCalls, isNotEmpty);
169 + expect(toolCalls.first!.tool, 'calc.multiply');
170 + expect(toolCalls.first!.arguments['a'], 5);
171 + expect(toolCalls.first!.arguments['b'], 6);
172 + });
173 + });
174 }