jarvis-ai-assistant 0.3.34__py3-none-any.whl → 0.4.0__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 +8 -5
- jarvis/jarvis_agent/agent_manager.py +8 -6
- jarvis/jarvis_agent/jarvis.py +276 -3
- jarvis/jarvis_agent/stdio_redirect.py +296 -0
- jarvis/jarvis_agent/web_bridge.py +189 -0
- jarvis/jarvis_agent/web_output_sink.py +53 -0
- jarvis/jarvis_agent/web_server.py +647 -0
- jarvis/jarvis_code_agent/code_agent.py +0 -5
- jarvis/jarvis_multi_agent/main.py +0 -1
- jarvis/jarvis_platform/base.py +1 -0
- jarvis/jarvis_tools/sub_agent.py +0 -3
- jarvis/jarvis_utils/config.py +2 -2
- {jarvis_ai_assistant-0.3.34.dist-info → jarvis_ai_assistant-0.4.0.dist-info}/METADATA +1 -1
- {jarvis_ai_assistant-0.3.34.dist-info → jarvis_ai_assistant-0.4.0.dist-info}/RECORD +19 -15
- {jarvis_ai_assistant-0.3.34.dist-info → jarvis_ai_assistant-0.4.0.dist-info}/WHEEL +0 -0
- {jarvis_ai_assistant-0.3.34.dist-info → jarvis_ai_assistant-0.4.0.dist-info}/entry_points.txt +0 -0
- {jarvis_ai_assistant-0.3.34.dist-info → jarvis_ai_assistant-0.4.0.dist-info}/licenses/LICENSE +0 -0
- {jarvis_ai_assistant-0.3.34.dist-info → jarvis_ai_assistant-0.4.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,647 @@
|
|
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
|
+
<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
|
+
<!-- xterm.js 与 fit 插件 -->
|
126
|
+
<script src="https://unpkg.com/xterm@5.3.0/lib/xterm.js"></script>
|
127
|
+
<script src="https://unpkg.com/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
|
128
|
+
|
129
|
+
<script>
|
130
|
+
// 初始化 xterm 终端
|
131
|
+
const term = new Terminal({
|
132
|
+
convertEol: true,
|
133
|
+
fontSize: 13,
|
134
|
+
fontFamily: '"FiraCode Nerd Font", "JetBrainsMono NF", "Fira Code", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
135
|
+
theme: {
|
136
|
+
background: '#000000',
|
137
|
+
foreground: '#e5e7eb',
|
138
|
+
cursor: '#e5e7eb',
|
139
|
+
}
|
140
|
+
});
|
141
|
+
const fitAddon = new FitAddon.FitAddon();
|
142
|
+
term.loadAddon(fitAddon);
|
143
|
+
term.open(document.getElementById('terminal'));
|
144
|
+
// 捕获前端键入并透传到后端作为 STDIN(与后端 sys.stdin 对接)
|
145
|
+
try {
|
146
|
+
term.onData((data) => {
|
147
|
+
try {
|
148
|
+
if (wsStd && wsStd.readyState === WebSocket.OPEN) {
|
149
|
+
wsStd.send(JSON.stringify({ type: 'stdin', data }));
|
150
|
+
}
|
151
|
+
} catch (e) {}
|
152
|
+
});
|
153
|
+
} catch (e) {}
|
154
|
+
|
155
|
+
function fitTerminal() {
|
156
|
+
try {
|
157
|
+
fitAddon.fit();
|
158
|
+
// 将终端尺寸通知后端(用于动态调整TTY宽度/高度)
|
159
|
+
if (typeof wsCtl !== 'undefined' && wsCtl && wsCtl.readyState === WebSocket.OPEN) {
|
160
|
+
try {
|
161
|
+
wsCtl.send(JSON.stringify({ type: 'resize', cols: term.cols || 200, rows: term.rows || 24 }));
|
162
|
+
} catch (e) {}
|
163
|
+
}
|
164
|
+
} catch (e) {}
|
165
|
+
}
|
166
|
+
fitTerminal();
|
167
|
+
window.addEventListener('resize', fitTerminal);
|
168
|
+
|
169
|
+
// 输出辅助
|
170
|
+
function writeLine(text) {
|
171
|
+
const lines = (text ?? '').toString().split('\\n');
|
172
|
+
for (const ln of lines) term.writeln(ln);
|
173
|
+
}
|
174
|
+
function write(text) {
|
175
|
+
term.write((text ?? '').toString());
|
176
|
+
}
|
177
|
+
|
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 通道
|
199
|
+
const wsProto = (location.protocol === 'https:') ? 'wss' : 'ws';
|
200
|
+
const ws = new WebSocket(wsProto + '://' + location.host + '/ws');
|
201
|
+
const wsStd = new WebSocket(wsProto + '://' + location.host + '/stdio');
|
202
|
+
const wsCtl = new WebSocket(wsProto + '://' + location.host + '/control');
|
203
|
+
let ctlReady = false;
|
204
|
+
|
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
|
+
|
220
|
+
wsStd.onopen = () => { writeLine('STDIO 通道已连接'); };
|
221
|
+
wsStd.onclose = () => { writeLine('STDIO 通道已关闭'); };
|
222
|
+
wsStd.onerror = (e) => { writeLine('STDIO 通道错误: ' + e); };
|
223
|
+
wsCtl.onopen = () => {
|
224
|
+
writeLine('控制通道已连接');
|
225
|
+
ctlReady = true;
|
226
|
+
// 初次连接时立即上报当前终端尺寸
|
227
|
+
try {
|
228
|
+
wsCtl.send(JSON.stringify({ type: 'resize', cols: term.cols || 200, rows: term.rows || 24 }));
|
229
|
+
} catch (e) {}
|
230
|
+
};
|
231
|
+
wsCtl.onclose = () => {
|
232
|
+
writeLine('控制通道已关闭');
|
233
|
+
ctlReady = false;
|
234
|
+
};
|
235
|
+
wsCtl.onerror = (e) => { writeLine('控制通道错误: ' + e); };
|
236
|
+
|
237
|
+
let pendingInputRequest = null; // {request_id, tip, print_on_empty}
|
238
|
+
let pendingConfirmRequest = null; // {request_id, tip, default}
|
239
|
+
|
240
|
+
// 主通道消息
|
241
|
+
ws.onmessage = (evt) => {
|
242
|
+
try {
|
243
|
+
const data = JSON.parse(evt.data || '{}');
|
244
|
+
if (data.type === 'output') {
|
245
|
+
// 忽略通过 Sink 推送的output事件,避免与STDIO通道的输出重复显示
|
246
|
+
} else if (data.type === 'input_request') {
|
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 通道重复显示
|
272
|
+
}
|
273
|
+
} catch (e) {
|
274
|
+
writeLine('消息解析失败: ' + e);
|
275
|
+
}
|
276
|
+
};
|
277
|
+
|
278
|
+
// STDIO 通道消息(原样写入,保留流式体验)
|
279
|
+
wsStd.onmessage = (evt) => {
|
280
|
+
try {
|
281
|
+
const data = JSON.parse(evt.data || '{}');
|
282
|
+
if (data.type === 'stdio') {
|
283
|
+
const text = data.text || '';
|
284
|
+
write(text);
|
285
|
+
}
|
286
|
+
} catch (e) {
|
287
|
+
writeLine('消息解析失败: ' + e);
|
288
|
+
}
|
289
|
+
};
|
290
|
+
|
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
|
+
|
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
|
+
|
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
|
+
</script>
|
375
|
+
</body>
|
376
|
+
</html>
|
377
|
+
"""
|
378
|
+
|
379
|
+
return app
|
380
|
+
|
381
|
+
# ---------------------------
|
382
|
+
# WebSocket 端点
|
383
|
+
# ---------------------------
|
384
|
+
async def _ws_sender_loop(ws: WebSocket, queue: "asyncio.Queue[Dict[str, Any]]") -> None:
|
385
|
+
try:
|
386
|
+
while True:
|
387
|
+
payload = await queue.get()
|
388
|
+
await ws.send_text(json.dumps(payload))
|
389
|
+
except Exception:
|
390
|
+
# 发送循环异常即退出
|
391
|
+
pass
|
392
|
+
|
393
|
+
def _make_sender(queue: "asyncio.Queue[Dict[str, Any]]") -> Callable[[Dict[str, Any]], None]:
|
394
|
+
# 同步函数,供 WebBridge 注册;将消息放入异步队列,由协程发送
|
395
|
+
def _sender(payload: Dict[str, Any]) -> None:
|
396
|
+
try:
|
397
|
+
queue.put_nowait(payload)
|
398
|
+
except Exception:
|
399
|
+
pass
|
400
|
+
return _sender
|
401
|
+
|
402
|
+
def _make_sender_filtered(queue: "asyncio.Queue[Dict[str, Any]]", allowed_types: Optional[list[str]] = None) -> Callable[[Dict[str, Any]], None]:
|
403
|
+
"""
|
404
|
+
过滤版 sender:仅将指定类型的payload放入队列(用于单独的STDIO通道)。
|
405
|
+
"""
|
406
|
+
allowed = set(allowed_types or [])
|
407
|
+
def _sender(payload: Dict[str, Any]) -> None:
|
408
|
+
try:
|
409
|
+
ptype = payload.get("type")
|
410
|
+
if ptype in allowed:
|
411
|
+
queue.put_nowait(payload)
|
412
|
+
except Exception:
|
413
|
+
pass
|
414
|
+
return _sender
|
415
|
+
|
416
|
+
def _run_and_notify(agent: Any, text: str) -> None:
|
417
|
+
try:
|
418
|
+
agent.run(text)
|
419
|
+
finally:
|
420
|
+
try:
|
421
|
+
WebBridge.instance().broadcast({"type": "agent_idle"})
|
422
|
+
except Exception:
|
423
|
+
pass
|
424
|
+
|
425
|
+
def start_web_server(agent: Any, host: str = "127.0.0.1", port: int = 8765) -> None:
|
426
|
+
"""
|
427
|
+
启动Web服务,并将Agent绑定到应用上下文。
|
428
|
+
- agent: 现有的 Agent 实例(已完成初始化)
|
429
|
+
"""
|
430
|
+
app = _build_app()
|
431
|
+
app.state.agent = agent # 供 WS 端点调用
|
432
|
+
# 兼容传入 Agent 或 AgentManager:
|
433
|
+
# - 若传入的是 AgentManager,则在每个任务开始前通过 initialize() 创建全新 Agent
|
434
|
+
# - 若传入的是 Agent 实例,则复用该 Agent(旧行为)
|
435
|
+
try:
|
436
|
+
app.state.agent_manager = agent if hasattr(agent, "initialize") else None
|
437
|
+
except Exception:
|
438
|
+
app.state.agent_manager = None
|
439
|
+
|
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
|
+
@app.websocket("/stdio")
|
512
|
+
async def websocket_stdio(ws: WebSocket) -> None:
|
513
|
+
await ws.accept()
|
514
|
+
queue: asyncio.Queue[Dict[str, Any]] = asyncio.Queue()
|
515
|
+
sender = _make_sender_filtered(queue, allowed_types=["stdio"])
|
516
|
+
bridge = WebBridge.instance()
|
517
|
+
bridge.add_client(sender)
|
518
|
+
send_task = asyncio.create_task(_ws_sender_loop(ws, queue))
|
519
|
+
try:
|
520
|
+
await ws.send_text(json.dumps({"type": "output", "payload": {"text": "STDIO 通道已就绪", "output_type": "INFO"}}))
|
521
|
+
except Exception:
|
522
|
+
pass
|
523
|
+
try:
|
524
|
+
while True:
|
525
|
+
# 接收来自前端 STDIN 数据并注入到后端
|
526
|
+
msg = await ws.receive_text()
|
527
|
+
try:
|
528
|
+
data = json.loads(msg)
|
529
|
+
except Exception:
|
530
|
+
continue
|
531
|
+
mtype = data.get("type")
|
532
|
+
if mtype == "stdin":
|
533
|
+
try:
|
534
|
+
from jarvis.jarvis_agent.stdio_redirect import feed_web_stdin
|
535
|
+
text = data.get("data", "")
|
536
|
+
if isinstance(text, str) and text:
|
537
|
+
feed_web_stdin(text)
|
538
|
+
except Exception:
|
539
|
+
pass
|
540
|
+
else:
|
541
|
+
# 忽略未知类型
|
542
|
+
pass
|
543
|
+
except WebSocketDisconnect:
|
544
|
+
pass
|
545
|
+
except Exception:
|
546
|
+
pass
|
547
|
+
pass
|
548
|
+
finally:
|
549
|
+
try:
|
550
|
+
bridge.remove_client(sender)
|
551
|
+
except Exception:
|
552
|
+
pass
|
553
|
+
try:
|
554
|
+
send_task.cancel()
|
555
|
+
except Exception:
|
556
|
+
pass
|
557
|
+
|
558
|
+
@app.websocket("/control")
|
559
|
+
async def websocket_control(ws: WebSocket) -> None:
|
560
|
+
await ws.accept()
|
561
|
+
try:
|
562
|
+
while True:
|
563
|
+
msg = await ws.receive_text()
|
564
|
+
try:
|
565
|
+
data = json.loads(msg)
|
566
|
+
except Exception:
|
567
|
+
continue
|
568
|
+
mtype = data.get("type")
|
569
|
+
if mtype == "interrupt":
|
570
|
+
try:
|
571
|
+
set_interrupt(True)
|
572
|
+
# 可选:发送回执
|
573
|
+
await ws.send_text(json.dumps({"type": "ack", "cmd": "interrupt"}))
|
574
|
+
except Exception:
|
575
|
+
pass
|
576
|
+
elif mtype == "resize":
|
577
|
+
# 动态调整后端TTY宽度(影响 PrettyOutput 和基于终端宽度的逻辑)
|
578
|
+
try:
|
579
|
+
cols = int(data.get("cols") or 0)
|
580
|
+
rows = int(data.get("rows") or 0)
|
581
|
+
except Exception:
|
582
|
+
cols = 0
|
583
|
+
rows = 0
|
584
|
+
try:
|
585
|
+
if cols > 0:
|
586
|
+
os.environ["COLUMNS"] = str(cols)
|
587
|
+
try:
|
588
|
+
# 覆盖全局 rich Console 的宽度,便于 PrettyOutput 按照前端列数换行
|
589
|
+
console._width = cols # type: ignore[attr-defined]
|
590
|
+
except Exception:
|
591
|
+
pass
|
592
|
+
if rows > 0:
|
593
|
+
os.environ["LINES"] = str(rows)
|
594
|
+
except Exception:
|
595
|
+
pass
|
596
|
+
except WebSocketDisconnect:
|
597
|
+
pass
|
598
|
+
except Exception:
|
599
|
+
pass
|
600
|
+
finally:
|
601
|
+
try:
|
602
|
+
await ws.close()
|
603
|
+
except Exception:
|
604
|
+
pass
|
605
|
+
|
606
|
+
PrettyOutput.print(f"启动 Jarvis Web 服务: http://{host}:{port}", OutputType.SUCCESS)
|
607
|
+
# 在服务端进程内也写入并维护 PID 文件,增强可检测性与可清理性
|
608
|
+
try:
|
609
|
+
pidfile = Path(os.path.expanduser("~/.jarvis")) / f"jarvis_web_{port}.pid"
|
610
|
+
try:
|
611
|
+
pidfile.parent.mkdir(parents=True, exist_ok=True)
|
612
|
+
except Exception:
|
613
|
+
pass
|
614
|
+
try:
|
615
|
+
pidfile.write_text(str(os.getpid()), encoding="utf-8")
|
616
|
+
except Exception:
|
617
|
+
pass
|
618
|
+
# 退出时清理 PID 文件
|
619
|
+
def _cleanup_pidfile() -> None:
|
620
|
+
try:
|
621
|
+
pidfile.unlink(missing_ok=True) # type: ignore[call-arg]
|
622
|
+
except Exception:
|
623
|
+
pass
|
624
|
+
try:
|
625
|
+
atexit.register(_cleanup_pidfile)
|
626
|
+
except Exception:
|
627
|
+
pass
|
628
|
+
# 处理 SIGTERM/SIGINT,清理后退出
|
629
|
+
def _signal_handler(signum, frame): # type: ignore[no-untyped-def]
|
630
|
+
try:
|
631
|
+
_cleanup_pidfile()
|
632
|
+
finally:
|
633
|
+
try:
|
634
|
+
os._exit(0)
|
635
|
+
except Exception:
|
636
|
+
pass
|
637
|
+
try:
|
638
|
+
signal.signal(signal.SIGTERM, _signal_handler)
|
639
|
+
except Exception:
|
640
|
+
pass
|
641
|
+
try:
|
642
|
+
signal.signal(signal.SIGINT, _signal_handler)
|
643
|
+
except Exception:
|
644
|
+
pass
|
645
|
+
except Exception:
|
646
|
+
pass
|
647
|
+
uvicorn.run(app, host=host, port=port)
|
@@ -91,11 +91,6 @@ class CodeAgent:
|
|
91
91
|
auto_complete=False,
|
92
92
|
output_handler=[tool_registry, EditFileHandler()], # type: ignore
|
93
93
|
model_group=model_group,
|
94
|
-
input_handler=[
|
95
|
-
shell_input_handler,
|
96
|
-
file_context_handler,
|
97
|
-
builtin_input_handler,
|
98
|
-
],
|
99
94
|
need_summary=need_summary,
|
100
95
|
use_methodology=False, # 禁用方法论
|
101
96
|
use_analysis=False, # 禁用分析
|
jarvis/jarvis_platform/base.py
CHANGED
@@ -223,6 +223,7 @@ class BasePlatform(ABC):
|
|
223
223
|
duration = end_time - start_time
|
224
224
|
panel.subtitle = f"[bold green]✓ 对话完成耗时: {duration:.2f}秒[/bold green]"
|
225
225
|
live.update(panel)
|
226
|
+
console.print()
|
226
227
|
else:
|
227
228
|
# Print a clear prefix line before streaming model output (non-pretty mode)
|
228
229
|
console.print(
|
jarvis/jarvis_tools/sub_agent.py
CHANGED
@@ -137,14 +137,12 @@ class SubAgentTool:
|
|
137
137
|
# 基于父Agent(如有)继承部分配置后创建子Agent
|
138
138
|
parent_agent = args.get("agent", None)
|
139
139
|
parent_model_group = None
|
140
|
-
parent_input_handler = None
|
141
140
|
parent_execute_tool_confirm = None
|
142
141
|
parent_multiline_inputer = None
|
143
142
|
try:
|
144
143
|
if parent_agent is not None:
|
145
144
|
if getattr(parent_agent, "model", None):
|
146
145
|
parent_model_group = getattr(parent_agent.model, "model_group", None)
|
147
|
-
parent_input_handler = getattr(parent_agent, "input_handler", None)
|
148
146
|
parent_execute_tool_confirm = getattr(parent_agent, "execute_tool_confirm", None)
|
149
147
|
parent_multiline_inputer = getattr(parent_agent, "multiline_inputer", None)
|
150
148
|
except Exception:
|
@@ -160,7 +158,6 @@ class SubAgentTool:
|
|
160
158
|
auto_complete=auto_complete,
|
161
159
|
output_handler=None,
|
162
160
|
use_tools=None,
|
163
|
-
input_handler=parent_input_handler,
|
164
161
|
execute_tool_confirm=parent_execute_tool_confirm,
|
165
162
|
need_summary=need_summary,
|
166
163
|
multiline_inputer=parent_multiline_inputer,
|