jarvis-ai-assistant 0.3.34__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.
Files changed (30) hide show
  1. jarvis/__init__.py +1 -1
  2. jarvis/jarvis_agent/__init__.py +44 -12
  3. jarvis/jarvis_agent/agent_manager.py +14 -10
  4. jarvis/jarvis_agent/config.py +2 -1
  5. jarvis/jarvis_agent/edit_file_handler.py +2 -2
  6. jarvis/jarvis_agent/jarvis.py +305 -1
  7. jarvis/jarvis_agent/rewrite_file_handler.py +143 -0
  8. jarvis/jarvis_agent/run_loop.py +5 -4
  9. jarvis/jarvis_agent/stdio_redirect.py +296 -0
  10. jarvis/jarvis_agent/utils.py +5 -1
  11. jarvis/jarvis_agent/web_bridge.py +189 -0
  12. jarvis/jarvis_agent/web_output_sink.py +53 -0
  13. jarvis/jarvis_agent/web_server.py +745 -0
  14. jarvis/jarvis_code_agent/code_agent.py +10 -12
  15. jarvis/jarvis_code_analysis/code_review.py +0 -1
  16. jarvis/jarvis_data/config_schema.json +5 -0
  17. jarvis/jarvis_multi_agent/__init__.py +205 -25
  18. jarvis/jarvis_multi_agent/main.py +10 -2
  19. jarvis/jarvis_platform/base.py +16 -6
  20. jarvis/jarvis_tools/sub_agent.py +11 -38
  21. jarvis/jarvis_tools/sub_code_agent.py +3 -1
  22. jarvis/jarvis_utils/config.py +12 -2
  23. {jarvis_ai_assistant-0.3.34.dist-info → jarvis_ai_assistant-0.4.1.dist-info}/METADATA +1 -1
  24. {jarvis_ai_assistant-0.3.34.dist-info → jarvis_ai_assistant-0.4.1.dist-info}/RECORD +28 -25
  25. jarvis/jarvis_tools/edit_file.py +0 -208
  26. jarvis/jarvis_tools/rewrite_file.py +0 -191
  27. {jarvis_ai_assistant-0.3.34.dist-info → jarvis_ai_assistant-0.4.1.dist-info}/WHEEL +0 -0
  28. {jarvis_ai_assistant-0.3.34.dist-info → jarvis_ai_assistant-0.4.1.dist-info}/entry_points.txt +0 -0
  29. {jarvis_ai_assistant-0.3.34.dist-info → jarvis_ai_assistant-0.4.1.dist-info}/licenses/LICENSE +0 -0
  30. {jarvis_ai_assistant-0.3.34.dist-info → jarvis_ai_assistant-0.4.1.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,745 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 基于 FastAPI 的 Web 服务:
4
+ - GET / 返回简易网页(含JS,连接 WebSocket,展示输出,处理输入/确认)
5
+ - WS /ws 建立双向通信:服务端通过 WebBridge 广播输出与输入请求;客户端上行提交 user_input/confirm_response 或 run_task
6
+ - WS /stdio 独立通道:专门接收标准输出/错误(sys.stdout/sys.stderr)重定向的流式文本
7
+
8
+ 集成方式(在 --web 模式下):
9
+ - 注册 WebSocketOutputSink,将 PrettyOutput 事件广播到前端
10
+ - 注入 web_multiline_input 与 web_user_confirm 到 Agent,使输入与确认经由浏览器完成
11
+ - 启动本服务,前端通过页面与 Agent 交互
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import json
17
+ import os
18
+ import signal
19
+ import atexit
20
+ from pathlib import Path
21
+ from typing import Any, Dict, Callable, Optional
22
+
23
+ import uvicorn
24
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
25
+ from fastapi.responses import HTMLResponse
26
+ from fastapi.middleware.cors import CORSMiddleware
27
+
28
+ from jarvis.jarvis_agent.web_bridge import WebBridge
29
+ from jarvis.jarvis_utils.globals import set_interrupt, console
30
+ from jarvis.jarvis_utils.output import PrettyOutput, OutputType
31
+
32
+ # ---------------------------
33
+ # 应用与页面
34
+ # ---------------------------
35
+ def _build_app() -> FastAPI:
36
+ app = FastAPI(title="Jarvis Web")
37
+
38
+ # 允许本地简单跨域调试
39
+ app.add_middleware(
40
+ CORSMiddleware,
41
+ allow_origins=["*"],
42
+ allow_credentials=True,
43
+ allow_methods=["*"],
44
+ allow_headers=["*"],
45
+ )
46
+
47
+ @app.get("/", response_class=HTMLResponse)
48
+ async def index() -> str:
49
+ # 上下布局 + xterm.js 终端显示输出;底部输入面板
50
+ return """
51
+ <!doctype html>
52
+ <html>
53
+ <head>
54
+ <meta charset="utf-8" />
55
+ <title>Jarvis Web</title>
56
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
57
+ <link rel="stylesheet" href="https://unpkg.com/xterm@5.3.0/css/xterm.css" />
58
+ <style>
59
+ html, body { height: 100%; }
60
+ body {
61
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
62
+ margin: 0;
63
+ padding: 0;
64
+ display: flex;
65
+ flex-direction: column; /* 上下布局:上输出,下输入 */
66
+ background: #000;
67
+ color: #eee;
68
+ }
69
+ /* 顶部:终端输出区域(占满剩余空间) */
70
+ #terminal {
71
+ flex: 1;
72
+ background: #000; /* 终端背景 */
73
+ overflow: hidden;
74
+ }
75
+ /* 底部:输入区域(固定高度) */
76
+ #input-panel {
77
+ display: flex;
78
+ flex-direction: column;
79
+ gap: 8px;
80
+ padding: 10px;
81
+ background: #0b0b0b;
82
+ border-top: 1px solid #222;
83
+ }
84
+ #tip { color: #9aa0a6; font-size: 13px; }
85
+ textarea#input {
86
+ width: 100%;
87
+ height: 140px;
88
+ background: #0f0f0f;
89
+ color: #e5e7eb;
90
+ border: 1px solid #333;
91
+ border-radius: 6px;
92
+ padding: 8px;
93
+ resize: vertical;
94
+ outline: none;
95
+ }
96
+ #actions {
97
+ display: flex;
98
+ gap: 10px;
99
+ align-items: center;
100
+ }
101
+ button {
102
+ padding: 8px 12px;
103
+ background: #1f2937;
104
+ color: #e5e7eb;
105
+ border: 1px solid #374151;
106
+ border-radius: 6px;
107
+ cursor: pointer;
108
+ }
109
+ button:hover { background: #374151; }
110
+ </style>
111
+ </head>
112
+ <body>
113
+ <div id="terminal"></div>
114
+
115
+ <!-- xterm.js 与 fit 插件 -->
116
+ <script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
117
+ <script src="https://unpkg.com/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
118
+
119
+ <script>
120
+ // 初始化 xterm 终端
121
+ const term = new Terminal({
122
+ convertEol: true,
123
+ fontSize: 13,
124
+ fontFamily: '"FiraCode Nerd Font", "JetBrainsMono NF", "Fira Code", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
125
+ theme: {
126
+ background: '#000000',
127
+ foreground: '#e5e7eb',
128
+ cursor: '#e5e7eb',
129
+ }
130
+ });
131
+ const fitAddon = new FitAddon.FitAddon();
132
+ term.loadAddon(fitAddon);
133
+ term.open(document.getElementById('terminal'));
134
+ // 捕获前端键入并透传到后端作为 STDIN(与后端 sys.stdin 对接)
135
+ try {
136
+ term.onData((data) => {
137
+ try {
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) {
142
+ wsStd.send(JSON.stringify({ type: 'stdin', data }));
143
+ }
144
+ } catch (e) {}
145
+ });
146
+ } catch (e) {}
147
+
148
+ function fitTerminal() {
149
+ try {
150
+ fitAddon.fit();
151
+ // 将终端尺寸通知后端(用于动态调整TTY宽度/高度)
152
+ if (typeof wsCtl !== 'undefined' && wsCtl && wsCtl.readyState === WebSocket.OPEN) {
153
+ try {
154
+ wsCtl.send(JSON.stringify({ type: 'resize', cols: term.cols || 200, rows: term.rows || 24 }));
155
+ } catch (e) {}
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
+ }
163
+ } catch (e) {}
164
+ }
165
+ fitTerminal();
166
+ window.addEventListener('resize', fitTerminal);
167
+
168
+ // 输出辅助
169
+ function writeLine(text) {
170
+ const lines = (text ?? '').toString().split('\\n');
171
+ for (const ln of lines) term.writeln(ln);
172
+ }
173
+ function write(text) {
174
+ term.write((text ?? '').toString());
175
+ }
176
+
177
+ // WebSocket 通道:STDIO、控制与交互式终端
178
+ const wsProto = (location.protocol === 'https:') ? 'wss' : 'ws';
179
+ const wsStd = new WebSocket(wsProto + '://' + location.host + '/stdio');
180
+ const wsCtl = new WebSocket(wsProto + '://' + location.host + '/control');
181
+ // 交互式终端(PTY)通道:用于真正的交互式命令
182
+ const wsTerm = new WebSocket(wsProto + '://' + location.host + '/terminal');
183
+ let ctlReady = false;
184
+
185
+
186
+ wsStd.onopen = () => { writeLine('STDIO 通道已连接'); };
187
+ wsStd.onclose = () => { writeLine('STDIO 通道已关闭'); };
188
+ wsStd.onerror = (e) => { writeLine('STDIO 通道错误: ' + e); };
189
+ wsCtl.onopen = () => {
190
+ writeLine('控制通道已连接');
191
+ ctlReady = true;
192
+ // 初次连接时立即上报当前终端尺寸
193
+ try {
194
+ wsCtl.send(JSON.stringify({ type: 'resize', cols: term.cols || 200, rows: term.rows || 24 }));
195
+ } catch (e) {}
196
+ };
197
+ wsCtl.onclose = () => {
198
+ writeLine('控制通道已关闭');
199
+ ctlReady = false;
200
+ };
201
+ wsCtl.onerror = (e) => { writeLine('控制通道错误: ' + e); };
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) => {
214
+ try {
215
+ const data = JSON.parse(evt.data || '{}');
216
+ if (data.type === 'stdio') {
217
+ const text = data.text || '';
218
+ write(text);
219
+ }
220
+ } catch (e) {
221
+ writeLine('消息解析失败: ' + e);
222
+ }
223
+ };
224
+
225
+
226
+
227
+ // STDIO 通道消息(原样写入,保留流式体验)
228
+ wsStd.onmessage = (evt) => {
229
+ try {
230
+ const data = JSON.parse(evt.data || '{}');
231
+ if (data.type === 'stdio') {
232
+ const text = data.text || '';
233
+ write(text);
234
+ }
235
+ } catch (e) {
236
+ writeLine('消息解析失败: ' + e);
237
+ }
238
+ };
239
+
240
+
241
+
242
+ </script>
243
+ </body>
244
+ </html>
245
+ """
246
+
247
+ return app
248
+
249
+ # ---------------------------
250
+ # WebSocket 端点
251
+ # ---------------------------
252
+ async def _ws_sender_loop(ws: WebSocket, queue: "asyncio.Queue[Dict[str, Any]]") -> None:
253
+ try:
254
+ while True:
255
+ payload = await queue.get()
256
+ await ws.send_text(json.dumps(payload))
257
+ except Exception:
258
+ # 发送循环异常即退出
259
+ pass
260
+
261
+ def _make_sender(queue: "asyncio.Queue[Dict[str, Any]]") -> Callable[[Dict[str, Any]], None]:
262
+ # 同步函数,供 WebBridge 注册;将消息放入异步队列,由协程发送
263
+ def _sender(payload: Dict[str, Any]) -> None:
264
+ try:
265
+ queue.put_nowait(payload)
266
+ except Exception:
267
+ pass
268
+ return _sender
269
+
270
+ def _make_sender_filtered(queue: "asyncio.Queue[Dict[str, Any]]", allowed_types: Optional[list[str]] = None) -> Callable[[Dict[str, Any]], None]:
271
+ """
272
+ 过滤版 sender:仅将指定类型的payload放入队列(用于单独的STDIO通道)。
273
+ """
274
+ allowed = set(allowed_types or [])
275
+ def _sender(payload: Dict[str, Any]) -> None:
276
+ try:
277
+ ptype = payload.get("type")
278
+ if ptype in allowed:
279
+ queue.put_nowait(payload)
280
+ except Exception:
281
+ pass
282
+ return _sender
283
+
284
+ def _run_and_notify(agent: Any, text: str) -> None:
285
+ try:
286
+ agent.run(text)
287
+ finally:
288
+ try:
289
+ WebBridge.instance().broadcast({"type": "agent_idle"})
290
+ except Exception:
291
+ pass
292
+
293
+ def start_web_server(agent: Any, host: str = "127.0.0.1", port: int = 8765) -> None:
294
+ """
295
+ 启动Web服务,并将Agent绑定到应用上下文。
296
+ - agent: 现有的 Agent 实例(已完成初始化)
297
+ """
298
+ app = _build_app()
299
+ app.state.agent = agent # 供 WS 端点调用
300
+ # 兼容传入 Agent 或 AgentManager:
301
+ # - 若传入的是 AgentManager,则在每个任务开始前通过 initialize() 创建全新 Agent
302
+ # - 若传入的是 Agent 实例,则复用该 Agent(旧行为)
303
+ try:
304
+ app.state.agent_manager = agent if hasattr(agent, "initialize") else None
305
+ except Exception:
306
+ app.state.agent_manager = None
307
+
308
+ @app.websocket("/stdio")
309
+ async def websocket_stdio(ws: WebSocket) -> None:
310
+ await ws.accept()
311
+ queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue()
312
+ sender = _make_sender_filtered(queue, allowed_types=["stdio"])
313
+ bridge = WebBridge.instance()
314
+ bridge.add_client(sender)
315
+ send_task = asyncio.create_task(_ws_sender_loop(ws, queue))
316
+ try:
317
+ await ws.send_text(json.dumps({"type": "output", "payload": {"text": "STDIO 通道已就绪", "output_type": "INFO"}}))
318
+ except Exception:
319
+ pass
320
+ try:
321
+ while True:
322
+ # 接收来自前端 STDIN 数据并注入到后端
323
+ msg = await ws.receive_text()
324
+ try:
325
+ data = json.loads(msg)
326
+ except Exception:
327
+ continue
328
+ mtype = data.get("type")
329
+ if mtype == "stdin":
330
+ try:
331
+ from jarvis.jarvis_agent.stdio_redirect import feed_web_stdin
332
+ text = data.get("data", "")
333
+ if isinstance(text, str) and text:
334
+ feed_web_stdin(text)
335
+ except Exception:
336
+ pass
337
+ else:
338
+ # 忽略未知类型
339
+ pass
340
+ except WebSocketDisconnect:
341
+ pass
342
+ except Exception:
343
+ pass
344
+ pass
345
+ finally:
346
+ try:
347
+ bridge.remove_client(sender)
348
+ except Exception:
349
+ pass
350
+ try:
351
+ send_task.cancel()
352
+ except Exception:
353
+ pass
354
+
355
+ @app.websocket("/control")
356
+ async def websocket_control(ws: WebSocket) -> None:
357
+ await ws.accept()
358
+ try:
359
+ while True:
360
+ msg = await ws.receive_text()
361
+ try:
362
+ data = json.loads(msg)
363
+ except Exception:
364
+ continue
365
+ mtype = data.get("type")
366
+ if mtype == "interrupt":
367
+ try:
368
+ set_interrupt(True)
369
+ # 可选:发送回执
370
+ await ws.send_text(json.dumps({"type": "ack", "cmd": "interrupt"}))
371
+ except Exception:
372
+ pass
373
+ elif mtype == "resize":
374
+ # 动态调整后端TTY宽度(影响 PrettyOutput 和基于终端宽度的逻辑)
375
+ try:
376
+ cols = int(data.get("cols") or 0)
377
+ rows = int(data.get("rows") or 0)
378
+ except Exception:
379
+ cols = 0
380
+ rows = 0
381
+ try:
382
+ if cols > 0:
383
+ os.environ["COLUMNS"] = str(cols)
384
+ try:
385
+ # 覆盖全局 rich Console 的宽度,便于 PrettyOutput 按照前端列数换行
386
+ console._width = cols # type: ignore[attr-defined]
387
+ except Exception:
388
+ pass
389
+ if rows > 0:
390
+ os.environ["LINES"] = str(rows)
391
+ except Exception:
392
+ pass
393
+ except WebSocketDisconnect:
394
+ pass
395
+ except Exception:
396
+ pass
397
+ finally:
398
+ try:
399
+ await ws.close()
400
+ except Exception:
401
+ pass
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
+
704
+ PrettyOutput.print(f"启动 Jarvis Web 服务: http://{host}:{port}", OutputType.SUCCESS)
705
+ # 在服务端进程内也写入并维护 PID 文件,增强可检测性与可清理性
706
+ try:
707
+ pidfile = Path(os.path.expanduser("~/.jarvis")) / f"jarvis_web_{port}.pid"
708
+ try:
709
+ pidfile.parent.mkdir(parents=True, exist_ok=True)
710
+ except Exception:
711
+ pass
712
+ try:
713
+ pidfile.write_text(str(os.getpid()), encoding="utf-8")
714
+ except Exception:
715
+ pass
716
+ # 退出时清理 PID 文件
717
+ def _cleanup_pidfile() -> None:
718
+ try:
719
+ pidfile.unlink(missing_ok=True) # type: ignore[call-arg]
720
+ except Exception:
721
+ pass
722
+ try:
723
+ atexit.register(_cleanup_pidfile)
724
+ except Exception:
725
+ pass
726
+ # 处理 SIGTERM/SIGINT,清理后退出
727
+ def _signal_handler(signum, frame): # type: ignore[no-untyped-def]
728
+ try:
729
+ _cleanup_pidfile()
730
+ finally:
731
+ try:
732
+ os._exit(0)
733
+ except Exception:
734
+ pass
735
+ try:
736
+ signal.signal(signal.SIGTERM, _signal_handler)
737
+ except Exception:
738
+ pass
739
+ try:
740
+ signal.signal(signal.SIGINT, _signal_handler)
741
+ except Exception:
742
+ pass
743
+ except Exception:
744
+ pass
745
+ uvicorn.run(app, host=host, port=port)