neoagent 2.3.1-beta.4 → 2.3.1-beta.41
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/.env.example +45 -0
- package/docs/capabilities.md +2 -2
- package/docs/configuration.md +12 -5
- package/docs/hardware.md +1 -1
- package/docs/integrations.md +2 -3
- package/flutter_app/.metadata +42 -0
- package/flutter_app/README.md +21 -0
- package/flutter_app/analysis_options.yaml +32 -0
- package/flutter_app/android/app/build.gradle.kts +109 -0
- package/flutter_app/android/app/src/debug/AndroidManifest.xml +7 -0
- package/flutter_app/android/app/src/main/AndroidManifest.xml +147 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +747 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthConnectGateway.kt +280 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncNotifications.kt +113 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncPayload.kt +57 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncScheduler.kt +78 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/HealthSyncWorker.kt +253 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/health/PermissionsRationaleActivity.kt +46 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +21 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +586 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +78 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +104 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiHomeWidgetProvider.kt +457 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/AiWidgetStore.kt +194 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/VoiceLaunchWidgetProvider.kt +67 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetConfigActivity.kt +228 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncScheduler.kt +72 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetSyncWorker.kt +186 -0
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/widgets/WidgetTaskRunWorker.kt +210 -0
- package/flutter_app/android/app/src/main/res/drawable/launch_background.xml +12 -0
- package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_bg.xml +11 -0
- package/flutter_app/android/app/src/main/res/drawable/neoagent_ai_widget_task_bg.xml +8 -0
- package/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml +12 -0
- package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget.xml +138 -0
- package/flutter_app/android/app/src/main/res/layout/neoagent_ai_widget_task_row.xml +52 -0
- package/flutter_app/android/app/src/main/res/layout/neoagent_voice_widget.xml +49 -0
- package/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png +0 -0
- package/flutter_app/android/app/src/main/res/values/strings.xml +12 -0
- package/flutter_app/android/app/src/main/res/values/styles.xml +18 -0
- package/flutter_app/android/app/src/main/res/values-night/styles.xml +18 -0
- package/flutter_app/android/app/src/main/res/xml/file_paths.xml +6 -0
- package/flutter_app/android/app/src/main/res/xml/neoagent_ai_widget_info.xml +12 -0
- package/flutter_app/android/app/src/main/res/xml/neoagent_voice_widget_info.xml +12 -0
- package/flutter_app/android/app/src/profile/AndroidManifest.xml +7 -0
- package/flutter_app/android/build.gradle.kts +24 -0
- package/flutter_app/android/ci-release.keystore +0 -0
- package/flutter_app/android/gradle/wrapper/gradle-wrapper.properties +5 -0
- package/flutter_app/android/gradle.properties +3 -0
- package/flutter_app/android/key.properties +4 -0
- package/flutter_app/android/settings.gradle.kts +26 -0
- package/flutter_app/assets/branding/app_icon_1024.png +0 -0
- package/flutter_app/assets/branding/app_icon_128.png +0 -0
- package/flutter_app/assets/branding/app_icon_192.png +0 -0
- package/flutter_app/assets/branding/app_icon_256.png +0 -0
- package/flutter_app/assets/branding/app_icon_32.png +0 -0
- package/flutter_app/assets/branding/app_icon_512.png +0 -0
- package/flutter_app/assets/branding/app_icon_64.png +0 -0
- package/flutter_app/assets/branding/tray_icon_template.png +0 -0
- package/flutter_app/lib/features/location/location_service.dart +119 -0
- package/flutter_app/lib/features/notifications/notification_interceptor.dart +97 -0
- package/flutter_app/lib/main.dart +99 -0
- package/flutter_app/lib/main_account_settings.dart +1250 -0
- package/flutter_app/lib/main_admin.dart +886 -0
- package/flutter_app/lib/main_app_shell.dart +1682 -0
- package/flutter_app/lib/main_chat.dart +3352 -0
- package/flutter_app/lib/main_controller.dart +6781 -0
- package/flutter_app/lib/main_devices.dart +2301 -0
- package/flutter_app/lib/main_integrations.dart +1129 -0
- package/flutter_app/lib/main_launcher.dart +959 -0
- package/flutter_app/lib/main_launcher_entry.dart +5 -0
- package/flutter_app/lib/main_models.dart +3546 -0
- package/flutter_app/lib/main_navigation.dart +193 -0
- package/flutter_app/lib/main_operations.dart +4851 -0
- package/flutter_app/lib/main_recordings.dart +870 -0
- package/flutter_app/lib/main_runtime.dart +806 -0
- package/flutter_app/lib/main_settings.dart +2024 -0
- package/flutter_app/lib/main_shared.dart +2861 -0
- package/flutter_app/lib/main_theme.dart +204 -0
- package/flutter_app/lib/main_voice_assistant.dart +957 -0
- package/flutter_app/lib/src/android_apk_drop_zone.dart +32 -0
- package/flutter_app/lib/src/android_apk_drop_zone_stub.dart +16 -0
- package/flutter_app/lib/src/android_apk_drop_zone_web.dart +348 -0
- package/flutter_app/lib/src/android_app_installer.dart +22 -0
- package/flutter_app/lib/src/android_app_installer_io.dart +122 -0
- package/flutter_app/lib/src/android_app_installer_stub.dart +21 -0
- package/flutter_app/lib/src/android_launcher_bridge.dart +239 -0
- package/flutter_app/lib/src/app_launch_bridge.dart +29 -0
- package/flutter_app/lib/src/app_release_updater.dart +511 -0
- package/flutter_app/lib/src/backend_client.dart +1833 -0
- package/flutter_app/lib/src/desktop_companion.dart +2 -0
- package/flutter_app/lib/src/desktop_companion_actions.dart +586 -0
- package/flutter_app/lib/src/desktop_companion_io.dart +538 -0
- package/flutter_app/lib/src/desktop_companion_stub.dart +59 -0
- package/flutter_app/lib/src/desktop_native_bridge.dart +91 -0
- package/flutter_app/lib/src/desktop_screen_capture.dart +21 -0
- package/flutter_app/lib/src/desktop_screen_capture_io.dart +142 -0
- package/flutter_app/lib/src/desktop_screen_capture_stub.dart +12 -0
- package/flutter_app/lib/src/diagnostics_logger.dart +119 -0
- package/flutter_app/lib/src/health_bridge.dart +136 -0
- package/flutter_app/lib/src/live_voice_capture.dart +85 -0
- package/flutter_app/lib/src/messaging_access_summary.dart +46 -0
- package/flutter_app/lib/src/network/app_http_client.dart +53 -0
- package/flutter_app/lib/src/network/app_http_client_factory.dart +6 -0
- package/flutter_app/lib/src/network/app_http_client_io.dart +138 -0
- package/flutter_app/lib/src/network/app_http_client_stub.dart +3 -0
- package/flutter_app/lib/src/network/app_http_client_web.dart +94 -0
- package/flutter_app/lib/src/oauth_launcher.dart +33 -0
- package/flutter_app/lib/src/oauth_launcher_io.dart +77 -0
- package/flutter_app/lib/src/oauth_launcher_stub.dart +33 -0
- package/flutter_app/lib/src/oauth_launcher_web.dart +107 -0
- package/flutter_app/lib/src/recording_bridge.dart +232 -0
- package/flutter_app/lib/src/recording_bridge_io.dart +1019 -0
- package/flutter_app/lib/src/recording_bridge_stub.dart +120 -0
- package/flutter_app/lib/src/recording_bridge_web.dart +689 -0
- package/flutter_app/lib/src/recording_payloads.dart +86 -0
- package/flutter_app/lib/src/theme/palette.dart +81 -0
- package/flutter_app/lib/src/widget_bridge.dart +49 -0
- package/flutter_app/linux/CMakeLists.txt +128 -0
- package/flutter_app/linux/flutter/CMakeLists.txt +88 -0
- package/flutter_app/linux/flutter/generated_plugin_registrant.cc +43 -0
- package/flutter_app/linux/flutter/generated_plugin_registrant.h +15 -0
- package/flutter_app/linux/flutter/generated_plugins.cmake +31 -0
- package/flutter_app/linux/runner/CMakeLists.txt +26 -0
- package/flutter_app/linux/runner/main.cc +6 -0
- package/flutter_app/linux/runner/my_application.cc +144 -0
- package/flutter_app/linux/runner/my_application.h +18 -0
- package/flutter_app/linux/runner/resources/app_icon.png +0 -0
- package/flutter_app/macos/Flutter/Flutter-Debug.xcconfig +2 -0
- package/flutter_app/macos/Flutter/Flutter-Release.xcconfig +2 -0
- package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +40 -0
- package/flutter_app/macos/Podfile +42 -0
- package/flutter_app/macos/Podfile.lock +87 -0
- package/flutter_app/macos/Runner/AppDelegate.swift +576 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +68 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png +0 -0
- package/flutter_app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png +0 -0
- package/flutter_app/macos/Runner/Base.lproj/MainMenu.xib +342 -0
- package/flutter_app/macos/Runner/Configs/AppInfo.xcconfig +14 -0
- package/flutter_app/macos/Runner/Configs/Debug.xcconfig +2 -0
- package/flutter_app/macos/Runner/Configs/Release.xcconfig +2 -0
- package/flutter_app/macos/Runner/Configs/Warnings.xcconfig +13 -0
- package/flutter_app/macos/Runner/DebugProfile.entitlements +16 -0
- package/flutter_app/macos/Runner/Info.plist +36 -0
- package/flutter_app/macos/Runner/MainFlutterWindow.swift +19 -0
- package/flutter_app/macos/Runner/Release.entitlements +12 -0
- package/flutter_app/macos/Runner.xcodeproj/project.pbxproj +801 -0
- package/flutter_app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- package/flutter_app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +99 -0
- package/flutter_app/macos/Runner.xcworkspace/contents.xcworkspacedata +10 -0
- package/flutter_app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- package/flutter_app/macos/RunnerTests/RunnerTests.swift +12 -0
- package/flutter_app/patch_strings.py +12 -0
- package/flutter_app/pubspec.lock +1088 -0
- package/flutter_app/pubspec.yaml +53 -0
- package/flutter_app/test/messaging_access_summary_test.dart +22 -0
- package/flutter_app/test/recording_payloads_test.dart +53 -0
- package/flutter_app/third_party/desktop_audio_capture/LICENSE +21 -0
- package/flutter_app/third_party/desktop_audio_capture/README.md +262 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +65 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +153 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +110 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +461 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +91 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +106 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +219 -0
- package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +336 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +101 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +692 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +35 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +36 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +32 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +32 -0
- package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +878 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +27 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +1172 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +655 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +12 -0
- package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +30 -0
- package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +87 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +105 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +80 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +31 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +12 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +23 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +25 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +1117 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +115 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +777 -0
- package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +87 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/linux/CMakeLists.txt +30 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/linux/flutter_secure_storage_linux_plugin.cc +215 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/linux/include/flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h +27 -0
- package/flutter_app/third_party/flutter_secure_storage_linux/pubspec.yaml +20 -0
- package/flutter_app/tool/generate_desktop_branding.py +219 -0
- package/flutter_app/web/favicon.png +0 -0
- package/flutter_app/web/favicon.svg +12 -0
- package/flutter_app/web/icons/Icon-192.png +0 -0
- package/flutter_app/web/icons/Icon-512.png +0 -0
- package/flutter_app/web/icons/Icon-maskable-192.png +0 -0
- package/flutter_app/web/icons/Icon-maskable-512.png +0 -0
- package/flutter_app/web/index.html +39 -0
- package/flutter_app/web/manifest.json +35 -0
- package/flutter_app/windows/CMakeLists.txt +108 -0
- package/flutter_app/windows/flutter/CMakeLists.txt +109 -0
- package/flutter_app/windows/flutter/generated_plugin_registrant.cc +47 -0
- package/flutter_app/windows/flutter/generated_plugin_registrant.h +15 -0
- package/flutter_app/windows/flutter/generated_plugins.cmake +35 -0
- package/flutter_app/windows/runner/CMakeLists.txt +41 -0
- package/flutter_app/windows/runner/Runner.rc +121 -0
- package/flutter_app/windows/runner/flutter_window.cpp +533 -0
- package/flutter_app/windows/runner/flutter_window.h +37 -0
- package/flutter_app/windows/runner/main.cpp +53 -0
- package/flutter_app/windows/runner/resource.h +16 -0
- package/flutter_app/windows/runner/resources/app_icon.ico +0 -0
- package/flutter_app/windows/runner/runner.exe.manifest +14 -0
- package/flutter_app/windows/runner/utils.cpp +65 -0
- package/flutter_app/windows/runner/utils.h +19 -0
- package/flutter_app/windows/runner/win32_window.cpp +299 -0
- package/flutter_app/windows/runner/win32_window.h +102 -0
- package/lib/install_helpers.js +31 -0
- package/lib/manager.js +227 -6
- package/package.json +3 -1
- package/server/db/database.js +110 -0
- package/server/http/middleware.js +55 -2
- package/server/http/routes.js +1 -0
- package/server/index.js +3 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/NOTICES +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/canvaskit/wimp.wasm +0 -0
- package/server/public/flutter_bootstrap.js +2 -2
- package/server/public/main.dart.js +74324 -73132
- package/server/routes/integrations.js +108 -1
- package/server/routes/memory.js +11 -2
- package/server/{http/routes → routes}/screenHistory.js +2 -2
- package/server/routes/settings.js +75 -2
- package/server/{http/routes → routes}/triggers.js +2 -2
- package/server/routes/wearable.js +67 -0
- package/server/services/ai/models.js +30 -0
- package/server/services/ai/providers/githubCopilot.js +97 -0
- package/server/services/ai/providers/openai.js +2 -1
- package/server/services/ai/providers/openaiCodex.js +31 -0
- package/server/services/ai/settings.js +20 -0
- package/server/services/ai/toolSelector.js +14 -1
- package/server/services/ai/tools.js +77 -4
- package/server/services/desktop/screenRecorder.js +65 -9
- package/server/services/integrations/env.js +5 -0
- package/server/services/integrations/figma/provider.js +1 -0
- package/server/services/integrations/github/common.js +106 -0
- package/server/services/integrations/github/provider.js +499 -0
- package/server/services/integrations/github/repos.js +1124 -0
- package/server/services/integrations/google/provider.js +1 -0
- package/server/services/integrations/home_assistant/provider.js +325 -26
- package/server/services/integrations/manager.js +88 -12
- package/server/services/integrations/microsoft/provider.js +1 -0
- package/server/services/integrations/oauth_provider.js +25 -8
- package/server/services/integrations/provider_config_store.js +85 -0
- package/server/services/integrations/registry.js +4 -0
- package/server/services/integrations/spotify/provider.js +1 -0
- package/server/services/integrations/trello/provider.js +842 -0
- package/server/services/manager.js +46 -1
- package/server/services/mcp/client.js +120 -23
- package/server/services/memory/manager.js +39 -2
- package/server/services/messaging/access_policy.js +10 -0
- package/server/services/messaging/manager.js +49 -0
- package/server/services/messaging/meshtastic.js +260 -0
- package/server/services/messaging/meshtastic_env.js +100 -0
- package/server/services/messaging/meshtastic_protocol.js +476 -0
- package/server/services/messaging/meshtastic_tcp_transport.js +25 -0
- package/server/services/tasks/runtime.js +1 -1
- package/server/services/voice/openaiClient.js +4 -1
- package/server/services/voice/openaiSpeech.js +6 -1
- package/server/services/voice/providers.js +52 -12
- package/server/services/voice/runtimeManager.js +136 -19
- package/server/services/voice/turnRunner.js +29 -9
- package/server/services/wearable/firmware_manifest.js +370 -0
- package/server/services/wearable/gateway.js +350 -0
- package/server/services/wearable/protocol.js +45 -0
- package/server/services/wearable/service.js +244 -0
- package/server/utils/local_secrets.js +56 -0
- package/server/utils/logger.js +37 -9
|
@@ -0,0 +1,3352 @@
|
|
|
1
|
+
part of 'main.dart';
|
|
2
|
+
|
|
3
|
+
class ChatPanel extends StatefulWidget {
|
|
4
|
+
const ChatPanel({super.key, required this.controller});
|
|
5
|
+
|
|
6
|
+
final NeoAgentController controller;
|
|
7
|
+
|
|
8
|
+
@override
|
|
9
|
+
State<ChatPanel> createState() => _ChatPanelState();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class _ChatPanelState extends State<ChatPanel> {
|
|
13
|
+
late final TextEditingController _composerController;
|
|
14
|
+
final ScrollController _scrollController = ScrollController();
|
|
15
|
+
int _lastMessageCount = 0;
|
|
16
|
+
int _lastToolCount = 0;
|
|
17
|
+
String _lastStream = '';
|
|
18
|
+
bool _isSendingChatMessage = false;
|
|
19
|
+
|
|
20
|
+
@override
|
|
21
|
+
void initState() {
|
|
22
|
+
super.initState();
|
|
23
|
+
_composerController = TextEditingController();
|
|
24
|
+
widget.controller.addListener(_consumeQueuedDraft);
|
|
25
|
+
_consumeQueuedDraft();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@override
|
|
29
|
+
void dispose() {
|
|
30
|
+
widget.controller.removeListener(_consumeQueuedDraft);
|
|
31
|
+
_composerController.dispose();
|
|
32
|
+
_scrollController.dispose();
|
|
33
|
+
super.dispose();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
void _consumeQueuedDraft() {
|
|
37
|
+
final draft = widget.controller.takePendingChatDraft();
|
|
38
|
+
if (draft == null || draft.isEmpty) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
_composerController
|
|
42
|
+
..text = draft
|
|
43
|
+
..selection = TextSelection.collapsed(offset: draft.length);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@override
|
|
47
|
+
Widget build(BuildContext context) {
|
|
48
|
+
final controller = widget.controller;
|
|
49
|
+
final messages = controller.visibleChatMessages;
|
|
50
|
+
if (_lastMessageCount != messages.length ||
|
|
51
|
+
_lastToolCount != controller.toolEvents.length ||
|
|
52
|
+
_lastStream != controller.streamingAssistant) {
|
|
53
|
+
_lastMessageCount = messages.length;
|
|
54
|
+
_lastToolCount = controller.toolEvents.length;
|
|
55
|
+
_lastStream = controller.streamingAssistant;
|
|
56
|
+
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
57
|
+
if (_scrollController.hasClients) {
|
|
58
|
+
_scrollController.animateTo(
|
|
59
|
+
_scrollController.position.maxScrollExtent,
|
|
60
|
+
duration: const Duration(milliseconds: 220),
|
|
61
|
+
curve: Curves.easeOut,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return Column(
|
|
68
|
+
children: <Widget>[
|
|
69
|
+
Expanded(
|
|
70
|
+
child: ListView(
|
|
71
|
+
controller: _scrollController,
|
|
72
|
+
padding: _pagePadding(context),
|
|
73
|
+
children: <Widget>[
|
|
74
|
+
_PageTitle(
|
|
75
|
+
title: 'Chat',
|
|
76
|
+
subtitle: 'Live agent chat with tool and stream status.',
|
|
77
|
+
trailing: Wrap(
|
|
78
|
+
spacing: 10,
|
|
79
|
+
runSpacing: 10,
|
|
80
|
+
crossAxisAlignment: WrapCrossAlignment.center,
|
|
81
|
+
children: <Widget>[
|
|
82
|
+
FilledButton.icon(
|
|
83
|
+
onPressed: () => controller.setSelectedSection(
|
|
84
|
+
AppSection.voiceAssistant,
|
|
85
|
+
),
|
|
86
|
+
icon: Icon(Icons.call),
|
|
87
|
+
label: Text('Call'),
|
|
88
|
+
),
|
|
89
|
+
_MetaPill(
|
|
90
|
+
label: controller.modelIndicator,
|
|
91
|
+
icon: Icons.memory_outlined,
|
|
92
|
+
),
|
|
93
|
+
_MetaPill(
|
|
94
|
+
label: 'Agent: ${controller.activeAgentLabel}',
|
|
95
|
+
icon: Icons.smart_toy_outlined,
|
|
96
|
+
),
|
|
97
|
+
],
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
if (controller.errorMessage != null) ...<Widget>[
|
|
101
|
+
_InlineError(message: controller.errorMessage!),
|
|
102
|
+
const SizedBox(height: 16),
|
|
103
|
+
],
|
|
104
|
+
if (controller.activeRun != null ||
|
|
105
|
+
controller.toolEvents.isNotEmpty)
|
|
106
|
+
Padding(
|
|
107
|
+
padding: const EdgeInsets.only(bottom: 16),
|
|
108
|
+
child: _RunStatusPanel(
|
|
109
|
+
run: controller.activeRun,
|
|
110
|
+
tools: controller.toolEvents,
|
|
111
|
+
),
|
|
112
|
+
),
|
|
113
|
+
if (messages.isEmpty)
|
|
114
|
+
Padding(
|
|
115
|
+
padding: EdgeInsets.only(top: 64),
|
|
116
|
+
child: Center(
|
|
117
|
+
child: _EmptyState(
|
|
118
|
+
title: 'How can I help?',
|
|
119
|
+
subtitle:
|
|
120
|
+
'Runs, tools, memory, scheduling, skills, and MCP are all available here.',
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
else
|
|
125
|
+
...messages.map(
|
|
126
|
+
(entry) => Padding(
|
|
127
|
+
padding: const EdgeInsets.only(bottom: 18),
|
|
128
|
+
child: _ChatBubble(
|
|
129
|
+
entry: entry,
|
|
130
|
+
onLoadRunDetail: controller.fetchRunDetail,
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
],
|
|
135
|
+
),
|
|
136
|
+
),
|
|
137
|
+
Container(
|
|
138
|
+
padding: const EdgeInsets.fromLTRB(20, 14, 20, 20),
|
|
139
|
+
decoration: BoxDecoration(
|
|
140
|
+
color: _bgPrimary,
|
|
141
|
+
border: Border(top: BorderSide(color: _border)),
|
|
142
|
+
),
|
|
143
|
+
child: Column(
|
|
144
|
+
children: <Widget>[
|
|
145
|
+
Container(
|
|
146
|
+
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
|
|
147
|
+
decoration: BoxDecoration(
|
|
148
|
+
color: _bgTertiary,
|
|
149
|
+
borderRadius: BorderRadius.circular(14),
|
|
150
|
+
border: Border.all(color: _border),
|
|
151
|
+
),
|
|
152
|
+
child: Row(
|
|
153
|
+
crossAxisAlignment: CrossAxisAlignment.end,
|
|
154
|
+
children: <Widget>[
|
|
155
|
+
Expanded(
|
|
156
|
+
child: TextField(
|
|
157
|
+
controller: _composerController,
|
|
158
|
+
minLines: 1,
|
|
159
|
+
maxLines: 6,
|
|
160
|
+
keyboardType: TextInputType.multiline,
|
|
161
|
+
textInputAction: TextInputAction.newline,
|
|
162
|
+
decoration: InputDecoration(
|
|
163
|
+
hintText: controller.chatComposerHint,
|
|
164
|
+
isDense: true,
|
|
165
|
+
filled: false,
|
|
166
|
+
border: InputBorder.none,
|
|
167
|
+
enabledBorder: InputBorder.none,
|
|
168
|
+
focusedBorder: InputBorder.none,
|
|
169
|
+
),
|
|
170
|
+
),
|
|
171
|
+
),
|
|
172
|
+
const SizedBox(width: 8),
|
|
173
|
+
FilledButton(
|
|
174
|
+
onPressed: () => controller.setSelectedSection(
|
|
175
|
+
AppSection.voiceAssistant,
|
|
176
|
+
),
|
|
177
|
+
style: FilledButton.styleFrom(
|
|
178
|
+
minimumSize: const Size(46, 42),
|
|
179
|
+
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
180
|
+
backgroundColor: _success,
|
|
181
|
+
shape: RoundedRectangleBorder(
|
|
182
|
+
borderRadius: BorderRadius.circular(10),
|
|
183
|
+
),
|
|
184
|
+
),
|
|
185
|
+
child: Icon(Icons.call_rounded, color: Colors.white),
|
|
186
|
+
),
|
|
187
|
+
const SizedBox(width: 8),
|
|
188
|
+
FilledButton(
|
|
189
|
+
onPressed: _isSendingChatMessage
|
|
190
|
+
? null
|
|
191
|
+
: () async {
|
|
192
|
+
final task = _composerController.text;
|
|
193
|
+
if (task.trim().isEmpty ||
|
|
194
|
+
_isSendingChatMessage) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
setState(() {
|
|
198
|
+
_isSendingChatMessage = true;
|
|
199
|
+
});
|
|
200
|
+
_composerController.clear();
|
|
201
|
+
try {
|
|
202
|
+
await controller.sendMessage(task);
|
|
203
|
+
} finally {
|
|
204
|
+
if (mounted) {
|
|
205
|
+
setState(() {
|
|
206
|
+
_isSendingChatMessage = false;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
style: FilledButton.styleFrom(
|
|
212
|
+
minimumSize: const Size(46, 42),
|
|
213
|
+
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
214
|
+
backgroundColor: _accent,
|
|
215
|
+
shape: RoundedRectangleBorder(
|
|
216
|
+
borderRadius: BorderRadius.circular(10),
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
child: Icon(
|
|
220
|
+
controller.hasLiveRun
|
|
221
|
+
? Icons.alt_route_rounded
|
|
222
|
+
: Icons.north_east_rounded,
|
|
223
|
+
color: Colors.white,
|
|
224
|
+
),
|
|
225
|
+
),
|
|
226
|
+
],
|
|
227
|
+
),
|
|
228
|
+
),
|
|
229
|
+
const SizedBox(height: 8),
|
|
230
|
+
Row(
|
|
231
|
+
children: <Widget>[
|
|
232
|
+
Expanded(
|
|
233
|
+
child: Text(
|
|
234
|
+
controller.chatStatusLabel,
|
|
235
|
+
maxLines: 1,
|
|
236
|
+
overflow: TextOverflow.ellipsis,
|
|
237
|
+
style: TextStyle(fontSize: 11, color: _textSecondary),
|
|
238
|
+
),
|
|
239
|
+
),
|
|
240
|
+
const SizedBox(width: 12),
|
|
241
|
+
Flexible(
|
|
242
|
+
child: Text(
|
|
243
|
+
controller.hasLiveRun
|
|
244
|
+
? 'Steering mode'
|
|
245
|
+
: controller.modelIndicator,
|
|
246
|
+
maxLines: 1,
|
|
247
|
+
overflow: TextOverflow.ellipsis,
|
|
248
|
+
textAlign: TextAlign.right,
|
|
249
|
+
style: TextStyle(fontSize: 11, color: _textSecondary),
|
|
250
|
+
),
|
|
251
|
+
),
|
|
252
|
+
],
|
|
253
|
+
),
|
|
254
|
+
],
|
|
255
|
+
),
|
|
256
|
+
),
|
|
257
|
+
],
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
class MessagingPanel extends StatefulWidget {
|
|
263
|
+
const MessagingPanel({super.key, required this.controller});
|
|
264
|
+
|
|
265
|
+
final NeoAgentController controller;
|
|
266
|
+
|
|
267
|
+
@override
|
|
268
|
+
State<MessagingPanel> createState() => _MessagingPanelState();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
MessagingPlatformDescriptor? _messagingPlatformById(String id) {
|
|
272
|
+
for (final platform in messagingPlatforms) {
|
|
273
|
+
if (platform.id == id) return platform;
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
class _MessagingPanelState extends State<MessagingPanel> {
|
|
279
|
+
final TextEditingController _searchController = TextEditingController();
|
|
280
|
+
String _statusFilter = 'all';
|
|
281
|
+
|
|
282
|
+
@override
|
|
283
|
+
void initState() {
|
|
284
|
+
super.initState();
|
|
285
|
+
_searchController.addListener(_handleSearchChanged);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
@override
|
|
289
|
+
void dispose() {
|
|
290
|
+
_searchController
|
|
291
|
+
..removeListener(_handleSearchChanged)
|
|
292
|
+
..dispose();
|
|
293
|
+
super.dispose();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
void _handleSearchChanged() => setState(() {});
|
|
297
|
+
|
|
298
|
+
@override
|
|
299
|
+
Widget build(BuildContext context) {
|
|
300
|
+
final controller = widget.controller;
|
|
301
|
+
final groups = [
|
|
302
|
+
const (
|
|
303
|
+
'Text & Chat',
|
|
304
|
+
'Personal channels and direct support surfaces.',
|
|
305
|
+
[
|
|
306
|
+
'whatsapp',
|
|
307
|
+
'signal',
|
|
308
|
+
'imessage',
|
|
309
|
+
'bluebubbles',
|
|
310
|
+
'line',
|
|
311
|
+
'zalo_personal',
|
|
312
|
+
],
|
|
313
|
+
),
|
|
314
|
+
const (
|
|
315
|
+
'Community & ChatOps',
|
|
316
|
+
'Team spaces, rooms, channels, and live communities.',
|
|
317
|
+
[
|
|
318
|
+
'discord',
|
|
319
|
+
'telegram',
|
|
320
|
+
'slack',
|
|
321
|
+
'google_chat',
|
|
322
|
+
'teams',
|
|
323
|
+
'matrix',
|
|
324
|
+
'mattermost',
|
|
325
|
+
'irc',
|
|
326
|
+
'twitch',
|
|
327
|
+
],
|
|
328
|
+
),
|
|
329
|
+
const (
|
|
330
|
+
'Configurable Webhooks',
|
|
331
|
+
'Bridge any provider that can post and receive webhook payloads.',
|
|
332
|
+
[
|
|
333
|
+
'feishu',
|
|
334
|
+
'nextcloud_talk',
|
|
335
|
+
'nostr',
|
|
336
|
+
'synology_chat',
|
|
337
|
+
'tlon',
|
|
338
|
+
'zalo',
|
|
339
|
+
'wechat',
|
|
340
|
+
'webchat',
|
|
341
|
+
],
|
|
342
|
+
),
|
|
343
|
+
const (
|
|
344
|
+
'Hardware Bridges',
|
|
345
|
+
'Local device bridges and TCP-connected integrations.',
|
|
346
|
+
[
|
|
347
|
+
'meshtastic',
|
|
348
|
+
],
|
|
349
|
+
),
|
|
350
|
+
const ('Voice', 'Telephony integrations.', ['telnyx']),
|
|
351
|
+
];
|
|
352
|
+
final query = _searchController.text.trim().toLowerCase();
|
|
353
|
+
final counts = _MessagingStatusCounts.from(controller.messagingStatuses);
|
|
354
|
+
final hasMatches = _hasMessagingMatches(controller, groups, query);
|
|
355
|
+
|
|
356
|
+
return ListView(
|
|
357
|
+
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
|
|
358
|
+
children: [
|
|
359
|
+
_PageTitle(
|
|
360
|
+
title: 'Messaging',
|
|
361
|
+
subtitle:
|
|
362
|
+
'Connect channels, limit who can reach the agent, and monitor activity.',
|
|
363
|
+
trailing: OutlinedButton.icon(
|
|
364
|
+
onPressed: controller.refreshMessaging,
|
|
365
|
+
icon: Icon(Icons.refresh_rounded),
|
|
366
|
+
label: Text('Refresh'),
|
|
367
|
+
),
|
|
368
|
+
),
|
|
369
|
+
const SizedBox(height: 18),
|
|
370
|
+
_MessagingOverviewStrip(counts: counts),
|
|
371
|
+
const SizedBox(height: 16),
|
|
372
|
+
_MessagingToolbar(
|
|
373
|
+
controller: _searchController,
|
|
374
|
+
selectedFilter: _statusFilter,
|
|
375
|
+
onFilterChanged: (value) => setState(() => _statusFilter = value),
|
|
376
|
+
counts: counts,
|
|
377
|
+
),
|
|
378
|
+
if (controller.pendingMessagingQr != null) ...[
|
|
379
|
+
const SizedBox(height: 18),
|
|
380
|
+
_MessagingQrPanel(qrState: controller.pendingMessagingQr!),
|
|
381
|
+
],
|
|
382
|
+
const SizedBox(height: 18),
|
|
383
|
+
for (final group in groups)
|
|
384
|
+
Builder(
|
|
385
|
+
builder: (context) {
|
|
386
|
+
final platforms = group.$3
|
|
387
|
+
.map(_messagingPlatformById)
|
|
388
|
+
.nonNulls
|
|
389
|
+
.where((platform) {
|
|
390
|
+
final status =
|
|
391
|
+
controller.messagingStatuses[platform.id] ??
|
|
392
|
+
MessagingPlatformStatus.empty(platform.id);
|
|
393
|
+
final haystack =
|
|
394
|
+
'${platform.label} ${platform.subtitle} ${group.$1}'
|
|
395
|
+
.toLowerCase();
|
|
396
|
+
return _matchesMessagingStatusFilter(status) &&
|
|
397
|
+
(query.isEmpty || haystack.contains(query));
|
|
398
|
+
})
|
|
399
|
+
.toList(growable: false);
|
|
400
|
+
if (platforms.isEmpty) return const SizedBox.shrink();
|
|
401
|
+
return Padding(
|
|
402
|
+
padding: const EdgeInsets.only(bottom: 22),
|
|
403
|
+
child: Column(
|
|
404
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
405
|
+
children: [
|
|
406
|
+
_MessagingGroupHeader(
|
|
407
|
+
title: group.$1,
|
|
408
|
+
subtitle: group.$2,
|
|
409
|
+
count: platforms.length,
|
|
410
|
+
),
|
|
411
|
+
const SizedBox(height: 12),
|
|
412
|
+
LayoutBuilder(
|
|
413
|
+
builder: (context, constraints) {
|
|
414
|
+
final width = constraints.maxWidth;
|
|
415
|
+
final crossAxisCount = width >= 1380
|
|
416
|
+
? 4
|
|
417
|
+
: width >= 1020
|
|
418
|
+
? 3
|
|
419
|
+
: width >= 700
|
|
420
|
+
? 2
|
|
421
|
+
: 1;
|
|
422
|
+
return GridView.builder(
|
|
423
|
+
shrinkWrap: true,
|
|
424
|
+
physics: const NeverScrollableScrollPhysics(),
|
|
425
|
+
itemCount: platforms.length,
|
|
426
|
+
gridDelegate:
|
|
427
|
+
SliverGridDelegateWithFixedCrossAxisCount(
|
|
428
|
+
crossAxisCount: crossAxisCount,
|
|
429
|
+
crossAxisSpacing: 12,
|
|
430
|
+
mainAxisSpacing: 12,
|
|
431
|
+
mainAxisExtent: 268,
|
|
432
|
+
),
|
|
433
|
+
itemBuilder: (context, index) {
|
|
434
|
+
final platform = platforms[index];
|
|
435
|
+
return _MessagingCard(
|
|
436
|
+
platform: platform,
|
|
437
|
+
status:
|
|
438
|
+
controller.messagingStatuses[platform.id] ??
|
|
439
|
+
MessagingPlatformStatus.empty(platform.id),
|
|
440
|
+
accessCatalog: controller
|
|
441
|
+
.currentMessagingAccessCatalog(platform.id),
|
|
442
|
+
controller: controller,
|
|
443
|
+
onConnect: () => _openMessagingConfig(platform),
|
|
444
|
+
onDisconnect: () => controller
|
|
445
|
+
.disconnectMessagingPlatform(platform.id),
|
|
446
|
+
onLogout: () => controller
|
|
447
|
+
.logoutMessagingPlatform(platform.id),
|
|
448
|
+
);
|
|
449
|
+
},
|
|
450
|
+
);
|
|
451
|
+
},
|
|
452
|
+
),
|
|
453
|
+
],
|
|
454
|
+
),
|
|
455
|
+
);
|
|
456
|
+
},
|
|
457
|
+
),
|
|
458
|
+
if (!hasMatches) ...[
|
|
459
|
+
const SizedBox(height: 10),
|
|
460
|
+
const _EmptyCard(
|
|
461
|
+
title: 'No platforms match',
|
|
462
|
+
subtitle:
|
|
463
|
+
'Adjust the search or status filter to see more messaging channels.',
|
|
464
|
+
),
|
|
465
|
+
const SizedBox(height: 22),
|
|
466
|
+
],
|
|
467
|
+
_MessagingActivityPanel(messages: controller.messagingMessages),
|
|
468
|
+
],
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
bool _hasMessagingMatches(
|
|
473
|
+
NeoAgentController controller,
|
|
474
|
+
List<(String, String, List<String>)> groups,
|
|
475
|
+
String query,
|
|
476
|
+
) {
|
|
477
|
+
for (final group in groups) {
|
|
478
|
+
for (final key in group.$3) {
|
|
479
|
+
final platform = _messagingPlatformById(key);
|
|
480
|
+
if (platform == null) continue;
|
|
481
|
+
final status =
|
|
482
|
+
controller.messagingStatuses[platform.id] ??
|
|
483
|
+
MessagingPlatformStatus.empty(platform.id);
|
|
484
|
+
final haystack = '${platform.label} ${platform.subtitle} ${group.$1}'
|
|
485
|
+
.toLowerCase();
|
|
486
|
+
if (_matchesMessagingStatusFilter(status) &&
|
|
487
|
+
(query.isEmpty || haystack.contains(query))) {
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
bool _matchesMessagingStatusFilter(MessagingPlatformStatus? status) {
|
|
496
|
+
final effective = status ?? MessagingPlatformStatus.empty('unknown');
|
|
497
|
+
return switch (_statusFilter) {
|
|
498
|
+
'connected' => effective.isConnected,
|
|
499
|
+
'configured' => effective.status != 'not_configured',
|
|
500
|
+
'attention' => const {
|
|
501
|
+
'connecting',
|
|
502
|
+
'awaiting_qr',
|
|
503
|
+
'logged_out',
|
|
504
|
+
'disconnected',
|
|
505
|
+
'error',
|
|
506
|
+
}.contains(effective.status),
|
|
507
|
+
_ => true,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
Future<void> _openMessagingConfig(
|
|
512
|
+
MessagingPlatformDescriptor platform,
|
|
513
|
+
) async {
|
|
514
|
+
switch (platform.id) {
|
|
515
|
+
case 'whatsapp':
|
|
516
|
+
await _connectMessagingPlatform(
|
|
517
|
+
platform: 'whatsapp',
|
|
518
|
+
platformLabel: platform.label,
|
|
519
|
+
);
|
|
520
|
+
return;
|
|
521
|
+
case 'telnyx':
|
|
522
|
+
return _openTelnyxConfig();
|
|
523
|
+
default:
|
|
524
|
+
return _openGenericMessagingConfig(platform);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
Future<bool> _connectMessagingPlatform({
|
|
529
|
+
required String platform,
|
|
530
|
+
required String platformLabel,
|
|
531
|
+
Map<String, dynamic>? config,
|
|
532
|
+
Map<String, dynamic>? configSnapshot,
|
|
533
|
+
}) async {
|
|
534
|
+
try {
|
|
535
|
+
await widget.controller.connectMessagingPlatform(
|
|
536
|
+
platform: platform,
|
|
537
|
+
config: config,
|
|
538
|
+
configSnapshot: configSnapshot,
|
|
539
|
+
);
|
|
540
|
+
return true;
|
|
541
|
+
} catch (error) {
|
|
542
|
+
if (!mounted) return false;
|
|
543
|
+
final messenger = ScaffoldMessenger.maybeOf(context);
|
|
544
|
+
messenger?.showSnackBar(
|
|
545
|
+
SnackBar(
|
|
546
|
+
content: Text(
|
|
547
|
+
'Failed to connect $platformLabel: ${widget.controller.friendlyErrorMessage(error)}',
|
|
548
|
+
),
|
|
549
|
+
),
|
|
550
|
+
);
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
Future<void> _openTelnyxConfig() async {
|
|
556
|
+
final saved = _jsonMap(
|
|
557
|
+
_decodeMaybeJson(widget.controller.settings['telnyx_config']),
|
|
558
|
+
);
|
|
559
|
+
final apiKey = TextEditingController(
|
|
560
|
+
text: saved['apiKey']?.toString() ?? '',
|
|
561
|
+
);
|
|
562
|
+
final phoneNumber = TextEditingController(
|
|
563
|
+
text: saved['phoneNumber']?.toString() ?? '',
|
|
564
|
+
);
|
|
565
|
+
final connectionId = TextEditingController(
|
|
566
|
+
text: saved['connectionId']?.toString() ?? '',
|
|
567
|
+
);
|
|
568
|
+
final webhookUrl = TextEditingController(
|
|
569
|
+
text: saved['webhookUrl']?.toString() ?? widget.controller.backendUrl,
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
try {
|
|
573
|
+
await showDialog<void>(
|
|
574
|
+
context: context,
|
|
575
|
+
builder: (context) {
|
|
576
|
+
return StatefulBuilder(
|
|
577
|
+
builder: (context, setLocalState) {
|
|
578
|
+
return AlertDialog(
|
|
579
|
+
backgroundColor: _bgCard,
|
|
580
|
+
title: Text('Telnyx Voice'),
|
|
581
|
+
content: SizedBox(
|
|
582
|
+
width: 620,
|
|
583
|
+
child: SingleChildScrollView(
|
|
584
|
+
child: Column(
|
|
585
|
+
mainAxisSize: MainAxisSize.min,
|
|
586
|
+
children: <Widget>[
|
|
587
|
+
TextField(
|
|
588
|
+
controller: apiKey,
|
|
589
|
+
obscureText: true,
|
|
590
|
+
decoration: const InputDecoration(
|
|
591
|
+
labelText: 'API Key',
|
|
592
|
+
),
|
|
593
|
+
),
|
|
594
|
+
const SizedBox(height: 12),
|
|
595
|
+
TextField(
|
|
596
|
+
controller: phoneNumber,
|
|
597
|
+
decoration: const InputDecoration(
|
|
598
|
+
labelText: 'Phone Number',
|
|
599
|
+
),
|
|
600
|
+
),
|
|
601
|
+
const SizedBox(height: 12),
|
|
602
|
+
TextField(
|
|
603
|
+
controller: connectionId,
|
|
604
|
+
decoration: const InputDecoration(
|
|
605
|
+
labelText: 'Connection ID',
|
|
606
|
+
),
|
|
607
|
+
),
|
|
608
|
+
const SizedBox(height: 12),
|
|
609
|
+
TextField(
|
|
610
|
+
controller: webhookUrl,
|
|
611
|
+
decoration: const InputDecoration(
|
|
612
|
+
labelText: 'Webhook Base URL',
|
|
613
|
+
),
|
|
614
|
+
),
|
|
615
|
+
const SizedBox(height: 12),
|
|
616
|
+
Text(
|
|
617
|
+
'Voice STT/TTS providers and models are configured in global Settings > Voice.',
|
|
618
|
+
style: TextStyle(color: _textSecondary),
|
|
619
|
+
),
|
|
620
|
+
],
|
|
621
|
+
),
|
|
622
|
+
),
|
|
623
|
+
),
|
|
624
|
+
actions: <Widget>[
|
|
625
|
+
TextButton(
|
|
626
|
+
onPressed: () => Navigator.of(context).pop(),
|
|
627
|
+
child: Text('Cancel'),
|
|
628
|
+
),
|
|
629
|
+
FilledButton(
|
|
630
|
+
onPressed: () async {
|
|
631
|
+
final config = <String, dynamic>{
|
|
632
|
+
'apiKey': apiKey.text.trim(),
|
|
633
|
+
'phoneNumber': phoneNumber.text.trim(),
|
|
634
|
+
'connectionId': connectionId.text.trim(),
|
|
635
|
+
'webhookUrl': webhookUrl.text.trim(),
|
|
636
|
+
};
|
|
637
|
+
final connected = await _connectMessagingPlatform(
|
|
638
|
+
platform: 'telnyx',
|
|
639
|
+
platformLabel: 'Telnyx Voice',
|
|
640
|
+
config: config,
|
|
641
|
+
configSnapshot: <String, dynamic>{
|
|
642
|
+
'telnyx_config': jsonEncode(config),
|
|
643
|
+
},
|
|
644
|
+
);
|
|
645
|
+
if (connected && context.mounted) {
|
|
646
|
+
Navigator.of(context).pop();
|
|
647
|
+
}
|
|
648
|
+
},
|
|
649
|
+
child: Text('Connect'),
|
|
650
|
+
),
|
|
651
|
+
],
|
|
652
|
+
);
|
|
653
|
+
},
|
|
654
|
+
);
|
|
655
|
+
},
|
|
656
|
+
);
|
|
657
|
+
} finally {
|
|
658
|
+
apiKey.dispose();
|
|
659
|
+
phoneNumber.dispose();
|
|
660
|
+
connectionId.dispose();
|
|
661
|
+
webhookUrl.dispose();
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
Future<void> _openGenericMessagingConfig(
|
|
666
|
+
MessagingPlatformDescriptor platform,
|
|
667
|
+
) async {
|
|
668
|
+
final saved = _jsonMap(
|
|
669
|
+
_decodeMaybeJson(widget.controller.settings[platform.settingsKey]),
|
|
670
|
+
);
|
|
671
|
+
final textControllers = <String, TextEditingController>{};
|
|
672
|
+
final boolValues = <String, bool>{};
|
|
673
|
+
for (final field in platform.configFields) {
|
|
674
|
+
final savedValue = field.settingsKey == null
|
|
675
|
+
? saved[field.key]
|
|
676
|
+
: widget.controller.settings[field.storageKey];
|
|
677
|
+
if (field.kind == MessagingConfigFieldKind.boolean) {
|
|
678
|
+
boolValues[field.key] =
|
|
679
|
+
savedValue == true || savedValue?.toString() == 'true';
|
|
680
|
+
} else {
|
|
681
|
+
textControllers[field.key] = TextEditingController(
|
|
682
|
+
text: savedValue?.toString() ?? field.defaultValue ?? '',
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
try {
|
|
688
|
+
await showDialog<void>(
|
|
689
|
+
context: context,
|
|
690
|
+
builder: (context) {
|
|
691
|
+
return StatefulBuilder(
|
|
692
|
+
builder: (context, setLocalState) {
|
|
693
|
+
return AlertDialog(
|
|
694
|
+
backgroundColor: _bgCard,
|
|
695
|
+
title: Text(platform.label),
|
|
696
|
+
content: SizedBox(
|
|
697
|
+
width: 620,
|
|
698
|
+
child: SingleChildScrollView(
|
|
699
|
+
child: Column(
|
|
700
|
+
mainAxisSize: MainAxisSize.min,
|
|
701
|
+
children: <Widget>[
|
|
702
|
+
if (platform.configFields.isEmpty)
|
|
703
|
+
Text(
|
|
704
|
+
'No extra settings are required.',
|
|
705
|
+
style: TextStyle(color: _textSecondary),
|
|
706
|
+
)
|
|
707
|
+
else
|
|
708
|
+
...platform.configFields.map((field) {
|
|
709
|
+
if (field.kind ==
|
|
710
|
+
MessagingConfigFieldKind.boolean) {
|
|
711
|
+
return SwitchListTile(
|
|
712
|
+
contentPadding: EdgeInsets.zero,
|
|
713
|
+
title: Text(field.label),
|
|
714
|
+
value: boolValues[field.key] ?? false,
|
|
715
|
+
onChanged: (value) {
|
|
716
|
+
setLocalState(() {
|
|
717
|
+
boolValues[field.key] = value;
|
|
718
|
+
});
|
|
719
|
+
},
|
|
720
|
+
);
|
|
721
|
+
}
|
|
722
|
+
final controller = textControllers[field.key]!;
|
|
723
|
+
return Padding(
|
|
724
|
+
padding: const EdgeInsets.only(bottom: 12),
|
|
725
|
+
child: TextField(
|
|
726
|
+
controller: controller,
|
|
727
|
+
obscureText:
|
|
728
|
+
field.obscure ||
|
|
729
|
+
field.kind ==
|
|
730
|
+
MessagingConfigFieldKind.password,
|
|
731
|
+
minLines:
|
|
732
|
+
field.kind ==
|
|
733
|
+
MessagingConfigFieldKind.multiline
|
|
734
|
+
? 4
|
|
735
|
+
: 1,
|
|
736
|
+
maxLines:
|
|
737
|
+
field.kind ==
|
|
738
|
+
MessagingConfigFieldKind.multiline
|
|
739
|
+
? 8
|
|
740
|
+
: 1,
|
|
741
|
+
decoration: InputDecoration(
|
|
742
|
+
labelText: field.label,
|
|
743
|
+
),
|
|
744
|
+
),
|
|
745
|
+
);
|
|
746
|
+
}),
|
|
747
|
+
const SizedBox(height: 8),
|
|
748
|
+
if (platform.id == 'meshtastic')
|
|
749
|
+
Text(
|
|
750
|
+
'Meshtastic connects directly to the device TCP API on port 4403 by default. Normal chat is limited to the configured channel.',
|
|
751
|
+
style: TextStyle(
|
|
752
|
+
color: _textSecondary,
|
|
753
|
+
fontSize: 12,
|
|
754
|
+
),
|
|
755
|
+
)
|
|
756
|
+
else
|
|
757
|
+
SelectableText(
|
|
758
|
+
'Inbound webhook: ${widget.controller.backendUrl}/api/messaging/webhook/${platform.id}',
|
|
759
|
+
style: TextStyle(
|
|
760
|
+
color: _textSecondary,
|
|
761
|
+
fontSize: 12,
|
|
762
|
+
),
|
|
763
|
+
),
|
|
764
|
+
],
|
|
765
|
+
),
|
|
766
|
+
),
|
|
767
|
+
),
|
|
768
|
+
actions: <Widget>[
|
|
769
|
+
TextButton(
|
|
770
|
+
onPressed: () => Navigator.of(context).pop(),
|
|
771
|
+
child: Text('Cancel'),
|
|
772
|
+
),
|
|
773
|
+
FilledButton(
|
|
774
|
+
onPressed: () async {
|
|
775
|
+
final config = <String, dynamic>{};
|
|
776
|
+
final snapshot = <String, dynamic>{};
|
|
777
|
+
for (final field in platform.configFields) {
|
|
778
|
+
if (field.kind == MessagingConfigFieldKind.boolean ||
|
|
779
|
+
!field.includeInConfig) {
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
final controller = textControllers[field.key];
|
|
783
|
+
final value = controller?.text.trim() ?? '';
|
|
784
|
+
if (value.isNotEmpty) config[field.key] = value;
|
|
785
|
+
}
|
|
786
|
+
for (final field in platform.configFields) {
|
|
787
|
+
if (field.kind == MessagingConfigFieldKind.boolean) {
|
|
788
|
+
final value = boolValues[field.key] ?? false;
|
|
789
|
+
if (field.includeInConfig) {
|
|
790
|
+
config[field.key] = value;
|
|
791
|
+
}
|
|
792
|
+
if (field.settingsKey != null) {
|
|
793
|
+
snapshot[field.storageKey] = value;
|
|
794
|
+
}
|
|
795
|
+
} else if (field.settingsKey != null) {
|
|
796
|
+
final controller = textControllers[field.key];
|
|
797
|
+
final value = controller?.text.trim() ?? '';
|
|
798
|
+
if (value.isNotEmpty) {
|
|
799
|
+
snapshot[field.storageKey] = value;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
snapshot[platform.settingsKey] = jsonEncode(config);
|
|
804
|
+
final meshtasticEnabled =
|
|
805
|
+
platform.id != 'meshtastic' ||
|
|
806
|
+
(boolValues['meshtastic_enabled'] ?? true);
|
|
807
|
+
var connected = false;
|
|
808
|
+
if (meshtasticEnabled) {
|
|
809
|
+
connected = await _connectMessagingPlatform(
|
|
810
|
+
platform: platform.id,
|
|
811
|
+
platformLabel: platform.label,
|
|
812
|
+
config: config,
|
|
813
|
+
configSnapshot: snapshot,
|
|
814
|
+
);
|
|
815
|
+
} else {
|
|
816
|
+
final messenger = ScaffoldMessenger.maybeOf(context);
|
|
817
|
+
try {
|
|
818
|
+
await widget.controller.saveSettingsPayload(snapshot);
|
|
819
|
+
} catch (error) {
|
|
820
|
+
if (!mounted) return;
|
|
821
|
+
messenger?.showSnackBar(
|
|
822
|
+
SnackBar(
|
|
823
|
+
content: Text(
|
|
824
|
+
'Failed to save ${platform.label}: ${widget.controller.friendlyErrorMessage(error)}',
|
|
825
|
+
),
|
|
826
|
+
),
|
|
827
|
+
);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
try {
|
|
831
|
+
await widget.controller.refreshMessaging();
|
|
832
|
+
connected = true;
|
|
833
|
+
} catch (error) {
|
|
834
|
+
if (!mounted) return;
|
|
835
|
+
messenger?.showSnackBar(
|
|
836
|
+
SnackBar(
|
|
837
|
+
content: Text(
|
|
838
|
+
'Saved ${platform.label}, but refresh failed: ${widget.controller.friendlyErrorMessage(error)}',
|
|
839
|
+
),
|
|
840
|
+
),
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
if (connected && context.mounted) {
|
|
845
|
+
Navigator.of(context).pop();
|
|
846
|
+
}
|
|
847
|
+
},
|
|
848
|
+
child: Text(
|
|
849
|
+
platform.id == 'meshtastic' &&
|
|
850
|
+
!(boolValues['meshtastic_enabled'] ?? true)
|
|
851
|
+
? 'Save'
|
|
852
|
+
: 'Connect',
|
|
853
|
+
),
|
|
854
|
+
),
|
|
855
|
+
],
|
|
856
|
+
);
|
|
857
|
+
},
|
|
858
|
+
);
|
|
859
|
+
},
|
|
860
|
+
);
|
|
861
|
+
} finally {
|
|
862
|
+
for (final controller in textControllers.values) {
|
|
863
|
+
controller.dispose();
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
class _MessagingStatusCounts {
|
|
870
|
+
const _MessagingStatusCounts({
|
|
871
|
+
required this.total,
|
|
872
|
+
required this.connected,
|
|
873
|
+
required this.configured,
|
|
874
|
+
required this.attention,
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
final int total;
|
|
878
|
+
final int connected;
|
|
879
|
+
final int configured;
|
|
880
|
+
final int attention;
|
|
881
|
+
|
|
882
|
+
factory _MessagingStatusCounts.from(
|
|
883
|
+
Map<String, MessagingPlatformStatus> statuses,
|
|
884
|
+
) {
|
|
885
|
+
var connected = 0;
|
|
886
|
+
var configured = 0;
|
|
887
|
+
var attention = 0;
|
|
888
|
+
for (final platform in messagingPlatforms) {
|
|
889
|
+
final status =
|
|
890
|
+
statuses[platform.id] ?? MessagingPlatformStatus.empty(platform.id);
|
|
891
|
+
if (status.isConnected) connected++;
|
|
892
|
+
if (status.status != 'not_configured') configured++;
|
|
893
|
+
if (const {
|
|
894
|
+
'connecting',
|
|
895
|
+
'awaiting_qr',
|
|
896
|
+
'logged_out',
|
|
897
|
+
'disconnected',
|
|
898
|
+
'error',
|
|
899
|
+
}.contains(status.status)) {
|
|
900
|
+
attention++;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
return _MessagingStatusCounts(
|
|
904
|
+
total: messagingPlatforms.length,
|
|
905
|
+
connected: connected,
|
|
906
|
+
configured: configured,
|
|
907
|
+
attention: attention,
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
class _MessagingOverviewStrip extends StatelessWidget {
|
|
913
|
+
const _MessagingOverviewStrip({required this.counts});
|
|
914
|
+
|
|
915
|
+
final _MessagingStatusCounts counts;
|
|
916
|
+
|
|
917
|
+
@override
|
|
918
|
+
Widget build(BuildContext context) {
|
|
919
|
+
final cards = [
|
|
920
|
+
_MessagingMetricCard(
|
|
921
|
+
icon: Icons.link_rounded,
|
|
922
|
+
label: 'Connected',
|
|
923
|
+
value: '${counts.connected}',
|
|
924
|
+
helper: '${counts.configured} configured',
|
|
925
|
+
color: _success,
|
|
926
|
+
),
|
|
927
|
+
_MessagingMetricCard(
|
|
928
|
+
icon: Icons.error_outline_rounded,
|
|
929
|
+
label: 'Needs attention',
|
|
930
|
+
value: '${counts.attention}',
|
|
931
|
+
helper: 'Reconnect or finish setup',
|
|
932
|
+
color: counts.attention > 0 ? _warning : _textSecondary,
|
|
933
|
+
),
|
|
934
|
+
_MessagingMetricCard(
|
|
935
|
+
icon: Icons.apps_rounded,
|
|
936
|
+
label: 'Available',
|
|
937
|
+
value: '${counts.total}',
|
|
938
|
+
helper: 'Native and webhook channels',
|
|
939
|
+
color: _info,
|
|
940
|
+
),
|
|
941
|
+
];
|
|
942
|
+
return LayoutBuilder(
|
|
943
|
+
builder: (context, constraints) {
|
|
944
|
+
final compact = constraints.maxWidth < 760;
|
|
945
|
+
if (compact) {
|
|
946
|
+
return Column(
|
|
947
|
+
children: [
|
|
948
|
+
for (var index = 0; index < cards.length; index++) ...[
|
|
949
|
+
if (index > 0) const SizedBox(height: 10),
|
|
950
|
+
cards[index],
|
|
951
|
+
],
|
|
952
|
+
],
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
return Row(
|
|
956
|
+
children: [
|
|
957
|
+
for (var index = 0; index < cards.length; index++) ...[
|
|
958
|
+
if (index > 0) const SizedBox(width: 12),
|
|
959
|
+
Expanded(child: cards[index]),
|
|
960
|
+
],
|
|
961
|
+
],
|
|
962
|
+
);
|
|
963
|
+
},
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
class _MessagingMetricCard extends StatelessWidget {
|
|
969
|
+
const _MessagingMetricCard({
|
|
970
|
+
required this.icon,
|
|
971
|
+
required this.label,
|
|
972
|
+
required this.value,
|
|
973
|
+
required this.helper,
|
|
974
|
+
required this.color,
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
final IconData icon;
|
|
978
|
+
final String label;
|
|
979
|
+
final String value;
|
|
980
|
+
final String helper;
|
|
981
|
+
final Color color;
|
|
982
|
+
|
|
983
|
+
@override
|
|
984
|
+
Widget build(BuildContext context) {
|
|
985
|
+
return Container(
|
|
986
|
+
padding: const EdgeInsets.all(16),
|
|
987
|
+
decoration: BoxDecoration(
|
|
988
|
+
color: _bgCard,
|
|
989
|
+
borderRadius: BorderRadius.circular(8),
|
|
990
|
+
border: Border.all(color: _borderLight),
|
|
991
|
+
),
|
|
992
|
+
child: Row(
|
|
993
|
+
children: [
|
|
994
|
+
Container(
|
|
995
|
+
width: 40,
|
|
996
|
+
height: 40,
|
|
997
|
+
decoration: BoxDecoration(
|
|
998
|
+
color: color.withValues(alpha: 0.12),
|
|
999
|
+
borderRadius: BorderRadius.circular(8),
|
|
1000
|
+
),
|
|
1001
|
+
child: Icon(icon, color: color, size: 22),
|
|
1002
|
+
),
|
|
1003
|
+
const SizedBox(width: 14),
|
|
1004
|
+
Expanded(
|
|
1005
|
+
child: Column(
|
|
1006
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1007
|
+
children: [
|
|
1008
|
+
Text(
|
|
1009
|
+
label,
|
|
1010
|
+
style: TextStyle(color: _textSecondary, fontSize: 12),
|
|
1011
|
+
),
|
|
1012
|
+
const SizedBox(height: 4),
|
|
1013
|
+
Text(
|
|
1014
|
+
value,
|
|
1015
|
+
style: TextStyle(
|
|
1016
|
+
color: _textPrimary,
|
|
1017
|
+
fontSize: 26,
|
|
1018
|
+
fontWeight: FontWeight.w800,
|
|
1019
|
+
),
|
|
1020
|
+
),
|
|
1021
|
+
const SizedBox(height: 2),
|
|
1022
|
+
Text(
|
|
1023
|
+
helper,
|
|
1024
|
+
style: TextStyle(color: _textMuted, fontSize: 12),
|
|
1025
|
+
overflow: TextOverflow.ellipsis,
|
|
1026
|
+
),
|
|
1027
|
+
],
|
|
1028
|
+
),
|
|
1029
|
+
),
|
|
1030
|
+
],
|
|
1031
|
+
),
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
class _MessagingToolbar extends StatelessWidget {
|
|
1037
|
+
const _MessagingToolbar({
|
|
1038
|
+
required this.controller,
|
|
1039
|
+
required this.selectedFilter,
|
|
1040
|
+
required this.onFilterChanged,
|
|
1041
|
+
required this.counts,
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
final TextEditingController controller;
|
|
1045
|
+
final String selectedFilter;
|
|
1046
|
+
final ValueChanged<String> onFilterChanged;
|
|
1047
|
+
final _MessagingStatusCounts counts;
|
|
1048
|
+
|
|
1049
|
+
@override
|
|
1050
|
+
Widget build(BuildContext context) {
|
|
1051
|
+
final filters = <(String, String)>[
|
|
1052
|
+
('all', 'All ${counts.total}'),
|
|
1053
|
+
('connected', 'Connected ${counts.connected}'),
|
|
1054
|
+
('configured', 'Configured ${counts.configured}'),
|
|
1055
|
+
('attention', 'Attention ${counts.attention}'),
|
|
1056
|
+
];
|
|
1057
|
+
return Container(
|
|
1058
|
+
padding: const EdgeInsets.all(14),
|
|
1059
|
+
decoration: BoxDecoration(
|
|
1060
|
+
color: _bgSecondary,
|
|
1061
|
+
borderRadius: BorderRadius.circular(8),
|
|
1062
|
+
border: Border.all(color: _borderLight),
|
|
1063
|
+
),
|
|
1064
|
+
child: LayoutBuilder(
|
|
1065
|
+
builder: (context, constraints) {
|
|
1066
|
+
final compact = constraints.maxWidth < 780;
|
|
1067
|
+
final search = TextField(
|
|
1068
|
+
controller: controller,
|
|
1069
|
+
style: TextStyle(color: _textPrimary),
|
|
1070
|
+
decoration: InputDecoration(
|
|
1071
|
+
labelText: 'Find a platform',
|
|
1072
|
+
prefixIcon: Icon(Icons.search_rounded),
|
|
1073
|
+
suffixIcon: controller.text.isEmpty
|
|
1074
|
+
? null
|
|
1075
|
+
: IconButton(
|
|
1076
|
+
onPressed: controller.clear,
|
|
1077
|
+
icon: Icon(Icons.close_rounded),
|
|
1078
|
+
),
|
|
1079
|
+
),
|
|
1080
|
+
);
|
|
1081
|
+
final chips = Wrap(
|
|
1082
|
+
spacing: 8,
|
|
1083
|
+
runSpacing: 8,
|
|
1084
|
+
children: [
|
|
1085
|
+
for (final filter in filters)
|
|
1086
|
+
ChoiceChip(
|
|
1087
|
+
label: Text(filter.$2),
|
|
1088
|
+
selected: selectedFilter == filter.$1,
|
|
1089
|
+
onSelected: (_) => onFilterChanged(filter.$1),
|
|
1090
|
+
selectedColor: _accent.withValues(alpha: 0.18),
|
|
1091
|
+
backgroundColor: _bgCard,
|
|
1092
|
+
side: BorderSide(
|
|
1093
|
+
color: selectedFilter == filter.$1
|
|
1094
|
+
? _accent.withValues(alpha: 0.42)
|
|
1095
|
+
: _borderLight,
|
|
1096
|
+
),
|
|
1097
|
+
labelStyle: TextStyle(
|
|
1098
|
+
color: selectedFilter == filter.$1
|
|
1099
|
+
? _textPrimary
|
|
1100
|
+
: _textSecondary,
|
|
1101
|
+
fontWeight: selectedFilter == filter.$1
|
|
1102
|
+
? FontWeight.w700
|
|
1103
|
+
: FontWeight.w500,
|
|
1104
|
+
),
|
|
1105
|
+
),
|
|
1106
|
+
],
|
|
1107
|
+
);
|
|
1108
|
+
if (compact) {
|
|
1109
|
+
return Column(
|
|
1110
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1111
|
+
children: [search, const SizedBox(height: 12), chips],
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
return Row(
|
|
1115
|
+
children: [
|
|
1116
|
+
Expanded(child: search),
|
|
1117
|
+
const SizedBox(width: 14),
|
|
1118
|
+
Flexible(child: chips),
|
|
1119
|
+
],
|
|
1120
|
+
);
|
|
1121
|
+
},
|
|
1122
|
+
),
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
class _MessagingQrPanel extends StatelessWidget {
|
|
1128
|
+
const _MessagingQrPanel({required this.qrState});
|
|
1129
|
+
|
|
1130
|
+
final MessagingQrState qrState;
|
|
1131
|
+
|
|
1132
|
+
@override
|
|
1133
|
+
Widget build(BuildContext context) {
|
|
1134
|
+
final qrImage = Container(
|
|
1135
|
+
padding: const EdgeInsets.all(10),
|
|
1136
|
+
decoration: BoxDecoration(
|
|
1137
|
+
color: Colors.white,
|
|
1138
|
+
borderRadius: BorderRadius.circular(8),
|
|
1139
|
+
),
|
|
1140
|
+
child: QrImageView(
|
|
1141
|
+
data: qrState.qr,
|
|
1142
|
+
size: 168,
|
|
1143
|
+
eyeStyle: const QrEyeStyle(
|
|
1144
|
+
eyeShape: QrEyeShape.square,
|
|
1145
|
+
color: Colors.black,
|
|
1146
|
+
),
|
|
1147
|
+
dataModuleStyle: const QrDataModuleStyle(
|
|
1148
|
+
dataModuleShape: QrDataModuleShape.square,
|
|
1149
|
+
color: Colors.black,
|
|
1150
|
+
),
|
|
1151
|
+
),
|
|
1152
|
+
);
|
|
1153
|
+
final copy = Column(
|
|
1154
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1155
|
+
children: [
|
|
1156
|
+
_StatusPill(label: 'Awaiting scan', color: _warning),
|
|
1157
|
+
const SizedBox(height: 12),
|
|
1158
|
+
Text(
|
|
1159
|
+
'Scan to finish ${qrState.platformLabel}',
|
|
1160
|
+
style: TextStyle(
|
|
1161
|
+
color: _textPrimary,
|
|
1162
|
+
fontSize: 22,
|
|
1163
|
+
fontWeight: FontWeight.w800,
|
|
1164
|
+
),
|
|
1165
|
+
),
|
|
1166
|
+
const SizedBox(height: 8),
|
|
1167
|
+
Text(
|
|
1168
|
+
'Keep this panel open until the platform confirms the connection.',
|
|
1169
|
+
style: TextStyle(color: _textSecondary, height: 1.45),
|
|
1170
|
+
),
|
|
1171
|
+
],
|
|
1172
|
+
);
|
|
1173
|
+
return Container(
|
|
1174
|
+
padding: const EdgeInsets.all(18),
|
|
1175
|
+
decoration: BoxDecoration(
|
|
1176
|
+
color: _warning.withValues(alpha: 0.08),
|
|
1177
|
+
borderRadius: BorderRadius.circular(8),
|
|
1178
|
+
border: Border.all(color: _warning.withValues(alpha: 0.3)),
|
|
1179
|
+
),
|
|
1180
|
+
child: LayoutBuilder(
|
|
1181
|
+
builder: (context, constraints) {
|
|
1182
|
+
if (constraints.maxWidth < 680) {
|
|
1183
|
+
return Column(
|
|
1184
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1185
|
+
children: [
|
|
1186
|
+
copy,
|
|
1187
|
+
const SizedBox(height: 16),
|
|
1188
|
+
Center(child: qrImage),
|
|
1189
|
+
],
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
return Row(
|
|
1193
|
+
children: [
|
|
1194
|
+
Expanded(child: copy),
|
|
1195
|
+
const SizedBox(width: 24),
|
|
1196
|
+
qrImage,
|
|
1197
|
+
],
|
|
1198
|
+
);
|
|
1199
|
+
},
|
|
1200
|
+
),
|
|
1201
|
+
);
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
class _MessagingGroupHeader extends StatelessWidget {
|
|
1206
|
+
const _MessagingGroupHeader({
|
|
1207
|
+
required this.title,
|
|
1208
|
+
required this.subtitle,
|
|
1209
|
+
required this.count,
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1212
|
+
final String title;
|
|
1213
|
+
final String subtitle;
|
|
1214
|
+
final int count;
|
|
1215
|
+
|
|
1216
|
+
@override
|
|
1217
|
+
Widget build(BuildContext context) {
|
|
1218
|
+
return Row(
|
|
1219
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1220
|
+
children: [
|
|
1221
|
+
Expanded(
|
|
1222
|
+
child: Column(
|
|
1223
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1224
|
+
children: [
|
|
1225
|
+
Text(
|
|
1226
|
+
title,
|
|
1227
|
+
style: TextStyle(
|
|
1228
|
+
color: _textPrimary,
|
|
1229
|
+
fontSize: 18,
|
|
1230
|
+
fontWeight: FontWeight.w800,
|
|
1231
|
+
),
|
|
1232
|
+
),
|
|
1233
|
+
const SizedBox(height: 4),
|
|
1234
|
+
Text(
|
|
1235
|
+
subtitle,
|
|
1236
|
+
style: TextStyle(color: _textSecondary, height: 1.35),
|
|
1237
|
+
),
|
|
1238
|
+
],
|
|
1239
|
+
),
|
|
1240
|
+
),
|
|
1241
|
+
const SizedBox(width: 12),
|
|
1242
|
+
_StatusPill(label: '$count shown', color: _textSecondary),
|
|
1243
|
+
],
|
|
1244
|
+
);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
class _MessagingActivityPanel extends StatelessWidget {
|
|
1249
|
+
const _MessagingActivityPanel({required this.messages});
|
|
1250
|
+
|
|
1251
|
+
final List<MessagingMessage> messages;
|
|
1252
|
+
|
|
1253
|
+
@override
|
|
1254
|
+
Widget build(BuildContext context) {
|
|
1255
|
+
return Container(
|
|
1256
|
+
padding: const EdgeInsets.all(18),
|
|
1257
|
+
decoration: BoxDecoration(
|
|
1258
|
+
color: _bgCard,
|
|
1259
|
+
borderRadius: BorderRadius.circular(8),
|
|
1260
|
+
border: Border.all(color: _borderLight),
|
|
1261
|
+
),
|
|
1262
|
+
child: Column(
|
|
1263
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1264
|
+
children: [
|
|
1265
|
+
Row(
|
|
1266
|
+
children: [
|
|
1267
|
+
Expanded(
|
|
1268
|
+
child: Text(
|
|
1269
|
+
'Recent Channel Activity',
|
|
1270
|
+
style: TextStyle(
|
|
1271
|
+
color: _textPrimary,
|
|
1272
|
+
fontSize: 18,
|
|
1273
|
+
fontWeight: FontWeight.w800,
|
|
1274
|
+
),
|
|
1275
|
+
),
|
|
1276
|
+
),
|
|
1277
|
+
_StatusPill(label: '${messages.length} events', color: _info),
|
|
1278
|
+
],
|
|
1279
|
+
),
|
|
1280
|
+
const SizedBox(height: 14),
|
|
1281
|
+
if (messages.isEmpty)
|
|
1282
|
+
const _EmptyCard(
|
|
1283
|
+
title: 'No recent channel activity',
|
|
1284
|
+
subtitle:
|
|
1285
|
+
'Incoming and outgoing channel messages will appear here.',
|
|
1286
|
+
)
|
|
1287
|
+
else
|
|
1288
|
+
Column(
|
|
1289
|
+
children: [
|
|
1290
|
+
for (final message in messages.take(12))
|
|
1291
|
+
_MessagingActivityItem(message: message),
|
|
1292
|
+
],
|
|
1293
|
+
),
|
|
1294
|
+
],
|
|
1295
|
+
),
|
|
1296
|
+
);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
class _MessagingActivityItem extends StatelessWidget {
|
|
1301
|
+
const _MessagingActivityItem({required this.message});
|
|
1302
|
+
|
|
1303
|
+
final MessagingMessage message;
|
|
1304
|
+
|
|
1305
|
+
@override
|
|
1306
|
+
Widget build(BuildContext context) {
|
|
1307
|
+
final isOutbound = message.outgoing;
|
|
1308
|
+
final color = isOutbound ? _accent : _success;
|
|
1309
|
+
return Container(
|
|
1310
|
+
margin: const EdgeInsets.only(bottom: 10),
|
|
1311
|
+
padding: const EdgeInsets.all(12),
|
|
1312
|
+
decoration: BoxDecoration(
|
|
1313
|
+
color: _bgSecondary,
|
|
1314
|
+
borderRadius: BorderRadius.circular(8),
|
|
1315
|
+
border: Border.all(color: _borderLight),
|
|
1316
|
+
),
|
|
1317
|
+
child: Row(
|
|
1318
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1319
|
+
children: [
|
|
1320
|
+
Container(
|
|
1321
|
+
width: 36,
|
|
1322
|
+
height: 36,
|
|
1323
|
+
decoration: BoxDecoration(
|
|
1324
|
+
color: color.withValues(alpha: 0.12),
|
|
1325
|
+
borderRadius: BorderRadius.circular(8),
|
|
1326
|
+
),
|
|
1327
|
+
child: Icon(
|
|
1328
|
+
isOutbound ? Icons.north_east_rounded : Icons.south_west_rounded,
|
|
1329
|
+
color: color,
|
|
1330
|
+
size: 18,
|
|
1331
|
+
),
|
|
1332
|
+
),
|
|
1333
|
+
const SizedBox(width: 12),
|
|
1334
|
+
Expanded(
|
|
1335
|
+
child: Column(
|
|
1336
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1337
|
+
children: [
|
|
1338
|
+
Wrap(
|
|
1339
|
+
spacing: 8,
|
|
1340
|
+
runSpacing: 6,
|
|
1341
|
+
crossAxisAlignment: WrapCrossAlignment.center,
|
|
1342
|
+
children: [
|
|
1343
|
+
_StatusPill(
|
|
1344
|
+
label: message.platform.toUpperCase(),
|
|
1345
|
+
color: _info,
|
|
1346
|
+
),
|
|
1347
|
+
Text(
|
|
1348
|
+
message.senderLabel,
|
|
1349
|
+
style: TextStyle(
|
|
1350
|
+
color: _textPrimary,
|
|
1351
|
+
fontWeight: FontWeight.w700,
|
|
1352
|
+
),
|
|
1353
|
+
),
|
|
1354
|
+
Text(
|
|
1355
|
+
message.createdAtLabel,
|
|
1356
|
+
style: TextStyle(color: _textMuted, fontSize: 12),
|
|
1357
|
+
),
|
|
1358
|
+
],
|
|
1359
|
+
),
|
|
1360
|
+
const SizedBox(height: 6),
|
|
1361
|
+
Text(
|
|
1362
|
+
message.content.ifEmpty('[empty]'),
|
|
1363
|
+
style: TextStyle(color: _textSecondary, height: 1.35),
|
|
1364
|
+
maxLines: 3,
|
|
1365
|
+
overflow: TextOverflow.ellipsis,
|
|
1366
|
+
),
|
|
1367
|
+
],
|
|
1368
|
+
),
|
|
1369
|
+
),
|
|
1370
|
+
],
|
|
1371
|
+
),
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
class RunsPanel extends StatefulWidget {
|
|
1377
|
+
const RunsPanel({super.key, required this.controller});
|
|
1378
|
+
|
|
1379
|
+
final NeoAgentController controller;
|
|
1380
|
+
|
|
1381
|
+
@override
|
|
1382
|
+
State<RunsPanel> createState() => _RunsPanelState();
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
class _RunsPanelState extends State<RunsPanel> {
|
|
1386
|
+
late final TextEditingController _searchController;
|
|
1387
|
+
String? _selectedRunId;
|
|
1388
|
+
String _statusFilter = 'all';
|
|
1389
|
+
RunDetailSnapshot? _detail;
|
|
1390
|
+
bool _loadingDetail = false;
|
|
1391
|
+
String? _detailError;
|
|
1392
|
+
|
|
1393
|
+
@override
|
|
1394
|
+
void initState() {
|
|
1395
|
+
super.initState();
|
|
1396
|
+
_searchController = TextEditingController()
|
|
1397
|
+
..addListener(_handleSearchChanged);
|
|
1398
|
+
_syncSelection();
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
@override
|
|
1402
|
+
void dispose() {
|
|
1403
|
+
_searchController
|
|
1404
|
+
..removeListener(_handleSearchChanged)
|
|
1405
|
+
..dispose();
|
|
1406
|
+
super.dispose();
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
@override
|
|
1410
|
+
void didUpdateWidget(covariant RunsPanel oldWidget) {
|
|
1411
|
+
super.didUpdateWidget(oldWidget);
|
|
1412
|
+
_syncSelection();
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
void _handleSearchChanged() {
|
|
1416
|
+
if (!mounted) {
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
setState(() {});
|
|
1420
|
+
_syncSelection();
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
List<RunSummary> get _filteredRuns {
|
|
1424
|
+
final query = _searchController.text.trim().toLowerCase();
|
|
1425
|
+
return widget.controller.recentRuns.where((run) {
|
|
1426
|
+
final statusMatches =
|
|
1427
|
+
_statusFilter == 'all' ||
|
|
1428
|
+
(_statusFilter == 'failed'
|
|
1429
|
+
? run.isFailure
|
|
1430
|
+
: run.status.toLowerCase() == _statusFilter);
|
|
1431
|
+
if (!statusMatches) {
|
|
1432
|
+
return false;
|
|
1433
|
+
}
|
|
1434
|
+
if (query.isEmpty) {
|
|
1435
|
+
return true;
|
|
1436
|
+
}
|
|
1437
|
+
final haystack = <String>[
|
|
1438
|
+
run.title,
|
|
1439
|
+
run.status,
|
|
1440
|
+
run.model,
|
|
1441
|
+
run.triggerSource,
|
|
1442
|
+
run.error,
|
|
1443
|
+
run.id,
|
|
1444
|
+
].join(' ').toLowerCase();
|
|
1445
|
+
return haystack.contains(query);
|
|
1446
|
+
}).toList();
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
void _syncSelection() {
|
|
1450
|
+
final runs = _filteredRuns;
|
|
1451
|
+
if (runs.isEmpty) {
|
|
1452
|
+
_selectedRunId = null;
|
|
1453
|
+
_detail = null;
|
|
1454
|
+
_detailError = null;
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
if (_selectedRunId == null ||
|
|
1458
|
+
!runs.any((run) => run.id == _selectedRunId)) {
|
|
1459
|
+
_selectRun(runs.first.id);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
Future<void> _selectRun(String runId, {bool force = false}) async {
|
|
1464
|
+
setState(() {
|
|
1465
|
+
_selectedRunId = runId;
|
|
1466
|
+
_loadingDetail = true;
|
|
1467
|
+
_detailError = null;
|
|
1468
|
+
});
|
|
1469
|
+
try {
|
|
1470
|
+
final detail = await widget.controller.fetchRunDetail(
|
|
1471
|
+
runId,
|
|
1472
|
+
force: force,
|
|
1473
|
+
);
|
|
1474
|
+
if (!mounted || _selectedRunId != runId) {
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
setState(() {
|
|
1478
|
+
_detail = detail;
|
|
1479
|
+
_loadingDetail = false;
|
|
1480
|
+
_detailError = null;
|
|
1481
|
+
});
|
|
1482
|
+
} catch (error, stackTrace) {
|
|
1483
|
+
AppDiagnostics.log(
|
|
1484
|
+
'runs.ui',
|
|
1485
|
+
'detail.fetch_failed',
|
|
1486
|
+
data: <String, Object?>{'runId': runId},
|
|
1487
|
+
error: error,
|
|
1488
|
+
stackTrace: stackTrace,
|
|
1489
|
+
);
|
|
1490
|
+
if (!mounted || _selectedRunId != runId) {
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
setState(() {
|
|
1494
|
+
_loadingDetail = false;
|
|
1495
|
+
_detailError = widget.controller.friendlyErrorMessage(error);
|
|
1496
|
+
});
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
Future<void> _refreshRuns() async {
|
|
1501
|
+
await widget.controller.refreshRunsOnly();
|
|
1502
|
+
if (!mounted) {
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
final selectedRunId = _selectedRunId;
|
|
1506
|
+
if (selectedRunId != null &&
|
|
1507
|
+
_filteredRuns.any((run) => run.id == selectedRunId)) {
|
|
1508
|
+
await _selectRun(selectedRunId, force: true);
|
|
1509
|
+
} else {
|
|
1510
|
+
_syncSelection();
|
|
1511
|
+
}
|
|
1512
|
+
setState(() {});
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
void _setStatusFilter(String value) {
|
|
1516
|
+
setState(() {
|
|
1517
|
+
_statusFilter = value;
|
|
1518
|
+
});
|
|
1519
|
+
_syncSelection();
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
Future<void> _copyResponse(String response) async {
|
|
1523
|
+
if (response.trim().isEmpty) {
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
await Clipboard.setData(ClipboardData(text: response));
|
|
1527
|
+
if (!mounted) {
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
ScaffoldMessenger.of(
|
|
1531
|
+
context,
|
|
1532
|
+
).showSnackBar(const SnackBar(content: Text('Copied final response')));
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
Future<void> _deleteSelectedRun() async {
|
|
1536
|
+
final run = widget.controller.recentRuns.cast<RunSummary?>().firstWhere(
|
|
1537
|
+
(item) => item?.id == _selectedRunId,
|
|
1538
|
+
orElse: () => null,
|
|
1539
|
+
);
|
|
1540
|
+
if (run == null) {
|
|
1541
|
+
return;
|
|
1542
|
+
}
|
|
1543
|
+
await _confirmDelete(
|
|
1544
|
+
context,
|
|
1545
|
+
title: 'Delete run?',
|
|
1546
|
+
message:
|
|
1547
|
+
'Remove "${run.title}" and its recorded steps from the run history?',
|
|
1548
|
+
onConfirm: () async {
|
|
1549
|
+
await widget.controller.deleteRun(run.id);
|
|
1550
|
+
if (!mounted) {
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
_syncSelection();
|
|
1554
|
+
setState(() {});
|
|
1555
|
+
},
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
@override
|
|
1560
|
+
Widget build(BuildContext context) {
|
|
1561
|
+
final controller = widget.controller;
|
|
1562
|
+
final filteredRuns = _filteredRuns;
|
|
1563
|
+
final selected = filteredRuns.cast<RunSummary?>().firstWhere(
|
|
1564
|
+
(run) => run?.id == _selectedRunId,
|
|
1565
|
+
orElse: () => null,
|
|
1566
|
+
);
|
|
1567
|
+
final detail = _detail?.run.id == selected?.id ? _detail : null;
|
|
1568
|
+
|
|
1569
|
+
return ListView(
|
|
1570
|
+
padding: _pagePadding(context),
|
|
1571
|
+
children: <Widget>[
|
|
1572
|
+
_PageTitle(
|
|
1573
|
+
title: 'Runs',
|
|
1574
|
+
subtitle:
|
|
1575
|
+
'Inspect recent runs, failures, tool steps, and final responses.',
|
|
1576
|
+
trailing: OutlinedButton.icon(
|
|
1577
|
+
onPressed: _refreshRuns,
|
|
1578
|
+
icon: Icon(Icons.refresh),
|
|
1579
|
+
label: Text('Refresh'),
|
|
1580
|
+
),
|
|
1581
|
+
),
|
|
1582
|
+
if (controller.errorMessage != null) ...<Widget>[
|
|
1583
|
+
_InlineError(message: controller.errorMessage!),
|
|
1584
|
+
const SizedBox(height: 16),
|
|
1585
|
+
],
|
|
1586
|
+
if (controller.activeRun != null ||
|
|
1587
|
+
controller.toolEvents.isNotEmpty) ...<Widget>[
|
|
1588
|
+
_RunStatusPanel(
|
|
1589
|
+
run: controller.activeRun,
|
|
1590
|
+
tools: controller.toolEvents,
|
|
1591
|
+
),
|
|
1592
|
+
const SizedBox(height: 16),
|
|
1593
|
+
],
|
|
1594
|
+
if (controller.recentRuns.isEmpty)
|
|
1595
|
+
const _EmptyCard(
|
|
1596
|
+
title: 'No runs yet',
|
|
1597
|
+
subtitle:
|
|
1598
|
+
'Send a task from chat and its execution history will show up here.',
|
|
1599
|
+
)
|
|
1600
|
+
else ...<Widget>[
|
|
1601
|
+
_RunsMetricsStrip(
|
|
1602
|
+
runs: filteredRuns,
|
|
1603
|
+
totalLoaded: controller.recentRuns.length,
|
|
1604
|
+
),
|
|
1605
|
+
const SizedBox(height: 16),
|
|
1606
|
+
_RunsFilterBar(
|
|
1607
|
+
searchController: _searchController,
|
|
1608
|
+
statusFilter: _statusFilter,
|
|
1609
|
+
onStatusChanged: _setStatusFilter,
|
|
1610
|
+
),
|
|
1611
|
+
const SizedBox(height: 16),
|
|
1612
|
+
if (filteredRuns.isEmpty)
|
|
1613
|
+
const _EmptyCard(
|
|
1614
|
+
title: 'No matching runs',
|
|
1615
|
+
subtitle:
|
|
1616
|
+
'Try clearing the search or switching the status filter.',
|
|
1617
|
+
)
|
|
1618
|
+
else
|
|
1619
|
+
LayoutBuilder(
|
|
1620
|
+
builder: (context, constraints) {
|
|
1621
|
+
final wide = constraints.maxWidth >= 1120;
|
|
1622
|
+
final historyPane = _RunsHistoryPane(
|
|
1623
|
+
runs: filteredRuns,
|
|
1624
|
+
selectedRunId: _selectedRunId,
|
|
1625
|
+
onSelect: _selectRun,
|
|
1626
|
+
);
|
|
1627
|
+
final detailPane = _RunDetailWorkspace(
|
|
1628
|
+
run: selected,
|
|
1629
|
+
detail: detail,
|
|
1630
|
+
errorMessage: _detailError,
|
|
1631
|
+
loading: _loadingDetail,
|
|
1632
|
+
onDelete: _deleteSelectedRun,
|
|
1633
|
+
onCopyResponse: _copyResponse,
|
|
1634
|
+
);
|
|
1635
|
+
if (!wide) {
|
|
1636
|
+
return Column(
|
|
1637
|
+
children: <Widget>[
|
|
1638
|
+
detailPane,
|
|
1639
|
+
const SizedBox(height: 16),
|
|
1640
|
+
historyPane,
|
|
1641
|
+
],
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1644
|
+
return Row(
|
|
1645
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1646
|
+
children: <Widget>[
|
|
1647
|
+
SizedBox(width: 360, child: historyPane),
|
|
1648
|
+
const SizedBox(width: 16),
|
|
1649
|
+
Expanded(child: detailPane),
|
|
1650
|
+
],
|
|
1651
|
+
);
|
|
1652
|
+
},
|
|
1653
|
+
),
|
|
1654
|
+
],
|
|
1655
|
+
],
|
|
1656
|
+
);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
class _MessagingCard extends StatelessWidget {
|
|
1661
|
+
const _MessagingCard({
|
|
1662
|
+
required this.platform,
|
|
1663
|
+
required this.status,
|
|
1664
|
+
required this.accessCatalog,
|
|
1665
|
+
required this.controller,
|
|
1666
|
+
required this.onConnect,
|
|
1667
|
+
required this.onDisconnect,
|
|
1668
|
+
required this.onLogout,
|
|
1669
|
+
});
|
|
1670
|
+
|
|
1671
|
+
final MessagingPlatformDescriptor platform;
|
|
1672
|
+
final MessagingPlatformStatus? status;
|
|
1673
|
+
final MessagingAccessCatalog accessCatalog;
|
|
1674
|
+
final NeoAgentController controller;
|
|
1675
|
+
final Future<void> Function() onConnect;
|
|
1676
|
+
final Future<void> Function() onDisconnect;
|
|
1677
|
+
final Future<void> Function() onLogout;
|
|
1678
|
+
|
|
1679
|
+
@override
|
|
1680
|
+
Widget build(BuildContext context) {
|
|
1681
|
+
final connected = status?.isConnected ?? false;
|
|
1682
|
+
final configured = status != null && status!.status != 'not_configured';
|
|
1683
|
+
final accent = platform.accent;
|
|
1684
|
+
final actionLabel = connected
|
|
1685
|
+
? 'Connected'
|
|
1686
|
+
: configured
|
|
1687
|
+
? 'Reconnect'
|
|
1688
|
+
: 'Connect';
|
|
1689
|
+
final accessLabel = accessCatalog.summary.ifEmpty('Access policy');
|
|
1690
|
+
return Container(
|
|
1691
|
+
padding: const EdgeInsets.all(16),
|
|
1692
|
+
decoration: BoxDecoration(
|
|
1693
|
+
color: _bgCard,
|
|
1694
|
+
borderRadius: BorderRadius.circular(8),
|
|
1695
|
+
border: Border.all(
|
|
1696
|
+
color: connected ? accent.withValues(alpha: 0.48) : _borderLight,
|
|
1697
|
+
),
|
|
1698
|
+
boxShadow: [
|
|
1699
|
+
if (connected)
|
|
1700
|
+
BoxShadow(
|
|
1701
|
+
color: accent.withValues(alpha: 0.08),
|
|
1702
|
+
blurRadius: 18,
|
|
1703
|
+
offset: const Offset(0, 10),
|
|
1704
|
+
),
|
|
1705
|
+
],
|
|
1706
|
+
),
|
|
1707
|
+
child: Column(
|
|
1708
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1709
|
+
children: [
|
|
1710
|
+
Row(
|
|
1711
|
+
children: [
|
|
1712
|
+
Container(
|
|
1713
|
+
width: 44,
|
|
1714
|
+
height: 44,
|
|
1715
|
+
decoration: BoxDecoration(
|
|
1716
|
+
color: accent.withValues(alpha: 0.12),
|
|
1717
|
+
borderRadius: BorderRadius.circular(8),
|
|
1718
|
+
),
|
|
1719
|
+
child: Icon(platform.icon, color: accent, size: 23),
|
|
1720
|
+
),
|
|
1721
|
+
const SizedBox(width: 12),
|
|
1722
|
+
Expanded(
|
|
1723
|
+
child: Column(
|
|
1724
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1725
|
+
children: [
|
|
1726
|
+
Text(
|
|
1727
|
+
platform.label,
|
|
1728
|
+
style: TextStyle(
|
|
1729
|
+
color: _textPrimary,
|
|
1730
|
+
fontWeight: FontWeight.w800,
|
|
1731
|
+
fontSize: 16,
|
|
1732
|
+
),
|
|
1733
|
+
maxLines: 1,
|
|
1734
|
+
overflow: TextOverflow.ellipsis,
|
|
1735
|
+
),
|
|
1736
|
+
const SizedBox(height: 3),
|
|
1737
|
+
Text(
|
|
1738
|
+
status?.authLabel ?? 'Not configured',
|
|
1739
|
+
style: TextStyle(color: _textSecondary, fontSize: 12),
|
|
1740
|
+
maxLines: 1,
|
|
1741
|
+
overflow: TextOverflow.ellipsis,
|
|
1742
|
+
),
|
|
1743
|
+
],
|
|
1744
|
+
),
|
|
1745
|
+
),
|
|
1746
|
+
const SizedBox(width: 10),
|
|
1747
|
+
_StatusPill(
|
|
1748
|
+
label: connected
|
|
1749
|
+
? 'Live'
|
|
1750
|
+
: configured
|
|
1751
|
+
? 'Ready'
|
|
1752
|
+
: 'Setup',
|
|
1753
|
+
color: connected
|
|
1754
|
+
? _success
|
|
1755
|
+
: configured
|
|
1756
|
+
? _warning
|
|
1757
|
+
: _textMuted,
|
|
1758
|
+
),
|
|
1759
|
+
],
|
|
1760
|
+
),
|
|
1761
|
+
const SizedBox(height: 14),
|
|
1762
|
+
Text(
|
|
1763
|
+
platform.subtitle,
|
|
1764
|
+
style: TextStyle(color: _textSecondary, height: 1.4),
|
|
1765
|
+
maxLines: 2,
|
|
1766
|
+
overflow: TextOverflow.ellipsis,
|
|
1767
|
+
),
|
|
1768
|
+
const Spacer(),
|
|
1769
|
+
Wrap(
|
|
1770
|
+
spacing: 8,
|
|
1771
|
+
runSpacing: 8,
|
|
1772
|
+
children: [
|
|
1773
|
+
_MessagingMiniPill(
|
|
1774
|
+
icon: Icons.admin_panel_settings_outlined,
|
|
1775
|
+
label: accessLabel,
|
|
1776
|
+
),
|
|
1777
|
+
if (configured && !connected)
|
|
1778
|
+
const _MessagingMiniPill(
|
|
1779
|
+
icon: Icons.tune_rounded,
|
|
1780
|
+
label: 'Configured',
|
|
1781
|
+
),
|
|
1782
|
+
if (platform.configFields.isNotEmpty)
|
|
1783
|
+
_MessagingMiniPill(
|
|
1784
|
+
icon: Icons.edit_note_rounded,
|
|
1785
|
+
label: '${platform.configFields.length} fields',
|
|
1786
|
+
),
|
|
1787
|
+
],
|
|
1788
|
+
),
|
|
1789
|
+
const SizedBox(height: 14),
|
|
1790
|
+
Row(
|
|
1791
|
+
children: [
|
|
1792
|
+
Expanded(
|
|
1793
|
+
child: connected
|
|
1794
|
+
? OutlinedButton.icon(
|
|
1795
|
+
onPressed: onDisconnect,
|
|
1796
|
+
icon: Icon(Icons.link_off_rounded, size: 18),
|
|
1797
|
+
label: Text(
|
|
1798
|
+
'Disconnect',
|
|
1799
|
+
overflow: TextOverflow.ellipsis,
|
|
1800
|
+
),
|
|
1801
|
+
)
|
|
1802
|
+
: FilledButton.icon(
|
|
1803
|
+
onPressed: onConnect,
|
|
1804
|
+
icon: Icon(Icons.power_settings_new_rounded, size: 18),
|
|
1805
|
+
label: Text(
|
|
1806
|
+
actionLabel,
|
|
1807
|
+
overflow: TextOverflow.ellipsis,
|
|
1808
|
+
),
|
|
1809
|
+
style: FilledButton.styleFrom(backgroundColor: accent),
|
|
1810
|
+
),
|
|
1811
|
+
),
|
|
1812
|
+
const SizedBox(width: 8),
|
|
1813
|
+
IconButton.outlined(
|
|
1814
|
+
tooltip: 'Access policy',
|
|
1815
|
+
onPressed: () => _editAccessPolicy(context, controller),
|
|
1816
|
+
icon: Icon(Icons.group_add_outlined),
|
|
1817
|
+
),
|
|
1818
|
+
if (platform.id == 'telnyx') ...[
|
|
1819
|
+
const SizedBox(width: 8),
|
|
1820
|
+
IconButton.outlined(
|
|
1821
|
+
tooltip: 'Voice PIN',
|
|
1822
|
+
onPressed: () => _editTelnyxSecret(context, controller),
|
|
1823
|
+
icon: Icon(Icons.password_outlined),
|
|
1824
|
+
),
|
|
1825
|
+
],
|
|
1826
|
+
if (connected) ...[
|
|
1827
|
+
const SizedBox(width: 8),
|
|
1828
|
+
IconButton.outlined(
|
|
1829
|
+
tooltip: 'Logout',
|
|
1830
|
+
onPressed: onLogout,
|
|
1831
|
+
icon: Icon(Icons.logout_rounded),
|
|
1832
|
+
),
|
|
1833
|
+
],
|
|
1834
|
+
],
|
|
1835
|
+
),
|
|
1836
|
+
],
|
|
1837
|
+
),
|
|
1838
|
+
);
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
Future<void> _editAccessPolicy(
|
|
1842
|
+
BuildContext context,
|
|
1843
|
+
NeoAgentController controller,
|
|
1844
|
+
) async {
|
|
1845
|
+
final catalog = await controller.loadMessagingAccessCatalog(
|
|
1846
|
+
platform.id,
|
|
1847
|
+
force: true,
|
|
1848
|
+
);
|
|
1849
|
+
if (!context.mounted) return;
|
|
1850
|
+
await _showMessagingAccessPolicyDialog(
|
|
1851
|
+
context,
|
|
1852
|
+
platform: platform,
|
|
1853
|
+
initialCatalog: catalog,
|
|
1854
|
+
onRefreshCatalog: () =>
|
|
1855
|
+
controller.loadMessagingAccessCatalog(platform.id, force: true),
|
|
1856
|
+
onSave: (policy) =>
|
|
1857
|
+
controller.saveMessagingAccessPolicy(platform.id, policy),
|
|
1858
|
+
);
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
Future<void> _editTelnyxSecret(
|
|
1862
|
+
BuildContext context,
|
|
1863
|
+
NeoAgentController controller,
|
|
1864
|
+
) async {
|
|
1865
|
+
final initial =
|
|
1866
|
+
controller.settings['platform_voice_secret_telnyx']?.toString() ?? '';
|
|
1867
|
+
final saved = await _showTextSettingDialog(
|
|
1868
|
+
context,
|
|
1869
|
+
title: 'Voice PIN',
|
|
1870
|
+
subtitle:
|
|
1871
|
+
'Set the PIN callers must enter before the voice agent answers.',
|
|
1872
|
+
label: 'PIN or passphrase',
|
|
1873
|
+
initialValue: initial,
|
|
1874
|
+
obscureText: true,
|
|
1875
|
+
);
|
|
1876
|
+
if (saved != null) {
|
|
1877
|
+
await controller.saveTelnyxVoiceSecret(saved);
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
class _MessagingRuleSelection {
|
|
1883
|
+
const _MessagingRuleSelection({required this.bucket, required this.rule});
|
|
1884
|
+
|
|
1885
|
+
final String bucket;
|
|
1886
|
+
final MessagingAccessRule rule;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
Future<void> _showMessagingAccessPolicyDialog(
|
|
1890
|
+
BuildContext context, {
|
|
1891
|
+
required MessagingPlatformDescriptor platform,
|
|
1892
|
+
required MessagingAccessCatalog initialCatalog,
|
|
1893
|
+
required Future<MessagingAccessCatalog> Function() onRefreshCatalog,
|
|
1894
|
+
required Future<void> Function(MessagingAccessPolicy policy) onSave,
|
|
1895
|
+
}) async {
|
|
1896
|
+
var catalog = initialCatalog;
|
|
1897
|
+
var policy = initialCatalog.policy;
|
|
1898
|
+
|
|
1899
|
+
List<MessagingAccessRule> dedupeRules(List<MessagingAccessRule> rules) {
|
|
1900
|
+
final seen = <String>{};
|
|
1901
|
+
final result = <MessagingAccessRule>[];
|
|
1902
|
+
for (final rule in rules) {
|
|
1903
|
+
if (rule.value.trim().isEmpty) continue;
|
|
1904
|
+
if (!seen.add(rule.id)) continue;
|
|
1905
|
+
result.add(rule);
|
|
1906
|
+
}
|
|
1907
|
+
return result;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
void addRule(
|
|
1911
|
+
_MessagingRuleSelection selection,
|
|
1912
|
+
void Function(void Function()) setLocalState,
|
|
1913
|
+
) {
|
|
1914
|
+
setLocalState(() {
|
|
1915
|
+
switch (selection.bucket) {
|
|
1916
|
+
case 'directRules':
|
|
1917
|
+
policy = policy.copyWith(
|
|
1918
|
+
directPolicy: policy.directPolicy == 'disabled'
|
|
1919
|
+
? 'allowlist'
|
|
1920
|
+
: policy.directPolicy,
|
|
1921
|
+
directRules: dedupeRules(<MessagingAccessRule>[
|
|
1922
|
+
...policy.directRules,
|
|
1923
|
+
selection.rule,
|
|
1924
|
+
]),
|
|
1925
|
+
);
|
|
1926
|
+
break;
|
|
1927
|
+
case 'sharedActorRules':
|
|
1928
|
+
policy = policy.copyWith(
|
|
1929
|
+
sharedPolicy: policy.sharedPolicy == 'disabled'
|
|
1930
|
+
? 'allowlist'
|
|
1931
|
+
: policy.sharedPolicy,
|
|
1932
|
+
sharedActorRules: dedupeRules(<MessagingAccessRule>[
|
|
1933
|
+
...policy.sharedActorRules,
|
|
1934
|
+
selection.rule,
|
|
1935
|
+
]),
|
|
1936
|
+
);
|
|
1937
|
+
break;
|
|
1938
|
+
default:
|
|
1939
|
+
policy = policy.copyWith(
|
|
1940
|
+
sharedPolicy: policy.sharedPolicy == 'disabled'
|
|
1941
|
+
? 'allowlist'
|
|
1942
|
+
: policy.sharedPolicy,
|
|
1943
|
+
sharedSpaceRules: dedupeRules(<MessagingAccessRule>[
|
|
1944
|
+
...policy.sharedSpaceRules,
|
|
1945
|
+
selection.rule,
|
|
1946
|
+
]),
|
|
1947
|
+
);
|
|
1948
|
+
}
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
void removeRule(
|
|
1953
|
+
String bucket,
|
|
1954
|
+
MessagingAccessRule rule,
|
|
1955
|
+
void Function(void Function()) setLocalState,
|
|
1956
|
+
) {
|
|
1957
|
+
setLocalState(() {
|
|
1958
|
+
switch (bucket) {
|
|
1959
|
+
case 'directRules':
|
|
1960
|
+
policy = policy.copyWith(
|
|
1961
|
+
directRules: policy.directRules
|
|
1962
|
+
.where((item) => item.id != rule.id)
|
|
1963
|
+
.toList(growable: false),
|
|
1964
|
+
);
|
|
1965
|
+
break;
|
|
1966
|
+
case 'sharedActorRules':
|
|
1967
|
+
policy = policy.copyWith(
|
|
1968
|
+
sharedActorRules: policy.sharedActorRules
|
|
1969
|
+
.where((item) => item.id != rule.id)
|
|
1970
|
+
.toList(growable: false),
|
|
1971
|
+
);
|
|
1972
|
+
break;
|
|
1973
|
+
default:
|
|
1974
|
+
policy = policy.copyWith(
|
|
1975
|
+
sharedSpaceRules: policy.sharedSpaceRules
|
|
1976
|
+
.where((item) => item.id != rule.id)
|
|
1977
|
+
.toList(growable: false),
|
|
1978
|
+
);
|
|
1979
|
+
}
|
|
1980
|
+
});
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
await showDialog<void>(
|
|
1984
|
+
context: context,
|
|
1985
|
+
builder: (dialogContext) {
|
|
1986
|
+
return StatefulBuilder(
|
|
1987
|
+
builder: (context, setLocalState) {
|
|
1988
|
+
final capabilities = catalog.capabilities;
|
|
1989
|
+
final summaryText = [
|
|
1990
|
+
'DMs ${policy.directPolicy}',
|
|
1991
|
+
if (capabilities.supportsSharedPolicy)
|
|
1992
|
+
'shared ${policy.sharedPolicy}',
|
|
1993
|
+
if (capabilities.supportsMentionGate)
|
|
1994
|
+
policy.requireMentionInShared
|
|
1995
|
+
? 'mentions required'
|
|
1996
|
+
: 'mentions optional',
|
|
1997
|
+
if (policy.totalRuleCount > 0) '${policy.totalRuleCount} rules',
|
|
1998
|
+
].join(' • ');
|
|
1999
|
+
|
|
2000
|
+
return AlertDialog(
|
|
2001
|
+
backgroundColor: _bgCard,
|
|
2002
|
+
insetPadding: const EdgeInsets.symmetric(
|
|
2003
|
+
horizontal: 24,
|
|
2004
|
+
vertical: 18,
|
|
2005
|
+
),
|
|
2006
|
+
title: Text('${platform.label} Access Policy'),
|
|
2007
|
+
content: SizedBox(
|
|
2008
|
+
width: 760,
|
|
2009
|
+
child: SingleChildScrollView(
|
|
2010
|
+
child: Column(
|
|
2011
|
+
mainAxisSize: MainAxisSize.min,
|
|
2012
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
2013
|
+
children: <Widget>[
|
|
2014
|
+
MessagingAccessSummaryCard(
|
|
2015
|
+
accent: platform.accent,
|
|
2016
|
+
summary: summaryText,
|
|
2017
|
+
hint: capabilities.manualEntryHint.ifEmpty(
|
|
2018
|
+
'Choose who can reach this platform and how shared spaces behave.',
|
|
2019
|
+
),
|
|
2020
|
+
),
|
|
2021
|
+
const SizedBox(height: 18),
|
|
2022
|
+
if (capabilities.supportsDirectPolicy)
|
|
2023
|
+
_AccessModeField(
|
|
2024
|
+
label: 'Direct messages',
|
|
2025
|
+
value: policy.directPolicy,
|
|
2026
|
+
onChanged: (value) => setLocalState(() {
|
|
2027
|
+
policy = policy.copyWith(directPolicy: value);
|
|
2028
|
+
}),
|
|
2029
|
+
),
|
|
2030
|
+
if (capabilities.supportsSharedPolicy) ...<Widget>[
|
|
2031
|
+
const SizedBox(height: 12),
|
|
2032
|
+
_AccessModeField(
|
|
2033
|
+
label: 'Shared spaces',
|
|
2034
|
+
value: policy.sharedPolicy,
|
|
2035
|
+
onChanged: (value) => setLocalState(() {
|
|
2036
|
+
policy = policy.copyWith(sharedPolicy: value);
|
|
2037
|
+
}),
|
|
2038
|
+
),
|
|
2039
|
+
],
|
|
2040
|
+
if (capabilities.supportsMentionGate) ...<Widget>[
|
|
2041
|
+
const SizedBox(height: 12),
|
|
2042
|
+
SwitchListTile(
|
|
2043
|
+
contentPadding: EdgeInsets.zero,
|
|
2044
|
+
title: Text('Require mention in shared spaces'),
|
|
2045
|
+
subtitle: Text(
|
|
2046
|
+
'Keep channels quiet until the bot is directly mentioned.',
|
|
2047
|
+
style: TextStyle(color: _textSecondary),
|
|
2048
|
+
),
|
|
2049
|
+
value: policy.requireMentionInShared,
|
|
2050
|
+
onChanged: (value) => setLocalState(() {
|
|
2051
|
+
policy = policy.copyWith(
|
|
2052
|
+
requireMentionInShared: value,
|
|
2053
|
+
);
|
|
2054
|
+
}),
|
|
2055
|
+
),
|
|
2056
|
+
],
|
|
2057
|
+
const SizedBox(height: 14),
|
|
2058
|
+
Row(
|
|
2059
|
+
children: <Widget>[
|
|
2060
|
+
FilledButton.icon(
|
|
2061
|
+
onPressed: () async {
|
|
2062
|
+
final selection =
|
|
2063
|
+
await _showMessagingAccessRulePicker(
|
|
2064
|
+
context,
|
|
2065
|
+
platform: platform,
|
|
2066
|
+
catalog: catalog,
|
|
2067
|
+
);
|
|
2068
|
+
if (selection != null) {
|
|
2069
|
+
addRule(selection, setLocalState);
|
|
2070
|
+
}
|
|
2071
|
+
},
|
|
2072
|
+
icon: Icon(Icons.add_rounded),
|
|
2073
|
+
label: Text('Add Rule'),
|
|
2074
|
+
),
|
|
2075
|
+
const SizedBox(width: 10),
|
|
2076
|
+
OutlinedButton.icon(
|
|
2077
|
+
onPressed: () async {
|
|
2078
|
+
final refreshed = await onRefreshCatalog();
|
|
2079
|
+
if (!context.mounted) return;
|
|
2080
|
+
setLocalState(() {
|
|
2081
|
+
catalog = refreshed;
|
|
2082
|
+
});
|
|
2083
|
+
},
|
|
2084
|
+
icon: Icon(Icons.travel_explore_rounded),
|
|
2085
|
+
label: Text('Refresh Discovery'),
|
|
2086
|
+
),
|
|
2087
|
+
],
|
|
2088
|
+
),
|
|
2089
|
+
const SizedBox(height: 18),
|
|
2090
|
+
_AccessRuleSection(
|
|
2091
|
+
title: 'Direct senders',
|
|
2092
|
+
subtitle: 'Who can start a one-to-one conversation.',
|
|
2093
|
+
rules: policy.directRules,
|
|
2094
|
+
emptyLabel: 'No direct sender rules yet.',
|
|
2095
|
+
onRemove: (rule) =>
|
|
2096
|
+
removeRule('directRules', rule, setLocalState),
|
|
2097
|
+
),
|
|
2098
|
+
if (capabilities.supportsSharedPolicy) ...<Widget>[
|
|
2099
|
+
const SizedBox(height: 16),
|
|
2100
|
+
_AccessRuleSection(
|
|
2101
|
+
title: 'Shared spaces',
|
|
2102
|
+
subtitle:
|
|
2103
|
+
'Which channels, groups, rooms, or servers can trigger the agent.',
|
|
2104
|
+
rules: policy.sharedSpaceRules,
|
|
2105
|
+
emptyLabel: 'No shared-space rules yet.',
|
|
2106
|
+
onRemove: (rule) =>
|
|
2107
|
+
removeRule('sharedSpaceRules', rule, setLocalState),
|
|
2108
|
+
),
|
|
2109
|
+
const SizedBox(height: 16),
|
|
2110
|
+
_AccessRuleSection(
|
|
2111
|
+
title: 'Shared actors',
|
|
2112
|
+
subtitle:
|
|
2113
|
+
'Optional extra filter for who inside allowed shared spaces can trigger the agent.',
|
|
2114
|
+
rules: policy.sharedActorRules,
|
|
2115
|
+
emptyLabel: 'No shared-actor rules yet.',
|
|
2116
|
+
onRemove: (rule) =>
|
|
2117
|
+
removeRule('sharedActorRules', rule, setLocalState),
|
|
2118
|
+
),
|
|
2119
|
+
],
|
|
2120
|
+
],
|
|
2121
|
+
),
|
|
2122
|
+
),
|
|
2123
|
+
),
|
|
2124
|
+
actions: <Widget>[
|
|
2125
|
+
TextButton(
|
|
2126
|
+
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
2127
|
+
child: Text('Cancel'),
|
|
2128
|
+
),
|
|
2129
|
+
FilledButton(
|
|
2130
|
+
onPressed: () async {
|
|
2131
|
+
await onSave(policy);
|
|
2132
|
+
if (dialogContext.mounted) {
|
|
2133
|
+
Navigator.of(dialogContext).pop();
|
|
2134
|
+
}
|
|
2135
|
+
},
|
|
2136
|
+
child: Text('Save Policy'),
|
|
2137
|
+
),
|
|
2138
|
+
],
|
|
2139
|
+
);
|
|
2140
|
+
},
|
|
2141
|
+
);
|
|
2142
|
+
},
|
|
2143
|
+
);
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
class _AccessModeField extends StatelessWidget {
|
|
2147
|
+
const _AccessModeField({
|
|
2148
|
+
required this.label,
|
|
2149
|
+
required this.value,
|
|
2150
|
+
required this.onChanged,
|
|
2151
|
+
});
|
|
2152
|
+
|
|
2153
|
+
final String label;
|
|
2154
|
+
final String value;
|
|
2155
|
+
final ValueChanged<String> onChanged;
|
|
2156
|
+
|
|
2157
|
+
@override
|
|
2158
|
+
Widget build(BuildContext context) {
|
|
2159
|
+
return InputDecorator(
|
|
2160
|
+
decoration: InputDecoration(labelText: label),
|
|
2161
|
+
child: DropdownButtonHideUnderline(
|
|
2162
|
+
child: DropdownButton<String>(
|
|
2163
|
+
value: value,
|
|
2164
|
+
isExpanded: true,
|
|
2165
|
+
items: const <DropdownMenuItem<String>>[
|
|
2166
|
+
DropdownMenuItem(value: 'allowlist', child: Text('Allowlist only')),
|
|
2167
|
+
DropdownMenuItem(value: 'open', child: Text('Open access')),
|
|
2168
|
+
DropdownMenuItem(value: 'disabled', child: Text('Disabled')),
|
|
2169
|
+
],
|
|
2170
|
+
onChanged: (next) {
|
|
2171
|
+
if (next != null) onChanged(next);
|
|
2172
|
+
},
|
|
2173
|
+
),
|
|
2174
|
+
),
|
|
2175
|
+
);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
class _AccessRuleSection extends StatelessWidget {
|
|
2180
|
+
const _AccessRuleSection({
|
|
2181
|
+
required this.title,
|
|
2182
|
+
required this.subtitle,
|
|
2183
|
+
required this.rules,
|
|
2184
|
+
required this.emptyLabel,
|
|
2185
|
+
required this.onRemove,
|
|
2186
|
+
});
|
|
2187
|
+
|
|
2188
|
+
final String title;
|
|
2189
|
+
final String subtitle;
|
|
2190
|
+
final List<MessagingAccessRule> rules;
|
|
2191
|
+
final String emptyLabel;
|
|
2192
|
+
final ValueChanged<MessagingAccessRule> onRemove;
|
|
2193
|
+
|
|
2194
|
+
@override
|
|
2195
|
+
Widget build(BuildContext context) {
|
|
2196
|
+
return Container(
|
|
2197
|
+
width: double.infinity,
|
|
2198
|
+
padding: const EdgeInsets.all(14),
|
|
2199
|
+
decoration: BoxDecoration(
|
|
2200
|
+
color: _bgCard,
|
|
2201
|
+
borderRadius: BorderRadius.circular(16),
|
|
2202
|
+
border: Border.all(color: _borderLight),
|
|
2203
|
+
),
|
|
2204
|
+
child: Column(
|
|
2205
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
2206
|
+
children: <Widget>[
|
|
2207
|
+
Text(title, style: TextStyle(fontWeight: FontWeight.w700)),
|
|
2208
|
+
const SizedBox(height: 4),
|
|
2209
|
+
Text(subtitle, style: TextStyle(color: _textSecondary)),
|
|
2210
|
+
const SizedBox(height: 12),
|
|
2211
|
+
if (rules.isEmpty)
|
|
2212
|
+
Text(emptyLabel, style: TextStyle(color: _textMuted))
|
|
2213
|
+
else
|
|
2214
|
+
Wrap(
|
|
2215
|
+
spacing: 8,
|
|
2216
|
+
runSpacing: 8,
|
|
2217
|
+
children: rules
|
|
2218
|
+
.map((rule) {
|
|
2219
|
+
return Chip(
|
|
2220
|
+
label: Text('${rule.scopeLabel}: ${rule.displayLabel}'),
|
|
2221
|
+
deleteIcon: Icon(Icons.close_rounded, size: 18),
|
|
2222
|
+
onDeleted: () => onRemove(rule),
|
|
2223
|
+
);
|
|
2224
|
+
})
|
|
2225
|
+
.toList(growable: false),
|
|
2226
|
+
),
|
|
2227
|
+
],
|
|
2228
|
+
),
|
|
2229
|
+
);
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
Future<_MessagingRuleSelection?> _showMessagingAccessRulePicker(
|
|
2234
|
+
BuildContext context, {
|
|
2235
|
+
required MessagingPlatformDescriptor platform,
|
|
2236
|
+
required MessagingAccessCatalog catalog,
|
|
2237
|
+
}) async {
|
|
2238
|
+
return showModalBottomSheet<_MessagingRuleSelection>(
|
|
2239
|
+
context: context,
|
|
2240
|
+
isScrollControlled: true,
|
|
2241
|
+
backgroundColor: _bgCard,
|
|
2242
|
+
builder: (sheetContext) =>
|
|
2243
|
+
_MessagingAccessRulePickerSheet(platform: platform, catalog: catalog),
|
|
2244
|
+
);
|
|
2245
|
+
}
|
|
2246
|
+
|
|
2247
|
+
class _MessagingAccessRulePickerSheet extends StatefulWidget {
|
|
2248
|
+
const _MessagingAccessRulePickerSheet({
|
|
2249
|
+
required this.platform,
|
|
2250
|
+
required this.catalog,
|
|
2251
|
+
});
|
|
2252
|
+
|
|
2253
|
+
final MessagingPlatformDescriptor platform;
|
|
2254
|
+
final MessagingAccessCatalog catalog;
|
|
2255
|
+
|
|
2256
|
+
@override
|
|
2257
|
+
State<_MessagingAccessRulePickerSheet> createState() =>
|
|
2258
|
+
_MessagingAccessRulePickerSheetState();
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
class _MessagingAccessRulePickerSheetState
|
|
2262
|
+
extends State<_MessagingAccessRulePickerSheet> {
|
|
2263
|
+
late final TextEditingController _queryController;
|
|
2264
|
+
late String _selectedBucket;
|
|
2265
|
+
late String _selectedScope;
|
|
2266
|
+
|
|
2267
|
+
@override
|
|
2268
|
+
void initState() {
|
|
2269
|
+
super.initState();
|
|
2270
|
+
_queryController = TextEditingController();
|
|
2271
|
+
_selectedBucket = widget.catalog.capabilities.directRuleScopes.isNotEmpty
|
|
2272
|
+
? 'directRules'
|
|
2273
|
+
: (widget.catalog.capabilities.sharedSpaceRuleScopes.isNotEmpty
|
|
2274
|
+
? 'sharedSpaceRules'
|
|
2275
|
+
: 'sharedActorRules');
|
|
2276
|
+
_selectedScope = widget.catalog.capabilities.directRuleScopes.isNotEmpty
|
|
2277
|
+
? widget.catalog.capabilities.directRuleScopes.first
|
|
2278
|
+
: (widget.catalog.capabilities.sharedSpaceRuleScopes.isNotEmpty
|
|
2279
|
+
? widget.catalog.capabilities.sharedSpaceRuleScopes.first
|
|
2280
|
+
: (widget.catalog.capabilities.sharedActorRuleScopes.isNotEmpty
|
|
2281
|
+
? widget.catalog.capabilities.sharedActorRuleScopes.first
|
|
2282
|
+
: 'chat'));
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
@override
|
|
2286
|
+
void dispose() {
|
|
2287
|
+
_queryController.dispose();
|
|
2288
|
+
super.dispose();
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
@override
|
|
2292
|
+
void didUpdateWidget(covariant _MessagingAccessRulePickerSheet oldWidget) {
|
|
2293
|
+
super.didUpdateWidget(oldWidget);
|
|
2294
|
+
_syncSelectedScope();
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
List<String> _scopesForBucket() {
|
|
2298
|
+
switch (_selectedBucket) {
|
|
2299
|
+
case 'directRules':
|
|
2300
|
+
return widget.catalog.capabilities.directRuleScopes;
|
|
2301
|
+
case 'sharedActorRules':
|
|
2302
|
+
return widget.catalog.capabilities.sharedActorRuleScopes;
|
|
2303
|
+
default:
|
|
2304
|
+
return widget.catalog.capabilities.sharedSpaceRuleScopes;
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
void _syncSelectedScope() {
|
|
2309
|
+
final availableScopes = _scopesForBucket();
|
|
2310
|
+
if (availableScopes.isEmpty || availableScopes.contains(_selectedScope)) {
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
_selectedScope = availableScopes.first;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
@override
|
|
2317
|
+
Widget build(BuildContext context) {
|
|
2318
|
+
final availableScopes = _scopesForBucket();
|
|
2319
|
+
final query = _queryController.text.trim().toLowerCase();
|
|
2320
|
+
final targets =
|
|
2321
|
+
<MessagingAccessTarget>[
|
|
2322
|
+
...widget.catalog.suggestedTargets,
|
|
2323
|
+
...widget.catalog.discoveredTargets,
|
|
2324
|
+
]
|
|
2325
|
+
.where((target) {
|
|
2326
|
+
if (target.bucket != _selectedBucket) return false;
|
|
2327
|
+
if (query.isEmpty) return true;
|
|
2328
|
+
final haystack =
|
|
2329
|
+
'${target.label} ${target.subtitle} ${target.scope} ${target.value}'
|
|
2330
|
+
.toLowerCase();
|
|
2331
|
+
return haystack.contains(query);
|
|
2332
|
+
})
|
|
2333
|
+
.toList(growable: false);
|
|
2334
|
+
|
|
2335
|
+
return Padding(
|
|
2336
|
+
padding: EdgeInsets.only(
|
|
2337
|
+
left: 20,
|
|
2338
|
+
right: 20,
|
|
2339
|
+
top: 18,
|
|
2340
|
+
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
|
2341
|
+
),
|
|
2342
|
+
child: SingleChildScrollView(
|
|
2343
|
+
child: Column(
|
|
2344
|
+
mainAxisSize: MainAxisSize.min,
|
|
2345
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
2346
|
+
children: <Widget>[
|
|
2347
|
+
Text(
|
|
2348
|
+
'Add Access Rule',
|
|
2349
|
+
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
|
|
2350
|
+
),
|
|
2351
|
+
const SizedBox(height: 6),
|
|
2352
|
+
Text(
|
|
2353
|
+
'Choose a preset, a discovered target, or enter an id manually for ${widget.platform.label}.',
|
|
2354
|
+
style: TextStyle(color: _textSecondary),
|
|
2355
|
+
),
|
|
2356
|
+
const SizedBox(height: 14),
|
|
2357
|
+
Wrap(
|
|
2358
|
+
spacing: 8,
|
|
2359
|
+
runSpacing: 8,
|
|
2360
|
+
children: <Widget>[
|
|
2361
|
+
if (widget.catalog.capabilities.directRuleScopes.isNotEmpty)
|
|
2362
|
+
ChoiceChip(
|
|
2363
|
+
label: Text('Direct'),
|
|
2364
|
+
selected: _selectedBucket == 'directRules',
|
|
2365
|
+
onSelected: (_) => setState(() {
|
|
2366
|
+
_selectedBucket = 'directRules';
|
|
2367
|
+
_syncSelectedScope();
|
|
2368
|
+
}),
|
|
2369
|
+
),
|
|
2370
|
+
if (widget
|
|
2371
|
+
.catalog
|
|
2372
|
+
.capabilities
|
|
2373
|
+
.sharedSpaceRuleScopes
|
|
2374
|
+
.isNotEmpty)
|
|
2375
|
+
ChoiceChip(
|
|
2376
|
+
label: Text('Shared spaces'),
|
|
2377
|
+
selected: _selectedBucket == 'sharedSpaceRules',
|
|
2378
|
+
onSelected: (_) => setState(() {
|
|
2379
|
+
_selectedBucket = 'sharedSpaceRules';
|
|
2380
|
+
_syncSelectedScope();
|
|
2381
|
+
}),
|
|
2382
|
+
),
|
|
2383
|
+
if (widget
|
|
2384
|
+
.catalog
|
|
2385
|
+
.capabilities
|
|
2386
|
+
.sharedActorRuleScopes
|
|
2387
|
+
.isNotEmpty)
|
|
2388
|
+
ChoiceChip(
|
|
2389
|
+
label: Text('Shared actors'),
|
|
2390
|
+
selected: _selectedBucket == 'sharedActorRules',
|
|
2391
|
+
onSelected: (_) => setState(() {
|
|
2392
|
+
_selectedBucket = 'sharedActorRules';
|
|
2393
|
+
_syncSelectedScope();
|
|
2394
|
+
}),
|
|
2395
|
+
),
|
|
2396
|
+
],
|
|
2397
|
+
),
|
|
2398
|
+
const SizedBox(height: 12),
|
|
2399
|
+
TextField(
|
|
2400
|
+
controller: _queryController,
|
|
2401
|
+
onChanged: (_) => setState(() {}),
|
|
2402
|
+
decoration: InputDecoration(
|
|
2403
|
+
prefixIcon: Icon(Icons.search_rounded),
|
|
2404
|
+
labelText: 'Search discovered targets',
|
|
2405
|
+
),
|
|
2406
|
+
),
|
|
2407
|
+
const SizedBox(height: 16),
|
|
2408
|
+
if (targets.isNotEmpty) ...<Widget>[
|
|
2409
|
+
Text(
|
|
2410
|
+
'Suggested & discovered',
|
|
2411
|
+
style: TextStyle(fontWeight: FontWeight.w700),
|
|
2412
|
+
),
|
|
2413
|
+
const SizedBox(height: 8),
|
|
2414
|
+
...targets.take(10).map((target) {
|
|
2415
|
+
return ListTile(
|
|
2416
|
+
contentPadding: EdgeInsets.zero,
|
|
2417
|
+
title: Text(target.label),
|
|
2418
|
+
subtitle: Text(
|
|
2419
|
+
target.subtitle.ifEmpty(
|
|
2420
|
+
'${target.scope} • ${target.value}',
|
|
2421
|
+
),
|
|
2422
|
+
),
|
|
2423
|
+
trailing: Icon(Icons.add_circle_outline_rounded),
|
|
2424
|
+
onTap: () => Navigator.of(context).pop(
|
|
2425
|
+
_MessagingRuleSelection(
|
|
2426
|
+
bucket: target.bucket,
|
|
2427
|
+
rule: target.asRule,
|
|
2428
|
+
),
|
|
2429
|
+
),
|
|
2430
|
+
);
|
|
2431
|
+
}),
|
|
2432
|
+
const Divider(height: 24),
|
|
2433
|
+
],
|
|
2434
|
+
Text('Manual entry', style: TextStyle(fontWeight: FontWeight.w700)),
|
|
2435
|
+
const SizedBox(height: 8),
|
|
2436
|
+
if (availableScopes.isNotEmpty)
|
|
2437
|
+
InputDecorator(
|
|
2438
|
+
decoration: InputDecoration(labelText: 'Rule scope'),
|
|
2439
|
+
child: DropdownButtonHideUnderline(
|
|
2440
|
+
child: DropdownButton<String>(
|
|
2441
|
+
value: _selectedScope,
|
|
2442
|
+
isExpanded: true,
|
|
2443
|
+
items: availableScopes
|
|
2444
|
+
.map(
|
|
2445
|
+
(scope) => DropdownMenuItem<String>(
|
|
2446
|
+
value: scope,
|
|
2447
|
+
child: Text(scope.replaceAll('_', ' ')),
|
|
2448
|
+
),
|
|
2449
|
+
)
|
|
2450
|
+
.toList(growable: false),
|
|
2451
|
+
onChanged: (value) {
|
|
2452
|
+
if (value != null) {
|
|
2453
|
+
setState(() => _selectedScope = value);
|
|
2454
|
+
}
|
|
2455
|
+
},
|
|
2456
|
+
),
|
|
2457
|
+
),
|
|
2458
|
+
),
|
|
2459
|
+
const SizedBox(height: 12),
|
|
2460
|
+
TextField(
|
|
2461
|
+
decoration: InputDecoration(
|
|
2462
|
+
labelText: 'ID / value',
|
|
2463
|
+
helperText: widget.catalog.capabilities.manualEntryHint,
|
|
2464
|
+
),
|
|
2465
|
+
onSubmitted: (value) {
|
|
2466
|
+
final trimmed = value.trim();
|
|
2467
|
+
if (trimmed.isEmpty) return;
|
|
2468
|
+
Navigator.of(context).pop(
|
|
2469
|
+
_MessagingRuleSelection(
|
|
2470
|
+
bucket: _selectedBucket,
|
|
2471
|
+
rule: MessagingAccessRule(
|
|
2472
|
+
scope: _selectedScope,
|
|
2473
|
+
value: trimmed,
|
|
2474
|
+
),
|
|
2475
|
+
),
|
|
2476
|
+
);
|
|
2477
|
+
},
|
|
2478
|
+
),
|
|
2479
|
+
],
|
|
2480
|
+
),
|
|
2481
|
+
),
|
|
2482
|
+
);
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
Future<String?> _showTextSettingDialog(
|
|
2487
|
+
BuildContext context, {
|
|
2488
|
+
required String title,
|
|
2489
|
+
required String subtitle,
|
|
2490
|
+
required String label,
|
|
2491
|
+
required String initialValue,
|
|
2492
|
+
bool obscureText = false,
|
|
2493
|
+
}) async {
|
|
2494
|
+
final controller = TextEditingController(text: initialValue);
|
|
2495
|
+
try {
|
|
2496
|
+
return showDialog<String>(
|
|
2497
|
+
context: context,
|
|
2498
|
+
builder: (context) => AlertDialog(
|
|
2499
|
+
backgroundColor: _bgCard,
|
|
2500
|
+
title: Text(title),
|
|
2501
|
+
content: SizedBox(
|
|
2502
|
+
width: 440,
|
|
2503
|
+
child: Column(
|
|
2504
|
+
mainAxisSize: MainAxisSize.min,
|
|
2505
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
2506
|
+
children: <Widget>[
|
|
2507
|
+
Text(subtitle, style: TextStyle(color: _textSecondary)),
|
|
2508
|
+
const SizedBox(height: 14),
|
|
2509
|
+
TextField(
|
|
2510
|
+
controller: controller,
|
|
2511
|
+
obscureText: obscureText,
|
|
2512
|
+
decoration: InputDecoration(labelText: label),
|
|
2513
|
+
),
|
|
2514
|
+
],
|
|
2515
|
+
),
|
|
2516
|
+
),
|
|
2517
|
+
actions: <Widget>[
|
|
2518
|
+
TextButton(
|
|
2519
|
+
onPressed: () => Navigator.of(context).pop(),
|
|
2520
|
+
child: Text('Cancel'),
|
|
2521
|
+
),
|
|
2522
|
+
FilledButton(
|
|
2523
|
+
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
|
|
2524
|
+
child: Text('Save'),
|
|
2525
|
+
),
|
|
2526
|
+
],
|
|
2527
|
+
),
|
|
2528
|
+
);
|
|
2529
|
+
} finally {
|
|
2530
|
+
controller.dispose();
|
|
2531
|
+
}
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
class _MessagingMiniPill extends StatelessWidget {
|
|
2535
|
+
const _MessagingMiniPill({required this.icon, required this.label});
|
|
2536
|
+
|
|
2537
|
+
final IconData icon;
|
|
2538
|
+
final String label;
|
|
2539
|
+
|
|
2540
|
+
@override
|
|
2541
|
+
Widget build(BuildContext context) {
|
|
2542
|
+
return Container(
|
|
2543
|
+
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6),
|
|
2544
|
+
decoration: BoxDecoration(
|
|
2545
|
+
color: _bgSecondary,
|
|
2546
|
+
borderRadius: BorderRadius.circular(8),
|
|
2547
|
+
border: Border.all(color: _borderLight),
|
|
2548
|
+
),
|
|
2549
|
+
child: ConstrainedBox(
|
|
2550
|
+
constraints: const BoxConstraints(maxWidth: 260),
|
|
2551
|
+
child: Row(
|
|
2552
|
+
mainAxisSize: MainAxisSize.min,
|
|
2553
|
+
children: [
|
|
2554
|
+
Icon(icon, size: 14, color: _textSecondary),
|
|
2555
|
+
const SizedBox(width: 6),
|
|
2556
|
+
Flexible(
|
|
2557
|
+
child: Text(
|
|
2558
|
+
label,
|
|
2559
|
+
maxLines: 2,
|
|
2560
|
+
overflow: TextOverflow.ellipsis,
|
|
2561
|
+
style: TextStyle(
|
|
2562
|
+
color: _textSecondary,
|
|
2563
|
+
fontSize: 12,
|
|
2564
|
+
fontWeight: FontWeight.w600,
|
|
2565
|
+
),
|
|
2566
|
+
),
|
|
2567
|
+
),
|
|
2568
|
+
],
|
|
2569
|
+
),
|
|
2570
|
+
),
|
|
2571
|
+
);
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
class _RunsMetricsStrip extends StatelessWidget {
|
|
2576
|
+
const _RunsMetricsStrip({required this.runs, required this.totalLoaded});
|
|
2577
|
+
|
|
2578
|
+
final List<RunSummary> runs;
|
|
2579
|
+
final int totalLoaded;
|
|
2580
|
+
|
|
2581
|
+
@override
|
|
2582
|
+
Widget build(BuildContext context) {
|
|
2583
|
+
final running = runs.where((run) => run.status == 'running').length;
|
|
2584
|
+
final failed = runs.where((run) => run.isFailure).length;
|
|
2585
|
+
final completed = runs.where((run) => run.status == 'completed').length;
|
|
2586
|
+
final tokens = runs.fold<int>(0, (sum, run) => sum + run.totalTokens);
|
|
2587
|
+
|
|
2588
|
+
return Wrap(
|
|
2589
|
+
spacing: 12,
|
|
2590
|
+
runSpacing: 12,
|
|
2591
|
+
children: <Widget>[
|
|
2592
|
+
_RunMetricCard(
|
|
2593
|
+
title: 'Showing',
|
|
2594
|
+
value: '${runs.length}',
|
|
2595
|
+
helper: totalLoaded == runs.length
|
|
2596
|
+
? 'Recent runs loaded'
|
|
2597
|
+
: 'Filtered from $totalLoaded loaded runs',
|
|
2598
|
+
color: _info,
|
|
2599
|
+
),
|
|
2600
|
+
_RunMetricCard(
|
|
2601
|
+
title: 'Completed',
|
|
2602
|
+
value: '$completed',
|
|
2603
|
+
helper: 'Finished successfully',
|
|
2604
|
+
color: _success,
|
|
2605
|
+
),
|
|
2606
|
+
_RunMetricCard(
|
|
2607
|
+
title: 'Failed',
|
|
2608
|
+
value: '$failed',
|
|
2609
|
+
helper: 'Need attention',
|
|
2610
|
+
color: _danger,
|
|
2611
|
+
),
|
|
2612
|
+
_RunMetricCard(
|
|
2613
|
+
title: 'Tokens',
|
|
2614
|
+
value: _formatNumber(tokens),
|
|
2615
|
+
helper: 'Across visible runs',
|
|
2616
|
+
color: _accentHover,
|
|
2617
|
+
),
|
|
2618
|
+
if (running > 0)
|
|
2619
|
+
_RunMetricCard(
|
|
2620
|
+
title: 'Running',
|
|
2621
|
+
value: '$running',
|
|
2622
|
+
helper: 'Still in progress',
|
|
2623
|
+
color: _warning,
|
|
2624
|
+
),
|
|
2625
|
+
],
|
|
2626
|
+
);
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
class _RunMetricCard extends StatelessWidget {
|
|
2631
|
+
const _RunMetricCard({
|
|
2632
|
+
required this.title,
|
|
2633
|
+
required this.value,
|
|
2634
|
+
required this.helper,
|
|
2635
|
+
required this.color,
|
|
2636
|
+
});
|
|
2637
|
+
|
|
2638
|
+
final String title;
|
|
2639
|
+
final String value;
|
|
2640
|
+
final String helper;
|
|
2641
|
+
final Color color;
|
|
2642
|
+
|
|
2643
|
+
@override
|
|
2644
|
+
Widget build(BuildContext context) {
|
|
2645
|
+
return Container(
|
|
2646
|
+
constraints: const BoxConstraints(minWidth: 180, maxWidth: 220),
|
|
2647
|
+
padding: const EdgeInsets.all(16),
|
|
2648
|
+
decoration: BoxDecoration(
|
|
2649
|
+
color: _bgCard,
|
|
2650
|
+
borderRadius: BorderRadius.circular(18),
|
|
2651
|
+
border: Border.all(color: _border),
|
|
2652
|
+
boxShadow: <BoxShadow>[
|
|
2653
|
+
BoxShadow(
|
|
2654
|
+
color: color.withValues(alpha: 0.08),
|
|
2655
|
+
blurRadius: 18,
|
|
2656
|
+
offset: const Offset(0, 6),
|
|
2657
|
+
),
|
|
2658
|
+
],
|
|
2659
|
+
),
|
|
2660
|
+
child: Column(
|
|
2661
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
2662
|
+
children: <Widget>[
|
|
2663
|
+
Text(title, style: TextStyle(color: _textSecondary)),
|
|
2664
|
+
const SizedBox(height: 8),
|
|
2665
|
+
Text(
|
|
2666
|
+
value,
|
|
2667
|
+
style: TextStyle(fontSize: 24, fontWeight: FontWeight.w800),
|
|
2668
|
+
),
|
|
2669
|
+
const SizedBox(height: 8),
|
|
2670
|
+
Text(helper, style: TextStyle(color: _textSecondary)),
|
|
2671
|
+
],
|
|
2672
|
+
),
|
|
2673
|
+
);
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
class _RunsFilterBar extends StatelessWidget {
|
|
2678
|
+
const _RunsFilterBar({
|
|
2679
|
+
required this.searchController,
|
|
2680
|
+
required this.statusFilter,
|
|
2681
|
+
required this.onStatusChanged,
|
|
2682
|
+
});
|
|
2683
|
+
|
|
2684
|
+
final TextEditingController searchController;
|
|
2685
|
+
final String statusFilter;
|
|
2686
|
+
final ValueChanged<String> onStatusChanged;
|
|
2687
|
+
|
|
2688
|
+
@override
|
|
2689
|
+
Widget build(BuildContext context) {
|
|
2690
|
+
const filters = <String>['all', 'running', 'completed', 'failed'];
|
|
2691
|
+
return Card(
|
|
2692
|
+
child: Padding(
|
|
2693
|
+
padding: const EdgeInsets.all(18),
|
|
2694
|
+
child: Column(
|
|
2695
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
2696
|
+
children: <Widget>[
|
|
2697
|
+
const _SectionTitle('Filter Runs'),
|
|
2698
|
+
const SizedBox(height: 12),
|
|
2699
|
+
TextField(
|
|
2700
|
+
controller: searchController,
|
|
2701
|
+
decoration: InputDecoration(
|
|
2702
|
+
prefixIcon: Icon(Icons.search),
|
|
2703
|
+
hintText: 'Search title, model, trigger, error, or run id',
|
|
2704
|
+
suffixIcon: searchController.text.trim().isEmpty
|
|
2705
|
+
? null
|
|
2706
|
+
: IconButton(
|
|
2707
|
+
onPressed: searchController.clear,
|
|
2708
|
+
icon: Icon(Icons.close),
|
|
2709
|
+
),
|
|
2710
|
+
),
|
|
2711
|
+
),
|
|
2712
|
+
const SizedBox(height: 14),
|
|
2713
|
+
Wrap(
|
|
2714
|
+
spacing: 10,
|
|
2715
|
+
runSpacing: 10,
|
|
2716
|
+
children: filters.map((filter) {
|
|
2717
|
+
return FilterChip(
|
|
2718
|
+
label: Text(_titleCase(filter)),
|
|
2719
|
+
selected: statusFilter == filter,
|
|
2720
|
+
selectedColor: _accentMuted,
|
|
2721
|
+
checkmarkColor: _accent,
|
|
2722
|
+
backgroundColor: _bgSecondary,
|
|
2723
|
+
side: BorderSide(color: _border),
|
|
2724
|
+
onSelected: (_) => onStatusChanged(filter),
|
|
2725
|
+
);
|
|
2726
|
+
}).toList(),
|
|
2727
|
+
),
|
|
2728
|
+
],
|
|
2729
|
+
),
|
|
2730
|
+
),
|
|
2731
|
+
);
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
class _RunsHistoryPane extends StatelessWidget {
|
|
2736
|
+
const _RunsHistoryPane({
|
|
2737
|
+
required this.runs,
|
|
2738
|
+
required this.selectedRunId,
|
|
2739
|
+
required this.onSelect,
|
|
2740
|
+
});
|
|
2741
|
+
|
|
2742
|
+
final List<RunSummary> runs;
|
|
2743
|
+
final String? selectedRunId;
|
|
2744
|
+
final ValueChanged<String> onSelect;
|
|
2745
|
+
|
|
2746
|
+
@override
|
|
2747
|
+
Widget build(BuildContext context) {
|
|
2748
|
+
return Card(
|
|
2749
|
+
child: Padding(
|
|
2750
|
+
padding: const EdgeInsets.all(18),
|
|
2751
|
+
child: Column(
|
|
2752
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
2753
|
+
children: <Widget>[
|
|
2754
|
+
Row(
|
|
2755
|
+
children: <Widget>[
|
|
2756
|
+
Expanded(child: _SectionTitle('Run History')),
|
|
2757
|
+
Text(
|
|
2758
|
+
'${runs.length} items',
|
|
2759
|
+
style: TextStyle(color: _textSecondary),
|
|
2760
|
+
),
|
|
2761
|
+
],
|
|
2762
|
+
),
|
|
2763
|
+
const SizedBox(height: 12),
|
|
2764
|
+
...runs.map((run) {
|
|
2765
|
+
return Padding(
|
|
2766
|
+
padding: const EdgeInsets.only(bottom: 10),
|
|
2767
|
+
child: _RunHistoryRow(
|
|
2768
|
+
run: run,
|
|
2769
|
+
selected: run.id == selectedRunId,
|
|
2770
|
+
onTap: () => onSelect(run.id),
|
|
2771
|
+
),
|
|
2772
|
+
);
|
|
2773
|
+
}),
|
|
2774
|
+
],
|
|
2775
|
+
),
|
|
2776
|
+
),
|
|
2777
|
+
);
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
class _RunHistoryRow extends StatelessWidget {
|
|
2782
|
+
const _RunHistoryRow({
|
|
2783
|
+
required this.run,
|
|
2784
|
+
required this.selected,
|
|
2785
|
+
required this.onTap,
|
|
2786
|
+
});
|
|
2787
|
+
|
|
2788
|
+
final RunSummary run;
|
|
2789
|
+
final bool selected;
|
|
2790
|
+
final VoidCallback onTap;
|
|
2791
|
+
|
|
2792
|
+
@override
|
|
2793
|
+
Widget build(BuildContext context) {
|
|
2794
|
+
return InkWell(
|
|
2795
|
+
borderRadius: BorderRadius.circular(16),
|
|
2796
|
+
onTap: onTap,
|
|
2797
|
+
child: Container(
|
|
2798
|
+
padding: const EdgeInsets.all(14),
|
|
2799
|
+
decoration: BoxDecoration(
|
|
2800
|
+
color: selected ? _accentMuted : _bgSecondary,
|
|
2801
|
+
borderRadius: BorderRadius.circular(16),
|
|
2802
|
+
border: Border.all(color: selected ? _accent : _border),
|
|
2803
|
+
),
|
|
2804
|
+
child: Row(
|
|
2805
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
2806
|
+
children: <Widget>[
|
|
2807
|
+
Container(
|
|
2808
|
+
width: 12,
|
|
2809
|
+
height: 12,
|
|
2810
|
+
margin: const EdgeInsets.only(top: 5),
|
|
2811
|
+
decoration: BoxDecoration(
|
|
2812
|
+
color: run.statusColor,
|
|
2813
|
+
shape: BoxShape.circle,
|
|
2814
|
+
),
|
|
2815
|
+
),
|
|
2816
|
+
const SizedBox(width: 12),
|
|
2817
|
+
Expanded(
|
|
2818
|
+
child: Column(
|
|
2819
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
2820
|
+
children: <Widget>[
|
|
2821
|
+
Text(
|
|
2822
|
+
run.title,
|
|
2823
|
+
maxLines: 2,
|
|
2824
|
+
overflow: TextOverflow.ellipsis,
|
|
2825
|
+
style: TextStyle(fontWeight: FontWeight.w700, height: 1.2),
|
|
2826
|
+
),
|
|
2827
|
+
const SizedBox(height: 6),
|
|
2828
|
+
Text(
|
|
2829
|
+
'${run.triggerLabel} • ${run.createdAtLabel}${run.durationLabel == 'In progress' ? '' : ' • ${run.durationLabel}'}',
|
|
2830
|
+
style: TextStyle(color: _textSecondary, fontSize: 12),
|
|
2831
|
+
),
|
|
2832
|
+
const SizedBox(height: 4),
|
|
2833
|
+
Text(
|
|
2834
|
+
'${run.modelLabel} • ${run.totalTokensLabel} tokens',
|
|
2835
|
+
style: TextStyle(color: _textSecondary, fontSize: 12),
|
|
2836
|
+
),
|
|
2837
|
+
if (run.error.trim().isNotEmpty) ...<Widget>[
|
|
2838
|
+
const SizedBox(height: 8),
|
|
2839
|
+
Text(
|
|
2840
|
+
run.error,
|
|
2841
|
+
maxLines: 2,
|
|
2842
|
+
overflow: TextOverflow.ellipsis,
|
|
2843
|
+
style: TextStyle(
|
|
2844
|
+
color: _danger,
|
|
2845
|
+
fontSize: 12,
|
|
2846
|
+
height: 1.4,
|
|
2847
|
+
),
|
|
2848
|
+
),
|
|
2849
|
+
],
|
|
2850
|
+
],
|
|
2851
|
+
),
|
|
2852
|
+
),
|
|
2853
|
+
const SizedBox(width: 10),
|
|
2854
|
+
Column(
|
|
2855
|
+
crossAxisAlignment: CrossAxisAlignment.end,
|
|
2856
|
+
children: <Widget>[
|
|
2857
|
+
_StatusPill(label: run.statusLabel, color: run.statusColor),
|
|
2858
|
+
const SizedBox(height: 12),
|
|
2859
|
+
Icon(
|
|
2860
|
+
Icons.chevron_right,
|
|
2861
|
+
color: selected ? _textPrimary : _textSecondary,
|
|
2862
|
+
),
|
|
2863
|
+
],
|
|
2864
|
+
),
|
|
2865
|
+
],
|
|
2866
|
+
),
|
|
2867
|
+
),
|
|
2868
|
+
);
|
|
2869
|
+
}
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
class _RunDetailWorkspace extends StatelessWidget {
|
|
2873
|
+
const _RunDetailWorkspace({
|
|
2874
|
+
required this.run,
|
|
2875
|
+
required this.detail,
|
|
2876
|
+
required this.errorMessage,
|
|
2877
|
+
required this.loading,
|
|
2878
|
+
required this.onDelete,
|
|
2879
|
+
required this.onCopyResponse,
|
|
2880
|
+
});
|
|
2881
|
+
|
|
2882
|
+
final RunSummary? run;
|
|
2883
|
+
final RunDetailSnapshot? detail;
|
|
2884
|
+
final String? errorMessage;
|
|
2885
|
+
final bool loading;
|
|
2886
|
+
final Future<void> Function() onDelete;
|
|
2887
|
+
final Future<void> Function(String response) onCopyResponse;
|
|
2888
|
+
|
|
2889
|
+
@override
|
|
2890
|
+
Widget build(BuildContext context) {
|
|
2891
|
+
if (run == null) {
|
|
2892
|
+
return const _EmptyCard(
|
|
2893
|
+
title: 'Select a run',
|
|
2894
|
+
subtitle: 'Pick a run from the history list to inspect its steps.',
|
|
2895
|
+
);
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
final selectedRun = run!;
|
|
2899
|
+
final snapshot = detail;
|
|
2900
|
+
return Column(
|
|
2901
|
+
children: <Widget>[
|
|
2902
|
+
_RunHeroCard(run: selectedRun, onDelete: onDelete),
|
|
2903
|
+
const SizedBox(height: 16),
|
|
2904
|
+
if (loading && snapshot == null)
|
|
2905
|
+
Card(
|
|
2906
|
+
child: Padding(
|
|
2907
|
+
padding: EdgeInsets.all(24),
|
|
2908
|
+
child: Row(
|
|
2909
|
+
children: <Widget>[
|
|
2910
|
+
SizedBox.square(
|
|
2911
|
+
dimension: 20,
|
|
2912
|
+
child: CircularProgressIndicator(strokeWidth: 2),
|
|
2913
|
+
),
|
|
2914
|
+
SizedBox(width: 12),
|
|
2915
|
+
Text(
|
|
2916
|
+
'Loading run detail...',
|
|
2917
|
+
style: TextStyle(color: _textSecondary),
|
|
2918
|
+
),
|
|
2919
|
+
],
|
|
2920
|
+
),
|
|
2921
|
+
),
|
|
2922
|
+
)
|
|
2923
|
+
else if (errorMessage case final message?) ...<Widget>[
|
|
2924
|
+
_InlineError(message: message),
|
|
2925
|
+
const SizedBox(height: 16),
|
|
2926
|
+
] else if (snapshot != null) ...<Widget>[
|
|
2927
|
+
Wrap(
|
|
2928
|
+
spacing: 12,
|
|
2929
|
+
runSpacing: 12,
|
|
2930
|
+
children: <Widget>[
|
|
2931
|
+
_RunMetricCard(
|
|
2932
|
+
title: 'Steps',
|
|
2933
|
+
value: '${snapshot.steps.length}',
|
|
2934
|
+
helper: 'Recorded events',
|
|
2935
|
+
color: _info,
|
|
2936
|
+
),
|
|
2937
|
+
_RunMetricCard(
|
|
2938
|
+
title: 'Completed tools',
|
|
2939
|
+
value: '${snapshot.completedTools}',
|
|
2940
|
+
helper: 'Successful tool calls',
|
|
2941
|
+
color: _success,
|
|
2942
|
+
),
|
|
2943
|
+
_RunMetricCard(
|
|
2944
|
+
title: 'Failures',
|
|
2945
|
+
value: '${snapshot.failedTools}',
|
|
2946
|
+
helper: 'Tool errors',
|
|
2947
|
+
color: _danger,
|
|
2948
|
+
),
|
|
2949
|
+
_RunMetricCard(
|
|
2950
|
+
title: 'Helpers',
|
|
2951
|
+
value: '${snapshot.helperCount}',
|
|
2952
|
+
helper: 'Subagents or helpers',
|
|
2953
|
+
color: _accentHover,
|
|
2954
|
+
),
|
|
2955
|
+
],
|
|
2956
|
+
),
|
|
2957
|
+
const SizedBox(height: 16),
|
|
2958
|
+
_RunResponseCard(
|
|
2959
|
+
response: snapshot.response,
|
|
2960
|
+
onCopy: () => onCopyResponse(snapshot.response),
|
|
2961
|
+
),
|
|
2962
|
+
const SizedBox(height: 16),
|
|
2963
|
+
_RunTimelineCard(steps: snapshot.steps, loading: loading),
|
|
2964
|
+
] else
|
|
2965
|
+
const _EmptyCard(
|
|
2966
|
+
title: 'No detail available',
|
|
2967
|
+
subtitle: 'This run does not have step detail yet.',
|
|
2968
|
+
),
|
|
2969
|
+
],
|
|
2970
|
+
);
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
|
|
2974
|
+
class _RunHeroCard extends StatelessWidget {
|
|
2975
|
+
const _RunHeroCard({required this.run, required this.onDelete});
|
|
2976
|
+
|
|
2977
|
+
final RunSummary run;
|
|
2978
|
+
final Future<void> Function() onDelete;
|
|
2979
|
+
|
|
2980
|
+
@override
|
|
2981
|
+
Widget build(BuildContext context) {
|
|
2982
|
+
return Container(
|
|
2983
|
+
width: double.infinity,
|
|
2984
|
+
padding: const EdgeInsets.all(22),
|
|
2985
|
+
decoration: BoxDecoration(
|
|
2986
|
+
gradient: LinearGradient(
|
|
2987
|
+
colors: <Color>[
|
|
2988
|
+
run.statusColor.withValues(alpha: 0.18),
|
|
2989
|
+
_bgSecondary,
|
|
2990
|
+
],
|
|
2991
|
+
begin: Alignment.topLeft,
|
|
2992
|
+
end: Alignment.bottomRight,
|
|
2993
|
+
),
|
|
2994
|
+
borderRadius: BorderRadius.circular(24),
|
|
2995
|
+
border: Border.all(color: _borderLight),
|
|
2996
|
+
),
|
|
2997
|
+
child: Column(
|
|
2998
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
2999
|
+
children: <Widget>[
|
|
3000
|
+
Row(
|
|
3001
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
3002
|
+
children: <Widget>[
|
|
3003
|
+
Expanded(
|
|
3004
|
+
child: Column(
|
|
3005
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
3006
|
+
children: <Widget>[
|
|
3007
|
+
Wrap(
|
|
3008
|
+
spacing: 10,
|
|
3009
|
+
runSpacing: 10,
|
|
3010
|
+
children: <Widget>[
|
|
3011
|
+
_StatusPill(
|
|
3012
|
+
label: run.statusLabel,
|
|
3013
|
+
color: run.statusColor,
|
|
3014
|
+
),
|
|
3015
|
+
_MetaPill(
|
|
3016
|
+
label: run.triggerLabel,
|
|
3017
|
+
icon: Icons.bolt_outlined,
|
|
3018
|
+
),
|
|
3019
|
+
_MetaPill(
|
|
3020
|
+
label: run.modelLabel,
|
|
3021
|
+
icon: Icons.memory_outlined,
|
|
3022
|
+
),
|
|
3023
|
+
],
|
|
3024
|
+
),
|
|
3025
|
+
const SizedBox(height: 16),
|
|
3026
|
+
Text(
|
|
3027
|
+
run.title,
|
|
3028
|
+
style: TextStyle(
|
|
3029
|
+
fontSize: 24,
|
|
3030
|
+
fontWeight: FontWeight.w800,
|
|
3031
|
+
height: 1.15,
|
|
3032
|
+
),
|
|
3033
|
+
),
|
|
3034
|
+
const SizedBox(height: 10),
|
|
3035
|
+
Wrap(
|
|
3036
|
+
spacing: 10,
|
|
3037
|
+
runSpacing: 10,
|
|
3038
|
+
children: <Widget>[
|
|
3039
|
+
_MetaPill(
|
|
3040
|
+
label: 'Started ${run.createdAtLabel}',
|
|
3041
|
+
icon: Icons.schedule_outlined,
|
|
3042
|
+
),
|
|
3043
|
+
_MetaPill(
|
|
3044
|
+
label: run.durationLabel,
|
|
3045
|
+
icon: Icons.timer_outlined,
|
|
3046
|
+
),
|
|
3047
|
+
_MetaPill(
|
|
3048
|
+
label: '${run.totalTokensLabel} tokens',
|
|
3049
|
+
icon: Icons.toll_outlined,
|
|
3050
|
+
),
|
|
3051
|
+
_MetaPill(
|
|
3052
|
+
label: run.id.length <= 12
|
|
3053
|
+
? run.id
|
|
3054
|
+
: '${run.id.substring(0, 12)}…',
|
|
3055
|
+
icon: Icons.tag_outlined,
|
|
3056
|
+
),
|
|
3057
|
+
],
|
|
3058
|
+
),
|
|
3059
|
+
],
|
|
3060
|
+
),
|
|
3061
|
+
),
|
|
3062
|
+
const SizedBox(width: 12),
|
|
3063
|
+
OutlinedButton.icon(
|
|
3064
|
+
onPressed: onDelete,
|
|
3065
|
+
icon: Icon(Icons.delete_outline),
|
|
3066
|
+
label: Text('Delete'),
|
|
3067
|
+
),
|
|
3068
|
+
],
|
|
3069
|
+
),
|
|
3070
|
+
if (run.error.trim().isNotEmpty) ...<Widget>[
|
|
3071
|
+
const SizedBox(height: 16),
|
|
3072
|
+
Container(
|
|
3073
|
+
width: double.infinity,
|
|
3074
|
+
padding: const EdgeInsets.all(14),
|
|
3075
|
+
decoration: BoxDecoration(
|
|
3076
|
+
color: const Color(0x19EF4444),
|
|
3077
|
+
borderRadius: BorderRadius.circular(14),
|
|
3078
|
+
border: Border.all(color: const Color(0x4CEF4444)),
|
|
3079
|
+
),
|
|
3080
|
+
child: Text(run.error, style: TextStyle(height: 1.45)),
|
|
3081
|
+
),
|
|
3082
|
+
],
|
|
3083
|
+
],
|
|
3084
|
+
),
|
|
3085
|
+
);
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
class _RunResponseCard extends StatelessWidget {
|
|
3090
|
+
const _RunResponseCard({required this.response, required this.onCopy});
|
|
3091
|
+
|
|
3092
|
+
final String response;
|
|
3093
|
+
final VoidCallback onCopy;
|
|
3094
|
+
|
|
3095
|
+
@override
|
|
3096
|
+
Widget build(BuildContext context) {
|
|
3097
|
+
return Card(
|
|
3098
|
+
child: Padding(
|
|
3099
|
+
padding: const EdgeInsets.all(18),
|
|
3100
|
+
child: Column(
|
|
3101
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
3102
|
+
children: <Widget>[
|
|
3103
|
+
Row(
|
|
3104
|
+
children: <Widget>[
|
|
3105
|
+
Expanded(child: _SectionTitle('Final Response')),
|
|
3106
|
+
OutlinedButton.icon(
|
|
3107
|
+
onPressed: response.trim().isEmpty ? null : onCopy,
|
|
3108
|
+
icon: Icon(Icons.copy_all_outlined),
|
|
3109
|
+
label: Text('Copy'),
|
|
3110
|
+
),
|
|
3111
|
+
],
|
|
3112
|
+
),
|
|
3113
|
+
const SizedBox(height: 12),
|
|
3114
|
+
if (response.trim().isEmpty)
|
|
3115
|
+
Text(
|
|
3116
|
+
'No final response was captured for this run.',
|
|
3117
|
+
style: TextStyle(color: _textSecondary),
|
|
3118
|
+
)
|
|
3119
|
+
else
|
|
3120
|
+
MarkdownBody(
|
|
3121
|
+
data: response,
|
|
3122
|
+
selectable: true,
|
|
3123
|
+
styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context))
|
|
3124
|
+
.copyWith(
|
|
3125
|
+
p: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
3126
|
+
color: _textPrimary,
|
|
3127
|
+
height: 1.6,
|
|
3128
|
+
),
|
|
3129
|
+
code: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
3130
|
+
fontFamily: GoogleFonts.jetBrainsMono().fontFamily,
|
|
3131
|
+
backgroundColor: _bgSecondary,
|
|
3132
|
+
color: _textPrimary,
|
|
3133
|
+
),
|
|
3134
|
+
blockquoteDecoration: BoxDecoration(
|
|
3135
|
+
borderRadius: BorderRadius.circular(12),
|
|
3136
|
+
color: _bgSecondary,
|
|
3137
|
+
border: Border.all(color: _border),
|
|
3138
|
+
),
|
|
3139
|
+
),
|
|
3140
|
+
),
|
|
3141
|
+
],
|
|
3142
|
+
),
|
|
3143
|
+
),
|
|
3144
|
+
);
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
class _RunTimelineCard extends StatelessWidget {
|
|
3149
|
+
const _RunTimelineCard({required this.steps, required this.loading});
|
|
3150
|
+
|
|
3151
|
+
final List<RunStepItem> steps;
|
|
3152
|
+
final bool loading;
|
|
3153
|
+
|
|
3154
|
+
@override
|
|
3155
|
+
Widget build(BuildContext context) {
|
|
3156
|
+
return Card(
|
|
3157
|
+
child: Padding(
|
|
3158
|
+
padding: const EdgeInsets.all(18),
|
|
3159
|
+
child: Column(
|
|
3160
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
3161
|
+
children: <Widget>[
|
|
3162
|
+
Row(
|
|
3163
|
+
children: <Widget>[
|
|
3164
|
+
Expanded(child: _SectionTitle('Step Timeline')),
|
|
3165
|
+
if (loading)
|
|
3166
|
+
const SizedBox.square(
|
|
3167
|
+
dimension: 16,
|
|
3168
|
+
child: CircularProgressIndicator(strokeWidth: 2),
|
|
3169
|
+
),
|
|
3170
|
+
],
|
|
3171
|
+
),
|
|
3172
|
+
const SizedBox(height: 12),
|
|
3173
|
+
if (steps.isEmpty)
|
|
3174
|
+
Text(
|
|
3175
|
+
'No run steps recorded yet.',
|
|
3176
|
+
style: TextStyle(color: _textSecondary),
|
|
3177
|
+
)
|
|
3178
|
+
else
|
|
3179
|
+
...steps.map((step) {
|
|
3180
|
+
return Padding(
|
|
3181
|
+
padding: const EdgeInsets.only(bottom: 10),
|
|
3182
|
+
child: _RunStepCard(step: step),
|
|
3183
|
+
);
|
|
3184
|
+
}),
|
|
3185
|
+
],
|
|
3186
|
+
),
|
|
3187
|
+
),
|
|
3188
|
+
);
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
|
|
3192
|
+
class _RunStepCard extends StatelessWidget {
|
|
3193
|
+
const _RunStepCard({required this.step});
|
|
3194
|
+
|
|
3195
|
+
final RunStepItem step;
|
|
3196
|
+
|
|
3197
|
+
@override
|
|
3198
|
+
Widget build(BuildContext context) {
|
|
3199
|
+
final theme = Theme.of(context);
|
|
3200
|
+
return Container(
|
|
3201
|
+
decoration: BoxDecoration(
|
|
3202
|
+
color: _bgSecondary,
|
|
3203
|
+
borderRadius: BorderRadius.circular(16),
|
|
3204
|
+
border: Border.all(color: _border),
|
|
3205
|
+
),
|
|
3206
|
+
child: Theme(
|
|
3207
|
+
data: theme.copyWith(dividerColor: Colors.transparent),
|
|
3208
|
+
child: ExpansionTile(
|
|
3209
|
+
tilePadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
3210
|
+
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
|
|
3211
|
+
initiallyExpanded:
|
|
3212
|
+
step.status == 'failed' || step.status == 'running',
|
|
3213
|
+
leading: Container(
|
|
3214
|
+
width: 34,
|
|
3215
|
+
height: 34,
|
|
3216
|
+
decoration: BoxDecoration(
|
|
3217
|
+
color: step.statusColor.withValues(alpha: 0.16),
|
|
3218
|
+
shape: BoxShape.circle,
|
|
3219
|
+
),
|
|
3220
|
+
child: Center(
|
|
3221
|
+
child: Text(
|
|
3222
|
+
'${step.displayIndex}',
|
|
3223
|
+
style: TextStyle(
|
|
3224
|
+
color: step.statusColor,
|
|
3225
|
+
fontWeight: FontWeight.w800,
|
|
3226
|
+
),
|
|
3227
|
+
),
|
|
3228
|
+
),
|
|
3229
|
+
),
|
|
3230
|
+
title: Column(
|
|
3231
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
3232
|
+
children: <Widget>[
|
|
3233
|
+
Text(step.label, style: TextStyle(fontWeight: FontWeight.w700)),
|
|
3234
|
+
const SizedBox(height: 6),
|
|
3235
|
+
Text(
|
|
3236
|
+
step.summary,
|
|
3237
|
+
maxLines: 2,
|
|
3238
|
+
overflow: TextOverflow.ellipsis,
|
|
3239
|
+
style: TextStyle(
|
|
3240
|
+
color: _textSecondary,
|
|
3241
|
+
fontSize: 12,
|
|
3242
|
+
height: 1.45,
|
|
3243
|
+
),
|
|
3244
|
+
),
|
|
3245
|
+
const SizedBox(height: 8),
|
|
3246
|
+
Wrap(
|
|
3247
|
+
spacing: 8,
|
|
3248
|
+
runSpacing: 8,
|
|
3249
|
+
children: <Widget>[
|
|
3250
|
+
_StatusPill(label: step.statusLabel, color: step.statusColor),
|
|
3251
|
+
_MetaPill(label: step.typeLabel, icon: Icons.layers_outlined),
|
|
3252
|
+
if (step.startedAt != null)
|
|
3253
|
+
_MetaPill(
|
|
3254
|
+
label: step.startedAtLabel!,
|
|
3255
|
+
icon: Icons.schedule_outlined,
|
|
3256
|
+
),
|
|
3257
|
+
if (step.durationLabel != null)
|
|
3258
|
+
_MetaPill(
|
|
3259
|
+
label: step.durationLabel!,
|
|
3260
|
+
icon: Icons.timer_outlined,
|
|
3261
|
+
),
|
|
3262
|
+
if (step.tokensUsed > 0)
|
|
3263
|
+
_MetaPill(
|
|
3264
|
+
label: '${_formatNumber(step.tokensUsed)} tokens',
|
|
3265
|
+
icon: Icons.toll_outlined,
|
|
3266
|
+
),
|
|
3267
|
+
],
|
|
3268
|
+
),
|
|
3269
|
+
],
|
|
3270
|
+
),
|
|
3271
|
+
children: <Widget>[
|
|
3272
|
+
if (step.description.trim().isNotEmpty &&
|
|
3273
|
+
step.description.trim() != step.summary.trim())
|
|
3274
|
+
_RunDetailBlock(label: 'Description', value: step.description),
|
|
3275
|
+
if (step.inputSummary.trim().isNotEmpty)
|
|
3276
|
+
_RunDetailBlock(label: 'Input summary', value: step.inputSummary),
|
|
3277
|
+
if (step.toolInput.trim().isNotEmpty)
|
|
3278
|
+
_RunDetailBlock(
|
|
3279
|
+
label: 'Tool input',
|
|
3280
|
+
value: _truncateRunText(step.toolInput),
|
|
3281
|
+
monospace: true,
|
|
3282
|
+
),
|
|
3283
|
+
if (step.error.trim().isNotEmpty)
|
|
3284
|
+
_RunDetailBlock(
|
|
3285
|
+
label: 'Error',
|
|
3286
|
+
value: step.error,
|
|
3287
|
+
monospace: true,
|
|
3288
|
+
)
|
|
3289
|
+
else if (step.result.trim().isNotEmpty)
|
|
3290
|
+
_RunDetailBlock(
|
|
3291
|
+
label: 'Result',
|
|
3292
|
+
value: _truncateRunText(step.result),
|
|
3293
|
+
monospace: true,
|
|
3294
|
+
),
|
|
3295
|
+
],
|
|
3296
|
+
),
|
|
3297
|
+
),
|
|
3298
|
+
);
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
class _RunDetailBlock extends StatelessWidget {
|
|
3303
|
+
const _RunDetailBlock({
|
|
3304
|
+
required this.label,
|
|
3305
|
+
required this.value,
|
|
3306
|
+
this.monospace = false,
|
|
3307
|
+
});
|
|
3308
|
+
|
|
3309
|
+
final String label;
|
|
3310
|
+
final String value;
|
|
3311
|
+
final bool monospace;
|
|
3312
|
+
|
|
3313
|
+
@override
|
|
3314
|
+
Widget build(BuildContext context) {
|
|
3315
|
+
return Padding(
|
|
3316
|
+
padding: const EdgeInsets.only(top: 12),
|
|
3317
|
+
child: Column(
|
|
3318
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
3319
|
+
children: <Widget>[
|
|
3320
|
+
Text(
|
|
3321
|
+
label,
|
|
3322
|
+
style: TextStyle(
|
|
3323
|
+
color: _textSecondary,
|
|
3324
|
+
fontWeight: FontWeight.w600,
|
|
3325
|
+
),
|
|
3326
|
+
),
|
|
3327
|
+
const SizedBox(height: 6),
|
|
3328
|
+
Container(
|
|
3329
|
+
width: double.infinity,
|
|
3330
|
+
padding: const EdgeInsets.all(12),
|
|
3331
|
+
decoration: BoxDecoration(
|
|
3332
|
+
color: _bgPrimary,
|
|
3333
|
+
borderRadius: BorderRadius.circular(12),
|
|
3334
|
+
border: Border.all(color: _border),
|
|
3335
|
+
),
|
|
3336
|
+
child: SelectableText(
|
|
3337
|
+
value,
|
|
3338
|
+
style: TextStyle(
|
|
3339
|
+
height: 1.5,
|
|
3340
|
+
fontSize: 12.5,
|
|
3341
|
+
color: _textPrimary,
|
|
3342
|
+
fontFamily: monospace
|
|
3343
|
+
? GoogleFonts.jetBrainsMono().fontFamily
|
|
3344
|
+
: null,
|
|
3345
|
+
),
|
|
3346
|
+
),
|
|
3347
|
+
),
|
|
3348
|
+
],
|
|
3349
|
+
),
|
|
3350
|
+
);
|
|
3351
|
+
}
|
|
3352
|
+
}
|