jarvis-ai-assistant 0.4.0__py3-none-any.whl → 0.4.1__py3-none-any.whl
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.
- jarvis/__init__.py +1 -1
- jarvis/jarvis_agent/__init__.py +36 -7
- jarvis/jarvis_agent/agent_manager.py +6 -4
- jarvis/jarvis_agent/config.py +2 -1
- jarvis/jarvis_agent/edit_file_handler.py +2 -2
- jarvis/jarvis_agent/jarvis.py +40 -9
- jarvis/jarvis_agent/rewrite_file_handler.py +143 -0
- jarvis/jarvis_agent/run_loop.py +5 -4
- jarvis/jarvis_agent/utils.py +5 -1
- jarvis/jarvis_agent/web_server.py +332 -234
- jarvis/jarvis_code_agent/code_agent.py +10 -7
- jarvis/jarvis_code_analysis/code_review.py +0 -1
- jarvis/jarvis_data/config_schema.json +5 -0
- jarvis/jarvis_multi_agent/__init__.py +205 -25
- jarvis/jarvis_multi_agent/main.py +10 -1
- jarvis/jarvis_platform/base.py +15 -6
- jarvis/jarvis_tools/sub_agent.py +11 -35
- jarvis/jarvis_tools/sub_code_agent.py +3 -1
- jarvis/jarvis_utils/config.py +10 -0
- {jarvis_ai_assistant-0.4.0.dist-info → jarvis_ai_assistant-0.4.1.dist-info}/METADATA +1 -1
- {jarvis_ai_assistant-0.4.0.dist-info → jarvis_ai_assistant-0.4.1.dist-info}/RECORD +25 -26
- jarvis/jarvis_tools/edit_file.py +0 -208
- jarvis/jarvis_tools/rewrite_file.py +0 -191
- {jarvis_ai_assistant-0.4.0.dist-info → jarvis_ai_assistant-0.4.1.dist-info}/WHEEL +0 -0
- {jarvis_ai_assistant-0.4.0.dist-info → jarvis_ai_assistant-0.4.1.dist-info}/entry_points.txt +0 -0
- {jarvis_ai_assistant-0.4.0.dist-info → jarvis_ai_assistant-0.4.1.dist-info}/licenses/LICENSE +0 -0
- {jarvis_ai_assistant-0.4.0.dist-info → jarvis_ai_assistant-0.4.1.dist-info}/top_level.txt +0 -0
@@ -112,16 +112,6 @@ def _build_app() -> FastAPI:
|
|
112
112
|
<body>
|
113
113
|
<div id="terminal"></div>
|
114
114
|
|
115
|
-
<div id="input-panel">
|
116
|
-
<div id="tip">输入任务或在请求时回复(Ctrl+Enter 提交)</div>
|
117
|
-
<textarea id="input" placeholder="在此输入..."></textarea>
|
118
|
-
<div id="actions">
|
119
|
-
<button id="send">发送为新任务</button>
|
120
|
-
<button id="clear">清空输出</button>
|
121
|
-
<button id="interrupt">干预</button>
|
122
|
-
</div>
|
123
|
-
</div>
|
124
|
-
|
125
115
|
<!-- xterm.js 与 fit 插件 -->
|
126
116
|
<script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
|
127
117
|
<script src="https://unpkg.com/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
@@ -145,7 +135,10 @@ def _build_app() -> FastAPI:
|
|
145
135
|
try {
|
146
136
|
term.onData((data) => {
|
147
137
|
try {
|
148
|
-
|
138
|
+
// 优先将键入数据发送到交互式终端(PTY)通道;若未就绪则回退到 STDIN 重定向通道
|
139
|
+
if (typeof wsTerm !== 'undefined' && wsTerm && wsTerm.readyState === WebSocket.OPEN) {
|
140
|
+
wsTerm.send(JSON.stringify({ type: 'stdin', data }));
|
141
|
+
} else if (wsStd && wsStd.readyState === WebSocket.OPEN) {
|
149
142
|
wsStd.send(JSON.stringify({ type: 'stdin', data }));
|
150
143
|
}
|
151
144
|
} catch (e) {}
|
@@ -161,6 +154,12 @@ def _build_app() -> FastAPI:
|
|
161
154
|
wsCtl.send(JSON.stringify({ type: 'resize', cols: term.cols || 200, rows: term.rows || 24 }));
|
162
155
|
} catch (e) {}
|
163
156
|
}
|
157
|
+
// 同步调整交互式终端(PTY)窗口大小
|
158
|
+
if (typeof wsTerm !== 'undefined' && wsTerm && wsTerm.readyState === WebSocket.OPEN) {
|
159
|
+
try {
|
160
|
+
wsTerm.send(JSON.stringify({ type: 'resize', cols: term.cols || 200, rows: term.rows || 24 }));
|
161
|
+
} catch (e) {}
|
162
|
+
}
|
164
163
|
} catch (e) {}
|
165
164
|
}
|
166
165
|
fitTerminal();
|
@@ -175,47 +174,14 @@ def _build_app() -> FastAPI:
|
|
175
174
|
term.write((text ?? '').toString());
|
176
175
|
}
|
177
176
|
|
178
|
-
//
|
179
|
-
const tip = document.getElementById('tip');
|
180
|
-
const input = document.getElementById('input');
|
181
|
-
const btnSend = document.getElementById('send');
|
182
|
-
const btnClear = document.getElementById('clear');
|
183
|
-
const btnInterrupt = document.getElementById('interrupt');
|
184
|
-
// 输入可用性开关:Agent 未请求输入时禁用输入框(但允许通过按钮发送空任务)
|
185
|
-
let inputEnabled = false;
|
186
|
-
function setInputEnabled(flag) {
|
187
|
-
inputEnabled = !!flag;
|
188
|
-
try {
|
189
|
-
input.disabled = !inputEnabled;
|
190
|
-
// 根据可用状态更新占位提示
|
191
|
-
input.placeholder = inputEnabled ? '在此输入...' : 'Agent正在运行';
|
192
|
-
} catch (e) {}
|
193
|
-
}
|
194
|
-
// 初始化(未请求输入时禁用输入框)
|
195
|
-
setInputEnabled(false);
|
196
|
-
try { btnSend.textContent = '发送为新任务'; } catch (e) {}
|
197
|
-
|
198
|
-
// WebSocket 通道:主通道与 STDIO 通道
|
177
|
+
// WebSocket 通道:STDIO、控制与交互式终端
|
199
178
|
const wsProto = (location.protocol === 'https:') ? 'wss' : 'ws';
|
200
|
-
const ws = new WebSocket(wsProto + '://' + location.host + '/ws');
|
201
179
|
const wsStd = new WebSocket(wsProto + '://' + location.host + '/stdio');
|
202
180
|
const wsCtl = new WebSocket(wsProto + '://' + location.host + '/control');
|
181
|
+
// 交互式终端(PTY)通道:用于真正的交互式命令
|
182
|
+
const wsTerm = new WebSocket(wsProto + '://' + location.host + '/terminal');
|
203
183
|
let ctlReady = false;
|
204
184
|
|
205
|
-
ws.onopen = () => {
|
206
|
-
writeLine('WebSocket 已连接');
|
207
|
-
// 连接成功后,允许直接输入首条任务
|
208
|
-
try {
|
209
|
-
setInputEnabled(true);
|
210
|
-
// 连接成功后,优先聚焦终端以便直接通过 xterm 进行交互
|
211
|
-
if (typeof term !== 'undefined' && term) {
|
212
|
-
try { term.focus(); } catch (e) {}
|
213
|
-
}
|
214
|
-
} catch (e) {}
|
215
|
-
fitTerminal();
|
216
|
-
};
|
217
|
-
ws.onclose = () => { writeLine('WebSocket 已关闭'); };
|
218
|
-
ws.onerror = (e) => { writeLine('WebSocket 错误: ' + e); };
|
219
185
|
|
220
186
|
wsStd.onopen = () => { writeLine('STDIO 通道已连接'); };
|
221
187
|
wsStd.onclose = () => { writeLine('STDIO 通道已关闭'); };
|
@@ -233,48 +199,31 @@ def _build_app() -> FastAPI:
|
|
233
199
|
ctlReady = false;
|
234
200
|
};
|
235
201
|
wsCtl.onerror = (e) => { writeLine('控制通道错误: ' + e); };
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
202
|
+
|
203
|
+
// 终端(PTY)通道
|
204
|
+
wsTerm.onopen = () => {
|
205
|
+
writeLine('终端通道已连接');
|
206
|
+
// 初次连接时上报当前终端尺寸
|
207
|
+
try {
|
208
|
+
wsTerm.send(JSON.stringify({ type: 'resize', cols: term.cols || 200, rows: term.rows || 24 }));
|
209
|
+
} catch (e) {}
|
210
|
+
};
|
211
|
+
wsTerm.onclose = () => { writeLine('终端通道已关闭'); };
|
212
|
+
wsTerm.onerror = (e) => { writeLine('终端通道错误: ' + e); };
|
213
|
+
wsTerm.onmessage = (evt) => {
|
242
214
|
try {
|
243
215
|
const data = JSON.parse(evt.data || '{}');
|
244
|
-
if (data.type === '
|
245
|
-
|
246
|
-
|
247
|
-
pendingInputRequest = data;
|
248
|
-
tip.textContent = '请求输入: ' + (data.tip || '');
|
249
|
-
setInputEnabled(true);
|
250
|
-
try { btnSend.textContent = '提交输入'; } catch (e) {}
|
251
|
-
input.focus();
|
252
|
-
} else if (data.type === 'confirm_request') {
|
253
|
-
pendingConfirmRequest = data;
|
254
|
-
// 确认请求期间不需要文本输入,禁用输入框并更新按钮文字
|
255
|
-
setInputEnabled(false);
|
256
|
-
try { btnSend.textContent = '确认中…'; } catch (e) {}
|
257
|
-
const ok = window.confirm((data.tip || '') + (data.default ? " [Y/n]" : " [y/N]"));
|
258
|
-
ws.send(JSON.stringify({
|
259
|
-
type: 'confirm_response',
|
260
|
-
request_id: data.request_id,
|
261
|
-
value: !!ok
|
262
|
-
}));
|
263
|
-
// 确认已提交,恢复按钮文字为“发送为新任务”
|
264
|
-
try { btnSend.textContent = '发送为新任务'; } catch (e) {}
|
265
|
-
pendingConfirmRequest = null;
|
266
|
-
} else if (data.type === 'agent_idle') {
|
267
|
-
// 任务结束提示,并恢复输入状态
|
268
|
-
try { writeLine('当前任务已结束'); } catch (e) {}
|
269
|
-
try { setInputEnabled(true); btnSend.textContent = '发送为新任务'; input.focus(); } catch (e) {}
|
270
|
-
} else if (data.type === 'stdio') {
|
271
|
-
// 忽略主通道的 stdio 以避免与独立 STDIO 通道重复显示
|
216
|
+
if (data.type === 'stdio') {
|
217
|
+
const text = data.text || '';
|
218
|
+
write(text);
|
272
219
|
}
|
273
220
|
} catch (e) {
|
274
|
-
writeLine('消息解析失败: ' + e);
|
221
|
+
writeLine('消息解析失败: ' + e);
|
275
222
|
}
|
276
223
|
};
|
277
224
|
|
225
|
+
|
226
|
+
|
278
227
|
// STDIO 通道消息(原样写入,保留流式体验)
|
279
228
|
wsStd.onmessage = (evt) => {
|
280
229
|
try {
|
@@ -288,89 +237,8 @@ writeLine('消息解析失败: ' + e);
|
|
288
237
|
}
|
289
238
|
};
|
290
239
|
|
291
|
-
// 操作区
|
292
|
-
btnSend.onclick = () => {
|
293
|
-
const text = input.value || '';
|
294
|
-
// 若当前处于输入请求阶段,则按钮行为为“提交输入”(允许空输入)
|
295
|
-
if (pendingInputRequest) {
|
296
|
-
// 发送前在终端回显用户输入
|
297
|
-
try { writeLine('> ' + text); } catch (e) {}
|
298
|
-
ws.send(JSON.stringify({
|
299
|
-
type: 'user_input',
|
300
|
-
request_id: pendingInputRequest.request_id,
|
301
|
-
text
|
302
|
-
}));
|
303
|
-
tip.textContent = '输入已提交';
|
304
|
-
pendingInputRequest = null;
|
305
|
-
input.value = '';
|
306
|
-
// 提交输入后禁用输入区,等待下一次请求或任务结束通知
|
307
|
-
setInputEnabled(false);
|
308
|
-
try { btnSend.textContent = '发送为新任务'; } catch (e) {}
|
309
|
-
fitTerminal();
|
310
|
-
return;
|
311
|
-
}
|
312
|
-
// 否则行为为“发送为新任务”(允许空输入)
|
313
|
-
// 发送前在终端回显用户输入
|
314
|
-
try { writeLine('> ' + text); } catch (e) {}
|
315
|
-
ws.send(JSON.stringify({ type: 'run_task', text }));
|
316
|
-
input.value = '';
|
317
|
-
// 发送新任务后,直到Agent请求输入前禁用输入区
|
318
|
-
setInputEnabled(false);
|
319
|
-
try { btnSend.textContent = '发送为新任务'; } catch (e) {}
|
320
|
-
fitTerminal();
|
321
|
-
};
|
322
240
|
|
323
|
-
btnClear.onclick = () => {
|
324
|
-
try {
|
325
|
-
if (typeof term.clear === 'function') term.clear();
|
326
|
-
else term.write('\\x1bc'); // 清屏/重置
|
327
|
-
} catch (e) {
|
328
|
-
term.write('\\x1bc');
|
329
|
-
}
|
330
|
-
};
|
331
|
-
// 干预(发送中断信号)
|
332
|
-
btnInterrupt.onclick = () => {
|
333
|
-
try {
|
334
|
-
wsCtl.send(JSON.stringify({ type: 'interrupt' }));
|
335
|
-
writeLine('已发送干预(中断)信号');
|
336
|
-
} catch (e) {
|
337
|
-
writeLine('发送干预失败: ' + e);
|
338
|
-
}
|
339
|
-
};
|
340
241
|
|
341
|
-
// Ctrl+Enter 提交输入或作为新任务
|
342
|
-
input.addEventListener('keydown', (e) => {
|
343
|
-
if (e.key === 'Enter' && e.ctrlKey) {
|
344
|
-
e.preventDefault();
|
345
|
-
const text = input.value || '';
|
346
|
-
if (pendingInputRequest) {
|
347
|
-
// 发送前在终端回显用户输入
|
348
|
-
try { writeLine('> ' + text); } catch (e) {}
|
349
|
-
ws.send(JSON.stringify({
|
350
|
-
type: 'user_input',
|
351
|
-
request_id: pendingInputRequest.request_id,
|
352
|
-
text
|
353
|
-
}));
|
354
|
-
tip.textContent = '输入已提交';
|
355
|
-
pendingInputRequest = null;
|
356
|
-
input.value = '';
|
357
|
-
// 提交输入后,直到下一次请求前禁用输入区
|
358
|
-
setInputEnabled(false);
|
359
|
-
try { btnSend.textContent = '发送为新任务'; } catch (e) {}
|
360
|
-
} else {
|
361
|
-
// 仅在输入区启用时允许通过 Ctrl+Enter 发送新任务
|
362
|
-
if (inputEnabled) {
|
363
|
-
// 发送前在终端回显用户输入
|
364
|
-
try { writeLine('> ' + text); } catch (e) {}
|
365
|
-
ws.send(JSON.stringify({ type: 'run_task', text }));
|
366
|
-
input.value = '';
|
367
|
-
setInputEnabled(false);
|
368
|
-
try { btnSend.textContent = '发送为新任务'; } catch (e) {}
|
369
|
-
}
|
370
|
-
}
|
371
|
-
fitTerminal();
|
372
|
-
}
|
373
|
-
});
|
374
242
|
</script>
|
375
243
|
</body>
|
376
244
|
</html>
|
@@ -437,77 +305,6 @@ def start_web_server(agent: Any, host: str = "127.0.0.1", port: int = 8765) -> N
|
|
437
305
|
except Exception:
|
438
306
|
app.state.agent_manager = None
|
439
307
|
|
440
|
-
@app.websocket("/ws")
|
441
|
-
async def websocket_endpoint(ws: WebSocket) -> None:
|
442
|
-
await ws.accept()
|
443
|
-
queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue()
|
444
|
-
sender = _make_sender_filtered(queue, allowed_types=["input_request", "confirm_request", "agent_idle"])
|
445
|
-
bridge = WebBridge.instance()
|
446
|
-
bridge.add_client(sender)
|
447
|
-
|
448
|
-
# 后台发送任务
|
449
|
-
send_task = asyncio.create_task(_ws_sender_loop(ws, queue))
|
450
|
-
|
451
|
-
# 初始欢迎
|
452
|
-
try:
|
453
|
-
await ws.send_text(json.dumps({"type": "output", "payload": {"text": "欢迎使用 Jarvis Web", "output_type": "INFO"}}))
|
454
|
-
except Exception:
|
455
|
-
pass
|
456
|
-
|
457
|
-
try:
|
458
|
-
while True:
|
459
|
-
msg = await ws.receive_text()
|
460
|
-
try:
|
461
|
-
data = json.loads(msg)
|
462
|
-
except Exception:
|
463
|
-
continue
|
464
|
-
|
465
|
-
mtype = data.get("type")
|
466
|
-
if mtype == "user_input":
|
467
|
-
req_id = data.get("request_id")
|
468
|
-
text = data.get("text", "") or ""
|
469
|
-
if isinstance(req_id, str):
|
470
|
-
bridge.post_user_input(req_id, str(text))
|
471
|
-
elif mtype == "confirm_response":
|
472
|
-
req_id = data.get("request_id")
|
473
|
-
val = bool(data.get("value", False))
|
474
|
-
if isinstance(req_id, str):
|
475
|
-
bridge.post_confirm(req_id, val)
|
476
|
-
elif mtype == "run_task":
|
477
|
-
# 允许空输入(空输入也具有语义)
|
478
|
-
text = data.get("text", "")
|
479
|
-
# 在后台线程运行,以避免阻塞事件循环
|
480
|
-
loop = asyncio.get_running_loop()
|
481
|
-
# 若提供了 AgentManager,则为每个任务创建新的 Agent 实例;否则复用现有 Agent
|
482
|
-
try:
|
483
|
-
if getattr(app.state, "agent_manager", None) and hasattr(app.state.agent_manager, "initialize"):
|
484
|
-
new_agent = app.state.agent_manager.initialize()
|
485
|
-
loop.run_in_executor(None, _run_and_notify, new_agent, text)
|
486
|
-
else:
|
487
|
-
loop.run_in_executor(None, _run_and_notify, app.state.agent, text)
|
488
|
-
except Exception:
|
489
|
-
# 回退到旧行为,避免因异常导致无法执行任务
|
490
|
-
try:
|
491
|
-
loop.run_in_executor(None, _run_and_notify, app.state.agent, text)
|
492
|
-
except Exception:
|
493
|
-
pass
|
494
|
-
else:
|
495
|
-
# 兼容未知消息类型
|
496
|
-
pass
|
497
|
-
except WebSocketDisconnect:
|
498
|
-
pass
|
499
|
-
except Exception:
|
500
|
-
pass
|
501
|
-
finally:
|
502
|
-
try:
|
503
|
-
bridge.remove_client(sender)
|
504
|
-
except Exception:
|
505
|
-
pass
|
506
|
-
try:
|
507
|
-
send_task.cancel()
|
508
|
-
except Exception:
|
509
|
-
pass
|
510
|
-
|
511
308
|
@app.websocket("/stdio")
|
512
309
|
async def websocket_stdio(ws: WebSocket) -> None:
|
513
310
|
await ws.accept()
|
@@ -603,6 +400,307 @@ def start_web_server(agent: Any, host: str = "127.0.0.1", port: int = 8765) -> N
|
|
603
400
|
except Exception:
|
604
401
|
pass
|
605
402
|
|
403
|
+
# 交互式终端通道:为前端 xterm 提供真实的 PTY 会话,以支持交互式命令
|
404
|
+
@app.websocket("/terminal")
|
405
|
+
async def websocket_terminal(ws: WebSocket) -> None:
|
406
|
+
await ws.accept()
|
407
|
+
# 仅在非 Windows 平台提供 PTY 功能
|
408
|
+
import sys as _sys
|
409
|
+
if _sys.platform == "win32":
|
410
|
+
try:
|
411
|
+
await ws.send_text(json.dumps({"type": "output", "payload": {"text": "当前平台不支持交互式终端(PTY)", "output_type": "ERROR"}}))
|
412
|
+
except Exception:
|
413
|
+
pass
|
414
|
+
try:
|
415
|
+
await ws.close()
|
416
|
+
except Exception:
|
417
|
+
pass
|
418
|
+
return
|
419
|
+
|
420
|
+
import os as _os
|
421
|
+
try:
|
422
|
+
import pty as _pty # type: ignore
|
423
|
+
import fcntl as _fcntl # type: ignore
|
424
|
+
import select as _select # type: ignore
|
425
|
+
import termios as _termios # type: ignore
|
426
|
+
import struct as _struct # type: ignore
|
427
|
+
except Exception:
|
428
|
+
try:
|
429
|
+
await ws.send_text(json.dumps({"type": "output", "payload": {"text": "服务端缺少 PTY 相关依赖,无法启动交互式终端", "output_type": "ERROR"}}))
|
430
|
+
except Exception:
|
431
|
+
pass
|
432
|
+
try:
|
433
|
+
await ws.close()
|
434
|
+
except Exception:
|
435
|
+
pass
|
436
|
+
return
|
437
|
+
|
438
|
+
def _set_winsize(fd: int, cols: int, rows: int) -> None:
|
439
|
+
try:
|
440
|
+
if cols > 0 and rows > 0:
|
441
|
+
winsz = _struct.pack("HHHH", rows, cols, 0, 0)
|
442
|
+
_fcntl.ioctl(fd, _termios.TIOCSWINSZ, winsz)
|
443
|
+
except Exception:
|
444
|
+
# 调整失败不影响主流程
|
445
|
+
pass
|
446
|
+
|
447
|
+
# 交互式会话状态与启动函数(优先执行 jvs 命令,失败回退到系统 shell)
|
448
|
+
session = {"pid": None, "master_fd": None}
|
449
|
+
last_cols = 0
|
450
|
+
last_rows = 0
|
451
|
+
# 会话结束后等待用户按回车再重启
|
452
|
+
waiting_for_ack = False
|
453
|
+
ack_event = asyncio.Event()
|
454
|
+
|
455
|
+
|
456
|
+
def _spawn_jvs_session() -> bool:
|
457
|
+
nonlocal session
|
458
|
+
try:
|
459
|
+
pid, master_fd = _pty.fork()
|
460
|
+
if pid == 0:
|
461
|
+
# 子进程:执行 jvs 启动命令(移除 web 相关参数),失败时回退到系统 shell
|
462
|
+
try:
|
463
|
+
import json as _json
|
464
|
+
_cmd_json = _os.environ.get("JARVIS_WEB_LAUNCH_JSON", "")
|
465
|
+
if _cmd_json:
|
466
|
+
try:
|
467
|
+
_argv = _json.loads(_cmd_json)
|
468
|
+
except Exception:
|
469
|
+
_argv = []
|
470
|
+
if isinstance(_argv, list) and len(_argv) > 0 and isinstance(_argv[0], str):
|
471
|
+
_os.execvp(_argv[0], _argv)
|
472
|
+
except Exception:
|
473
|
+
pass
|
474
|
+
# 若未配置或执行失败,回退到 /bin/bash 或 /bin/sh
|
475
|
+
try:
|
476
|
+
_os.execvp("/bin/bash", ["/bin/bash"])
|
477
|
+
except Exception:
|
478
|
+
try:
|
479
|
+
_os.execvp("/bin/sh", ["/bin/sh"])
|
480
|
+
except Exception:
|
481
|
+
_os._exit(1)
|
482
|
+
else:
|
483
|
+
# 父进程:设置非阻塞模式并记录状态
|
484
|
+
try:
|
485
|
+
_fcntl.fcntl(master_fd, _fcntl.F_SETFL, _os.O_NONBLOCK)
|
486
|
+
except Exception:
|
487
|
+
pass
|
488
|
+
session["pid"] = pid
|
489
|
+
session["master_fd"] = master_fd
|
490
|
+
# 如果已有窗口大小设置,应用到新会话
|
491
|
+
try:
|
492
|
+
if last_cols > 0 and last_rows > 0:
|
493
|
+
winsz = _struct.pack("HHHH", last_rows, last_cols, 0, 0)
|
494
|
+
_fcntl.ioctl(master_fd, _termios.TIOCSWINSZ, winsz)
|
495
|
+
except Exception:
|
496
|
+
pass
|
497
|
+
return True
|
498
|
+
except Exception:
|
499
|
+
return False
|
500
|
+
return False
|
501
|
+
|
502
|
+
# 启动首个会话
|
503
|
+
ok = _spawn_jvs_session()
|
504
|
+
if not ok:
|
505
|
+
try:
|
506
|
+
await ws.send_text(json.dumps({"type": "output", "payload": {"text": "启动交互式终端失败", "output_type": "ERROR"}}))
|
507
|
+
except Exception:
|
508
|
+
pass
|
509
|
+
try:
|
510
|
+
await ws.close()
|
511
|
+
except Exception:
|
512
|
+
pass
|
513
|
+
return
|
514
|
+
|
515
|
+
async def _tty_read_loop() -> None:
|
516
|
+
nonlocal waiting_for_ack
|
517
|
+
try:
|
518
|
+
while True:
|
519
|
+
fd = session.get("master_fd")
|
520
|
+
if fd is None:
|
521
|
+
# 若正在等待用户按回车确认,则暂不重启
|
522
|
+
if waiting_for_ack:
|
523
|
+
if ack_event.is_set():
|
524
|
+
try:
|
525
|
+
ack_event.clear()
|
526
|
+
except Exception:
|
527
|
+
pass
|
528
|
+
waiting_for_ack = False
|
529
|
+
if _spawn_jvs_session():
|
530
|
+
try:
|
531
|
+
await ws.send_text(json.dumps({"type": "stdio", "text": "\r\njvs 会话已重启\r\n"}))
|
532
|
+
except Exception:
|
533
|
+
pass
|
534
|
+
fd = session.get("master_fd")
|
535
|
+
else:
|
536
|
+
await asyncio.sleep(0.5)
|
537
|
+
continue
|
538
|
+
# 等待用户按回车
|
539
|
+
await asyncio.sleep(0.1)
|
540
|
+
continue
|
541
|
+
# 非确认流程:自动重启
|
542
|
+
if _spawn_jvs_session():
|
543
|
+
try:
|
544
|
+
await ws.send_text(json.dumps({"type": "stdio", "text": "\r\njvs 会话已重启\r\n"}))
|
545
|
+
except Exception:
|
546
|
+
pass
|
547
|
+
fd = session.get("master_fd")
|
548
|
+
else:
|
549
|
+
await asyncio.sleep(0.5)
|
550
|
+
continue
|
551
|
+
try:
|
552
|
+
r, _, _ = _select.select([fd], [], [], 0.1)
|
553
|
+
except Exception:
|
554
|
+
r = []
|
555
|
+
if r:
|
556
|
+
try:
|
557
|
+
data = _os.read(fd, 4096)
|
558
|
+
except BlockingIOError:
|
559
|
+
data = b""
|
560
|
+
except Exception:
|
561
|
+
data = b""
|
562
|
+
if data:
|
563
|
+
try:
|
564
|
+
await ws.send_text(json.dumps({"type": "stdio", "text": data.decode(errors="ignore")}))
|
565
|
+
except Exception:
|
566
|
+
break
|
567
|
+
else:
|
568
|
+
# 读取到 EOF,说明子进程已退出;提示后等待用户按回车再重启
|
569
|
+
try:
|
570
|
+
# 关闭旧 master
|
571
|
+
try:
|
572
|
+
if session.get("master_fd") is not None:
|
573
|
+
_os.close(session["master_fd"]) # type: ignore[index]
|
574
|
+
except Exception:
|
575
|
+
pass
|
576
|
+
session["master_fd"] = None
|
577
|
+
session["pid"] = None
|
578
|
+
# 标记等待用户回车,并提示
|
579
|
+
waiting_for_ack = True
|
580
|
+
try:
|
581
|
+
await ws.send_text(json.dumps({"type": "stdio", "text": "\r\nAgent 已结束。按回车继续,系统将重启新的 Agent。\r\n> "}))
|
582
|
+
except Exception:
|
583
|
+
pass
|
584
|
+
# 不立即重启,等待顶部 fd None 分支在收到回车后处理
|
585
|
+
await asyncio.sleep(0.1)
|
586
|
+
except Exception:
|
587
|
+
pass
|
588
|
+
# 让出事件循环
|
589
|
+
try:
|
590
|
+
await asyncio.sleep(0)
|
591
|
+
except Exception:
|
592
|
+
pass
|
593
|
+
except Exception:
|
594
|
+
pass
|
595
|
+
|
596
|
+
# 后台读取任务
|
597
|
+
read_task = asyncio.create_task(_tty_read_loop())
|
598
|
+
|
599
|
+
# 初次连接:尝试根据控制通道设定的列数调整终端大小
|
600
|
+
try:
|
601
|
+
cols = int(_os.environ.get("COLUMNS", "0"))
|
602
|
+
except Exception:
|
603
|
+
cols = 0
|
604
|
+
try:
|
605
|
+
rows = int(_os.environ.get("LINES", "0"))
|
606
|
+
except Exception:
|
607
|
+
rows = 0
|
608
|
+
try:
|
609
|
+
if cols > 0 and rows > 0:
|
610
|
+
_set_winsize(session["master_fd"], cols, rows)
|
611
|
+
last_cols = cols
|
612
|
+
last_rows = rows
|
613
|
+
except Exception:
|
614
|
+
pass
|
615
|
+
# 发送就绪提示
|
616
|
+
try:
|
617
|
+
await ws.send_text(json.dumps({"type": "output", "payload": {"text": "交互式终端已就绪(PTY)", "output_type": "INFO"}}))
|
618
|
+
except Exception:
|
619
|
+
pass
|
620
|
+
|
621
|
+
try:
|
622
|
+
while True:
|
623
|
+
msg = await ws.receive_text()
|
624
|
+
try:
|
625
|
+
data = json.loads(msg)
|
626
|
+
except Exception:
|
627
|
+
continue
|
628
|
+
mtype = data.get("type")
|
629
|
+
if mtype == "stdin":
|
630
|
+
# 前端键入数据:若等待回车,则捕获回车;否则透传到 PTY
|
631
|
+
try:
|
632
|
+
text = data.get("data", "")
|
633
|
+
if isinstance(text, str) and text:
|
634
|
+
if waiting_for_ack:
|
635
|
+
# Enter 键触发继续
|
636
|
+
if "\r" in text or "\n" in text:
|
637
|
+
try:
|
638
|
+
ack_event.set()
|
639
|
+
except Exception:
|
640
|
+
pass
|
641
|
+
else:
|
642
|
+
# 非回车输入时轻提示
|
643
|
+
try:
|
644
|
+
await ws.send_text(json.dumps({"type": "stdio", "text": "\r\n按回车继续。\r\n> "}))
|
645
|
+
except Exception:
|
646
|
+
pass
|
647
|
+
else:
|
648
|
+
# 原样写入(保留控制字符);前端可按需发送回车
|
649
|
+
_os.write(session.get("master_fd") or -1, text.encode(errors="ignore"))
|
650
|
+
except Exception:
|
651
|
+
pass
|
652
|
+
elif mtype == "resize":
|
653
|
+
# 终端窗口大小调整(与控制通道一致,但作用于 PTY)
|
654
|
+
try:
|
655
|
+
cols = int(data.get("cols") or 0)
|
656
|
+
except Exception:
|
657
|
+
cols = 0
|
658
|
+
try:
|
659
|
+
rows = int(data.get("rows") or 0)
|
660
|
+
except Exception:
|
661
|
+
rows = 0
|
662
|
+
try:
|
663
|
+
if cols > 0 and rows > 0:
|
664
|
+
_set_winsize(session.get("master_fd") or -1, cols, rows)
|
665
|
+
last_cols = cols
|
666
|
+
last_rows = rows
|
667
|
+
except Exception:
|
668
|
+
pass
|
669
|
+
else:
|
670
|
+
# 忽略未知类型
|
671
|
+
pass
|
672
|
+
except WebSocketDisconnect:
|
673
|
+
pass
|
674
|
+
except Exception:
|
675
|
+
pass
|
676
|
+
finally:
|
677
|
+
# 清理资源
|
678
|
+
try:
|
679
|
+
read_task.cancel()
|
680
|
+
except Exception:
|
681
|
+
pass
|
682
|
+
try:
|
683
|
+
if session.get("master_fd") is not None:
|
684
|
+
try:
|
685
|
+
_os.close(session["master_fd"]) # type: ignore[index]
|
686
|
+
except Exception:
|
687
|
+
pass
|
688
|
+
except Exception:
|
689
|
+
pass
|
690
|
+
try:
|
691
|
+
if session.get("pid"):
|
692
|
+
import signal as _signal # type: ignore
|
693
|
+
try:
|
694
|
+
_os.kill(session["pid"], _signal.SIGTERM) # type: ignore[index]
|
695
|
+
except Exception:
|
696
|
+
pass
|
697
|
+
except Exception:
|
698
|
+
pass
|
699
|
+
try:
|
700
|
+
await ws.close()
|
701
|
+
except Exception:
|
702
|
+
pass
|
703
|
+
|
606
704
|
PrettyOutput.print(f"启动 Jarvis Web 服务: http://{host}:{port}", OutputType.SUCCESS)
|
607
705
|
# 在服务端进程内也写入并维护 PID 文件,增强可检测性与可清理性
|
608
706
|
try:
|