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.
- jarvis/__init__.py +1 -1
- jarvis/jarvis_agent/__init__.py +44 -12
- jarvis/jarvis_agent/agent_manager.py +14 -10
- jarvis/jarvis_agent/config.py +2 -1
- jarvis/jarvis_agent/edit_file_handler.py +2 -2
- jarvis/jarvis_agent/jarvis.py +305 -1
- jarvis/jarvis_agent/rewrite_file_handler.py +143 -0
- jarvis/jarvis_agent/run_loop.py +5 -4
- jarvis/jarvis_agent/stdio_redirect.py +296 -0
- jarvis/jarvis_agent/utils.py +5 -1
- jarvis/jarvis_agent/web_bridge.py +189 -0
- jarvis/jarvis_agent/web_output_sink.py +53 -0
- jarvis/jarvis_agent/web_server.py +745 -0
- jarvis/jarvis_code_agent/code_agent.py +10 -12
- 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 -2
- jarvis/jarvis_platform/base.py +16 -6
- jarvis/jarvis_tools/sub_agent.py +11 -38
- jarvis/jarvis_tools/sub_code_agent.py +3 -1
- jarvis/jarvis_utils/config.py +12 -2
- {jarvis_ai_assistant-0.3.34.dist-info → jarvis_ai_assistant-0.4.1.dist-info}/METADATA +1 -1
- {jarvis_ai_assistant-0.3.34.dist-info → jarvis_ai_assistant-0.4.1.dist-info}/RECORD +28 -25
- jarvis/jarvis_tools/edit_file.py +0 -208
- jarvis/jarvis_tools/rewrite_file.py +0 -191
- {jarvis_ai_assistant-0.3.34.dist-info → jarvis_ai_assistant-0.4.1.dist-info}/WHEEL +0 -0
- {jarvis_ai_assistant-0.3.34.dist-info → jarvis_ai_assistant-0.4.1.dist-info}/entry_points.txt +0 -0
- {jarvis_ai_assistant-0.3.34.dist-info → jarvis_ai_assistant-0.4.1.dist-info}/licenses/LICENSE +0 -0
- {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)
|