neoagent 2.4.0 → 2.4.1-beta.6
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/LICENSE +619 -21
- package/README.md +1 -1
- package/extensions/chrome-browser/icons/icon128.png +0 -0
- package/extensions/chrome-browser/icons/icon16.png +0 -0
- package/extensions/chrome-browser/icons/icon48.png +0 -0
- package/extensions/chrome-browser/icons/logo.svg +12 -0
- package/extensions/chrome-browser/manifest.json +11 -1
- package/extensions/chrome-browser/popup.css +5 -0
- package/extensions/chrome-browser/popup.html +2 -4
- package/extensions/chrome-browser/popup.js +1 -5
- package/flutter_app/lib/main_controller.dart +16 -0
- package/flutter_app/lib/main_devices.dart +70 -1
- package/flutter_app/lib/main_settings.dart +215 -31
- package/flutter_app/lib/src/backend_client.dart +12 -0
- package/flutter_app/lib/src/desktop_companion_actions.dart +72 -0
- package/flutter_app/lib/src/desktop_companion_io.dart +9 -4
- package/flutter_app/macos/Runner/AppDelegate.swift +12 -1
- package/package.json +2 -2
- package/server/guest_agent.js +12 -1
- package/server/http/routes.js +190 -0
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +51613 -51262
- package/server/services/ai/tools.js +39 -28
- package/server/services/desktop/protocol.js +1 -0
- package/server/services/desktop/provider.js +11 -0
- package/server/services/desktop/registry.js +51 -10
- package/server/services/runtime/docker-vm-manager.js +26 -3
- package/server/services/runtime/manager.js +25 -2
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
4
|
+
<stop offset="0%" stop-color="#8f6d3e"/>
|
|
5
|
+
<stop offset="100%" stop-color="#2f7d6e"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect x="5.76" y="5.76" width="20.48" height="20.48" rx="6.96" fill="url(#bg)" stroke="#ffffff" stroke-opacity="0.16" stroke-width="1"/>
|
|
9
|
+
<polygon points="16,9.76 9.35,13.12 16,16.48 22.65,13.12" fill="white"/>
|
|
10
|
+
<polyline points="9.35,16.48 16,19.68 22.65,16.48" fill="none" stroke="white" stroke-width="1.44" stroke-linecap="round" stroke-linejoin="round"/>
|
|
11
|
+
<polyline points="9.35,19.68 16,22.72 22.65,19.68" fill="none" stroke="white" stroke-width="1.44" stroke-linecap="round" stroke-linejoin="round"/>
|
|
12
|
+
</svg>
|
|
@@ -6,9 +6,19 @@
|
|
|
6
6
|
"minimum_chrome_version": "118",
|
|
7
7
|
"permissions": ["debugger", "storage", "tabs"],
|
|
8
8
|
"host_permissions": ["http://*/*", "https://*/*"],
|
|
9
|
+
"icons": {
|
|
10
|
+
"16": "icons/icon16.png",
|
|
11
|
+
"48": "icons/icon48.png",
|
|
12
|
+
"128": "icons/icon128.png"
|
|
13
|
+
},
|
|
9
14
|
"action": {
|
|
10
15
|
"default_title": "NeoAgent Browser",
|
|
11
|
-
"default_popup": "popup.html"
|
|
16
|
+
"default_popup": "popup.html",
|
|
17
|
+
"default_icon": {
|
|
18
|
+
"16": "icons/icon16.png",
|
|
19
|
+
"48": "icons/icon48.png",
|
|
20
|
+
"128": "icons/icon128.png"
|
|
21
|
+
}
|
|
12
22
|
},
|
|
13
23
|
"background": {
|
|
14
24
|
"service_worker": "background.mjs",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
<body>
|
|
9
9
|
<main>
|
|
10
10
|
<header>
|
|
11
|
+
<img src="icons/logo.svg" alt="NeoAgent" class="logo">
|
|
11
12
|
<p class="eyebrow">NeoAgent Browser</p>
|
|
12
13
|
<h1>Connect this Chrome</h1>
|
|
13
14
|
<p class="intro">Pair once, then let NeoAgent control this browser when you ask it to.</p>
|
|
@@ -31,8 +32,6 @@
|
|
|
31
32
|
</div>
|
|
32
33
|
</section>
|
|
33
34
|
|
|
34
|
-
<button id="openApp" type="button" class="link-button">Open NeoAgent web page</button>
|
|
35
|
-
|
|
36
35
|
<details id="settings" class="settings">
|
|
37
36
|
<summary>Settings & updates</summary>
|
|
38
37
|
<label>
|
|
@@ -47,8 +46,7 @@
|
|
|
47
46
|
<p class="hint">The server URL is usually filled in by the ZIP downloaded from NeoAgent.</p>
|
|
48
47
|
</details>
|
|
49
48
|
|
|
50
|
-
|
|
51
|
-
<p id="message" class="message"></p>
|
|
49
|
+
<p id="message" class="message"></p>
|
|
52
50
|
</main>
|
|
53
51
|
<script src="popup.js" type="module"></script>
|
|
54
52
|
</body>
|
|
@@ -9,7 +9,6 @@ const flowTitleEl = document.querySelector('#flowTitle');
|
|
|
9
9
|
const flowDescriptionEl = document.querySelector('#flowDescription');
|
|
10
10
|
const primaryActionEl = document.querySelector('#primaryAction');
|
|
11
11
|
const secondaryActionEl = document.querySelector('#secondaryAction');
|
|
12
|
-
const openAppEl = document.querySelector('#openApp');
|
|
13
12
|
const disconnectEl = document.querySelector('#disconnect');
|
|
14
13
|
const checkUpdateEl = document.querySelector('#checkUpdate');
|
|
15
14
|
const downloadEl = document.querySelector('#download');
|
|
@@ -59,7 +58,7 @@ function setBusy(isBusy, label = 'Working...') {
|
|
|
59
58
|
}
|
|
60
59
|
const busy = pendingActions > 0;
|
|
61
60
|
|
|
62
|
-
[primaryActionEl, secondaryActionEl,
|
|
61
|
+
[primaryActionEl, secondaryActionEl, disconnectEl, checkUpdateEl, downloadEl].forEach((button) => {
|
|
63
62
|
if (!button || button.hidden) return;
|
|
64
63
|
if (busy) {
|
|
65
64
|
if (!Object.prototype.hasOwnProperty.call(button.dataset, 'wasDisabled')) {
|
|
@@ -99,8 +98,6 @@ function updateFlow() {
|
|
|
99
98
|
const hasToken = Boolean(currentState.token || currentState.tokenId);
|
|
100
99
|
const approvalUrl = currentState.approvalUrl || '';
|
|
101
100
|
|
|
102
|
-
openAppEl.disabled = !hasServerUrl;
|
|
103
|
-
|
|
104
101
|
if (!hasServerUrl) {
|
|
105
102
|
stepLabelEl.textContent = 'Step 1 of 3';
|
|
106
103
|
flowTitleEl.textContent = 'Add your NeoAgent server';
|
|
@@ -243,7 +240,6 @@ serverUrlEl.addEventListener('input', updateFlow);
|
|
|
243
240
|
|
|
244
241
|
bindAsyncClick(primaryActionEl, () => runAction(primaryActionEl.dataset.action));
|
|
245
242
|
bindAsyncClick(secondaryActionEl, () => runAction(secondaryActionEl.dataset.action));
|
|
246
|
-
bindAsyncClick(openAppEl, () => runAction('openApp'));
|
|
247
243
|
bindAsyncClick(disconnectEl, async () => {
|
|
248
244
|
await send('disconnect');
|
|
249
245
|
await refresh();
|
|
@@ -1040,6 +1040,15 @@ class NeoAgentController extends ChangeNotifier {
|
|
|
1040
1040
|
}
|
|
1041
1041
|
}
|
|
1042
1042
|
|
|
1043
|
+
Future<Map<String, dynamic>> testCliRuntime() =>
|
|
1044
|
+
_backendClient.testCli(backendUrl);
|
|
1045
|
+
|
|
1046
|
+
Future<Map<String, dynamic>> testBrowserExtension() =>
|
|
1047
|
+
_backendClient.testExtension(backendUrl);
|
|
1048
|
+
|
|
1049
|
+
Future<Map<String, dynamic>> testDesktopCompanion() =>
|
|
1050
|
+
_backendClient.testDesktop(backendUrl);
|
|
1051
|
+
|
|
1043
1052
|
Future<void> openAppUpdate() async {
|
|
1044
1053
|
final release = availableAppUpdate;
|
|
1045
1054
|
if (release == null || isOpeningAppUpdate) {
|
|
@@ -4565,6 +4574,7 @@ class NeoAgentController extends ChangeNotifier {
|
|
|
4565
4574
|
Future<void> saveSettings({
|
|
4566
4575
|
required String browserBackend,
|
|
4567
4576
|
required String cliBackend,
|
|
4577
|
+
String? cliDesktopDeviceId,
|
|
4568
4578
|
required bool smarterSelector,
|
|
4569
4579
|
required List<String> enabledModels,
|
|
4570
4580
|
required String defaultChatModel,
|
|
@@ -4594,6 +4604,7 @@ class NeoAgentController extends ChangeNotifier {
|
|
|
4594
4604
|
'headless_browser': true,
|
|
4595
4605
|
'browser_backend': browserBackend,
|
|
4596
4606
|
'cli_backend': cliBackend,
|
|
4607
|
+
if (cliDesktopDeviceId != null) 'cli_desktop_device_id': cliDesktopDeviceId,
|
|
4597
4608
|
'smarter_model_selector': smarterSelector,
|
|
4598
4609
|
'enabled_models': enabledModels,
|
|
4599
4610
|
'default_chat_model': defaultChatModel,
|
|
@@ -6131,6 +6142,11 @@ class NeoAgentController extends ChangeNotifier {
|
|
|
6131
6142
|
String get cliBackend =>
|
|
6132
6143
|
settings['cli_backend']?.toString().trim().toLowerCase() ?? 'vm';
|
|
6133
6144
|
|
|
6145
|
+
String? get cliDesktopDeviceId {
|
|
6146
|
+
final v = settings['cli_desktop_device_id']?.toString().trim();
|
|
6147
|
+
return (v == null || v.isEmpty) ? null : v;
|
|
6148
|
+
}
|
|
6149
|
+
|
|
6134
6150
|
String get cloudBrowserBackend {
|
|
6135
6151
|
final browser = browserBackend;
|
|
6136
6152
|
final profile = settings['runtime_profile']
|
|
@@ -103,8 +103,13 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
103
103
|
_onlineDesktopDevices.length > 1 &&
|
|
104
104
|
(widget.controller.selectedDesktopDeviceId ?? '').isEmpty;
|
|
105
105
|
|
|
106
|
+
bool get _extensionPreferredButOffline =>
|
|
107
|
+
widget.controller.browserBackend == 'extension' &&
|
|
108
|
+
!widget.controller.browserExtensionConnected;
|
|
109
|
+
|
|
106
110
|
String? get _activeScreenshotPath {
|
|
107
111
|
if (_isBrowser) {
|
|
112
|
+
if (_extensionPreferredButOffline) return null;
|
|
108
113
|
return widget.controller.browserScreenshotPath;
|
|
109
114
|
}
|
|
110
115
|
if (_isDesktop) {
|
|
@@ -121,6 +126,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
121
126
|
Future<void> _ensurePreview() async {
|
|
122
127
|
final controller = widget.controller;
|
|
123
128
|
if (_isBrowser) {
|
|
129
|
+
if (_extensionPreferredButOffline) return;
|
|
124
130
|
if (controller.browserRuntime['launched'] != true) {
|
|
125
131
|
return;
|
|
126
132
|
}
|
|
@@ -161,6 +167,7 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
161
167
|
return;
|
|
162
168
|
}
|
|
163
169
|
if (_isBrowser) {
|
|
170
|
+
if (_extensionPreferredButOffline) return;
|
|
164
171
|
await widget.controller.refreshBrowserFrameRuntime();
|
|
165
172
|
return;
|
|
166
173
|
}
|
|
@@ -451,6 +458,14 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
451
458
|
browserExtensionActive: usingExtension,
|
|
452
459
|
browserFallbackLabel: browserFallbackLabel,
|
|
453
460
|
),
|
|
461
|
+
if (_isBrowser && prefersExtension) ...<Widget>[
|
|
462
|
+
const SizedBox(height: 14),
|
|
463
|
+
_ExtensionStatusBar(
|
|
464
|
+
connected: extensionConnected,
|
|
465
|
+
onDownload: controller.downloadBrowserExtension,
|
|
466
|
+
onRefresh: controller.refreshBrowserExtensionStatus,
|
|
467
|
+
),
|
|
468
|
+
],
|
|
454
469
|
if (_isDesktop) ...<Widget>[
|
|
455
470
|
const SizedBox(height: 14),
|
|
456
471
|
DropdownButtonFormField<String>(
|
|
@@ -558,7 +573,9 @@ class _DevicesPanelState extends State<DevicesPanel> {
|
|
|
558
573
|
busy: _isCurrentSurfaceBusy,
|
|
559
574
|
wakingUp: !_isBrowser && !_isDesktop && _androidStarting,
|
|
560
575
|
enabled: _isBrowser || _isDesktop || _androidOnline,
|
|
561
|
-
connectRequired:
|
|
576
|
+
connectRequired: _isBrowser
|
|
577
|
+
? _extensionPreferredButOffline
|
|
578
|
+
: _desktopRequiresSelection,
|
|
562
579
|
onTapPoint: _handleTap,
|
|
563
580
|
onSwipe: _handleSwipe,
|
|
564
581
|
onWakeRequested: _openPrimary,
|
|
@@ -1701,6 +1718,58 @@ class _RuntimePreview extends StatelessWidget {
|
|
|
1701
1718
|
}
|
|
1702
1719
|
}
|
|
1703
1720
|
|
|
1721
|
+
class _ExtensionStatusBar extends StatelessWidget {
|
|
1722
|
+
const _ExtensionStatusBar({
|
|
1723
|
+
required this.connected,
|
|
1724
|
+
required this.onDownload,
|
|
1725
|
+
required this.onRefresh,
|
|
1726
|
+
});
|
|
1727
|
+
|
|
1728
|
+
final bool connected;
|
|
1729
|
+
final Future<void> Function() onDownload;
|
|
1730
|
+
final Future<void> Function() onRefresh;
|
|
1731
|
+
|
|
1732
|
+
@override
|
|
1733
|
+
Widget build(BuildContext context) {
|
|
1734
|
+
return Container(
|
|
1735
|
+
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
1736
|
+
decoration: BoxDecoration(
|
|
1737
|
+
color: _bgSecondary,
|
|
1738
|
+
borderRadius: BorderRadius.circular(16),
|
|
1739
|
+
border: Border.all(color: _borderLight),
|
|
1740
|
+
),
|
|
1741
|
+
child: Row(
|
|
1742
|
+
children: <Widget>[
|
|
1743
|
+
_DotStatus(
|
|
1744
|
+
label: connected ? 'Extension connected' : 'Extension not connected',
|
|
1745
|
+
color: connected ? _success : _warning,
|
|
1746
|
+
),
|
|
1747
|
+
const Spacer(),
|
|
1748
|
+
OutlinedButton.icon(
|
|
1749
|
+
onPressed: onDownload,
|
|
1750
|
+
icon: const Icon(Icons.download_outlined, size: 18),
|
|
1751
|
+
label: const Text('Download'),
|
|
1752
|
+
style: OutlinedButton.styleFrom(
|
|
1753
|
+
visualDensity: VisualDensity.compact,
|
|
1754
|
+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
1755
|
+
),
|
|
1756
|
+
),
|
|
1757
|
+
const SizedBox(width: 8),
|
|
1758
|
+
OutlinedButton.icon(
|
|
1759
|
+
onPressed: onRefresh,
|
|
1760
|
+
icon: const Icon(Icons.sync, size: 18),
|
|
1761
|
+
label: const Text('Refresh'),
|
|
1762
|
+
style: OutlinedButton.styleFrom(
|
|
1763
|
+
visualDensity: VisualDensity.compact,
|
|
1764
|
+
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
1765
|
+
),
|
|
1766
|
+
),
|
|
1767
|
+
],
|
|
1768
|
+
),
|
|
1769
|
+
);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1704
1773
|
class _ResultBlock extends StatelessWidget {
|
|
1705
1774
|
const _ResultBlock({required this.label, required this.value});
|
|
1706
1775
|
|
|
@@ -171,6 +171,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
|
|
|
171
171
|
late final TextEditingController _searchController;
|
|
172
172
|
late String _browserBackend;
|
|
173
173
|
late String _cliBackend;
|
|
174
|
+
String? _cliDesktopDeviceId;
|
|
174
175
|
late bool _smarterSelector;
|
|
175
176
|
late Set<String> _enabledModels;
|
|
176
177
|
late String _defaultChatModel;
|
|
@@ -187,6 +188,14 @@ class _SettingsPanelState extends State<SettingsPanel> {
|
|
|
187
188
|
<String, TextEditingController>{};
|
|
188
189
|
final Set<String> _expandedProviderIds = <String>{};
|
|
189
190
|
|
|
191
|
+
// Inline runtime test state — ephemeral, not stored in controller.
|
|
192
|
+
bool _cliTestRunning = false;
|
|
193
|
+
Map<String, dynamic>? _cliTestResult;
|
|
194
|
+
bool _extensionTestRunning = false;
|
|
195
|
+
Map<String, dynamic>? _extensionTestResult;
|
|
196
|
+
bool _desktopTestRunning = false;
|
|
197
|
+
Map<String, dynamic>? _desktopTestResult;
|
|
198
|
+
|
|
190
199
|
@override
|
|
191
200
|
void initState() {
|
|
192
201
|
super.initState();
|
|
@@ -225,6 +234,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
|
|
|
225
234
|
.toSet();
|
|
226
235
|
_browserBackend = _normalizeBrowserBackend(controller.browserBackend);
|
|
227
236
|
_cliBackend = _normalizeCliBackend(controller.cliBackend);
|
|
237
|
+
_cliDesktopDeviceId = controller.cliDesktopDeviceId;
|
|
228
238
|
_smarterSelector = controller.smarterSelector;
|
|
229
239
|
_enabledModels = controller.enabledModelIds
|
|
230
240
|
.where((id) => knownModels.contains(id))
|
|
@@ -467,6 +477,7 @@ class _SettingsPanelState extends State<SettingsPanel> {
|
|
|
467
477
|
? 'extension'
|
|
468
478
|
: 'vm',
|
|
469
479
|
cliBackend: _cliBackend == 'desktop' ? 'desktop' : 'vm',
|
|
480
|
+
cliDesktopDeviceId: _cliDesktopDeviceId,
|
|
470
481
|
smarterSelector: _smarterSelector,
|
|
471
482
|
enabledModels: _enabledModels.toList(),
|
|
472
483
|
defaultChatModel: _defaultChatModel,
|
|
@@ -636,31 +647,46 @@ class _SettingsPanelState extends State<SettingsPanel> {
|
|
|
636
647
|
},
|
|
637
648
|
),
|
|
638
649
|
const SizedBox(height: 10),
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
650
|
+
if (_browserBackend == 'extension') ...<Widget>[
|
|
651
|
+
_buildInlineTestRow(
|
|
652
|
+
label: 'Chrome extension',
|
|
653
|
+
running: _extensionTestRunning,
|
|
654
|
+
result: _extensionTestResult,
|
|
655
|
+
note: controller.browserExtensionConnected
|
|
656
|
+
? 'Connected — tap Test to verify the live link.'
|
|
657
|
+
: 'Not connected — download the extension, load it in Chrome, then pair after login.',
|
|
658
|
+
onTest: () async {
|
|
659
|
+
setState(() { _extensionTestRunning = true; _extensionTestResult = null; });
|
|
660
|
+
try {
|
|
661
|
+
final r = await controller.testBrowserExtension();
|
|
662
|
+
setState(() => _extensionTestResult = r);
|
|
663
|
+
} catch (e) {
|
|
664
|
+
setState(() => _extensionTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
|
|
665
|
+
} finally {
|
|
666
|
+
setState(() => _extensionTestRunning = false);
|
|
667
|
+
}
|
|
668
|
+
},
|
|
669
|
+
),
|
|
670
|
+
const SizedBox(height: 10),
|
|
671
|
+
Wrap(
|
|
672
|
+
spacing: 10,
|
|
673
|
+
runSpacing: 10,
|
|
674
|
+
children: <Widget>[
|
|
675
|
+
OutlinedButton.icon(
|
|
676
|
+
onPressed: controller.downloadBrowserExtension,
|
|
677
|
+
icon: Icon(Icons.download_outlined),
|
|
678
|
+
label: Text('Download extension'),
|
|
679
|
+
),
|
|
680
|
+
OutlinedButton.icon(
|
|
681
|
+
onPressed: controller.refreshBrowserExtensionStatus,
|
|
682
|
+
icon: Icon(Icons.sync),
|
|
683
|
+
label: Text('Refresh'),
|
|
684
|
+
),
|
|
685
|
+
],
|
|
686
|
+
),
|
|
687
|
+
] else ...<Widget>[
|
|
688
|
+
Text('Cloud browser runtime is active.', style: TextStyle(color: _textSecondary, height: 1.4)),
|
|
689
|
+
],
|
|
664
690
|
const Divider(height: 32),
|
|
665
691
|
Text(
|
|
666
692
|
'CLI Runtime',
|
|
@@ -691,14 +717,67 @@ class _SettingsPanelState extends State<SettingsPanel> {
|
|
|
691
717
|
}
|
|
692
718
|
},
|
|
693
719
|
),
|
|
720
|
+
if (_cliBackend == 'desktop' && controller.desktopDevices.length > 1) ...<Widget>[
|
|
721
|
+
const SizedBox(height: 12),
|
|
722
|
+
DropdownButtonFormField<String>(
|
|
723
|
+
initialValue: controller.desktopDevices.any(
|
|
724
|
+
(d) => d['deviceId']?.toString() == _cliDesktopDeviceId,
|
|
725
|
+
)
|
|
726
|
+
? _cliDesktopDeviceId
|
|
727
|
+
: null,
|
|
728
|
+
decoration: const InputDecoration(
|
|
729
|
+
labelText: 'Desktop device',
|
|
730
|
+
helperText: 'Choose which desktop companion runs CLI commands.',
|
|
731
|
+
),
|
|
732
|
+
items: controller.desktopDevices.map((device) {
|
|
733
|
+
final deviceId = device['deviceId']?.toString() ?? '';
|
|
734
|
+
final label = device['hostname']?.toString().isNotEmpty == true
|
|
735
|
+
? device['hostname']!.toString()
|
|
736
|
+
: deviceId;
|
|
737
|
+
final online = device['online'] == true;
|
|
738
|
+
return DropdownMenuItem<String>(
|
|
739
|
+
value: deviceId,
|
|
740
|
+
child: Row(
|
|
741
|
+
children: <Widget>[
|
|
742
|
+
Icon(
|
|
743
|
+
online ? Icons.circle : Icons.circle_outlined,
|
|
744
|
+
size: 10,
|
|
745
|
+
color: online ? Colors.green : Colors.grey,
|
|
746
|
+
),
|
|
747
|
+
const SizedBox(width: 8),
|
|
748
|
+
Text(label),
|
|
749
|
+
],
|
|
750
|
+
),
|
|
751
|
+
);
|
|
752
|
+
}).toList(),
|
|
753
|
+
onChanged: (value) {
|
|
754
|
+
if (value != null) {
|
|
755
|
+
setState(() => _cliDesktopDeviceId = value);
|
|
756
|
+
}
|
|
757
|
+
},
|
|
758
|
+
),
|
|
759
|
+
],
|
|
694
760
|
const SizedBox(height: 10),
|
|
695
|
-
|
|
696
|
-
|
|
761
|
+
_buildInlineTestRow(
|
|
762
|
+
label: 'CLI',
|
|
763
|
+
running: _cliTestRunning,
|
|
764
|
+
result: _cliTestResult,
|
|
765
|
+
note: _cliBackend == 'desktop'
|
|
697
766
|
? (controller.desktopCompanionConnected
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
: 'Cloud
|
|
701
|
-
|
|
767
|
+
? 'Desktop app connected — commands currently route through the cloud VM (desktop routing coming soon).'
|
|
768
|
+
: 'Desktop app selected but not connected. Commands fall back to cloud VM until the companion is online.')
|
|
769
|
+
: 'Cloud VM — commands run in an isolated container.',
|
|
770
|
+
onTest: () async {
|
|
771
|
+
setState(() { _cliTestRunning = true; _cliTestResult = null; });
|
|
772
|
+
try {
|
|
773
|
+
final r = await controller.testCliRuntime();
|
|
774
|
+
setState(() => _cliTestResult = r);
|
|
775
|
+
} catch (e) {
|
|
776
|
+
setState(() => _cliTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
|
|
777
|
+
} finally {
|
|
778
|
+
setState(() => _cliTestRunning = false);
|
|
779
|
+
}
|
|
780
|
+
},
|
|
702
781
|
),
|
|
703
782
|
const Divider(height: 32),
|
|
704
783
|
Text(
|
|
@@ -1344,6 +1423,47 @@ class _SettingsPanelState extends State<SettingsPanel> {
|
|
|
1344
1423
|
const SizedBox(height: 12),
|
|
1345
1424
|
_InlineError(message: message),
|
|
1346
1425
|
],
|
|
1426
|
+
const SizedBox(height: 12),
|
|
1427
|
+
_buildInlineTestRow(
|
|
1428
|
+
label: 'Desktop companion',
|
|
1429
|
+
running: _desktopTestRunning,
|
|
1430
|
+
result: _desktopTestResult != null
|
|
1431
|
+
? <String, dynamic>{
|
|
1432
|
+
'passed': _desktopTestResult!['passed'] == true,
|
|
1433
|
+
'detail': _desktopTestResult!['detail']?.toString() ?? '',
|
|
1434
|
+
}
|
|
1435
|
+
: null,
|
|
1436
|
+
note: controller.desktopCompanionConnected
|
|
1437
|
+
? 'Connected — tap Test to fetch live device status from the server.'
|
|
1438
|
+
: 'Not connected. Make sure the desktop app is running on the target machine.',
|
|
1439
|
+
onTest: () async {
|
|
1440
|
+
setState(() { _desktopTestRunning = true; _desktopTestResult = null; });
|
|
1441
|
+
try {
|
|
1442
|
+
final r = await controller.testDesktopCompanion();
|
|
1443
|
+
final active = r['activeDevice'];
|
|
1444
|
+
final multi = r['multipleOnline'] == true;
|
|
1445
|
+
String detail = r['detail']?.toString() ?? '';
|
|
1446
|
+
if (r['passed'] == true && active != null) {
|
|
1447
|
+
final label = active['label']?.toString() ?? 'Device';
|
|
1448
|
+
final plat = active['platform']?.toString() ?? '';
|
|
1449
|
+
final sc = active['permissions']?['screenCapture'] == true;
|
|
1450
|
+
final ic = active['permissions']?['inputControl'] == true;
|
|
1451
|
+
detail = '$label${plat.isNotEmpty ? " ($plat)" : ""}'
|
|
1452
|
+
' — screen: ${sc ? "✓" : "✗"}, input: ${ic ? "✓" : "✗"}';
|
|
1453
|
+
} else if (multi) {
|
|
1454
|
+
detail = '${r['onlineCount']} devices online — select one in Desktop › Devices';
|
|
1455
|
+
}
|
|
1456
|
+
setState(() => _desktopTestResult = <String, dynamic>{
|
|
1457
|
+
...r,
|
|
1458
|
+
'detail': detail,
|
|
1459
|
+
});
|
|
1460
|
+
} catch (e) {
|
|
1461
|
+
setState(() => _desktopTestResult = <String, dynamic>{'passed': false, 'detail': e.toString()});
|
|
1462
|
+
} finally {
|
|
1463
|
+
setState(() => _desktopTestRunning = false);
|
|
1464
|
+
}
|
|
1465
|
+
},
|
|
1466
|
+
),
|
|
1347
1467
|
const SizedBox(height: 14),
|
|
1348
1468
|
Builder(
|
|
1349
1469
|
builder: (context) {
|
|
@@ -1999,6 +2119,70 @@ class _SettingsPanelState extends State<SettingsPanel> {
|
|
|
1999
2119
|
)
|
|
2000
2120
|
.toList();
|
|
2001
2121
|
}
|
|
2122
|
+
|
|
2123
|
+
// Shared helper: small "Test" button + inline result row.
|
|
2124
|
+
Widget _buildInlineTestRow({
|
|
2125
|
+
required String label,
|
|
2126
|
+
required bool running,
|
|
2127
|
+
required Map<String, dynamic>? result,
|
|
2128
|
+
required VoidCallback onTest,
|
|
2129
|
+
String? note,
|
|
2130
|
+
}) {
|
|
2131
|
+
final passed = result?['passed'] == true;
|
|
2132
|
+
final detail = result?['detail']?.toString() ?? '';
|
|
2133
|
+
return Row(
|
|
2134
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
2135
|
+
children: <Widget>[
|
|
2136
|
+
Expanded(
|
|
2137
|
+
child: Column(
|
|
2138
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
2139
|
+
children: <Widget>[
|
|
2140
|
+
if (result != null)
|
|
2141
|
+
Row(
|
|
2142
|
+
children: <Widget>[
|
|
2143
|
+
Icon(
|
|
2144
|
+
passed ? Icons.check_circle_rounded : Icons.cancel_rounded,
|
|
2145
|
+
size: 15,
|
|
2146
|
+
color: passed ? const Color(0xFF22C55E) : const Color(0xFFEF4444),
|
|
2147
|
+
),
|
|
2148
|
+
const SizedBox(width: 6),
|
|
2149
|
+
Expanded(
|
|
2150
|
+
child: Text(
|
|
2151
|
+
passed ? (detail.isNotEmpty ? detail : '$label: OK') : detail,
|
|
2152
|
+
style: TextStyle(
|
|
2153
|
+
fontSize: 13,
|
|
2154
|
+
color: passed ? null : const Color(0xFFEF4444),
|
|
2155
|
+
),
|
|
2156
|
+
),
|
|
2157
|
+
),
|
|
2158
|
+
],
|
|
2159
|
+
)
|
|
2160
|
+
else if (note != null)
|
|
2161
|
+
Text(note, style: TextStyle(fontSize: 13, color: _textSecondary, height: 1.4)),
|
|
2162
|
+
],
|
|
2163
|
+
),
|
|
2164
|
+
),
|
|
2165
|
+
const SizedBox(width: 12),
|
|
2166
|
+
SizedBox(
|
|
2167
|
+
width: 80,
|
|
2168
|
+
child: OutlinedButton(
|
|
2169
|
+
onPressed: running ? null : onTest,
|
|
2170
|
+
style: OutlinedButton.styleFrom(
|
|
2171
|
+
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
2172
|
+
textStyle: const TextStyle(fontSize: 12),
|
|
2173
|
+
),
|
|
2174
|
+
child: running
|
|
2175
|
+
? const SizedBox(
|
|
2176
|
+
width: 13,
|
|
2177
|
+
height: 13,
|
|
2178
|
+
child: CircularProgressIndicator(strokeWidth: 2),
|
|
2179
|
+
)
|
|
2180
|
+
: const Text('Test'),
|
|
2181
|
+
),
|
|
2182
|
+
),
|
|
2183
|
+
],
|
|
2184
|
+
);
|
|
2185
|
+
}
|
|
2002
2186
|
}
|
|
2003
2187
|
|
|
2004
2188
|
class _AiProviderCard extends StatelessWidget {
|
|
@@ -476,6 +476,18 @@ class BackendClient {
|
|
|
476
476
|
return getMap(baseUrl, '/api/runtime/config', allowUnauthorized: true);
|
|
477
477
|
}
|
|
478
478
|
|
|
479
|
+
Future<Map<String, dynamic>> testCli(String baseUrl) async {
|
|
480
|
+
return getMap(baseUrl, '/api/system/test/cli');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
Future<Map<String, dynamic>> testExtension(String baseUrl) async {
|
|
484
|
+
return getMap(baseUrl, '/api/system/test/extension');
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
Future<Map<String, dynamic>> testDesktop(String baseUrl) async {
|
|
488
|
+
return getMap(baseUrl, '/api/system/test/desktop');
|
|
489
|
+
}
|
|
490
|
+
|
|
479
491
|
Future<Map<String, dynamic>> fetchBrowserStatus(String baseUrl) async {
|
|
480
492
|
return getMap(baseUrl, '/api/browser/status');
|
|
481
493
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import 'dart:async';
|
|
1
2
|
import 'dart:convert';
|
|
2
3
|
import 'dart:io';
|
|
3
4
|
|
|
@@ -396,6 +397,77 @@ class DesktopCompanionActions {
|
|
|
396
397
|
};
|
|
397
398
|
}
|
|
398
399
|
|
|
400
|
+
Future<Map<String, Object?>> executeShellCommand({
|
|
401
|
+
required String command,
|
|
402
|
+
String? cwd,
|
|
403
|
+
int? timeoutMs,
|
|
404
|
+
String? stdinInput,
|
|
405
|
+
}) async {
|
|
406
|
+
final shell = Platform.isWindows ? 'cmd.exe' : (Platform.environment['SHELL'] ?? '/bin/sh');
|
|
407
|
+
final args = Platform.isWindows ? <String>['/c', command] : <String>['-lc', command];
|
|
408
|
+
final workingDir = cwd?.trim().isNotEmpty == true ? cwd : Platform.environment['HOME'];
|
|
409
|
+
final startedAt = DateTime.now();
|
|
410
|
+
|
|
411
|
+
final process = await Process.start(
|
|
412
|
+
shell,
|
|
413
|
+
args,
|
|
414
|
+
workingDirectory: workingDir,
|
|
415
|
+
runInShell: false,
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
if (stdinInput != null && stdinInput.isNotEmpty) {
|
|
419
|
+
process.stdin.write(stdinInput);
|
|
420
|
+
await process.stdin.close();
|
|
421
|
+
} else {
|
|
422
|
+
unawaited(process.stdin.close());
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const maxChars = 50000;
|
|
426
|
+
final stdoutBuf = StringBuffer();
|
|
427
|
+
final stderrBuf = StringBuffer();
|
|
428
|
+
|
|
429
|
+
final stdoutSub = process.stdout.transform(utf8.decoder).listen((data) {
|
|
430
|
+
stdoutBuf.write(data);
|
|
431
|
+
});
|
|
432
|
+
final stderrSub = process.stderr.transform(utf8.decoder).listen((data) {
|
|
433
|
+
stderrBuf.write(data);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
final effectiveTimeout = Duration(
|
|
437
|
+
milliseconds: (timeoutMs != null && timeoutMs > 0) ? timeoutMs : 15 * 60 * 1000,
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
bool timedOut = false;
|
|
441
|
+
int? exitCode;
|
|
442
|
+
try {
|
|
443
|
+
exitCode = await process.exitCode.timeout(effectiveTimeout);
|
|
444
|
+
} on TimeoutException {
|
|
445
|
+
timedOut = true;
|
|
446
|
+
process.kill(ProcessSignal.sigterm);
|
|
447
|
+
exitCode = null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
await stdoutSub.cancel();
|
|
451
|
+
await stderrSub.cancel();
|
|
452
|
+
|
|
453
|
+
String _trim(StringBuffer buf) {
|
|
454
|
+
final s = buf.toString().trim();
|
|
455
|
+
return s.length > maxChars ? '${s.substring(0, maxChars)}\n...[truncated, ${s.length} total chars]' : s;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return <String, Object?>{
|
|
459
|
+
'exitCode': exitCode,
|
|
460
|
+
'stdout': _trim(stdoutBuf),
|
|
461
|
+
'stderr': _trim(stderrBuf),
|
|
462
|
+
'timedOut': timedOut,
|
|
463
|
+
'killed': timedOut,
|
|
464
|
+
'durationMs': DateTime.now().difference(startedAt).inMilliseconds,
|
|
465
|
+
'command': command,
|
|
466
|
+
'cwd': workingDir,
|
|
467
|
+
'backend': 'desktop-companion',
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
399
471
|
Future<Map<String, Object?>> _capabilities({
|
|
400
472
|
Map<String, Object?>? platformStatus,
|
|
401
473
|
}) async {
|