thrust-cli 1.0.0
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 +3 -0
- package/frontend/index.html +489 -0
- package/index.js +87 -0
- package/package.json +33 -0
- package/utils/config.js +55 -0
- package/utils/daemon.js +252 -0
package/README.md
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Thrust Local Dashboard</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg-main: #050508;
|
|
10
|
+
--bg-panel: #0F0F16;
|
|
11
|
+
--bg-explorer: #0A0A0F;
|
|
12
|
+
--bg-hover: #16161E;
|
|
13
|
+
--border-color: #22222E;
|
|
14
|
+
--text-main: #F3F4F6;
|
|
15
|
+
--text-muted: #9CA3AF;
|
|
16
|
+
--primary: #8B5CF6;
|
|
17
|
+
--primary-hover: #7C3AED;
|
|
18
|
+
--success: #10B981;
|
|
19
|
+
--danger: #EF4444;
|
|
20
|
+
--warning: #F59E0B;
|
|
21
|
+
--sync: #3B82F6;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
body {
|
|
25
|
+
background-color: var(--bg-main);
|
|
26
|
+
color: var(--text-main);
|
|
27
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
28
|
+
margin: 0;
|
|
29
|
+
padding: 1.5rem;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.container { max-width: 800px; margin: auto; }
|
|
33
|
+
|
|
34
|
+
h1 { color: var(--primary); margin-top: 0; font-size: 1.8rem; }
|
|
35
|
+
h2 { font-size: 1.1rem; margin-bottom: 1rem; color: var(--text-main); border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem; }
|
|
36
|
+
|
|
37
|
+
.status-box {
|
|
38
|
+
background-color: var(--bg-panel);
|
|
39
|
+
border: 1px solid var(--border-color);
|
|
40
|
+
padding: 1rem;
|
|
41
|
+
border-radius: 8px;
|
|
42
|
+
margin-bottom: 2rem;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.status-item { display: flex; align-items: center; margin-bottom: 0.75rem; font-weight: 500; }
|
|
46
|
+
.status-item:last-child { margin-bottom: 0; justify-content: space-between; }
|
|
47
|
+
|
|
48
|
+
.status-content { display: flex; align-items: center; word-break: break-all; padding-right: 1rem; }
|
|
49
|
+
|
|
50
|
+
.status-dot { width: 12px; height: 12px; border-radius: 50%; margin-right: 12px; flex-shrink: 0; }
|
|
51
|
+
.dot-red { background-color: var(--danger); box-shadow: 0 0 8px rgba(239, 68, 68, 0.4); }
|
|
52
|
+
.dot-green { background-color: var(--success); box-shadow: 0 0 8px rgba(16, 185, 129, 0.4); }
|
|
53
|
+
.dot-yellow { background-color: var(--warning); box-shadow: 0 0 8px rgba(245, 158, 11, 0.4); }
|
|
54
|
+
|
|
55
|
+
.btn-unlink {
|
|
56
|
+
background-color: transparent;
|
|
57
|
+
color: var(--danger);
|
|
58
|
+
border: 1px solid var(--danger);
|
|
59
|
+
padding: 0.4rem 0.8rem;
|
|
60
|
+
border-radius: 6px;
|
|
61
|
+
font-size: 0.85rem;
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
transition: all 0.2s;
|
|
64
|
+
display: none;
|
|
65
|
+
white-space: nowrap;
|
|
66
|
+
}
|
|
67
|
+
.btn-unlink:hover { background-color: var(--danger); color: white; }
|
|
68
|
+
|
|
69
|
+
.input-group { margin-bottom: 1.5rem; }
|
|
70
|
+
.input-group label { display: block; margin-bottom: 0.5rem; font-size: 0.9rem; color: var(--text-muted); }
|
|
71
|
+
input[type="text"] {
|
|
72
|
+
width: 100%;
|
|
73
|
+
background-color: var(--bg-hover);
|
|
74
|
+
color: var(--text-main);
|
|
75
|
+
border: 1px solid var(--border-color);
|
|
76
|
+
padding: 0.8rem;
|
|
77
|
+
border-radius: 6px;
|
|
78
|
+
font-size: 1rem;
|
|
79
|
+
box-sizing: border-box;
|
|
80
|
+
}
|
|
81
|
+
input[type="text"]:focus { outline: none; border-color: var(--primary); }
|
|
82
|
+
|
|
83
|
+
.explorer-container {
|
|
84
|
+
background-color: var(--bg-explorer);
|
|
85
|
+
border: 1px solid var(--border-color);
|
|
86
|
+
border-radius: 8px;
|
|
87
|
+
display: flex;
|
|
88
|
+
flex-direction: column;
|
|
89
|
+
overflow: hidden;
|
|
90
|
+
margin-bottom: 2rem;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.explorer-header {
|
|
94
|
+
background-color: var(--bg-panel);
|
|
95
|
+
padding: 1rem;
|
|
96
|
+
border-bottom: 1px solid var(--border-color);
|
|
97
|
+
font-family: monospace;
|
|
98
|
+
font-size: 0.85rem;
|
|
99
|
+
color: var(--primary);
|
|
100
|
+
word-break: break-all;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.explorer-body {
|
|
104
|
+
height: 300px;
|
|
105
|
+
overflow-y: auto;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.folder-item {
|
|
109
|
+
display: flex;
|
|
110
|
+
justify-content: space-between;
|
|
111
|
+
align-items: center;
|
|
112
|
+
padding: 0.6rem 1rem;
|
|
113
|
+
border-bottom: 1px solid #1A1A24;
|
|
114
|
+
transition: background-color 0.2s;
|
|
115
|
+
}
|
|
116
|
+
.folder-item:hover { background-color: var(--bg-hover); }
|
|
117
|
+
|
|
118
|
+
.folder-info {
|
|
119
|
+
display: flex;
|
|
120
|
+
align-items: center;
|
|
121
|
+
flex-grow: 1;
|
|
122
|
+
overflow: hidden;
|
|
123
|
+
}
|
|
124
|
+
.folder-icon { margin-right: 12px; font-size: 1.2rem; flex-shrink: 0; }
|
|
125
|
+
.folder-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 0.95rem; }
|
|
126
|
+
|
|
127
|
+
.folder-actions {
|
|
128
|
+
display: flex;
|
|
129
|
+
gap: 0.5rem;
|
|
130
|
+
flex-shrink: 0;
|
|
131
|
+
margin-left: 10px;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.btn-small {
|
|
135
|
+
padding: 0.4rem 0.8rem;
|
|
136
|
+
border-radius: 4px;
|
|
137
|
+
border: none;
|
|
138
|
+
font-size: 0.85rem;
|
|
139
|
+
font-weight: bold;
|
|
140
|
+
cursor: pointer;
|
|
141
|
+
transition: background-color 0.2s;
|
|
142
|
+
}
|
|
143
|
+
.btn-open { background-color: #2D2D3D; color: #F3F4F6; }
|
|
144
|
+
.btn-open:hover { background-color: #3F3F5A; }
|
|
145
|
+
|
|
146
|
+
.btn-watch { background-color: var(--primary); color: white; }
|
|
147
|
+
.btn-watch:hover { background-color: var(--primary-hover); }
|
|
148
|
+
|
|
149
|
+
.explorer-footer {
|
|
150
|
+
background-color: var(--bg-panel);
|
|
151
|
+
padding: 0.75rem 1rem;
|
|
152
|
+
border-top: 1px solid var(--border-color);
|
|
153
|
+
display: flex;
|
|
154
|
+
justify-content: space-between;
|
|
155
|
+
align-items: center;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* Log & Terminal Box */
|
|
159
|
+
.terminal-container {
|
|
160
|
+
background-color: var(--bg-panel);
|
|
161
|
+
border: 1px solid var(--border-color);
|
|
162
|
+
border-radius: 8px;
|
|
163
|
+
display: flex;
|
|
164
|
+
flex-direction: column;
|
|
165
|
+
overflow: hidden;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.log-box {
|
|
169
|
+
height: 250px;
|
|
170
|
+
overflow-y: auto;
|
|
171
|
+
padding: 1rem;
|
|
172
|
+
font-family: monospace;
|
|
173
|
+
font-size: 0.85rem;
|
|
174
|
+
color: var(--text-muted);
|
|
175
|
+
}
|
|
176
|
+
.log-box p { margin: 0 0 0.5rem 0; line-height: 1.4; word-break: break-all; }
|
|
177
|
+
.log-time { color: var(--primary); margin-right: 8px; flex-shrink: 0; }
|
|
178
|
+
|
|
179
|
+
.prompt-bar {
|
|
180
|
+
display: flex;
|
|
181
|
+
border-top: 1px solid var(--border-color);
|
|
182
|
+
background-color: #12121A;
|
|
183
|
+
padding: 0.5rem;
|
|
184
|
+
}
|
|
185
|
+
.prompt-input {
|
|
186
|
+
flex-grow: 1;
|
|
187
|
+
background: transparent;
|
|
188
|
+
border: none;
|
|
189
|
+
color: white;
|
|
190
|
+
padding: 0.5rem;
|
|
191
|
+
font-family: monospace;
|
|
192
|
+
font-size: 0.9rem;
|
|
193
|
+
outline: none;
|
|
194
|
+
}
|
|
195
|
+
.prompt-btn {
|
|
196
|
+
background-color: var(--primary);
|
|
197
|
+
color: white;
|
|
198
|
+
border: none;
|
|
199
|
+
border-radius: 4px;
|
|
200
|
+
padding: 0.5rem 1rem;
|
|
201
|
+
cursor: pointer;
|
|
202
|
+
font-weight: bold;
|
|
203
|
+
}
|
|
204
|
+
.prompt-btn:hover { background-color: var(--primary-hover); }
|
|
205
|
+
|
|
206
|
+
</style>
|
|
207
|
+
</head>
|
|
208
|
+
<body>
|
|
209
|
+
<div class="container">
|
|
210
|
+
<h1>Thrust Local Agent</h1>
|
|
211
|
+
|
|
212
|
+
<div class="status-box">
|
|
213
|
+
<div class="status-item">
|
|
214
|
+
<div class="status-content">
|
|
215
|
+
<div id="ws-dot" class="status-dot dot-red"></div>
|
|
216
|
+
<span id="ws-status">Disconnected from Cloud Gateway</span>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
<div class="status-item">
|
|
220
|
+
<div class="status-content">
|
|
221
|
+
<div id="proj-dot" class="status-dot dot-yellow"></div>
|
|
222
|
+
<span id="proj-status">No active project linked</span>
|
|
223
|
+
</div>
|
|
224
|
+
<button id="btn-unlink" class="btn-unlink" onclick="unlinkProject()">Stop Watching</button>
|
|
225
|
+
</div>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<h2>1. Link to Cloud Project</h2>
|
|
229
|
+
<div class="input-group">
|
|
230
|
+
<label for="lead-id">Lead ID (From your Web Dashboard)</label>
|
|
231
|
+
<input id="lead-id" type="text" placeholder="e.g. lead_12345abcde">
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<h2>2. Select Local Folder to Watch</h2>
|
|
235
|
+
|
|
236
|
+
<div class="explorer-container">
|
|
237
|
+
<div class="explorer-header">
|
|
238
|
+
<span style="color: var(--text-muted);">Current Path:</span>
|
|
239
|
+
<strong id="current-path">Loading...</strong>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<div id="directory-list" class="explorer-body">
|
|
243
|
+
<!-- Folders injected here -->
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<div class="explorer-footer">
|
|
247
|
+
<span style="font-size: 0.85rem; color: var(--text-muted);">Want to watch the folder you are currently inside?</span>
|
|
248
|
+
<button class="btn-small btn-watch" onclick="submitWatch(currentExplorerPath, currentFolderName)" id="select-current-btn">
|
|
249
|
+
Watch Current
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<h2>Agent Terminal</h2>
|
|
255
|
+
<div class="terminal-container">
|
|
256
|
+
<div id="log" class="log-box"></div>
|
|
257
|
+
|
|
258
|
+
<!-- NEW: Two-Way WebSocket Input -->
|
|
259
|
+
<form id="prompt-form" class="prompt-bar" onsubmit="sendPrompt(event)">
|
|
260
|
+
<input type="text" id="prompt-input" class="prompt-input" placeholder="> Send a command to the Local Daemon..." autocomplete="off">
|
|
261
|
+
<button type="submit" class="prompt-btn">Send</button>
|
|
262
|
+
</form>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<script>
|
|
267
|
+
const logBox = document.getElementById('log');
|
|
268
|
+
let currentExplorerPath = "";
|
|
269
|
+
let currentFolderName = "";
|
|
270
|
+
let localWs = null; // Native WebSocket Client
|
|
271
|
+
|
|
272
|
+
function addLog(message, color = "var(--text-muted)") {
|
|
273
|
+
const p = document.createElement('p');
|
|
274
|
+
p.style.display = 'flex';
|
|
275
|
+
|
|
276
|
+
const timeSpan = document.createElement('span');
|
|
277
|
+
timeSpan.className = 'log-time';
|
|
278
|
+
timeSpan.textContent = `[${new Date().toLocaleTimeString()}]`;
|
|
279
|
+
|
|
280
|
+
const msgSpan = document.createElement('span');
|
|
281
|
+
msgSpan.style.color = color;
|
|
282
|
+
msgSpan.textContent = message;
|
|
283
|
+
|
|
284
|
+
p.appendChild(timeSpan);
|
|
285
|
+
p.appendChild(msgSpan);
|
|
286
|
+
|
|
287
|
+
logBox.appendChild(p);
|
|
288
|
+
logBox.scrollTop = logBox.scrollHeight;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// --- Native Two-Way WebSocket Connection ---
|
|
292
|
+
function connectLocalWebSocket() {
|
|
293
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
294
|
+
const wsUrl = `${protocol}//${window.location.host}`;
|
|
295
|
+
|
|
296
|
+
localWs = new WebSocket(wsUrl);
|
|
297
|
+
|
|
298
|
+
localWs.onopen = () => {
|
|
299
|
+
addLog('š Local WebSocket pipe established.');
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
localWs.onmessage = (event) => {
|
|
303
|
+
const data = JSON.parse(event.data);
|
|
304
|
+
|
|
305
|
+
if (data.type === 'watch') {
|
|
306
|
+
addLog(`š ${data.message}`, 'var(--success)');
|
|
307
|
+
} else if (data.type === 'sync') {
|
|
308
|
+
addLog(data.message, 'var(--sync)');
|
|
309
|
+
} else if (data.type === 'ai') {
|
|
310
|
+
addLog(data.message, 'var(--warning)');
|
|
311
|
+
} else if (data.type === 'error') {
|
|
312
|
+
addLog(`ā ${data.message}`, 'var(--danger)');
|
|
313
|
+
} else {
|
|
314
|
+
addLog(data.message);
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
localWs.onclose = () => {
|
|
319
|
+
addLog('ā ļø Local pipe disconnected. Retrying in 3s...', 'var(--danger)');
|
|
320
|
+
setTimeout(connectLocalWebSocket, 3000);
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Send data to Local Daemon via WS
|
|
325
|
+
function sendPrompt(event) {
|
|
326
|
+
event.preventDefault();
|
|
327
|
+
const input = document.getElementById('prompt-input');
|
|
328
|
+
const message = input.value.trim();
|
|
329
|
+
|
|
330
|
+
if (!message || !localWs || localWs.readyState !== WebSocket.OPEN) return;
|
|
331
|
+
|
|
332
|
+
addLog(`> You: ${message}`, '#FFFFFF'); // Show user input in white
|
|
333
|
+
|
|
334
|
+
localWs.send(JSON.stringify({
|
|
335
|
+
type: 'frontend_prompt',
|
|
336
|
+
payload: message
|
|
337
|
+
}));
|
|
338
|
+
|
|
339
|
+
input.value = ''; // Clear input
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function fetchStatus() {
|
|
343
|
+
try {
|
|
344
|
+
const response = await fetch('/api/status');
|
|
345
|
+
const data = await response.json();
|
|
346
|
+
|
|
347
|
+
if (data.activeProject) {
|
|
348
|
+
document.getElementById('proj-dot').className = 'status-dot dot-green';
|
|
349
|
+
document.getElementById('proj-status').textContent = `Watching: ${data.activeProject.path}`;
|
|
350
|
+
document.getElementById('btn-unlink').style.display = 'block';
|
|
351
|
+
document.getElementById('lead-id').value = data.activeProject.id;
|
|
352
|
+
addLog(`Loaded config for: ${data.activeProject.id}`);
|
|
353
|
+
} else {
|
|
354
|
+
document.getElementById('proj-dot').className = 'status-dot dot-yellow';
|
|
355
|
+
document.getElementById('proj-status').textContent = `No active project linked`;
|
|
356
|
+
document.getElementById('btn-unlink').style.display = 'none';
|
|
357
|
+
}
|
|
358
|
+
} catch (e) { addLog('Error fetching daemon status.'); }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function unlinkProject() {
|
|
362
|
+
try {
|
|
363
|
+
addLog('Stopping watcher and unlinking project...');
|
|
364
|
+
const response = await fetch('/api/unlink', { method: 'POST' });
|
|
365
|
+
const data = await response.json();
|
|
366
|
+
|
|
367
|
+
if (data.success) {
|
|
368
|
+
document.getElementById('proj-dot').className = 'status-dot dot-yellow';
|
|
369
|
+
document.getElementById('proj-status').textContent = `No active project linked`;
|
|
370
|
+
document.getElementById('btn-unlink').style.display = 'none';
|
|
371
|
+
document.getElementById('lead-id').value = '';
|
|
372
|
+
}
|
|
373
|
+
} catch (e) {
|
|
374
|
+
addLog('ā Failed to unlink project.', 'var(--danger)');
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function loadDirectory(targetPath = "") {
|
|
379
|
+
try {
|
|
380
|
+
const url = targetPath ? `/api/explore?path=${encodeURIComponent(targetPath)}` : '/api/explore';
|
|
381
|
+
const response = await fetch(url);
|
|
382
|
+
const data = await response.json();
|
|
383
|
+
|
|
384
|
+
if (data.error) {
|
|
385
|
+
addLog(`Explorer Error: ${data.error}`, 'var(--danger)');
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
currentExplorerPath = data.currentPath;
|
|
390
|
+
const pathParts = data.currentPath.split(/[/\\]/);
|
|
391
|
+
currentFolderName = pathParts[pathParts.length - 1] || "Root";
|
|
392
|
+
|
|
393
|
+
document.getElementById('current-path').textContent = data.currentPath;
|
|
394
|
+
document.getElementById('select-current-btn').textContent = `Watch '${currentFolderName}'`;
|
|
395
|
+
|
|
396
|
+
const dirList = document.getElementById('directory-list');
|
|
397
|
+
dirList.innerHTML = '';
|
|
398
|
+
|
|
399
|
+
if (data.parentPath) {
|
|
400
|
+
const upRow = document.createElement('div');
|
|
401
|
+
upRow.className = 'folder-item';
|
|
402
|
+
|
|
403
|
+
const info = document.createElement('div');
|
|
404
|
+
info.className = 'folder-info';
|
|
405
|
+
info.innerHTML = `<span class="folder-icon">ā¬ļø</span> <span class="folder-text"><strong>.. (Go Up)</strong></span>`;
|
|
406
|
+
|
|
407
|
+
const actions = document.createElement('div');
|
|
408
|
+
actions.className = 'folder-actions';
|
|
409
|
+
|
|
410
|
+
const openBtn = document.createElement('button');
|
|
411
|
+
openBtn.className = 'btn-small btn-open';
|
|
412
|
+
// openBtn.textContent = 'Open';
|
|
413
|
+
openBtn.textContent = 'Go back';
|
|
414
|
+
openBtn.onclick = () => loadDirectory(data.parentPath);
|
|
415
|
+
|
|
416
|
+
actions.appendChild(openBtn);
|
|
417
|
+
upRow.appendChild(info);
|
|
418
|
+
upRow.appendChild(actions);
|
|
419
|
+
dirList.appendChild(upRow);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
data.directories.forEach(dir => {
|
|
423
|
+
const fullPath = `${data.currentPath}/${dir}`;
|
|
424
|
+
const div = document.createElement('div');
|
|
425
|
+
div.className = 'folder-item';
|
|
426
|
+
|
|
427
|
+
const info = document.createElement('div');
|
|
428
|
+
info.className = 'folder-info';
|
|
429
|
+
info.innerHTML = `<span class="folder-icon">š</span> <span class="folder-text">${dir}</span>`;
|
|
430
|
+
|
|
431
|
+
const actions = document.createElement('div');
|
|
432
|
+
actions.className = 'folder-actions';
|
|
433
|
+
|
|
434
|
+
const openBtn = document.createElement('button');
|
|
435
|
+
openBtn.className = 'btn-small btn-open';
|
|
436
|
+
openBtn.textContent = 'Open š';
|
|
437
|
+
openBtn.onclick = () => loadDirectory(fullPath);
|
|
438
|
+
|
|
439
|
+
const watchBtn = document.createElement('button');
|
|
440
|
+
watchBtn.className = 'btn-small btn-watch';
|
|
441
|
+
watchBtn.textContent = 'Watch šļø';
|
|
442
|
+
watchBtn.onclick = () => submitWatch(fullPath, dir);
|
|
443
|
+
|
|
444
|
+
actions.appendChild(openBtn);
|
|
445
|
+
actions.appendChild(watchBtn);
|
|
446
|
+
|
|
447
|
+
div.appendChild(info);
|
|
448
|
+
div.appendChild(actions);
|
|
449
|
+
dirList.appendChild(div);
|
|
450
|
+
});
|
|
451
|
+
} catch (e) {
|
|
452
|
+
addLog('Failed to load directory from server.', 'var(--danger)');
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function submitWatch(absolutePath, folderName) {
|
|
457
|
+
const leadId = document.getElementById('lead-id').value.trim();
|
|
458
|
+
|
|
459
|
+
if (!leadId) {
|
|
460
|
+
addLog('ā Error: You must enter a Lead ID in Step 1.', 'var(--danger)');
|
|
461
|
+
document.getElementById('lead-id').focus();
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
const response = await fetch('/api/link', {
|
|
467
|
+
method: 'POST',
|
|
468
|
+
headers: { 'Content-Type': 'application/json' },
|
|
469
|
+
body: JSON.stringify({ leadId, folderPath: absolutePath })
|
|
470
|
+
});
|
|
471
|
+
const data = await response.json();
|
|
472
|
+
|
|
473
|
+
if (data.success) {
|
|
474
|
+
fetchStatus();
|
|
475
|
+
} else {
|
|
476
|
+
addLog(`ā Error: ${data.error}`, 'var(--danger)');
|
|
477
|
+
}
|
|
478
|
+
} catch (e) { addLog('ā API call failed. Is the daemon running?', 'var(--danger)'); }
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
482
|
+
fetchStatus();
|
|
483
|
+
loadDirectory();
|
|
484
|
+
connectLocalWebSocket(); // Start Two-Way WebSockets
|
|
485
|
+
addLog('Dashboard UI initialized.');
|
|
486
|
+
});
|
|
487
|
+
</script>
|
|
488
|
+
</body>
|
|
489
|
+
</html>
|
package/index.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import AutoLaunch from 'auto-launch';
|
|
8
|
+
import { startDaemon } from './utils/daemon.js';
|
|
9
|
+
import { getConfig, saveConfig } from './utils/config.js';
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = path.dirname(__filename);
|
|
13
|
+
|
|
14
|
+
let packageJson = { version: "1.0.0" };
|
|
15
|
+
try {
|
|
16
|
+
packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
|
|
17
|
+
} catch (e) {
|
|
18
|
+
console.warn("ā ļø Could not read package.json, defaulting version. ");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const program = new Command();
|
|
22
|
+
|
|
23
|
+
program
|
|
24
|
+
.name('thrust')
|
|
25
|
+
.description('Thrust Local Agent - Proactive AI Project Director')
|
|
26
|
+
.version(packageJson.version, '-v, --version', 'Output the current version')
|
|
27
|
+
.option('-p, --port <number>', 'Port for the local dashboard', '8765')
|
|
28
|
+
.action(async (options) => {
|
|
29
|
+
console.log('š Booting Thrust Agent...');
|
|
30
|
+
|
|
31
|
+
await enableAutoLaunchSafe();
|
|
32
|
+
|
|
33
|
+
startDaemon(parseInt(options.port, 10));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
program
|
|
37
|
+
.command('link <leadId> <folderPath>')
|
|
38
|
+
.description('Link a local folder to a Thrust Project/Lead')
|
|
39
|
+
.action((leadId, folderPath) => {
|
|
40
|
+
const absolutePath = path.resolve(folderPath);
|
|
41
|
+
|
|
42
|
+
if (!fs.existsSync(absolutePath)) {
|
|
43
|
+
console.error(`ā Folder does not exist: ${absolutePath}`);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const config = getConfig();
|
|
48
|
+
config.leads[leadId] = {
|
|
49
|
+
path: absolutePath,
|
|
50
|
+
linkedAt: new Date().toISOString()
|
|
51
|
+
};
|
|
52
|
+
config.activeLeadId = leadId;
|
|
53
|
+
|
|
54
|
+
saveConfig(config);
|
|
55
|
+
console.log(`ā
Successfully linked Lead [${leadId}] to ${absolutePath}`);
|
|
56
|
+
console.log(`Type 'thrust' to begin tracking.`);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
program.parse(process.argv);
|
|
60
|
+
|
|
61
|
+
async function enableAutoLaunchSafe() {
|
|
62
|
+
if (process.env.TERMUX_VERSION) {
|
|
63
|
+
console.log('š± Termux detected: Skipping desktop AutoLaunch. (Tip: Use termux-boot to run on startup)');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (process.platform === 'linux' && !process.env.DISPLAY) {
|
|
68
|
+
console.log('š„ļø Headless Linux detected: Skipping GUI AutoLaunch. (Tip: Set up a systemd service)');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const thrustAutoLauncher = new AutoLaunch({
|
|
74
|
+
name: 'ThrustAgent',
|
|
75
|
+
path: process.execPath,
|
|
76
|
+
args: [__filename]
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const isEnabled = await thrustAutoLauncher.isEnabled();
|
|
80
|
+
if (!isEnabled) {
|
|
81
|
+
await thrustAutoLauncher.enable();
|
|
82
|
+
console.log('āļø Added to OS Startup programs.');
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.log(`ā ļø Auto-launch setup skipped (not supported on this setup).`);
|
|
86
|
+
}
|
|
87
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "thrust-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "The local agent for Thrust AI Director",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"homepage": "https://thrust.web.app",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"AI",
|
|
13
|
+
"Agent",
|
|
14
|
+
"CLI",
|
|
15
|
+
"Local"
|
|
16
|
+
],
|
|
17
|
+
"author": "Thrust",
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"auto-launch": "^5.0.6",
|
|
20
|
+
"axios": "^1.13.2",
|
|
21
|
+
"chokidar": "^3.6.0",
|
|
22
|
+
"commander": "^12.0.0",
|
|
23
|
+
"cors": "^2.8.6",
|
|
24
|
+
"express": "5.2.1",
|
|
25
|
+
"inquirer": "^9.3.8",
|
|
26
|
+
"inquirer-file-tree-selection-prompt": "^2.0.5",
|
|
27
|
+
"jsonwebtoken": "^9.0.2",
|
|
28
|
+
"open": "11.0.0",
|
|
29
|
+
"semver": "^7.6.0",
|
|
30
|
+
"simple-git": "^3.22.0",
|
|
31
|
+
"ws": "^8.16.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
package/utils/config.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = path.join(os.homedir(), '.thrust');
|
|
6
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_CONFIG = {
|
|
9
|
+
token: null,
|
|
10
|
+
activeLeadId: null,
|
|
11
|
+
leads: {}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function initConfig() {
|
|
15
|
+
try {
|
|
16
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
17
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
20
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(DEFAULT_CONFIG, null, 2), 'utf8');
|
|
21
|
+
}
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.error(`ā [Config Error] Failed to initialize config directory at ${CONFIG_DIR}:`, error.message);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getConfig() {
|
|
28
|
+
initConfig();
|
|
29
|
+
try {
|
|
30
|
+
const data = fs.readFileSync(CONFIG_FILE, 'utf8');
|
|
31
|
+
return JSON.parse(data);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error('ā ļø [Config Error] Corrupted config file. Returning defaults.', error.message);
|
|
34
|
+
return { ...DEFAULT_CONFIG };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function saveConfig(newConfig) {
|
|
39
|
+
initConfig();
|
|
40
|
+
try {
|
|
41
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(newConfig, null, 2), 'utf8');
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.error('ā [Config Error] Failed to save configuration:', error.message);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getActiveProject() {
|
|
48
|
+
const config = getConfig();
|
|
49
|
+
if (!config.activeLeadId || !config.leads[config.activeLeadId]) return null;
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
id: config.activeLeadId,
|
|
53
|
+
...config.leads[config.activeLeadId]
|
|
54
|
+
};
|
|
55
|
+
}
|
package/utils/daemon.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { WebSocket, WebSocketServer } from 'ws'; // Use named imports
|
|
2
|
+
import chokidar from 'chokidar';
|
|
3
|
+
import simpleGit from 'simple-git';
|
|
4
|
+
import express from 'express';
|
|
5
|
+
import open from 'open';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import net from 'net';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import os from 'os';
|
|
10
|
+
import cors from 'cors';
|
|
11
|
+
import { exec } from 'child_process';
|
|
12
|
+
import { fileURLToPath } from 'url';
|
|
13
|
+
import { getActiveProject, getConfig, saveConfig } from './config.js';
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = path.dirname(__filename);
|
|
17
|
+
|
|
18
|
+
const GATEWAY_URL = process.env.FRONT_URL || "ws://localhost:7860";
|
|
19
|
+
|
|
20
|
+
let currentWatcher = null;
|
|
21
|
+
let syncTimeout = null;
|
|
22
|
+
let globalWs = null;
|
|
23
|
+
let localWss = null;
|
|
24
|
+
let wsRetryLogged = false;
|
|
25
|
+
let eaccesWarningLogged = false;
|
|
26
|
+
|
|
27
|
+
const findAvailablePort = (startPort) => {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const server = net.createServer();
|
|
30
|
+
server.unref();
|
|
31
|
+
server.on('error', () => resolve(findAvailablePort(startPort + 1)));
|
|
32
|
+
server.listen(startPort, () => {
|
|
33
|
+
server.close(() => resolve(startPort));
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function broadcastLocalLog(type, message) {
|
|
39
|
+
if (!localWss) return;
|
|
40
|
+
const payload = JSON.stringify({ type, message });
|
|
41
|
+
localWss.clients.forEach(client => {
|
|
42
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
43
|
+
client.send(payload);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function startDaemon(preferredPort) {
|
|
49
|
+
const actualPort = await findAvailablePort(preferredPort);
|
|
50
|
+
const app = express();
|
|
51
|
+
|
|
52
|
+
const corsOptions = {
|
|
53
|
+
origin: `http://localhost:${actualPort}`,
|
|
54
|
+
optionsSuccessStatus: 200
|
|
55
|
+
};
|
|
56
|
+
app.use('/api', cors(corsOptions), express.json());
|
|
57
|
+
|
|
58
|
+
const frontendPath = path.join(__dirname, '..', 'frontend');
|
|
59
|
+
|
|
60
|
+
app.get('/api/status', (req, res) => res.json({ activeProject: getActiveProject() }));
|
|
61
|
+
|
|
62
|
+
app.get('/api/explore', (req, res) => {
|
|
63
|
+
try {
|
|
64
|
+
let targetPath = req.query.path || os.homedir();
|
|
65
|
+
targetPath = path.resolve(targetPath);
|
|
66
|
+
|
|
67
|
+
if (!fs.existsSync(targetPath)) {
|
|
68
|
+
return res.status(404).json({ error: "Directory not found" });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const items = fs.readdirSync(targetPath, { withFileTypes: true });
|
|
72
|
+
|
|
73
|
+
const directories = items
|
|
74
|
+
.filter(item => item.isDirectory() && !item.name.startsWith('.'))
|
|
75
|
+
.map(item => item.name)
|
|
76
|
+
.sort();
|
|
77
|
+
|
|
78
|
+
const parentPath = path.dirname(targetPath);
|
|
79
|
+
|
|
80
|
+
res.json({
|
|
81
|
+
currentPath: targetPath,
|
|
82
|
+
parentPath: targetPath === parentPath ? null : parentPath,
|
|
83
|
+
directories: directories
|
|
84
|
+
});
|
|
85
|
+
} catch (error) {
|
|
86
|
+
res.status(403).json({ error: "Permission denied." });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
app.post('/api/link', async (req, res) => {
|
|
91
|
+
const { leadId, folderPath } = req.body;
|
|
92
|
+
if (!fs.existsSync(folderPath)) {
|
|
93
|
+
return res.status(400).json({ error: "Folder path not found." });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const config = getConfig();
|
|
97
|
+
config.leads[leadId] = { path: folderPath, linkedAt: new Date().toISOString() };
|
|
98
|
+
config.activeLeadId = leadId;
|
|
99
|
+
saveConfig(config);
|
|
100
|
+
|
|
101
|
+
await startWatching(folderPath);
|
|
102
|
+
res.json({ success: true });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
app.post('/api/unlink', async (req, res) => {
|
|
106
|
+
const config = getConfig();
|
|
107
|
+
config.activeLeadId = null;
|
|
108
|
+
saveConfig(config);
|
|
109
|
+
|
|
110
|
+
if (currentWatcher) {
|
|
111
|
+
await currentWatcher.close();
|
|
112
|
+
currentWatcher = null;
|
|
113
|
+
broadcastLocalLog('system', 'š Stopped watching.');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
res.json({ success: true });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (fs.existsSync(frontendPath)) {
|
|
120
|
+
app.use(express.static(frontendPath));
|
|
121
|
+
app.get(/.*$/, (req, res) => res.sendFile(path.join(frontendPath, 'index.html')));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const server = app.listen(actualPort, () => {
|
|
125
|
+
const url = `http://localhost:${actualPort}`;
|
|
126
|
+
console.log(`\n========================================`);
|
|
127
|
+
console.log(`š Thrust Dashboard is live at: ${url}`);
|
|
128
|
+
console.log(`========================================\n`);
|
|
129
|
+
safeOpenBrowser(url);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// --- FIX: Using WebSocketServer constructor ---
|
|
133
|
+
localWss = new WebSocketServer({ server });
|
|
134
|
+
|
|
135
|
+
localWss.on('connection', (ws) => {
|
|
136
|
+
ws.send(JSON.stringify({ type: 'system', message: 'š¢ Local WebSocket connected.' }));
|
|
137
|
+
ws.on('message', (message) => {
|
|
138
|
+
try {
|
|
139
|
+
const data = JSON.parse(message.toString());
|
|
140
|
+
if (data.type === 'frontend_prompt') {
|
|
141
|
+
console.log(`\nš¬ [LOCAL UI]: ${data.payload}`);
|
|
142
|
+
broadcastLocalLog('ai', `Daemon received prompt: "${data.payload}"`);
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {}
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
connectWebSocket();
|
|
149
|
+
const initialProject = getActiveProject();
|
|
150
|
+
if (initialProject?.path) {
|
|
151
|
+
startWatching(initialProject.path);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function safeOpenBrowser(url) {
|
|
156
|
+
try {
|
|
157
|
+
if (process.env.TERMUX_VERSION) {
|
|
158
|
+
exec(`termux-open-url ${url}`);
|
|
159
|
+
} else {
|
|
160
|
+
open(url, { app: { name: open.apps.chrome, arguments: [`--app=${url}`, '--window-size=1000,800'] } }).catch(() => open(url));
|
|
161
|
+
}
|
|
162
|
+
} catch (e) {}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function startWatching(projectPath) {
|
|
166
|
+
try {
|
|
167
|
+
if (currentWatcher) await currentWatcher.close();
|
|
168
|
+
|
|
169
|
+
console.log(`šļø Watching: ${projectPath}`);
|
|
170
|
+
broadcastLocalLog('system', `šļø Started watching: ${projectPath}`);
|
|
171
|
+
|
|
172
|
+
currentWatcher = chokidar.watch(projectPath, {
|
|
173
|
+
ignored: [/(^|[\/\\])\../, '**/node_modules/**'],
|
|
174
|
+
persistent: true,
|
|
175
|
+
ignoreInitial: true
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
currentWatcher.on('all', (event, filePath) => {
|
|
179
|
+
const relativePath = path.relative(projectPath, filePath);
|
|
180
|
+
broadcastLocalLog('watch', `[${event.toUpperCase()}] ${relativePath}`);
|
|
181
|
+
clearTimeout(syncTimeout);
|
|
182
|
+
syncTimeout = setTimeout(() => syncContext(projectPath), 3000);
|
|
183
|
+
});
|
|
184
|
+
} catch (err) {
|
|
185
|
+
console.error(`ā Failed: ${err.message}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/*
|
|
190
|
+
function connectWebSocket() {
|
|
191
|
+
try {
|
|
192
|
+
globalWs = new WebSocket(`${GATEWAY_URL}?token=YOUR_TOKEN_HERE`);
|
|
193
|
+
globalWs.on('open', () => console.log('š¢ Connected to Cloud Gateway.'));
|
|
194
|
+
globalWs.on('close', () => setTimeout(connectWebSocket, 5000));
|
|
195
|
+
} catch (err) {}
|
|
196
|
+
} */
|
|
197
|
+
function connectWebSocket() {
|
|
198
|
+
try {
|
|
199
|
+
globalWs = new WebSocket(`${GATEWAY_URL}?token=YOUR_TOKEN_HERE`);
|
|
200
|
+
|
|
201
|
+
// 1. ATTACH ERROR LISTENER IMMEDIATELY to prevent "Unhandled error" crash
|
|
202
|
+
globalWs.on('error', (err) => {
|
|
203
|
+
// We log the code, but we do NOT let it bubble up to the process
|
|
204
|
+
if (err.code === 'ECONNREFUSED') {
|
|
205
|
+
console.log('š“ Cloud Gateway offline (ECONNREFUSED). Will retry...');
|
|
206
|
+
} else {
|
|
207
|
+
console.error(`ā ļø Cloud Gateway Error: ${err.message}`);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
globalWs.on('open', () => {
|
|
212
|
+
console.log('š¢ Connected to Cloud Gateway.');
|
|
213
|
+
wsRetryLogged = false;
|
|
214
|
+
const activeProj = getActiveProject();
|
|
215
|
+
if (activeProj) syncContext(activeProj.path);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
globalWs.on('message', (data) => {
|
|
219
|
+
try {
|
|
220
|
+
const msg = JSON.parse(data.toString());
|
|
221
|
+
if (msg.type === 'toast') {
|
|
222
|
+
console.log(`\nš [DIRECTOR]: ${msg.message}`);
|
|
223
|
+
broadcastLocalLog('ai', `š [AI DIRECTOR] ${msg.message}`);
|
|
224
|
+
}
|
|
225
|
+
} catch (err) {}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
globalWs.on('close', () => {
|
|
229
|
+
if (!wsRetryLogged) {
|
|
230
|
+
console.log('š“ Disconnected from Cloud Gateway. Retrying silently...');
|
|
231
|
+
wsRetryLogged = true;
|
|
232
|
+
}
|
|
233
|
+
setTimeout(connectWebSocket, 5000);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
} catch (err) {
|
|
237
|
+
// Fallback catch
|
|
238
|
+
console.error('ā Failed to initialize WebSocket:', err.message);
|
|
239
|
+
setTimeout(connectWebSocket, 5000);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function syncContext(projectPath) {
|
|
244
|
+
if (!globalWs || globalWs.readyState !== WebSocket.OPEN) return;
|
|
245
|
+
try {
|
|
246
|
+
const git = simpleGit(projectPath);
|
|
247
|
+
const status = await git.status();
|
|
248
|
+
const diff = await git.diff();
|
|
249
|
+
globalWs.send(JSON.stringify({ type: "context_sync", data: { files_changed: status.modified, diffs: diff } }));
|
|
250
|
+
broadcastLocalLog('sync', `ā
Git context synced to AI.`);
|
|
251
|
+
} catch (e) {}
|
|
252
|
+
}
|