neoagent 3.0.1-beta.3 → 3.0.1-beta.5
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/README.md +11 -0
- package/flutter_app/lib/main.dart +2 -1
- package/flutter_app/lib/main_app_shell.dart +306 -0
- package/flutter_app/lib/main_install.dart +1147 -0
- package/flutter_app/lib/main_navigation.dart +6 -0
- package/package.json +1 -1
- package/server/admin/admin.js +4 -13
- package/server/admin/index.html +1 -10
- 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 +72908 -71991
- package/server/services/ai/loop/conversation_loop.js +81 -0
- package/server/services/ai/loop/progress_monitor.js +6 -6
- package/server/services/ai/loopPolicy.js +25 -12
- package/server/services/ai/messagingFallback.js +3 -1
- package/server/services/ai/preModelCompaction.js +25 -5
- package/server/services/ai/tools.js +36 -7
- package/server/services/browser/contentExtractor.js +629 -0
- package/server/services/browser/controller.js +4 -8
- package/server/services/integrations/google/calendar.js +22 -2
- package/server/services/messaging/automation.js +1 -1
- package/server/services/tasks/runtime.js +25 -2
- package/server/services/workspace/manager.js +14 -3
|
@@ -0,0 +1,1147 @@
|
|
|
1
|
+
part of 'main.dart';
|
|
2
|
+
|
|
3
|
+
// ─── Enums ────────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
enum _ServerPhase {
|
|
6
|
+
checking,
|
|
7
|
+
prereqsFailed,
|
|
8
|
+
notInstalled,
|
|
9
|
+
installing,
|
|
10
|
+
installed,
|
|
11
|
+
installFailed,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
enum _ServerTab { overview, configure, logs, update, danger }
|
|
15
|
+
|
|
16
|
+
extension _ServerTabX on _ServerTab {
|
|
17
|
+
String get label {
|
|
18
|
+
switch (this) {
|
|
19
|
+
case _ServerTab.overview:
|
|
20
|
+
return 'Overview';
|
|
21
|
+
case _ServerTab.configure:
|
|
22
|
+
return 'Configure';
|
|
23
|
+
case _ServerTab.logs:
|
|
24
|
+
return 'Logs';
|
|
25
|
+
case _ServerTab.update:
|
|
26
|
+
return 'Update';
|
|
27
|
+
case _ServerTab.danger:
|
|
28
|
+
return 'Danger';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Field descriptors ────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
class _EnvField {
|
|
36
|
+
const _EnvField(this.key, this.label, {this.isSecret = false, this.hint = ''});
|
|
37
|
+
|
|
38
|
+
final String key;
|
|
39
|
+
final String label;
|
|
40
|
+
final bool isSecret;
|
|
41
|
+
final String hint;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const _serverFields = <_EnvField>[
|
|
45
|
+
_EnvField('PORT', 'Port', hint: '3333'),
|
|
46
|
+
_EnvField('PUBLIC_URL', 'Public URL', hint: 'https://neoagent.example.com'),
|
|
47
|
+
_EnvField('NEOAGENT_DEPLOYMENT_MODE', 'Deployment mode', hint: 'self_hosted'),
|
|
48
|
+
_EnvField('NEOAGENT_RELEASE_CHANNEL', 'Release channel', hint: 'stable'),
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const _aiKeyFields = <_EnvField>[
|
|
52
|
+
_EnvField('ANTHROPIC_API_KEY', 'Anthropic API key', isSecret: true),
|
|
53
|
+
_EnvField('ANTHROPIC_BASE_URL', 'Anthropic base URL'),
|
|
54
|
+
_EnvField('OPENAI_API_KEY', 'OpenAI API key', isSecret: true),
|
|
55
|
+
_EnvField('OPENAI_BASE_URL', 'OpenAI base URL'),
|
|
56
|
+
_EnvField('XAI_API_KEY', 'xAI API key', isSecret: true),
|
|
57
|
+
_EnvField('XAI_BASE_URL', 'xAI base URL', hint: 'https://api.x.ai/v1'),
|
|
58
|
+
_EnvField('GOOGLE_AI_KEY', 'Google AI key', isSecret: true),
|
|
59
|
+
_EnvField('MINIMAX_API_KEY', 'MiniMax API key', isSecret: true),
|
|
60
|
+
_EnvField('BRAVE_SEARCH_API_KEY', 'Brave Search API key', isSecret: true),
|
|
61
|
+
_EnvField('OLLAMA_URL', 'Ollama URL', hint: 'http://localhost:11434'),
|
|
62
|
+
_EnvField('DEEPGRAM_API_KEY', 'Deepgram API key', isSecret: true),
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
const _oauthFields = <_EnvField>[
|
|
66
|
+
_EnvField('GOOGLE_OAUTH_CLIENT_ID', 'Google client ID', isSecret: true),
|
|
67
|
+
_EnvField('GOOGLE_OAUTH_CLIENT_SECRET', 'Google client secret', isSecret: true),
|
|
68
|
+
_EnvField('GOOGLE_OAUTH_REDIRECT_URI', 'Google redirect URI'),
|
|
69
|
+
_EnvField('NOTION_OAUTH_CLIENT_ID', 'Notion client ID', isSecret: true),
|
|
70
|
+
_EnvField('NOTION_OAUTH_CLIENT_SECRET', 'Notion client secret', isSecret: true),
|
|
71
|
+
_EnvField('NOTION_OAUTH_REDIRECT_URI', 'Notion redirect URI'),
|
|
72
|
+
_EnvField('MICROSOFT_OAUTH_CLIENT_ID', 'Microsoft client ID', isSecret: true),
|
|
73
|
+
_EnvField('MICROSOFT_OAUTH_CLIENT_SECRET', 'Microsoft client secret', isSecret: true),
|
|
74
|
+
_EnvField('MICROSOFT_OAUTH_REDIRECT_URI', 'Microsoft redirect URI'),
|
|
75
|
+
_EnvField('MICROSOFT_OAUTH_TENANT_ID', 'Microsoft tenant ID'),
|
|
76
|
+
_EnvField('SLACK_OAUTH_CLIENT_ID', 'Slack client ID', isSecret: true),
|
|
77
|
+
_EnvField('SLACK_OAUTH_CLIENT_SECRET', 'Slack client secret', isSecret: true),
|
|
78
|
+
_EnvField('SLACK_OAUTH_REDIRECT_URI', 'Slack redirect URI'),
|
|
79
|
+
_EnvField('FIGMA_OAUTH_CLIENT_ID', 'Figma client ID', isSecret: true),
|
|
80
|
+
_EnvField('FIGMA_OAUTH_CLIENT_SECRET', 'Figma client secret', isSecret: true),
|
|
81
|
+
_EnvField('FIGMA_OAUTH_REDIRECT_URI', 'Figma redirect URI'),
|
|
82
|
+
_EnvField('GITHUB_OAUTH_CLIENT_ID', 'GitHub client ID', isSecret: true),
|
|
83
|
+
_EnvField('GITHUB_OAUTH_CLIENT_SECRET', 'GitHub client secret', isSecret: true),
|
|
84
|
+
_EnvField('GITHUB_OAUTH_REDIRECT_URI', 'GitHub redirect URI'),
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const _voiceFields = <_EnvField>[
|
|
88
|
+
_EnvField('DEEPGRAM_BASE_URL', 'Deepgram base URL',
|
|
89
|
+
hint: 'https://api.deepgram.com'),
|
|
90
|
+
_EnvField('DEEPGRAM_MODEL', 'Deepgram model', hint: 'nova-3'),
|
|
91
|
+
_EnvField('DEEPGRAM_LANGUAGE', 'Deepgram language', hint: 'multi'),
|
|
92
|
+
_EnvField('TELNYX_WEBHOOK_TOKEN', 'Telnyx webhook token', isSecret: true),
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
// ─── .env helpers (pure functions) ────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
Map<String, String> _parseEnvFile(String path) {
|
|
98
|
+
final f = File(path);
|
|
99
|
+
if (!f.existsSync()) return {};
|
|
100
|
+
final result = <String, String>{};
|
|
101
|
+
for (final line in f.readAsLinesSync()) {
|
|
102
|
+
final trimmed = line.trim();
|
|
103
|
+
if (trimmed.isEmpty || trimmed.startsWith('#')) continue;
|
|
104
|
+
final idx = line.indexOf('=');
|
|
105
|
+
if (idx < 0) continue;
|
|
106
|
+
result[line.substring(0, idx).trim()] = line.substring(idx + 1);
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
void _writeEnvFile(String path, Map<String, String> updated) {
|
|
112
|
+
final f = File(path);
|
|
113
|
+
final original = f.existsSync() ? f.readAsLinesSync() : <String>[];
|
|
114
|
+
final seen = <String>{};
|
|
115
|
+
final out = <String>[];
|
|
116
|
+
|
|
117
|
+
for (final line in original) {
|
|
118
|
+
final trimmed = line.trim();
|
|
119
|
+
if (trimmed.isEmpty || trimmed.startsWith('#')) {
|
|
120
|
+
out.add(line);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
final idx = line.indexOf('=');
|
|
124
|
+
if (idx < 0) {
|
|
125
|
+
out.add(line);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
final key = line.substring(0, idx).trim();
|
|
129
|
+
seen.add(key);
|
|
130
|
+
final newVal = updated[key];
|
|
131
|
+
if (newVal != null && newVal.isNotEmpty) {
|
|
132
|
+
out.add('$key=$newVal');
|
|
133
|
+
} else if (newVal == null) {
|
|
134
|
+
out.add(line); // preserve unmanaged keys untouched
|
|
135
|
+
}
|
|
136
|
+
// empty newVal → omit the line (clear the value)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (final entry in updated.entries) {
|
|
140
|
+
if (!seen.contains(entry.key) && entry.value.isNotEmpty) {
|
|
141
|
+
out.add('${entry.key}=${entry.value}');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
f.writeAsStringSync('${out.join('\n')}\n');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── ServerPanel ──────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
class ServerPanel extends StatefulWidget {
|
|
151
|
+
const ServerPanel({super.key, required this.controller});
|
|
152
|
+
|
|
153
|
+
final NeoAgentController controller;
|
|
154
|
+
|
|
155
|
+
@override
|
|
156
|
+
State<ServerPanel> createState() => _ServerPanelState();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
class _ServerPanelState extends State<ServerPanel> {
|
|
160
|
+
_ServerPhase _phase = _ServerPhase.checking;
|
|
161
|
+
_ServerTab _activeTab = _ServerTab.overview;
|
|
162
|
+
|
|
163
|
+
// prereq / install
|
|
164
|
+
String? _nodePath;
|
|
165
|
+
String? _npmPath;
|
|
166
|
+
bool _hasGit = false;
|
|
167
|
+
String? _installDir;
|
|
168
|
+
bool _serviceRegistered = false;
|
|
169
|
+
bool _serviceRunning = false;
|
|
170
|
+
bool _serviceActionRunning = false;
|
|
171
|
+
|
|
172
|
+
// streaming
|
|
173
|
+
final List<String> _log = [];
|
|
174
|
+
final _logScrollCtrl = ScrollController();
|
|
175
|
+
Process? _runningProcess;
|
|
176
|
+
|
|
177
|
+
// install form
|
|
178
|
+
final _portCtrl = TextEditingController(text: '3333');
|
|
179
|
+
final _dirCtrl = TextEditingController();
|
|
180
|
+
final _keyCtrl = TextEditingController();
|
|
181
|
+
|
|
182
|
+
// configure tab
|
|
183
|
+
bool _configLoaded = false;
|
|
184
|
+
bool _configSaving = false;
|
|
185
|
+
final Map<String, TextEditingController> _envCtrl = {};
|
|
186
|
+
bool _secureCookies = false;
|
|
187
|
+
bool _trustProxy = false;
|
|
188
|
+
|
|
189
|
+
String get _home => Platform.isWindows
|
|
190
|
+
? (Platform.environment['USERPROFILE'] ?? '')
|
|
191
|
+
: (Platform.environment['HOME'] ?? '');
|
|
192
|
+
String get _envPath => '$_home/.neoagent/runtime/.env';
|
|
193
|
+
|
|
194
|
+
@override
|
|
195
|
+
void initState() {
|
|
196
|
+
super.initState();
|
|
197
|
+
_refresh();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
@override
|
|
201
|
+
void dispose() {
|
|
202
|
+
_logScrollCtrl.dispose();
|
|
203
|
+
_portCtrl.dispose();
|
|
204
|
+
_dirCtrl.dispose();
|
|
205
|
+
_keyCtrl.dispose();
|
|
206
|
+
for (final c in _envCtrl.values) {
|
|
207
|
+
c.dispose();
|
|
208
|
+
}
|
|
209
|
+
_runningProcess?.kill(ProcessSignal.sigterm);
|
|
210
|
+
super.dispose();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
Future<String?> _which(String cmd) async {
|
|
214
|
+
try {
|
|
215
|
+
final r = await Process.run(
|
|
216
|
+
Platform.isWindows ? 'where' : 'which', [cmd]);
|
|
217
|
+
if (r.exitCode == 0) {
|
|
218
|
+
return (r.stdout as String).trim().split('\n').first.trim();
|
|
219
|
+
}
|
|
220
|
+
} catch (_) {}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
Future<void> _refresh() async {
|
|
225
|
+
if (!mounted) return;
|
|
226
|
+
setState(() => _phase = _ServerPhase.checking);
|
|
227
|
+
|
|
228
|
+
_nodePath = await _which('node');
|
|
229
|
+
if (_nodePath == null && !Platform.isWindows) {
|
|
230
|
+
for (final p in ['/opt/homebrew/bin/node', '/usr/local/bin/node']) {
|
|
231
|
+
if (File(p).existsSync()) {
|
|
232
|
+
_nodePath = p;
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
_npmPath = await _which('npm');
|
|
238
|
+
_hasGit = (await _which('git')) != null;
|
|
239
|
+
|
|
240
|
+
if (_nodePath == null || _npmPath == null || !_hasGit) {
|
|
241
|
+
if (mounted) setState(() => _phase = _ServerPhase.prereqsFailed);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
final defaultDir = '$_home/NeoAgent';
|
|
246
|
+
if (File('$defaultDir/bin/neoagent.js').existsSync()) {
|
|
247
|
+
_installDir = defaultDir;
|
|
248
|
+
if (_dirCtrl.text.isEmpty) _dirCtrl.text = _installDir!;
|
|
249
|
+
} else if (_dirCtrl.text.isNotEmpty &&
|
|
250
|
+
File('${_dirCtrl.text}/bin/neoagent.js').existsSync()) {
|
|
251
|
+
_installDir = _dirCtrl.text;
|
|
252
|
+
} else {
|
|
253
|
+
_installDir = null;
|
|
254
|
+
if (_dirCtrl.text.isEmpty) _dirCtrl.text = defaultDir;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
_serviceRegistered = _checkServiceRegistered();
|
|
258
|
+
if (_serviceRegistered) {
|
|
259
|
+
_serviceRunning = await _checkServiceRunning();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!mounted) return;
|
|
263
|
+
setState(() {
|
|
264
|
+
_phase = _serviceRegistered
|
|
265
|
+
? _ServerPhase.installed
|
|
266
|
+
: _ServerPhase.notInstalled;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
bool _checkServiceRegistered() {
|
|
271
|
+
if (Platform.isMacOS) {
|
|
272
|
+
return File('$_home/Library/LaunchAgents/com.neoagent.plist')
|
|
273
|
+
.existsSync();
|
|
274
|
+
}
|
|
275
|
+
if (Platform.isLinux) {
|
|
276
|
+
return File('$_home/.config/systemd/user/neoagent.service').existsSync();
|
|
277
|
+
}
|
|
278
|
+
return File('$_home/.neoagent/runtime/neoagent.pid').existsSync();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
Future<bool> _checkServiceRunning() async {
|
|
282
|
+
try {
|
|
283
|
+
if (Platform.isMacOS) {
|
|
284
|
+
final r = await Process.run('launchctl', ['list', 'com.neoagent']);
|
|
285
|
+
return r.exitCode == 0;
|
|
286
|
+
}
|
|
287
|
+
if (Platform.isLinux) {
|
|
288
|
+
final r = await Process.run(
|
|
289
|
+
'systemctl',
|
|
290
|
+
['--user', 'is-active', '--quiet', 'neoagent'],
|
|
291
|
+
);
|
|
292
|
+
return r.exitCode == 0;
|
|
293
|
+
}
|
|
294
|
+
} catch (_) {}
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
void _ensureEnvFile() {
|
|
299
|
+
if (!File(_envPath).existsSync()) {
|
|
300
|
+
Directory('$_home/.neoagent/runtime').createSync(recursive: true);
|
|
301
|
+
final port =
|
|
302
|
+
_portCtrl.text.trim().isEmpty ? '3333' : _portCtrl.text.trim();
|
|
303
|
+
final buf = StringBuffer()
|
|
304
|
+
..writeln('NODE_ENV=production')
|
|
305
|
+
..writeln('PORT=$port');
|
|
306
|
+
final key = _keyCtrl.text.trim();
|
|
307
|
+
if (key.isNotEmpty) buf.writeln('ANTHROPIC_API_KEY=$key');
|
|
308
|
+
File(_envPath).writeAsStringSync(buf.toString());
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
Future<void> _runStreamedCommand(
|
|
313
|
+
List<String> args, {
|
|
314
|
+
required VoidCallback onSuccess,
|
|
315
|
+
required VoidCallback onFailure,
|
|
316
|
+
}) async {
|
|
317
|
+
if (_installDir == null) {
|
|
318
|
+
onFailure();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
final process = await Process.start(
|
|
322
|
+
_nodePath!,
|
|
323
|
+
args,
|
|
324
|
+
workingDirectory: _installDir,
|
|
325
|
+
);
|
|
326
|
+
_runningProcess = process;
|
|
327
|
+
|
|
328
|
+
void onChunk(String data) {
|
|
329
|
+
if (!mounted) return;
|
|
330
|
+
setState(() => _log.add(data));
|
|
331
|
+
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
332
|
+
if (_logScrollCtrl.hasClients) {
|
|
333
|
+
_logScrollCtrl.jumpTo(_logScrollCtrl.position.maxScrollExtent);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
process.stdout.transform(utf8.decoder).listen(onChunk);
|
|
339
|
+
process.stderr.transform(utf8.decoder).listen(onChunk);
|
|
340
|
+
|
|
341
|
+
final exit = await process.exitCode;
|
|
342
|
+
_runningProcess = null;
|
|
343
|
+
if (!mounted) return;
|
|
344
|
+
if (exit == 0) {
|
|
345
|
+
onSuccess();
|
|
346
|
+
} else {
|
|
347
|
+
onFailure();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
Future<void> _runInstall() async {
|
|
352
|
+
final dir = _dirCtrl.text.trim().isEmpty
|
|
353
|
+
? '$_home/NeoAgent'
|
|
354
|
+
: _dirCtrl.text.trim();
|
|
355
|
+
if (!File('$dir/bin/neoagent.js').existsSync()) {
|
|
356
|
+
setState(() {
|
|
357
|
+
_phase = _ServerPhase.installFailed;
|
|
358
|
+
_log
|
|
359
|
+
..clear()
|
|
360
|
+
..add('Cannot find $dir/bin/neoagent.js — check the install directory.\n');
|
|
361
|
+
});
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
_installDir = dir;
|
|
365
|
+
_ensureEnvFile();
|
|
366
|
+
setState(() {
|
|
367
|
+
_phase = _ServerPhase.installing;
|
|
368
|
+
_log.clear();
|
|
369
|
+
});
|
|
370
|
+
await _runStreamedCommand(
|
|
371
|
+
['bin/neoagent.js', 'install'],
|
|
372
|
+
onSuccess: () => _refresh(),
|
|
373
|
+
onFailure: () => setState(() => _phase = _ServerPhase.installFailed),
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
Future<void> _runUpdate() async {
|
|
378
|
+
setState(() => _log.clear());
|
|
379
|
+
await _runStreamedCommand(
|
|
380
|
+
['bin/neoagent.js', 'update'],
|
|
381
|
+
onSuccess: () {
|
|
382
|
+
setState(() => _log.add('\n✓ Update complete.\n'));
|
|
383
|
+
_refresh();
|
|
384
|
+
},
|
|
385
|
+
onFailure: () =>
|
|
386
|
+
setState(() => _log.add('\n✗ Update failed.\n')),
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
Future<void> _runUninstall() async {
|
|
391
|
+
setState(() => _log.clear());
|
|
392
|
+
await _runStreamedCommand(
|
|
393
|
+
['bin/neoagent.js', 'uninstall'],
|
|
394
|
+
onSuccess: () => _refresh(),
|
|
395
|
+
onFailure: () =>
|
|
396
|
+
setState(() => _log.add('\n✗ Uninstall failed.\n')),
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
Future<void> _serviceAction(String cmd) async {
|
|
401
|
+
if (_installDir == null || _nodePath == null || _serviceActionRunning) return;
|
|
402
|
+
setState(() => _serviceActionRunning = true);
|
|
403
|
+
final result = await Process.run(
|
|
404
|
+
_nodePath!,
|
|
405
|
+
['bin/neoagent.js', cmd],
|
|
406
|
+
workingDirectory: _installDir,
|
|
407
|
+
);
|
|
408
|
+
if (result.exitCode != 0 && mounted) {
|
|
409
|
+
ScaffoldMessenger.of(context).showSnackBar(
|
|
410
|
+
SnackBar(
|
|
411
|
+
content: Text('Failed to $cmd server (exit ${result.exitCode}).'),
|
|
412
|
+
duration: const Duration(seconds: 3),
|
|
413
|
+
),
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
if (mounted) setState(() => _serviceActionRunning = false);
|
|
417
|
+
await _refresh();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
void _openDashboard() {
|
|
421
|
+
final port = _envCtrl['PORT']?.text.trim().isNotEmpty == true
|
|
422
|
+
? _envCtrl['PORT']!.text.trim()
|
|
423
|
+
: _portCtrl.text.trim().isEmpty
|
|
424
|
+
? '3333'
|
|
425
|
+
: _portCtrl.text.trim();
|
|
426
|
+
final url = 'http://localhost:$port';
|
|
427
|
+
if (Platform.isMacOS) {
|
|
428
|
+
Process.run('open', [url]);
|
|
429
|
+
} else if (Platform.isWindows) {
|
|
430
|
+
Process.run('start', [url], runInShell: true);
|
|
431
|
+
} else if (Platform.isLinux) {
|
|
432
|
+
Process.run('xdg-open', [url]);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
void _loadConfig() {
|
|
437
|
+
if (_configLoaded) return;
|
|
438
|
+
final env = _parseEnvFile(_envPath);
|
|
439
|
+
for (final group in [_serverFields, _aiKeyFields, _oauthFields, _voiceFields]) {
|
|
440
|
+
for (final field in group) {
|
|
441
|
+
_envCtrl[field.key] =
|
|
442
|
+
TextEditingController(text: env[field.key] ?? '');
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
_secureCookies =
|
|
446
|
+
(env['SECURE_COOKIES'] ?? 'false').toLowerCase() == 'true';
|
|
447
|
+
_trustProxy = (env['TRUST_PROXY'] ?? 'false').toLowerCase() == 'true';
|
|
448
|
+
_configLoaded = true;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
Future<void> _saveConfig() async {
|
|
452
|
+
if (_installDir == null) return;
|
|
453
|
+
setState(() => _configSaving = true);
|
|
454
|
+
final updated = <String, String>{};
|
|
455
|
+
for (final entry in _envCtrl.entries) {
|
|
456
|
+
updated[entry.key] = entry.value.text.trim();
|
|
457
|
+
}
|
|
458
|
+
updated['SECURE_COOKIES'] = _secureCookies ? 'true' : 'false';
|
|
459
|
+
updated['TRUST_PROXY'] = _trustProxy ? 'true' : 'false';
|
|
460
|
+
updated['NODE_ENV'] = 'production';
|
|
461
|
+
_writeEnvFile(_envPath, updated);
|
|
462
|
+
if (mounted) {
|
|
463
|
+
setState(() => _configSaving = false);
|
|
464
|
+
ScaffoldMessenger.of(context).showSnackBar(
|
|
465
|
+
const SnackBar(
|
|
466
|
+
content: Text('Settings saved. Restart the server for changes to take effect.'),
|
|
467
|
+
duration: Duration(seconds: 4),
|
|
468
|
+
),
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ─── Build ──────────────────────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
@override
|
|
476
|
+
Widget build(BuildContext context) {
|
|
477
|
+
return ListView(
|
|
478
|
+
padding: _pagePadding(context),
|
|
479
|
+
children: <Widget>[
|
|
480
|
+
const _PageTitle(
|
|
481
|
+
title: 'Server',
|
|
482
|
+
subtitle:
|
|
483
|
+
'Install and manage the NeoAgent background service on this machine.',
|
|
484
|
+
),
|
|
485
|
+
const SizedBox(height: 16),
|
|
486
|
+
_buildPhaseContent(),
|
|
487
|
+
],
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
Widget _buildPhaseContent() {
|
|
492
|
+
switch (_phase) {
|
|
493
|
+
case _ServerPhase.checking:
|
|
494
|
+
return const Center(
|
|
495
|
+
child: Padding(
|
|
496
|
+
padding: EdgeInsets.all(48),
|
|
497
|
+
child: CircularProgressIndicator(),
|
|
498
|
+
),
|
|
499
|
+
);
|
|
500
|
+
case _ServerPhase.prereqsFailed:
|
|
501
|
+
return _buildPrereqFailed();
|
|
502
|
+
case _ServerPhase.notInstalled:
|
|
503
|
+
return _buildNotInstalled();
|
|
504
|
+
case _ServerPhase.installing:
|
|
505
|
+
return _buildInstalling();
|
|
506
|
+
case _ServerPhase.installed:
|
|
507
|
+
return _buildInstalled();
|
|
508
|
+
case _ServerPhase.installFailed:
|
|
509
|
+
return _buildInstallFailed();
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
Widget _buildPrereqFailed() {
|
|
514
|
+
final missing = <String>[];
|
|
515
|
+
if (_nodePath == null) missing.add('Node.js (nodejs.org)');
|
|
516
|
+
if (_npmPath == null) missing.add('npm (bundled with Node.js)');
|
|
517
|
+
if (!_hasGit) missing.add('git (git-scm.com)');
|
|
518
|
+
return Card(
|
|
519
|
+
child: Padding(
|
|
520
|
+
padding: const EdgeInsets.all(20),
|
|
521
|
+
child: Column(
|
|
522
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
523
|
+
children: <Widget>[
|
|
524
|
+
_InlineError(
|
|
525
|
+
message:
|
|
526
|
+
'Missing: ${missing.join(', ')}. Install them then click Retry.',
|
|
527
|
+
),
|
|
528
|
+
const SizedBox(height: 16),
|
|
529
|
+
OutlinedButton.icon(
|
|
530
|
+
onPressed: _refresh,
|
|
531
|
+
icon: const Icon(Icons.refresh),
|
|
532
|
+
label: const Text('Retry'),
|
|
533
|
+
),
|
|
534
|
+
],
|
|
535
|
+
),
|
|
536
|
+
),
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
Widget _buildNotInstalled() {
|
|
541
|
+
return Card(
|
|
542
|
+
child: Padding(
|
|
543
|
+
padding: const EdgeInsets.all(20),
|
|
544
|
+
child: Column(
|
|
545
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
546
|
+
children: <Widget>[
|
|
547
|
+
_PrereqRow(label: 'Node.js', ok: _nodePath != null),
|
|
548
|
+
const SizedBox(height: 6),
|
|
549
|
+
_PrereqRow(label: 'npm', ok: _npmPath != null),
|
|
550
|
+
const SizedBox(height: 6),
|
|
551
|
+
_PrereqRow(label: 'git', ok: _hasGit),
|
|
552
|
+
const Divider(height: 28),
|
|
553
|
+
TextField(
|
|
554
|
+
controller: _dirCtrl,
|
|
555
|
+
decoration: const InputDecoration(
|
|
556
|
+
labelText: 'Install directory',
|
|
557
|
+
hintText: '~/NeoAgent',
|
|
558
|
+
prefixIcon: Icon(Icons.folder_outlined),
|
|
559
|
+
),
|
|
560
|
+
),
|
|
561
|
+
const SizedBox(height: 12),
|
|
562
|
+
TextField(
|
|
563
|
+
controller: _portCtrl,
|
|
564
|
+
keyboardType: TextInputType.number,
|
|
565
|
+
decoration: const InputDecoration(
|
|
566
|
+
labelText: 'Port',
|
|
567
|
+
hintText: '3333',
|
|
568
|
+
prefixIcon: Icon(Icons.lan_outlined),
|
|
569
|
+
),
|
|
570
|
+
),
|
|
571
|
+
const SizedBox(height: 12),
|
|
572
|
+
TextField(
|
|
573
|
+
controller: _keyCtrl,
|
|
574
|
+
obscureText: true,
|
|
575
|
+
decoration: const InputDecoration(
|
|
576
|
+
labelText: 'Anthropic API key (optional)',
|
|
577
|
+
hintText: 'sk-ant-...',
|
|
578
|
+
prefixIcon: Icon(Icons.key_outlined),
|
|
579
|
+
),
|
|
580
|
+
),
|
|
581
|
+
const SizedBox(height: 20),
|
|
582
|
+
SizedBox(
|
|
583
|
+
width: double.infinity,
|
|
584
|
+
child: FilledButton.icon(
|
|
585
|
+
onPressed: _runInstall,
|
|
586
|
+
style: FilledButton.styleFrom(
|
|
587
|
+
backgroundColor: _accent,
|
|
588
|
+
foregroundColor: _bgPrimary,
|
|
589
|
+
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
590
|
+
),
|
|
591
|
+
icon: const Icon(Icons.download_outlined),
|
|
592
|
+
label: const Text('Install NeoAgent'),
|
|
593
|
+
),
|
|
594
|
+
),
|
|
595
|
+
],
|
|
596
|
+
),
|
|
597
|
+
),
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
Widget _buildInstalling() {
|
|
602
|
+
return Card(
|
|
603
|
+
child: Padding(
|
|
604
|
+
padding: const EdgeInsets.all(20),
|
|
605
|
+
child: Column(
|
|
606
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
607
|
+
children: <Widget>[
|
|
608
|
+
Row(
|
|
609
|
+
children: <Widget>[
|
|
610
|
+
const SizedBox(
|
|
611
|
+
width: 18,
|
|
612
|
+
height: 18,
|
|
613
|
+
child: CircularProgressIndicator(strokeWidth: 2),
|
|
614
|
+
),
|
|
615
|
+
const SizedBox(width: 12),
|
|
616
|
+
const Expanded(child: Text('Installing NeoAgent...')),
|
|
617
|
+
TextButton(
|
|
618
|
+
onPressed: () {
|
|
619
|
+
_runningProcess?.kill(ProcessSignal.sigterm);
|
|
620
|
+
setState(() => _phase = _ServerPhase.notInstalled);
|
|
621
|
+
},
|
|
622
|
+
child: const Text('Cancel'),
|
|
623
|
+
),
|
|
624
|
+
],
|
|
625
|
+
),
|
|
626
|
+
const SizedBox(height: 12),
|
|
627
|
+
_InstallLogCard(lines: _log, scrollController: _logScrollCtrl),
|
|
628
|
+
],
|
|
629
|
+
),
|
|
630
|
+
),
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
Widget _buildInstalled() {
|
|
635
|
+
return Card(
|
|
636
|
+
child: Padding(
|
|
637
|
+
padding: const EdgeInsets.all(20),
|
|
638
|
+
child: Column(
|
|
639
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
640
|
+
children: <Widget>[
|
|
641
|
+
// Sub-navigation
|
|
642
|
+
SingleChildScrollView(
|
|
643
|
+
scrollDirection: Axis.horizontal,
|
|
644
|
+
child: SegmentedButton<_ServerTab>(
|
|
645
|
+
segments: _ServerTab.values
|
|
646
|
+
.map(
|
|
647
|
+
(t) => ButtonSegment<_ServerTab>(
|
|
648
|
+
value: t,
|
|
649
|
+
label: Text(t.label),
|
|
650
|
+
),
|
|
651
|
+
)
|
|
652
|
+
.toList(),
|
|
653
|
+
selected: {_activeTab},
|
|
654
|
+
onSelectionChanged: (Set<_ServerTab> sel) {
|
|
655
|
+
final tab = sel.first;
|
|
656
|
+
if (tab == _ServerTab.configure) _loadConfig();
|
|
657
|
+
setState(() => _activeTab = tab);
|
|
658
|
+
},
|
|
659
|
+
showSelectedIcon: false,
|
|
660
|
+
),
|
|
661
|
+
),
|
|
662
|
+
const SizedBox(height: 20),
|
|
663
|
+
_buildActiveTab(),
|
|
664
|
+
],
|
|
665
|
+
),
|
|
666
|
+
),
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
Widget _buildActiveTab() {
|
|
671
|
+
switch (_activeTab) {
|
|
672
|
+
case _ServerTab.overview:
|
|
673
|
+
return _buildOverviewTab();
|
|
674
|
+
case _ServerTab.configure:
|
|
675
|
+
return _buildConfigureTab();
|
|
676
|
+
case _ServerTab.logs:
|
|
677
|
+
return _buildLogsTab();
|
|
678
|
+
case _ServerTab.update:
|
|
679
|
+
return _buildUpdateTab();
|
|
680
|
+
case _ServerTab.danger:
|
|
681
|
+
return _buildDangerTab();
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
Widget _buildOverviewTab() {
|
|
686
|
+
return Column(
|
|
687
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
688
|
+
children: <Widget>[
|
|
689
|
+
Row(
|
|
690
|
+
children: <Widget>[
|
|
691
|
+
_StatusPill(
|
|
692
|
+
label: _serviceRunning ? 'Running' : 'Stopped',
|
|
693
|
+
color: _serviceRunning ? _success : _textSecondary,
|
|
694
|
+
),
|
|
695
|
+
if (_serviceActionRunning) ...<Widget>[
|
|
696
|
+
const SizedBox(width: 10),
|
|
697
|
+
const SizedBox.square(
|
|
698
|
+
dimension: 14,
|
|
699
|
+
child: CircularProgressIndicator(strokeWidth: 2),
|
|
700
|
+
),
|
|
701
|
+
],
|
|
702
|
+
const Spacer(),
|
|
703
|
+
IconButton(
|
|
704
|
+
onPressed: _serviceActionRunning ? null : _refresh,
|
|
705
|
+
icon: const Icon(Icons.refresh),
|
|
706
|
+
tooltip: 'Refresh status',
|
|
707
|
+
),
|
|
708
|
+
],
|
|
709
|
+
),
|
|
710
|
+
const SizedBox(height: 16),
|
|
711
|
+
Wrap(
|
|
712
|
+
spacing: 10,
|
|
713
|
+
runSpacing: 10,
|
|
714
|
+
children: <Widget>[
|
|
715
|
+
FilledButton.icon(
|
|
716
|
+
onPressed: _openDashboard,
|
|
717
|
+
style: FilledButton.styleFrom(
|
|
718
|
+
backgroundColor: _accent,
|
|
719
|
+
foregroundColor: _bgPrimary,
|
|
720
|
+
),
|
|
721
|
+
icon: const Icon(Icons.open_in_browser_outlined),
|
|
722
|
+
label: const Text('Open Dashboard'),
|
|
723
|
+
),
|
|
724
|
+
if (!_serviceRunning)
|
|
725
|
+
OutlinedButton.icon(
|
|
726
|
+
onPressed: _serviceActionRunning ? null : () => _serviceAction('start'),
|
|
727
|
+
icon: const Icon(Icons.play_arrow_outlined),
|
|
728
|
+
label: const Text('Start'),
|
|
729
|
+
),
|
|
730
|
+
if (_serviceRunning)
|
|
731
|
+
OutlinedButton.icon(
|
|
732
|
+
onPressed: _serviceActionRunning ? null : () => _serviceAction('stop'),
|
|
733
|
+
icon: const Icon(Icons.stop_outlined),
|
|
734
|
+
label: const Text('Stop'),
|
|
735
|
+
),
|
|
736
|
+
OutlinedButton.icon(
|
|
737
|
+
onPressed: _serviceActionRunning ? null : () => _serviceAction('restart'),
|
|
738
|
+
icon: const Icon(Icons.restart_alt_outlined),
|
|
739
|
+
label: const Text('Restart'),
|
|
740
|
+
),
|
|
741
|
+
],
|
|
742
|
+
),
|
|
743
|
+
const SizedBox(height: 12),
|
|
744
|
+
if (_installDir != null)
|
|
745
|
+
Text(
|
|
746
|
+
_installDir!,
|
|
747
|
+
style: TextStyle(
|
|
748
|
+
color: _textSecondary,
|
|
749
|
+
fontSize: 12,
|
|
750
|
+
fontFamily: 'monospace',
|
|
751
|
+
),
|
|
752
|
+
),
|
|
753
|
+
],
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
Widget _buildConfigureTab() {
|
|
758
|
+
return Column(
|
|
759
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
760
|
+
children: <Widget>[
|
|
761
|
+
// Server section
|
|
762
|
+
_SectionLabel(label: 'Server'),
|
|
763
|
+
const SizedBox(height: 12),
|
|
764
|
+
SwitchListTile(
|
|
765
|
+
title: const Text('Secure cookies'),
|
|
766
|
+
subtitle: const Text('Enable when behind HTTPS'),
|
|
767
|
+
value: _secureCookies,
|
|
768
|
+
dense: true,
|
|
769
|
+
contentPadding: EdgeInsets.zero,
|
|
770
|
+
onChanged: (v) => setState(() => _secureCookies = v),
|
|
771
|
+
),
|
|
772
|
+
SwitchListTile(
|
|
773
|
+
title: const Text('Trust proxy'),
|
|
774
|
+
subtitle: const Text('Enable when behind a reverse proxy'),
|
|
775
|
+
value: _trustProxy,
|
|
776
|
+
dense: true,
|
|
777
|
+
contentPadding: EdgeInsets.zero,
|
|
778
|
+
onChanged: (v) => setState(() => _trustProxy = v),
|
|
779
|
+
),
|
|
780
|
+
const SizedBox(height: 8),
|
|
781
|
+
for (final f in _serverFields) ...<Widget>[
|
|
782
|
+
_EnvTextField(field: f, controller: _envCtrl[f.key]!),
|
|
783
|
+
const SizedBox(height: 10),
|
|
784
|
+
],
|
|
785
|
+
const SizedBox(height: 8),
|
|
786
|
+
|
|
787
|
+
// AI Keys section
|
|
788
|
+
_SectionLabel(label: 'AI Keys'),
|
|
789
|
+
const SizedBox(height: 12),
|
|
790
|
+
for (final f in _aiKeyFields) ...<Widget>[
|
|
791
|
+
_EnvTextField(field: f, controller: _envCtrl[f.key]!),
|
|
792
|
+
const SizedBox(height: 10),
|
|
793
|
+
],
|
|
794
|
+
const SizedBox(height: 8),
|
|
795
|
+
|
|
796
|
+
// OAuth section (collapsed)
|
|
797
|
+
ExpansionTile(
|
|
798
|
+
tilePadding: EdgeInsets.zero,
|
|
799
|
+
title: const Text('OAuth integrations'),
|
|
800
|
+
children: <Widget>[
|
|
801
|
+
const SizedBox(height: 8),
|
|
802
|
+
for (final f in _oauthFields) ...<Widget>[
|
|
803
|
+
_EnvTextField(field: f, controller: _envCtrl[f.key]!),
|
|
804
|
+
const SizedBox(height: 10),
|
|
805
|
+
],
|
|
806
|
+
],
|
|
807
|
+
),
|
|
808
|
+
|
|
809
|
+
// Voice section (collapsed)
|
|
810
|
+
ExpansionTile(
|
|
811
|
+
tilePadding: EdgeInsets.zero,
|
|
812
|
+
title: const Text('Voice & telephony'),
|
|
813
|
+
children: <Widget>[
|
|
814
|
+
const SizedBox(height: 8),
|
|
815
|
+
for (final f in _voiceFields) ...<Widget>[
|
|
816
|
+
_EnvTextField(field: f, controller: _envCtrl[f.key]!),
|
|
817
|
+
const SizedBox(height: 10),
|
|
818
|
+
],
|
|
819
|
+
],
|
|
820
|
+
),
|
|
821
|
+
|
|
822
|
+
const SizedBox(height: 20),
|
|
823
|
+
SizedBox(
|
|
824
|
+
width: double.infinity,
|
|
825
|
+
child: FilledButton.icon(
|
|
826
|
+
onPressed: _configSaving ? null : _saveConfig,
|
|
827
|
+
style: FilledButton.styleFrom(
|
|
828
|
+
backgroundColor: _accent,
|
|
829
|
+
foregroundColor: _bgPrimary,
|
|
830
|
+
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
831
|
+
),
|
|
832
|
+
icon: _configSaving
|
|
833
|
+
? const SizedBox.square(
|
|
834
|
+
dimension: 18,
|
|
835
|
+
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
|
836
|
+
)
|
|
837
|
+
: const Icon(Icons.save_outlined),
|
|
838
|
+
label: const Text('Save'),
|
|
839
|
+
),
|
|
840
|
+
),
|
|
841
|
+
],
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
Widget _buildLogsTab() {
|
|
846
|
+
final path = '$_home/.neoagent/runtime/logs/neoagent.log';
|
|
847
|
+
final f = File(path);
|
|
848
|
+
String content;
|
|
849
|
+
if (f.existsSync()) {
|
|
850
|
+
final lines = f.readAsLinesSync();
|
|
851
|
+
final tail =
|
|
852
|
+
lines.length > 300 ? lines.sublist(lines.length - 300) : lines;
|
|
853
|
+
content = tail.join('\n');
|
|
854
|
+
} else {
|
|
855
|
+
content = '(log file not found at $path)';
|
|
856
|
+
}
|
|
857
|
+
return Column(
|
|
858
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
859
|
+
children: <Widget>[
|
|
860
|
+
Row(
|
|
861
|
+
children: <Widget>[
|
|
862
|
+
Text('Last 300 lines', style: TextStyle(color: _textSecondary, fontSize: 12)),
|
|
863
|
+
const Spacer(),
|
|
864
|
+
TextButton.icon(
|
|
865
|
+
onPressed: () => setState(() {}),
|
|
866
|
+
icon: const Icon(Icons.refresh, size: 16),
|
|
867
|
+
label: const Text('Refresh'),
|
|
868
|
+
),
|
|
869
|
+
],
|
|
870
|
+
),
|
|
871
|
+
const SizedBox(height: 8),
|
|
872
|
+
Container(
|
|
873
|
+
height: 340,
|
|
874
|
+
decoration: BoxDecoration(
|
|
875
|
+
color: const Color(0xFF0D1117),
|
|
876
|
+
borderRadius: BorderRadius.circular(10),
|
|
877
|
+
),
|
|
878
|
+
child: SingleChildScrollView(
|
|
879
|
+
padding: const EdgeInsets.all(14),
|
|
880
|
+
child: SelectableText(
|
|
881
|
+
content,
|
|
882
|
+
style: const TextStyle(
|
|
883
|
+
fontFamily: 'monospace',
|
|
884
|
+
fontSize: 11,
|
|
885
|
+
color: Color(0xFFE6EDF3),
|
|
886
|
+
height: 1.5,
|
|
887
|
+
),
|
|
888
|
+
),
|
|
889
|
+
),
|
|
890
|
+
),
|
|
891
|
+
],
|
|
892
|
+
);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
Widget _buildUpdateTab() {
|
|
896
|
+
final isRunning = _runningProcess != null;
|
|
897
|
+
return Column(
|
|
898
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
899
|
+
children: <Widget>[
|
|
900
|
+
Text(
|
|
901
|
+
'Pull the latest version and restart the service.',
|
|
902
|
+
style: TextStyle(color: _textSecondary, height: 1.55),
|
|
903
|
+
),
|
|
904
|
+
const SizedBox(height: 16),
|
|
905
|
+
Row(
|
|
906
|
+
children: <Widget>[
|
|
907
|
+
FilledButton.icon(
|
|
908
|
+
onPressed: isRunning ? null : _runUpdate,
|
|
909
|
+
style: FilledButton.styleFrom(
|
|
910
|
+
backgroundColor: _accent,
|
|
911
|
+
foregroundColor: _bgPrimary,
|
|
912
|
+
),
|
|
913
|
+
icon: isRunning
|
|
914
|
+
? const SizedBox.square(
|
|
915
|
+
dimension: 16,
|
|
916
|
+
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
|
917
|
+
)
|
|
918
|
+
: const Icon(Icons.system_update_outlined),
|
|
919
|
+
label: const Text('Update NeoAgent'),
|
|
920
|
+
),
|
|
921
|
+
if (isRunning) ...<Widget>[
|
|
922
|
+
const SizedBox(width: 12),
|
|
923
|
+
TextButton(
|
|
924
|
+
onPressed: () {
|
|
925
|
+
_runningProcess?.kill(ProcessSignal.sigterm);
|
|
926
|
+
setState(() {});
|
|
927
|
+
},
|
|
928
|
+
child: const Text('Cancel'),
|
|
929
|
+
),
|
|
930
|
+
],
|
|
931
|
+
],
|
|
932
|
+
),
|
|
933
|
+
if (_log.isNotEmpty) ...<Widget>[
|
|
934
|
+
const SizedBox(height: 12),
|
|
935
|
+
_InstallLogCard(lines: _log, scrollController: _logScrollCtrl),
|
|
936
|
+
],
|
|
937
|
+
],
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
Widget _buildDangerTab() {
|
|
942
|
+
final isRunning = _runningProcess != null;
|
|
943
|
+
return Column(
|
|
944
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
945
|
+
children: <Widget>[
|
|
946
|
+
Container(
|
|
947
|
+
padding: const EdgeInsets.all(14),
|
|
948
|
+
decoration: BoxDecoration(
|
|
949
|
+
color: _danger.withValues(alpha: 0.08),
|
|
950
|
+
borderRadius: BorderRadius.circular(10),
|
|
951
|
+
border: Border.all(color: _danger.withValues(alpha: 0.3)),
|
|
952
|
+
),
|
|
953
|
+
child: Row(
|
|
954
|
+
children: <Widget>[
|
|
955
|
+
Icon(Icons.warning_outlined, color: _danger, size: 18),
|
|
956
|
+
const SizedBox(width: 10),
|
|
957
|
+
Expanded(
|
|
958
|
+
child: Text(
|
|
959
|
+
'Uninstalling removes the service and stops NeoAgent from starting at login. Your data and configuration are preserved.',
|
|
960
|
+
style: TextStyle(color: _textSecondary, height: 1.45),
|
|
961
|
+
),
|
|
962
|
+
),
|
|
963
|
+
],
|
|
964
|
+
),
|
|
965
|
+
),
|
|
966
|
+
const SizedBox(height: 20),
|
|
967
|
+
OutlinedButton.icon(
|
|
968
|
+
onPressed: isRunning
|
|
969
|
+
? null
|
|
970
|
+
: () => _confirmUninstall(context),
|
|
971
|
+
style: OutlinedButton.styleFrom(
|
|
972
|
+
foregroundColor: _danger,
|
|
973
|
+
side: BorderSide(color: _danger),
|
|
974
|
+
),
|
|
975
|
+
icon: const Icon(Icons.delete_outline),
|
|
976
|
+
label: const Text('Uninstall NeoAgent'),
|
|
977
|
+
),
|
|
978
|
+
if (_log.isNotEmpty) ...<Widget>[
|
|
979
|
+
const SizedBox(height: 12),
|
|
980
|
+
_InstallLogCard(lines: _log, scrollController: _logScrollCtrl),
|
|
981
|
+
],
|
|
982
|
+
],
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
Future<void> _confirmUninstall(BuildContext context) async {
|
|
987
|
+
final confirmed = await showDialog<bool>(
|
|
988
|
+
context: context,
|
|
989
|
+
builder: (ctx) => AlertDialog(
|
|
990
|
+
title: const Text('Uninstall NeoAgent?'),
|
|
991
|
+
content: const Text(
|
|
992
|
+
'This removes the background service and prevents NeoAgent from starting at login. Your data and .env configuration are preserved.',
|
|
993
|
+
),
|
|
994
|
+
actions: <Widget>[
|
|
995
|
+
TextButton(
|
|
996
|
+
onPressed: () => Navigator.pop(ctx, false),
|
|
997
|
+
child: const Text('Cancel'),
|
|
998
|
+
),
|
|
999
|
+
TextButton(
|
|
1000
|
+
onPressed: () => Navigator.pop(ctx, true),
|
|
1001
|
+
style: TextButton.styleFrom(foregroundColor: _danger),
|
|
1002
|
+
child: const Text('Uninstall'),
|
|
1003
|
+
),
|
|
1004
|
+
],
|
|
1005
|
+
),
|
|
1006
|
+
);
|
|
1007
|
+
if (confirmed == true) {
|
|
1008
|
+
setState(() {
|
|
1009
|
+
_log.clear();
|
|
1010
|
+
_activeTab = _ServerTab.danger;
|
|
1011
|
+
});
|
|
1012
|
+
await _runUninstall();
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
Widget _buildInstallFailed() {
|
|
1017
|
+
return Card(
|
|
1018
|
+
child: Padding(
|
|
1019
|
+
padding: const EdgeInsets.all(20),
|
|
1020
|
+
child: Column(
|
|
1021
|
+
crossAxisAlignment: CrossAxisAlignment.start,
|
|
1022
|
+
children: <Widget>[
|
|
1023
|
+
const _InlineError(
|
|
1024
|
+
message: 'Installation failed. Check the log below.'),
|
|
1025
|
+
const SizedBox(height: 12),
|
|
1026
|
+
if (_log.isNotEmpty)
|
|
1027
|
+
_InstallLogCard(lines: _log, scrollController: _logScrollCtrl),
|
|
1028
|
+
const SizedBox(height: 16),
|
|
1029
|
+
OutlinedButton.icon(
|
|
1030
|
+
onPressed: _runInstall,
|
|
1031
|
+
icon: const Icon(Icons.refresh),
|
|
1032
|
+
label: const Text('Retry'),
|
|
1033
|
+
),
|
|
1034
|
+
],
|
|
1035
|
+
),
|
|
1036
|
+
),
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// ─── Shared helpers ──────────────────────────────────────────────────────────
|
|
1042
|
+
|
|
1043
|
+
class _PrereqRow extends StatelessWidget {
|
|
1044
|
+
const _PrereqRow({required this.label, required this.ok});
|
|
1045
|
+
|
|
1046
|
+
final String label;
|
|
1047
|
+
final bool ok;
|
|
1048
|
+
|
|
1049
|
+
@override
|
|
1050
|
+
Widget build(BuildContext context) {
|
|
1051
|
+
return Row(
|
|
1052
|
+
children: <Widget>[
|
|
1053
|
+
Icon(
|
|
1054
|
+
ok ? Icons.check_circle_outline : Icons.cancel_outlined,
|
|
1055
|
+
color: ok ? _success : _danger,
|
|
1056
|
+
size: 18,
|
|
1057
|
+
),
|
|
1058
|
+
const SizedBox(width: 8),
|
|
1059
|
+
Text(label),
|
|
1060
|
+
],
|
|
1061
|
+
);
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
class _SectionLabel extends StatelessWidget {
|
|
1066
|
+
const _SectionLabel({required this.label});
|
|
1067
|
+
|
|
1068
|
+
final String label;
|
|
1069
|
+
|
|
1070
|
+
@override
|
|
1071
|
+
Widget build(BuildContext context) {
|
|
1072
|
+
return Text(
|
|
1073
|
+
label.toUpperCase(),
|
|
1074
|
+
style: TextStyle(
|
|
1075
|
+
color: _textSecondary,
|
|
1076
|
+
fontSize: 11,
|
|
1077
|
+
fontWeight: FontWeight.w600,
|
|
1078
|
+
letterSpacing: 0.8,
|
|
1079
|
+
),
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
class _EnvTextField extends StatefulWidget {
|
|
1085
|
+
const _EnvTextField({required this.field, required this.controller});
|
|
1086
|
+
|
|
1087
|
+
final _EnvField field;
|
|
1088
|
+
final TextEditingController controller;
|
|
1089
|
+
|
|
1090
|
+
@override
|
|
1091
|
+
State<_EnvTextField> createState() => _EnvTextFieldState();
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
class _EnvTextFieldState extends State<_EnvTextField> {
|
|
1095
|
+
bool _obscure = true;
|
|
1096
|
+
|
|
1097
|
+
@override
|
|
1098
|
+
Widget build(BuildContext context) {
|
|
1099
|
+
return TextField(
|
|
1100
|
+
controller: widget.controller,
|
|
1101
|
+
obscureText: widget.field.isSecret && _obscure,
|
|
1102
|
+
decoration: InputDecoration(
|
|
1103
|
+
labelText: widget.field.label,
|
|
1104
|
+
hintText: widget.field.hint.isNotEmpty ? widget.field.hint : null,
|
|
1105
|
+
suffixIcon: widget.field.isSecret
|
|
1106
|
+
? IconButton(
|
|
1107
|
+
icon: Icon(
|
|
1108
|
+
_obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined,
|
|
1109
|
+
size: 18),
|
|
1110
|
+
onPressed: () => setState(() => _obscure = !_obscure),
|
|
1111
|
+
)
|
|
1112
|
+
: null,
|
|
1113
|
+
),
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
class _InstallLogCard extends StatelessWidget {
|
|
1119
|
+
const _InstallLogCard({required this.lines, required this.scrollController});
|
|
1120
|
+
|
|
1121
|
+
final List<String> lines;
|
|
1122
|
+
final ScrollController scrollController;
|
|
1123
|
+
|
|
1124
|
+
@override
|
|
1125
|
+
Widget build(BuildContext context) {
|
|
1126
|
+
return Container(
|
|
1127
|
+
height: 280,
|
|
1128
|
+
decoration: BoxDecoration(
|
|
1129
|
+
color: const Color(0xFF0D1117),
|
|
1130
|
+
borderRadius: BorderRadius.circular(10),
|
|
1131
|
+
),
|
|
1132
|
+
child: SingleChildScrollView(
|
|
1133
|
+
controller: scrollController,
|
|
1134
|
+
padding: const EdgeInsets.all(14),
|
|
1135
|
+
child: SelectableText(
|
|
1136
|
+
lines.isEmpty ? '(waiting for output…)' : lines.join(),
|
|
1137
|
+
style: const TextStyle(
|
|
1138
|
+
fontFamily: 'monospace',
|
|
1139
|
+
fontSize: 12,
|
|
1140
|
+
color: Color(0xFFE6EDF3),
|
|
1141
|
+
height: 1.5,
|
|
1142
|
+
),
|
|
1143
|
+
),
|
|
1144
|
+
),
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
}
|