neoagent 2.3.1-beta.86 → 2.3.1-beta.88
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/capabilities.md +2 -0
- package/flutter_app/android/app/src/main/AndroidManifest.xml +14 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +84 -0
- package/flutter_app/lib/features/notifications/notification_interceptor.dart +11 -45
- package/flutter_app/lib/main.dart +1 -0
- package/flutter_app/lib/main_app_shell.dart +23 -14
- package/flutter_app/lib/main_chat.dart +267 -7
- package/flutter_app/lib/main_controller.dart +151 -13
- package/flutter_app/lib/main_models.dart +69 -0
- package/flutter_app/lib/main_operations.dart +248 -0
- package/flutter_app/lib/main_runtime.dart +11 -2
- package/flutter_app/lib/main_settings.dart +182 -183
- package/flutter_app/lib/main_shared.dart +99 -16
- package/flutter_app/lib/src/app_launch_bridge.dart +39 -10
- package/flutter_app/lib/src/backend_client.dart +28 -0
- package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +2 -0
- package/flutter_app/pubspec.lock +24 -0
- package/flutter_app/pubspec.yaml +1 -0
- package/package.json +1 -1
- package/server/db/database.js +42 -4
- package/server/guest_agent.js +8 -1
- package/server/http/routes.js +1 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/NOTICES +70 -44
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +70947 -70038
- package/server/routes/browser.js +1 -1
- package/server/routes/memory.js +90 -0
- package/server/routes/social_video.js +66 -0
- package/server/services/ai/systemPrompt.js +1 -0
- package/server/services/ai/toolResult.js +20 -0
- package/server/services/ai/tools.js +30 -0
- package/server/services/android/android_bootstrap_worker.js +1 -0
- package/server/services/android/controller.js +244 -76
- package/server/services/browser/controller.js +24 -8
- package/server/services/manager.js +15 -0
- package/server/services/memory/llm_transfer.js +217 -0
- package/server/services/runtime/backends/local-vm.js +29 -5
- package/server/services/social_video/adapters/base.js +26 -0
- package/server/services/social_video/adapters/index.js +27 -0
- package/server/services/social_video/adapters/instagram.js +17 -0
- package/server/services/social_video/adapters/tiktok.js +17 -0
- package/server/services/social_video/adapters/x.js +17 -0
- package/server/services/social_video/adapters/youtube.js +17 -0
- package/server/services/social_video/captions.js +187 -0
- package/server/services/social_video/frame.js +42 -0
- package/server/services/social_video/index.js +7 -0
- package/server/services/social_video/metadata.js +63 -0
- package/server/services/social_video/result.js +63 -0
- package/server/services/social_video/service.js +650 -0
- package/server/services/social_video/url.js +83 -0
package/docs/capabilities.md
CHANGED
|
@@ -33,6 +33,7 @@ Recording sessions support:
|
|
|
33
33
|
- Retry transcription and delete transcript segment actions.
|
|
34
34
|
- Full session deletion with storage cleanup.
|
|
35
35
|
- Agent tools for listing, opening, and searching transcripts: `recordings_list`, `recordings_get`, and `recordings_search`.
|
|
36
|
+
- A keyless social video extractor for public YouTube, TikTok, Instagram, and X URLs through `social_video_extract`, with metadata-first fetch, captions-first transcript strategy, STT fallback, and representative frame extraction.
|
|
36
37
|
|
|
37
38
|
Transcription uses Deepgram when `DEEPGRAM_API_KEY` is configured. The default speech model is `nova-3`, and the default language mode is `multi`. When `auto_recording_insights` is enabled in AI settings, NeoAgent can generate structured recording insights such as a summary, action items, and events.
|
|
38
39
|
|
|
@@ -102,6 +103,7 @@ NeoAgent's agent tool surface includes more than basic chat:
|
|
|
102
103
|
| Output | Generate markdown tables and Mermaid graphs |
|
|
103
104
|
| Images | Generate images with Grok and analyze local image files with a vision-capable model |
|
|
104
105
|
| Recordings | List, inspect, and search recording transcripts |
|
|
106
|
+
| Social video | Extract title, description, transcript, and a representative frame from public social video URLs |
|
|
105
107
|
| Health | Read synced mobile health metrics |
|
|
106
108
|
|
|
107
109
|
Generated binary or text artifacts can be promoted into user-scoped artifact storage under `~/.neoagent/data/artifacts` and served through authenticated `/api/artifacts/:id/content` URLs.
|
|
@@ -56,6 +56,20 @@
|
|
|
56
56
|
<action android:name="android.intent.action.MAIN"/>
|
|
57
57
|
<category android:name="android.intent.category.LAUNCHER"/>
|
|
58
58
|
</intent-filter>
|
|
59
|
+
<intent-filter>
|
|
60
|
+
<action android:name="android.intent.action.SEND" />
|
|
61
|
+
<category android:name="android.intent.category.DEFAULT" />
|
|
62
|
+
<data android:mimeType="text/*" />
|
|
63
|
+
<data android:mimeType="video/*" />
|
|
64
|
+
<data android:mimeType="image/*" />
|
|
65
|
+
<data android:mimeType="audio/*" />
|
|
66
|
+
<data android:mimeType="application/*" />
|
|
67
|
+
</intent-filter>
|
|
68
|
+
<intent-filter>
|
|
69
|
+
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
|
70
|
+
<category android:name="android.intent.category.DEFAULT" />
|
|
71
|
+
<data android:mimeType="*/*" />
|
|
72
|
+
</intent-filter>
|
|
59
73
|
</activity>
|
|
60
74
|
<provider
|
|
61
75
|
android:name="androidx.core.content.FileProvider"
|
|
@@ -8,6 +8,7 @@ import android.net.Uri
|
|
|
8
8
|
import android.os.BatteryManager
|
|
9
9
|
import android.os.Build
|
|
10
10
|
import android.provider.Settings
|
|
11
|
+
import android.provider.OpenableColumns
|
|
11
12
|
import android.view.KeyEvent
|
|
12
13
|
import androidx.activity.result.ActivityResultLauncher
|
|
13
14
|
import androidx.activity.result.contract.ActivityResultContracts
|
|
@@ -55,6 +56,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|
|
55
56
|
private var widgetEventSink: EventChannel.EventSink? = null
|
|
56
57
|
private var appLaunchEventSink: EventChannel.EventSink? = null
|
|
57
58
|
private var pendingAppLaunchAction: String? = null
|
|
59
|
+
private var pendingSharePayload: Map<String, Any?>? = null
|
|
58
60
|
|
|
59
61
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
|
60
62
|
super.configureFlutterEngine(flutterEngine)
|
|
@@ -590,6 +592,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|
|
590
592
|
|
|
591
593
|
captureWidgetIntent(intent)
|
|
592
594
|
captureAppLaunchIntent(intent)
|
|
595
|
+
captureShareIntent(intent)
|
|
593
596
|
}
|
|
594
597
|
|
|
595
598
|
override fun onNewIntent(intent: Intent) {
|
|
@@ -597,6 +600,7 @@ class MainActivity : FlutterFragmentActivity() {
|
|
|
597
600
|
setIntent(intent)
|
|
598
601
|
captureWidgetIntent(intent)
|
|
599
602
|
captureAppLaunchIntent(intent)
|
|
603
|
+
captureShareIntent(intent)
|
|
600
604
|
}
|
|
601
605
|
|
|
602
606
|
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
|
@@ -723,11 +727,91 @@ class MainActivity : FlutterFragmentActivity() {
|
|
|
723
727
|
|
|
724
728
|
private fun emitPendingAppLaunchIntent() {
|
|
725
729
|
val sink = appLaunchEventSink ?: return
|
|
730
|
+
val sharePayload = pendingSharePayload
|
|
731
|
+
if (sharePayload != null) {
|
|
732
|
+
pendingSharePayload = null
|
|
733
|
+
sink.success(sharePayload)
|
|
734
|
+
return
|
|
735
|
+
}
|
|
726
736
|
val action = pendingAppLaunchAction ?: return
|
|
727
737
|
pendingAppLaunchAction = null
|
|
728
738
|
sink.success(mapOf("action" to action))
|
|
729
739
|
}
|
|
730
740
|
|
|
741
|
+
private fun captureShareIntent(intent: Intent?) {
|
|
742
|
+
if (intent == null) {
|
|
743
|
+
return
|
|
744
|
+
}
|
|
745
|
+
if (intent.action != Intent.ACTION_SEND && intent.action != Intent.ACTION_SEND_MULTIPLE) {
|
|
746
|
+
return
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)?.trim().orEmpty()
|
|
750
|
+
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)?.trim().orEmpty()
|
|
751
|
+
val fileUris = mutableListOf<Uri>()
|
|
752
|
+
|
|
753
|
+
@Suppress("DEPRECATION")
|
|
754
|
+
val single = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM)
|
|
755
|
+
if (single != null) {
|
|
756
|
+
fileUris.add(single)
|
|
757
|
+
}
|
|
758
|
+
@Suppress("DEPRECATION")
|
|
759
|
+
val multiple = intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)
|
|
760
|
+
if (multiple != null) {
|
|
761
|
+
fileUris.addAll(multiple)
|
|
762
|
+
}
|
|
763
|
+
val clip = intent.clipData
|
|
764
|
+
if (clip != null) {
|
|
765
|
+
for (index in 0 until clip.itemCount) {
|
|
766
|
+
val uri = clip.getItemAt(index)?.uri ?: continue
|
|
767
|
+
fileUris.add(uri)
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
val uniqueUris = linkedSetOf<String>()
|
|
772
|
+
val files = mutableListOf<Map<String, Any?>>()
|
|
773
|
+
for (uri in fileUris) {
|
|
774
|
+
val key = uri.toString()
|
|
775
|
+
if (!uniqueUris.add(key)) continue
|
|
776
|
+
files.add(buildSharedFileDescriptor(uri))
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
pendingSharePayload = mapOf(
|
|
780
|
+
"action" to "share_to_chat",
|
|
781
|
+
"text" to sharedText,
|
|
782
|
+
"subject" to subject,
|
|
783
|
+
"files" to files,
|
|
784
|
+
)
|
|
785
|
+
emitPendingAppLaunchIntent()
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
private fun buildSharedFileDescriptor(uri: Uri): Map<String, Any?> {
|
|
789
|
+
var name: String? = null
|
|
790
|
+
var sizeBytes: Long? = null
|
|
791
|
+
val type = contentResolver.getType(uri)?.trim().orEmpty()
|
|
792
|
+
contentResolver.query(uri, null, null, null, null)?.use { cursor ->
|
|
793
|
+
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
|
794
|
+
val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
|
|
795
|
+
if (cursor.moveToFirst()) {
|
|
796
|
+
if (nameIndex >= 0) {
|
|
797
|
+
name = cursor.getString(nameIndex)?.trim()
|
|
798
|
+
}
|
|
799
|
+
if (sizeIndex >= 0 && !cursor.isNull(sizeIndex)) {
|
|
800
|
+
sizeBytes = cursor.getLong(sizeIndex)
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
val fallbackName = uri.lastPathSegment?.substringAfterLast('/')?.trim().orEmpty()
|
|
805
|
+
val resolvedName = (name ?: fallbackName).ifBlank { "Attachment" }
|
|
806
|
+
return mapOf(
|
|
807
|
+
"uri" to uri.toString(),
|
|
808
|
+
"name" to resolvedName,
|
|
809
|
+
"mimeType" to if (type.isBlank()) "application/octet-stream" else type,
|
|
810
|
+
"sizeBytes" to sizeBytes,
|
|
811
|
+
"source" to "android_share_intent",
|
|
812
|
+
)
|
|
813
|
+
}
|
|
814
|
+
|
|
731
815
|
private fun buildVolumeState(audioManager: AudioManager): Map<String, Any> {
|
|
732
816
|
val minVolume =
|
|
733
817
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
@@ -5,7 +5,8 @@ import 'package:notification_listener_service/notification_listener_service.dart
|
|
|
5
5
|
import 'package:notification_listener_service/notification_event.dart';
|
|
6
6
|
|
|
7
7
|
class NotificationInterceptor {
|
|
8
|
-
static final NotificationInterceptor _instance =
|
|
8
|
+
static final NotificationInterceptor _instance =
|
|
9
|
+
NotificationInterceptor._internal();
|
|
9
10
|
factory NotificationInterceptor() => _instance;
|
|
10
11
|
NotificationInterceptor._internal();
|
|
11
12
|
|
|
@@ -13,59 +14,27 @@ class NotificationInterceptor {
|
|
|
13
14
|
String _backendUrl = '';
|
|
14
15
|
String _token = '';
|
|
15
16
|
|
|
16
|
-
Future<void> initialize(
|
|
17
|
+
Future<void> initialize(String backendUrl, String token) async {
|
|
17
18
|
_backendUrl = backendUrl;
|
|
18
19
|
_token = token;
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
if (!isGranted) {
|
|
22
|
-
bool userAgreed = await _showPermissionRationale(context);
|
|
23
|
-
if (userAgreed) {
|
|
24
|
-
await NotificationListenerService.requestPermission();
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
isGranted = await NotificationListenerService.isPermissionGranted();
|
|
21
|
+
final isGranted = await NotificationListenerService.isPermissionGranted();
|
|
29
22
|
if (isGranted && !_isListening) {
|
|
30
23
|
_startListening();
|
|
31
24
|
}
|
|
32
25
|
}
|
|
33
26
|
|
|
34
|
-
Future<bool> _showPermissionRationale(BuildContext context) async {
|
|
35
|
-
return await showDialog<bool>(
|
|
36
|
-
context: context,
|
|
37
|
-
builder: (context) => AlertDialog(
|
|
38
|
-
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
39
|
-
title: const Text('Notification Access Needed'),
|
|
40
|
-
content: const Text(
|
|
41
|
-
'NeoAgent can read your incoming notifications (like Calendar alarms or urgent messages) and proactively act on them.\n\n'
|
|
42
|
-
'For example, if you receive a reminder, NeoAgent can automatically prepare relevant context for you.',
|
|
43
|
-
),
|
|
44
|
-
actions: [
|
|
45
|
-
TextButton(
|
|
46
|
-
onPressed: () => Navigator.of(context).pop(false),
|
|
47
|
-
child: const Text('Not Now'),
|
|
48
|
-
),
|
|
49
|
-
ElevatedButton(
|
|
50
|
-
style: ElevatedButton.styleFrom(
|
|
51
|
-
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
52
|
-
),
|
|
53
|
-
onPressed: () => Navigator.of(context).pop(true),
|
|
54
|
-
child: const Text('Allow Access'),
|
|
55
|
-
),
|
|
56
|
-
],
|
|
57
|
-
),
|
|
58
|
-
) ?? false;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
27
|
void _startListening() {
|
|
62
28
|
_isListening = true;
|
|
63
|
-
NotificationListenerService.notificationsStream.listen((
|
|
29
|
+
NotificationListenerService.notificationsStream.listen((
|
|
30
|
+
ServiceNotificationEvent event,
|
|
31
|
+
) {
|
|
64
32
|
// Filter out noisy system notifications or ongoing foreground services
|
|
65
|
-
if (event.packageName == null ||
|
|
33
|
+
if (event.packageName == null ||
|
|
34
|
+
event.packageName!.contains('android.system')) {
|
|
66
35
|
return;
|
|
67
36
|
}
|
|
68
|
-
|
|
37
|
+
|
|
69
38
|
// We only want to intercept newly posted notifications, not removed ones
|
|
70
39
|
if (event.hasRemoved == true) return;
|
|
71
40
|
|
|
@@ -79,10 +48,7 @@ class NotificationInterceptor {
|
|
|
79
48
|
try {
|
|
80
49
|
await http.post(
|
|
81
50
|
Uri.parse('$_backendUrl/api/triggers/notification'),
|
|
82
|
-
headers: {
|
|
83
|
-
'Content-Type': 'application/json',
|
|
84
|
-
'Cookie': _token,
|
|
85
|
-
},
|
|
51
|
+
headers: {'Content-Type': 'application/json', 'Cookie': _token},
|
|
86
52
|
body: jsonEncode({
|
|
87
53
|
'app_package': event.packageName ?? 'unknown',
|
|
88
54
|
'title': event.title ?? '',
|
|
@@ -11,6 +11,7 @@ import 'package:flutter/gestures.dart';
|
|
|
11
11
|
import 'package:flutter/material.dart';
|
|
12
12
|
import 'package:flutter/services.dart';
|
|
13
13
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
|
14
|
+
import 'package:file_picker/file_picker.dart';
|
|
14
15
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
15
16
|
import 'package:google_fonts/google_fonts.dart';
|
|
16
17
|
import 'package:image/image.dart' as img;
|
|
@@ -13,17 +13,23 @@ class SplashView extends StatelessWidget {
|
|
|
13
13
|
colors: <Color>[_accent, _bgSecondary, _bgPrimary],
|
|
14
14
|
),
|
|
15
15
|
),
|
|
16
|
-
child:
|
|
16
|
+
child: Scaffold(
|
|
17
17
|
backgroundColor: Colors.transparent,
|
|
18
18
|
body: Center(
|
|
19
19
|
child: Column(
|
|
20
20
|
mainAxisSize: MainAxisSize.min,
|
|
21
21
|
children: <Widget>[
|
|
22
22
|
_BrandLockup(logoSize: 52),
|
|
23
|
-
SizedBox(height: 18),
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
const SizedBox(height: 18),
|
|
24
|
+
SizedBox(
|
|
25
|
+
width: 180,
|
|
26
|
+
child: ClipRRect(
|
|
27
|
+
borderRadius: BorderRadius.circular(999),
|
|
28
|
+
child: const LinearProgressIndicator(minHeight: 4),
|
|
29
|
+
),
|
|
30
|
+
),
|
|
31
|
+
const SizedBox(height: 14),
|
|
32
|
+
const Text('Loading NeoOS'),
|
|
27
33
|
],
|
|
28
34
|
),
|
|
29
35
|
),
|
|
@@ -1090,11 +1096,7 @@ class _HomeViewState extends State<HomeView> {
|
|
|
1090
1096
|
});
|
|
1091
1097
|
|
|
1092
1098
|
if (Platform.isAndroid) {
|
|
1093
|
-
NotificationInterceptor().initialize(
|
|
1094
|
-
context,
|
|
1095
|
-
backendUrl,
|
|
1096
|
-
sessionCookie,
|
|
1097
|
-
);
|
|
1099
|
+
NotificationInterceptor().initialize(backendUrl, sessionCookie);
|
|
1098
1100
|
}
|
|
1099
1101
|
}
|
|
1100
1102
|
}
|
|
@@ -1242,12 +1244,19 @@ class _HomeViewState extends State<HomeView> {
|
|
|
1242
1244
|
onToggleGroup: _toggleSidebarGroup,
|
|
1243
1245
|
),
|
|
1244
1246
|
appBar: AppBar(
|
|
1247
|
+
toolbarHeight: 48,
|
|
1248
|
+
titleSpacing: 0,
|
|
1249
|
+
leadingWidth: 44,
|
|
1250
|
+
centerTitle: false,
|
|
1245
1251
|
title: Text(controller.selectedSection.navigationTitle),
|
|
1252
|
+
titleTextStyle: Theme.of(
|
|
1253
|
+
context,
|
|
1254
|
+
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
|
1246
1255
|
elevation: 0,
|
|
1247
1256
|
),
|
|
1248
1257
|
body: SafeArea(
|
|
1249
1258
|
child: Padding(
|
|
1250
|
-
padding: const EdgeInsets.fromLTRB(12, 0, 12,
|
|
1259
|
+
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
|
|
1251
1260
|
child: _GlassSurface(
|
|
1252
1261
|
borderRadius: BorderRadius.circular(26),
|
|
1253
1262
|
blurSigma: 24,
|
|
@@ -2014,7 +2023,7 @@ class _MobileDrawer extends StatelessWidget {
|
|
|
2014
2023
|
child: Column(
|
|
2015
2024
|
children: <Widget>[
|
|
2016
2025
|
Padding(
|
|
2017
|
-
padding: const EdgeInsets.fromLTRB(16,
|
|
2026
|
+
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
|
|
2018
2027
|
child: Column(
|
|
2019
2028
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
2020
2029
|
children: <Widget>[
|
|
@@ -2032,7 +2041,7 @@ class _MobileDrawer extends StatelessWidget {
|
|
|
2032
2041
|
],
|
|
2033
2042
|
),
|
|
2034
2043
|
if (controller.agentProfiles.isNotEmpty) ...<Widget>[
|
|
2035
|
-
const SizedBox(height:
|
|
2044
|
+
const SizedBox(height: 10),
|
|
2036
2045
|
_AgentSwitcher(
|
|
2037
2046
|
controller: controller,
|
|
2038
2047
|
onChanged: () => Navigator.of(context).pop(),
|
|
@@ -2056,7 +2065,7 @@ class _MobileDrawer extends StatelessWidget {
|
|
|
2056
2065
|
),
|
|
2057
2066
|
),
|
|
2058
2067
|
Padding(
|
|
2059
|
-
padding: const EdgeInsets.
|
|
2068
|
+
padding: const EdgeInsets.fromLTRB(8, 6, 8, 8),
|
|
2060
2069
|
child: Row(
|
|
2061
2070
|
children: <Widget>[
|
|
2062
2071
|
const Spacer(),
|
|
@@ -12,6 +12,9 @@ class ChatPanel extends StatefulWidget {
|
|
|
12
12
|
class _ChatPanelState extends State<ChatPanel> {
|
|
13
13
|
late final TextEditingController _composerController;
|
|
14
14
|
final ScrollController _scrollController = ScrollController();
|
|
15
|
+
List<SharedChatAttachment> _pendingSharedAttachments =
|
|
16
|
+
const <SharedChatAttachment>[];
|
|
17
|
+
String? _appliedSharedPayloadSignature;
|
|
15
18
|
int _lastMessageCount = 0;
|
|
16
19
|
int _lastToolCount = 0;
|
|
17
20
|
String _lastStream = '';
|
|
@@ -25,6 +28,17 @@ class _ChatPanelState extends State<ChatPanel> {
|
|
|
25
28
|
_consumeQueuedDraft();
|
|
26
29
|
}
|
|
27
30
|
|
|
31
|
+
@override
|
|
32
|
+
void didUpdateWidget(covariant ChatPanel oldWidget) {
|
|
33
|
+
super.didUpdateWidget(oldWidget);
|
|
34
|
+
if (oldWidget.controller != widget.controller) {
|
|
35
|
+
oldWidget.controller.removeListener(_consumeQueuedDraft);
|
|
36
|
+
widget.controller.addListener(_consumeQueuedDraft);
|
|
37
|
+
_appliedSharedPayloadSignature = null;
|
|
38
|
+
_consumeQueuedDraft();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
28
42
|
@override
|
|
29
43
|
void dispose() {
|
|
30
44
|
widget.controller.removeListener(_consumeQueuedDraft);
|
|
@@ -34,13 +48,114 @@ class _ChatPanelState extends State<ChatPanel> {
|
|
|
34
48
|
}
|
|
35
49
|
|
|
36
50
|
void _consumeQueuedDraft() {
|
|
37
|
-
final draft = widget.controller.
|
|
38
|
-
|
|
51
|
+
final draft = widget.controller.peekPendingChatDraft();
|
|
52
|
+
final attachments = widget.controller.peekPendingSharedChatAttachments();
|
|
53
|
+
final signature = _sharedPayloadSignature(draft, attachments);
|
|
54
|
+
if (draft == null && attachments.isEmpty) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (_appliedSharedPayloadSignature == signature) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
_appliedSharedPayloadSignature = signature;
|
|
61
|
+
if (!mounted) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
setState(() {
|
|
65
|
+
if ((draft ?? '').isNotEmpty && _composerController.text.trim().isEmpty) {
|
|
66
|
+
_composerController
|
|
67
|
+
..text = draft!
|
|
68
|
+
..selection = TextSelection.collapsed(offset: draft.length);
|
|
69
|
+
}
|
|
70
|
+
_pendingSharedAttachments = attachments;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
String _sharedPayloadSignature(
|
|
75
|
+
String? draft,
|
|
76
|
+
List<SharedChatAttachment> attachments,
|
|
77
|
+
) {
|
|
78
|
+
final attachmentSignature = attachments
|
|
79
|
+
.map((item) => '${item.uri}|${item.name}|${item.mimeType}')
|
|
80
|
+
.join('::');
|
|
81
|
+
return '${draft ?? ''}::$attachmentSignature';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
void _clearSharedPayload() {
|
|
85
|
+
widget.controller.clearPendingSharedChatPayload();
|
|
86
|
+
_appliedSharedPayloadSignature = null;
|
|
87
|
+
if (!mounted) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
setState(() {
|
|
91
|
+
_pendingSharedAttachments = const <SharedChatAttachment>[];
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
String _mimeTypeForFileName(String fileName) {
|
|
96
|
+
final ext = fileName.split('.').last.toLowerCase();
|
|
97
|
+
switch (ext) {
|
|
98
|
+
case 'jpg':
|
|
99
|
+
case 'jpeg':
|
|
100
|
+
return 'image/jpeg';
|
|
101
|
+
case 'png':
|
|
102
|
+
return 'image/png';
|
|
103
|
+
case 'webp':
|
|
104
|
+
return 'image/webp';
|
|
105
|
+
case 'gif':
|
|
106
|
+
return 'image/gif';
|
|
107
|
+
case 'mp4':
|
|
108
|
+
return 'video/mp4';
|
|
109
|
+
case 'mov':
|
|
110
|
+
return 'video/quicktime';
|
|
111
|
+
case 'm4v':
|
|
112
|
+
return 'video/x-m4v';
|
|
113
|
+
case 'mp3':
|
|
114
|
+
return 'audio/mpeg';
|
|
115
|
+
case 'm4a':
|
|
116
|
+
return 'audio/mp4';
|
|
117
|
+
case 'wav':
|
|
118
|
+
return 'audio/wav';
|
|
119
|
+
case 'pdf':
|
|
120
|
+
return 'application/pdf';
|
|
121
|
+
case 'txt':
|
|
122
|
+
return 'text/plain';
|
|
123
|
+
default:
|
|
124
|
+
return 'application/octet-stream';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
Future<void> _attachFiles() async {
|
|
129
|
+
final result = await FilePicker.platform.pickFiles(
|
|
130
|
+
allowMultiple: true,
|
|
131
|
+
withData: false,
|
|
132
|
+
type: FileType.any,
|
|
133
|
+
);
|
|
134
|
+
if (!mounted || result == null || result.files.isEmpty) {
|
|
39
135
|
return;
|
|
40
136
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
137
|
+
final attachments = result.files
|
|
138
|
+
.where((file) => file.path?.trim().isNotEmpty == true)
|
|
139
|
+
.map(
|
|
140
|
+
(file) => SharedChatAttachment(
|
|
141
|
+
uri: file.path!,
|
|
142
|
+
name: file.name,
|
|
143
|
+
mimeType: _mimeTypeForFileName(file.name),
|
|
144
|
+
sizeBytes: file.size,
|
|
145
|
+
source: 'file_picker',
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
.where((item) => item.isValid)
|
|
149
|
+
.toList(growable: false);
|
|
150
|
+
if (attachments.isEmpty) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
setState(() {
|
|
154
|
+
_pendingSharedAttachments = <SharedChatAttachment>[
|
|
155
|
+
..._pendingSharedAttachments,
|
|
156
|
+
...attachments,
|
|
157
|
+
];
|
|
158
|
+
});
|
|
44
159
|
}
|
|
45
160
|
|
|
46
161
|
void _scrollToBottom() {
|
|
@@ -151,6 +266,29 @@ class _ChatPanelState extends State<ChatPanel> {
|
|
|
151
266
|
),
|
|
152
267
|
child: Column(
|
|
153
268
|
children: <Widget>[
|
|
269
|
+
if (_pendingSharedAttachments.isNotEmpty)
|
|
270
|
+
Padding(
|
|
271
|
+
padding: const EdgeInsets.only(bottom: 10),
|
|
272
|
+
child: _SharedAttachmentTray(
|
|
273
|
+
attachments: _pendingSharedAttachments,
|
|
274
|
+
onRemoveAt: (index) {
|
|
275
|
+
setState(() {
|
|
276
|
+
_pendingSharedAttachments = _pendingSharedAttachments
|
|
277
|
+
.asMap()
|
|
278
|
+
.entries
|
|
279
|
+
.where((entry) => entry.key != index)
|
|
280
|
+
.map((entry) => entry.value)
|
|
281
|
+
.toList(growable: false);
|
|
282
|
+
});
|
|
283
|
+
if (_pendingSharedAttachments.isEmpty) {
|
|
284
|
+
_clearSharedPayload();
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
onClear: () {
|
|
288
|
+
_clearSharedPayload();
|
|
289
|
+
},
|
|
290
|
+
),
|
|
291
|
+
),
|
|
154
292
|
Container(
|
|
155
293
|
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
|
|
156
294
|
decoration: BoxDecoration(
|
|
@@ -179,6 +317,13 @@ class _ChatPanelState extends State<ChatPanel> {
|
|
|
179
317
|
),
|
|
180
318
|
),
|
|
181
319
|
const SizedBox(width: 8),
|
|
320
|
+
IconButton(
|
|
321
|
+
tooltip: 'Attach files',
|
|
322
|
+
onPressed: _attachFiles,
|
|
323
|
+
icon: const Icon(Icons.attach_file_rounded),
|
|
324
|
+
color: _textSecondary,
|
|
325
|
+
),
|
|
326
|
+
const SizedBox(width: 2),
|
|
182
327
|
FilledButton(
|
|
183
328
|
onPressed: () => controller.setSelectedSection(
|
|
184
329
|
AppSection.voiceAssistant,
|
|
@@ -199,7 +344,8 @@ class _ChatPanelState extends State<ChatPanel> {
|
|
|
199
344
|
? null
|
|
200
345
|
: () async {
|
|
201
346
|
final task = _composerController.text;
|
|
202
|
-
if (task.trim().isEmpty
|
|
347
|
+
if ((task.trim().isEmpty &&
|
|
348
|
+
_pendingSharedAttachments.isEmpty) ||
|
|
203
349
|
_isSendingChatMessage) {
|
|
204
350
|
return;
|
|
205
351
|
}
|
|
@@ -207,8 +353,14 @@ class _ChatPanelState extends State<ChatPanel> {
|
|
|
207
353
|
_isSendingChatMessage = true;
|
|
208
354
|
});
|
|
209
355
|
_composerController.clear();
|
|
356
|
+
final outgoingAttachments =
|
|
357
|
+
_pendingSharedAttachments;
|
|
358
|
+
_clearSharedPayload();
|
|
210
359
|
try {
|
|
211
|
-
await controller.sendMessage(
|
|
360
|
+
await controller.sendMessage(
|
|
361
|
+
task,
|
|
362
|
+
sharedAttachments: outgoingAttachments,
|
|
363
|
+
);
|
|
212
364
|
} finally {
|
|
213
365
|
if (mounted) {
|
|
214
366
|
setState(() {
|
|
@@ -268,6 +420,114 @@ class _ChatPanelState extends State<ChatPanel> {
|
|
|
268
420
|
}
|
|
269
421
|
}
|
|
270
422
|
|
|
423
|
+
class _SharedAttachmentTray extends StatelessWidget {
|
|
424
|
+
const _SharedAttachmentTray({
|
|
425
|
+
required this.attachments,
|
|
426
|
+
required this.onRemoveAt,
|
|
427
|
+
required this.onClear,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
final List<SharedChatAttachment> attachments;
|
|
431
|
+
final void Function(int index) onRemoveAt;
|
|
432
|
+
final VoidCallback onClear;
|
|
433
|
+
|
|
434
|
+
IconData _iconForMime(String mime) {
|
|
435
|
+
final normalized = mime.toLowerCase();
|
|
436
|
+
if (normalized.startsWith('image/')) return Icons.image_outlined;
|
|
437
|
+
if (normalized.startsWith('video/')) return Icons.videocam_outlined;
|
|
438
|
+
if (normalized.startsWith('audio/')) return Icons.audiotrack_outlined;
|
|
439
|
+
if (normalized.contains('pdf')) return Icons.picture_as_pdf_outlined;
|
|
440
|
+
return Icons.attach_file_rounded;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
@override
|
|
444
|
+
Widget build(BuildContext context) {
|
|
445
|
+
return Container(
|
|
446
|
+
width: double.infinity,
|
|
447
|
+
padding: const EdgeInsets.all(10),
|
|
448
|
+
decoration: BoxDecoration(
|
|
449
|
+
color: _bgSecondary,
|
|
450
|
+
borderRadius: BorderRadius.circular(12),
|
|
451
|
+
border: Border.all(color: _borderLight),
|
|
452
|
+
),
|
|
453
|
+
child: Column(
|
|
454
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
455
|
+
children: <Widget>[
|
|
456
|
+
Row(
|
|
457
|
+
children: <Widget>[
|
|
458
|
+
Icon(Icons.forward_to_inbox_outlined, size: 15, color: _info),
|
|
459
|
+
const SizedBox(width: 6),
|
|
460
|
+
Expanded(
|
|
461
|
+
child: Text(
|
|
462
|
+
'Shared from another app',
|
|
463
|
+
style: TextStyle(
|
|
464
|
+
color: _textPrimary,
|
|
465
|
+
fontSize: 12,
|
|
466
|
+
fontWeight: FontWeight.w700,
|
|
467
|
+
),
|
|
468
|
+
),
|
|
469
|
+
),
|
|
470
|
+
TextButton(onPressed: onClear, child: const Text('Clear')),
|
|
471
|
+
],
|
|
472
|
+
),
|
|
473
|
+
const SizedBox(height: 6),
|
|
474
|
+
Wrap(
|
|
475
|
+
spacing: 8,
|
|
476
|
+
runSpacing: 8,
|
|
477
|
+
children: attachments
|
|
478
|
+
.asMap()
|
|
479
|
+
.entries
|
|
480
|
+
.map((entry) {
|
|
481
|
+
final item = entry.value;
|
|
482
|
+
return Container(
|
|
483
|
+
padding: const EdgeInsets.symmetric(
|
|
484
|
+
horizontal: 10,
|
|
485
|
+
vertical: 7,
|
|
486
|
+
),
|
|
487
|
+
decoration: BoxDecoration(
|
|
488
|
+
color: _bgCard,
|
|
489
|
+
borderRadius: BorderRadius.circular(999),
|
|
490
|
+
border: Border.all(color: _border),
|
|
491
|
+
),
|
|
492
|
+
child: Row(
|
|
493
|
+
mainAxisSize: MainAxisSize.min,
|
|
494
|
+
children: <Widget>[
|
|
495
|
+
Icon(
|
|
496
|
+
_iconForMime(item.mimeType),
|
|
497
|
+
size: 14,
|
|
498
|
+
color: _textSecondary,
|
|
499
|
+
),
|
|
500
|
+
const SizedBox(width: 6),
|
|
501
|
+
ConstrainedBox(
|
|
502
|
+
constraints: const BoxConstraints(maxWidth: 170),
|
|
503
|
+
child: Text(
|
|
504
|
+
item.name,
|
|
505
|
+
maxLines: 1,
|
|
506
|
+
overflow: TextOverflow.ellipsis,
|
|
507
|
+
style: TextStyle(color: _textPrimary, fontSize: 12),
|
|
508
|
+
),
|
|
509
|
+
),
|
|
510
|
+
const SizedBox(width: 6),
|
|
511
|
+
GestureDetector(
|
|
512
|
+
onTap: () => onRemoveAt(entry.key),
|
|
513
|
+
child: Icon(
|
|
514
|
+
Icons.close_rounded,
|
|
515
|
+
size: 14,
|
|
516
|
+
color: _textMuted,
|
|
517
|
+
),
|
|
518
|
+
),
|
|
519
|
+
],
|
|
520
|
+
),
|
|
521
|
+
);
|
|
522
|
+
})
|
|
523
|
+
.toList(growable: false),
|
|
524
|
+
),
|
|
525
|
+
],
|
|
526
|
+
),
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
271
531
|
class _TypingIndicatorBubble extends StatefulWidget {
|
|
272
532
|
const _TypingIndicatorBubble();
|
|
273
533
|
|