jarvis-ai-assistant 0.3.33__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.
@@ -0,0 +1,296 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Web STDIO 重定向模块:
4
+ - 在 Web 模式下,将 Python 层的标准输出/错误(sys.stdout/sys.stderr)重定向到 WebSocket,通过 WebBridge 广播。
5
+ - 适用于工具或第三方库直接使用 print()/stdout/stderr 的输出,从而不经过 PrettyOutput Sink 的场景。
6
+
7
+ 注意:
8
+ - 这是进程级重定向,可能带来重复输出(PrettyOutput 已通过 Sink 广播一次,console.print 也会走到 stdout)。若需要避免重复,可在前端针对 'stdio' 类型进行独立显示或折叠。
9
+ - 对于子进程输出(subprocess),通常由调用方决定是否捕获和打印;若直接透传到父进程的 stdout/stderr,也会被此重定向捕获。
10
+
11
+ 前端消息结构(通过 WebBridge.broadcast):
12
+ { "type": "stdio", "stream": "stdout" | "stderr", "text": "..." }
13
+
14
+ 使用:
15
+ from jarvis.jarvis_agent.stdio_redirect import enable_web_stdio_redirect, disable_web_stdio_redirect
16
+ enable_web_stdio_redirect()
17
+ # ... 运行期间输出将通过 WS 广播 ...
18
+ disable_web_stdio_redirect()
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import sys
23
+ import threading
24
+ from typing import Optional
25
+
26
+ from jarvis.jarvis_agent.web_bridge import WebBridge
27
+
28
+
29
+ _original_stdout = sys.stdout
30
+ _original_stderr = sys.stderr
31
+ _redirect_enabled = False
32
+ _lock = threading.Lock()
33
+
34
+
35
+ class _WebStreamWrapper:
36
+ """文件类兼容包装器,将 write() 的内容通过 WebBridge 广播。"""
37
+
38
+ def __init__(self, stream_name: str) -> None:
39
+ self._stream_name = stream_name
40
+ try:
41
+ self._encoding = getattr(_original_stdout, "encoding", "utf-8")
42
+ except Exception:
43
+ self._encoding = "utf-8"
44
+
45
+ def write(self, s: object) -> int:
46
+ try:
47
+ text = s if isinstance(s, str) else str(s)
48
+ except Exception:
49
+ text = repr(s)
50
+ try:
51
+ WebBridge.instance().broadcast({
52
+ "type": "stdio",
53
+ "stream": self._stream_name,
54
+ "text": text,
55
+ })
56
+ except Exception:
57
+ # 广播异常不影响主流程
58
+ pass
59
+ # 返回写入长度以兼容部分调用方
60
+ try:
61
+ return len(text)
62
+ except Exception:
63
+ return 0
64
+
65
+ def flush(self) -> None:
66
+ # 无需实际刷新;保持接口兼容
67
+ pass
68
+
69
+ def isatty(self) -> bool:
70
+ return False
71
+
72
+ @property
73
+ def encoding(self) -> str:
74
+ return self._encoding
75
+
76
+ def writelines(self, lines) -> None:
77
+ for ln in lines:
78
+ self.write(ln)
79
+
80
+ def __getattr__(self, name: str):
81
+ # 兼容性:必要时委派到原始 stdout/stderr 的属性(尽量避免)
82
+ try:
83
+ return getattr(_original_stdout if self._stream_name == "stdout" else _original_stderr, name)
84
+ except Exception:
85
+ raise AttributeError(name)
86
+
87
+
88
+ def enable_web_stdio_redirect() -> None:
89
+ """启用全局 STDOUT/STDERR 到 WebSocket 的重定向。"""
90
+ global _redirect_enabled
91
+ with _lock:
92
+ if _redirect_enabled:
93
+ return
94
+ try:
95
+ sys.stdout = _WebStreamWrapper("stdout") # type: ignore[assignment]
96
+ sys.stderr = _WebStreamWrapper("stderr") # type: ignore[assignment]
97
+ _redirect_enabled = True
98
+ except Exception:
99
+ # 回退:保持原始输出
100
+ sys.stdout = _original_stdout
101
+ sys.stderr = _original_stderr
102
+ _redirect_enabled = False
103
+
104
+
105
+ def disable_web_stdio_redirect() -> None:
106
+ """禁用全局 STDOUT/STDERR 重定向,恢复原始输出。"""
107
+ global _redirect_enabled
108
+ with _lock:
109
+ try:
110
+ sys.stdout = _original_stdout
111
+ sys.stderr = _original_stderr
112
+ except Exception:
113
+ pass
114
+ _redirect_enabled = False
115
+
116
+
117
+ # ---------------------------
118
+ # Web STDIN 重定向(浏览器 -> 后端)
119
+ # ---------------------------
120
+ # 目的:
121
+ # - 将前端 xterm 的按键数据通过 WS 送回服务端,并作为 sys.stdin 的数据源
122
+ # - 使得 Python 层的 input()/sys.stdin.readline() 等可以从浏览器获得输入
123
+ # - 仅适用于部分交互式场景(非真正 PTY 行为),可满足基础行缓冲输入
124
+ from queue import Queue, Empty
125
+
126
+
127
+ _original_stdin = sys.stdin
128
+ _stdin_enabled = False
129
+ _stdin_wrapper = None # type: ignore[assignment]
130
+
131
+
132
+ class _WebInputWrapper:
133
+ """文件类兼容包装器:作为 sys.stdin 的替身,从队列中读取浏览器送来的数据。"""
134
+
135
+ def __init__(self) -> None:
136
+ self._queue: "Queue[str]" = Queue()
137
+ self._buffer: str = ""
138
+ self._lock = threading.Lock()
139
+ try:
140
+ self._encoding = getattr(_original_stdin, "encoding", "utf-8") # type: ignore[name-defined]
141
+ except Exception:
142
+ self._encoding = "utf-8"
143
+
144
+ # 外部注入:由 WebSocket 端点调用
145
+ def feed(self, data: str) -> None:
146
+ try:
147
+ s = data if isinstance(data, str) else str(data)
148
+ except Exception:
149
+ s = repr(data)
150
+ # 将回车转换为换行,方便基于 readline 的读取
151
+ s = s.replace("\r", "\n")
152
+ self._queue.put_nowait(s)
153
+
154
+ # 基础读取:尽可能兼容常用调用
155
+ def read(self, size: int = -1) -> str:
156
+ # size < 0 表示尽可能多地读取(直到当前缓冲区内容)
157
+ if size == 0:
158
+ return ""
159
+
160
+ while True:
161
+ with self._lock:
162
+ if size > 0 and len(self._buffer) >= size:
163
+ out = self._buffer[:size]
164
+ self._buffer = self._buffer[size:]
165
+ return out
166
+ if size < 0 and self._buffer:
167
+ out = self._buffer
168
+ self._buffer = ""
169
+ return out
170
+ # 需要更多数据,阻塞等待
171
+ try:
172
+ chunk = self._queue.get(timeout=None)
173
+ except Exception:
174
+ chunk = ""
175
+ if not isinstance(chunk, str):
176
+ try:
177
+ chunk = str(chunk)
178
+ except Exception:
179
+ chunk = ""
180
+ with self._lock:
181
+ self._buffer += chunk
182
+
183
+ def readline(self, size: int = -1) -> str:
184
+ # 读取到换行符为止(包含换行),可选 size 限制
185
+ while True:
186
+ with self._lock:
187
+ idx = self._buffer.find("\n")
188
+ if idx != -1:
189
+ # 找到换行
190
+ end_index = idx + 1
191
+ if size > 0:
192
+ end_index = min(end_index, size)
193
+ out = self._buffer[:end_index]
194
+ self._buffer = self._buffer[end_index:]
195
+ return out
196
+ # 未找到换行,但如果指定了 size 且缓冲已有足够数据,则返回
197
+ if size > 0 and len(self._buffer) >= size:
198
+ out = self._buffer[:size]
199
+ self._buffer = self._buffer[size:]
200
+ return out
201
+ # 更多数据
202
+ try:
203
+ chunk = self._queue.get(timeout=None)
204
+ except Exception:
205
+ chunk = ""
206
+ if not isinstance(chunk, str):
207
+ try:
208
+ chunk = str(chunk)
209
+ except Exception:
210
+ chunk = ""
211
+ with self._lock:
212
+ self._buffer += chunk
213
+
214
+ def readlines(self, hint: int = -1):
215
+ lines = []
216
+ total = 0
217
+ while True:
218
+ ln = self.readline()
219
+ if not ln:
220
+ break
221
+ lines.append(ln)
222
+ total += len(ln)
223
+ if hint > 0 and total >= hint:
224
+ break
225
+ return lines
226
+
227
+ def writable(self) -> bool:
228
+ return False
229
+
230
+ def readable(self) -> bool:
231
+ return True
232
+
233
+ def seekable(self) -> bool:
234
+ return False
235
+
236
+ def flush(self) -> None:
237
+ pass
238
+
239
+ def isatty(self) -> bool:
240
+ # 伪装为 TTY,可改善部分库的行为(注意并非真正 PTY)
241
+ return True
242
+
243
+ @property
244
+ def encoding(self) -> str:
245
+ return self._encoding
246
+
247
+ def __getattr__(self, name: str):
248
+ # 尽量代理到原始 stdin 的属性以增强兼容性
249
+ try:
250
+ return getattr(_original_stdin, name)
251
+ except Exception:
252
+ raise AttributeError(name)
253
+
254
+
255
+ def enable_web_stdin_redirect() -> None:
256
+ """启用 Web STDIN 重定向:将 sys.stdin 替换为浏览器数据源。"""
257
+ global _stdin_enabled, _stdin_wrapper, _original_stdin
258
+ with _lock:
259
+ if _stdin_enabled:
260
+ return
261
+ try:
262
+ # 记录原始 stdin(若尚未记录)
263
+ if "_original_stdin" not in globals() or _original_stdin is None:
264
+ _original_stdin = sys.stdin # type: ignore[assignment]
265
+ _stdin_wrapper = _WebInputWrapper()
266
+ sys.stdin = _stdin_wrapper # type: ignore[assignment]
267
+ _stdin_enabled = True
268
+ except Exception:
269
+ # 回退:保持原始输入
270
+ try:
271
+ sys.stdin = _original_stdin # type: ignore[assignment]
272
+ except Exception:
273
+ pass
274
+ _stdin_enabled = False
275
+
276
+
277
+ def disable_web_stdin_redirect() -> None:
278
+ """禁用 Web STDIN 重定向,恢复原始输入。"""
279
+ global _stdin_enabled, _stdin_wrapper
280
+ with _lock:
281
+ try:
282
+ sys.stdin = _original_stdin # type: ignore[assignment]
283
+ except Exception:
284
+ pass
285
+ _stdin_wrapper = None
286
+ _stdin_enabled = False
287
+
288
+
289
+ def feed_web_stdin(data: str) -> None:
290
+ """向 Web STDIN 注入数据(由 WebSocket /stdio 端点调用)。"""
291
+ try:
292
+ if _stdin_enabled and _stdin_wrapper is not None:
293
+ _stdin_wrapper.feed(data) # type: ignore[attr-defined]
294
+ except Exception:
295
+ # 注入失败不影响主流程
296
+ pass
@@ -0,0 +1,189 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ WebBridge: WebSocket 交互桥
4
+ - 提供线程安全的广播能力(后续由 WebSocket 服务注册发送函数)
5
+ - 提供阻塞式的多行输入与确认请求(通过 request_* 发起请求,等待浏览器端响应)
6
+ - 适配 Agent 的输入注入接口:web_multiline_input / web_user_confirm
7
+ - 事件约定(发往前端,均为 JSON 对象):
8
+ * {"type":"input_request","mode":"multiline","tip": "...","print_on_empty": true/false,"request_id":"..."}
9
+ * {"type":"confirm_request","tip":"...","default": true/false,"request_id":"..."}
10
+ 后续输出事件由输出Sink负责(使用 PrettyOutput.add_sink 接入),不在本桥内实现。
11
+ - 事件约定(来自前端):
12
+ * {"type":"user_input","request_id":"...","text":"..."}
13
+ * {"type":"confirm_response","request_id":"...","value": true/false}
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import threading
18
+ import uuid
19
+ from queue import Queue, Empty
20
+ from typing import Callable, Dict, Optional, Set, Any
21
+
22
+ DEFAULT_WAIT_TIMEOUT = None # 阻塞等待直到收到响应(可按需改为秒数)
23
+
24
+
25
+ class WebBridge:
26
+ """
27
+ 线程安全的 WebSocket 交互桥。
28
+ - 维护一组客户端发送函数(由Web服务注册),用于广播事件
29
+ - 维护挂起的输入/确认请求队列,按 request_id 匹配响应
30
+ """
31
+
32
+ _instance_lock = threading.Lock()
33
+ _instance: Optional["WebBridge"] = None
34
+
35
+ def __init__(self) -> None:
36
+ self._clients: Set[Callable[[Dict[str, Any]], None]] = set()
37
+ self._clients_lock = threading.Lock()
38
+
39
+ # 按 request_id 等待的阻塞队列
40
+ self._pending_inputs: Dict[str, Queue] = {}
41
+ self._pending_confirms: Dict[str, Queue] = {}
42
+ self._pending_lock = threading.Lock()
43
+
44
+ @classmethod
45
+ def instance(cls) -> "WebBridge":
46
+ with cls._instance_lock:
47
+ if cls._instance is None:
48
+ cls._instance = WebBridge()
49
+ return cls._instance
50
+
51
+ # ---------------------------
52
+ # 客户端管理与广播
53
+ # ---------------------------
54
+ def add_client(self, sender: Callable[[Dict[str, Any]], None]) -> None:
55
+ """
56
+ 注册一个客户端发送函数。发送函数需接受一个 dict,并自行完成异步发送。
57
+ 例如在 FastAPI/WS 中包装成 enqueue 到事件循环的任务。
58
+ """
59
+ with self._clients_lock:
60
+ self._clients.add(sender)
61
+
62
+ def remove_client(self, sender: Callable[[Dict[str, Any]], None]) -> None:
63
+ with self._clients_lock:
64
+ if sender in self._clients:
65
+ self._clients.remove(sender)
66
+
67
+ def broadcast(self, payload: Dict[str, Any]) -> None:
68
+ """
69
+ 广播一条消息给所有客户端。失败的客户端不影响其他客户端。
70
+ """
71
+ with self._clients_lock:
72
+ targets = list(self._clients)
73
+ for send in targets:
74
+ try:
75
+ send(payload)
76
+ except Exception:
77
+ # 静默忽略单个客户端的发送异常
78
+ pass
79
+
80
+ # ---------------------------
81
+ # 输入/确认 请求-响应 管理
82
+ # ---------------------------
83
+ def request_multiline_input(self, tip: str, print_on_empty: bool = True, timeout: Optional[float] = DEFAULT_WAIT_TIMEOUT) -> str:
84
+ """
85
+ 发起一个多行输入请求并阻塞等待浏览器返回。
86
+ 返回用户输入的文本(可能为空字符串,表示取消)。
87
+ """
88
+ req_id = uuid.uuid4().hex
89
+ q: Queue = Queue(maxsize=1)
90
+ with self._pending_lock:
91
+ self._pending_inputs[req_id] = q
92
+
93
+ self.broadcast({
94
+ "type": "input_request",
95
+ "mode": "multiline",
96
+ "tip": tip,
97
+ "print_on_empty": bool(print_on_empty),
98
+ "request_id": req_id,
99
+ })
100
+
101
+ try:
102
+ if timeout is None:
103
+ result = q.get() # 阻塞直到有结果
104
+ else:
105
+ result = q.get(timeout=timeout)
106
+ except Empty:
107
+ result = "" # 超时回退为空
108
+ finally:
109
+ with self._pending_lock:
110
+ self._pending_inputs.pop(req_id, None)
111
+
112
+ # 规范化为字符串
113
+ return str(result or "")
114
+
115
+ def request_confirm(self, tip: str, default: bool = True, timeout: Optional[float] = DEFAULT_WAIT_TIMEOUT) -> bool:
116
+ """
117
+ 发起一个确认请求并阻塞等待浏览器返回。
118
+ 返回 True/False,若超时则回退为 default。
119
+ """
120
+ req_id = uuid.uuid4().hex
121
+ q: Queue = Queue(maxsize=1)
122
+ with self._pending_lock:
123
+ self._pending_confirms[req_id] = q
124
+
125
+ self.broadcast({
126
+ "type": "confirm_request",
127
+ "tip": tip,
128
+ "default": bool(default),
129
+ "request_id": req_id,
130
+ })
131
+
132
+ try:
133
+ if timeout is None:
134
+ result = q.get()
135
+ else:
136
+ result = q.get(timeout=timeout)
137
+ except Empty:
138
+ result = default
139
+ finally:
140
+ with self._pending_lock:
141
+ self._pending_confirms.pop(req_id, None)
142
+
143
+ return bool(result)
144
+
145
+ # ---------------------------
146
+ # 由 Web 服务回调:注入用户响应
147
+ # ---------------------------
148
+ def post_user_input(self, request_id: str, text: str) -> None:
149
+ """
150
+ 注入浏览器端的多行输入响应。
151
+ """
152
+ with self._pending_lock:
153
+ q = self._pending_inputs.get(request_id)
154
+ if q:
155
+ try:
156
+ q.put_nowait(text)
157
+ except Exception:
158
+ pass
159
+
160
+ def post_confirm(self, request_id: str, value: bool) -> None:
161
+ """
162
+ 注入浏览器端的确认响应。
163
+ """
164
+ with self._pending_lock:
165
+ q = self._pending_confirms.get(request_id)
166
+ if q:
167
+ try:
168
+ q.put_nowait(bool(value))
169
+ except Exception:
170
+ pass
171
+
172
+
173
+ # ---------------------------
174
+ # 供 Agent 注入的输入函数
175
+ # ---------------------------
176
+ def web_multiline_input(tip: str, print_on_empty: bool = True) -> str:
177
+ """
178
+ 适配 Agent.multiline_inputer 签名的多行输入函数。
179
+ 在 Web 模式下被注入到 Agent,转由浏览器端输入。
180
+ """
181
+ return WebBridge.instance().request_multiline_input(tip, print_on_empty)
182
+
183
+
184
+ def web_user_confirm(tip: str, default: bool = True) -> bool:
185
+ """
186
+ 适配 Agent.confirm_callback 签名的确认函数。
187
+ 在 Web 模式下被注入到 Agent,转由浏览器端确认。
188
+ """
189
+ return WebBridge.instance().request_confirm(tip, default)
@@ -0,0 +1,53 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ WebSocketOutputSink: 将 PrettyOutput 的输出事件通过 WebBridge 广播给前端(WebSocket 客户端)
4
+
5
+ 用法:
6
+ - 在 Web 模式启动时,注册该 Sink:
7
+ from jarvis.jarvis_utils.output import PrettyOutput
8
+ from jarvis.jarvis_agent.web_output_sink import WebSocketOutputSink
9
+ PrettyOutput.add_sink(WebSocketOutputSink())
10
+
11
+ - Web 端收到的消息结构:
12
+ {
13
+ "type": "output",
14
+ "payload": {
15
+ "text": "...",
16
+ "output_type": "INFO" | "ERROR" | ...,
17
+ "timestamp": true/false,
18
+ "lang": "markdown" | "python" | ... | null,
19
+ "traceback": false,
20
+ "section": null | "标题",
21
+ "context": { ... } | null
22
+ }
23
+ }
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from typing import Any, Dict
28
+
29
+ from jarvis.jarvis_utils.output import OutputSink, OutputEvent
30
+ from jarvis.jarvis_agent.web_bridge import WebBridge
31
+
32
+
33
+ class WebSocketOutputSink(OutputSink):
34
+ """将输出事件广播到 WebSocket 前端的 OutputSink 实现。"""
35
+
36
+ def emit(self, event: OutputEvent) -> None:
37
+ try:
38
+ payload: Dict[str, Any] = {
39
+ "type": "output",
40
+ "payload": {
41
+ "text": event.text,
42
+ "output_type": event.output_type.value,
43
+ "timestamp": bool(event.timestamp),
44
+ "lang": event.lang,
45
+ "traceback": bool(event.traceback),
46
+ "section": event.section,
47
+ "context": event.context,
48
+ },
49
+ }
50
+ WebBridge.instance().broadcast(payload)
51
+ except Exception:
52
+ # 广播过程中的异常不应影响其他输出后端
53
+ pass