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,143 @@
|
|
1
|
+
# -*- coding: utf-8 -*-
|
2
|
+
import os
|
3
|
+
import re
|
4
|
+
from typing import Any, Dict, List, Tuple
|
5
|
+
|
6
|
+
from jarvis.jarvis_agent.output_handler import OutputHandler
|
7
|
+
from jarvis.jarvis_utils.output import OutputType, PrettyOutput
|
8
|
+
from jarvis.jarvis_utils.tag import ct, ot
|
9
|
+
|
10
|
+
|
11
|
+
class RewriteFileHandler(OutputHandler):
|
12
|
+
"""
|
13
|
+
处理整文件重写指令的输出处理器。
|
14
|
+
|
15
|
+
指令格式:
|
16
|
+
<REWRITE file=文件路径>
|
17
|
+
新的文件完整内容
|
18
|
+
</REWRITE>
|
19
|
+
|
20
|
+
等价支持以下写法:
|
21
|
+
<REWRITE file=文件路径>
|
22
|
+
新的文件完整内容
|
23
|
+
</REWRITE>
|
24
|
+
|
25
|
+
说明:
|
26
|
+
- 该处理器用于完全重写文件内容,适用于新增文件或大范围改写
|
27
|
+
- 内部直接执行写入,提供失败回滚能力
|
28
|
+
- 支持同一响应中包含多个 REWRITE/REWRITE 块
|
29
|
+
"""
|
30
|
+
|
31
|
+
def __init__(self) -> None:
|
32
|
+
# 允许 file 参数为单引号、双引号或无引号
|
33
|
+
self.rewrite_pattern_file = re.compile(
|
34
|
+
ot("REWRITE file=(?:'([^']+)'|\"([^\"]+)\"|([^>]+))")
|
35
|
+
+ r"\s*"
|
36
|
+
+ r"(.*?)"
|
37
|
+
+ r"\s*"
|
38
|
+
+ r"^"
|
39
|
+
+ ct("REWRITE"),
|
40
|
+
re.DOTALL | re.MULTILINE,
|
41
|
+
)
|
42
|
+
|
43
|
+
def name(self) -> str:
|
44
|
+
"""获取处理器名称,用于操作列表展示"""
|
45
|
+
return "REWRITE"
|
46
|
+
|
47
|
+
def prompt(self) -> str:
|
48
|
+
"""返回用户提示,描述使用方法与格式"""
|
49
|
+
return f"""文件重写指令格式:
|
50
|
+
{ot("REWRITE file=文件路径")}
|
51
|
+
新的文件完整内容
|
52
|
+
{ct("REWRITE")}
|
53
|
+
|
54
|
+
注意:
|
55
|
+
- {ot("REWRITE")}、{ct("REWRITE")} 必须出现在行首,否则不生效(会被忽略)
|
56
|
+
- 整文件重写会完全替换文件内容,如需局部修改请使用 PATCH 操作
|
57
|
+
- 该操作由处理器直接执行,具备失败回滚能力"""
|
58
|
+
|
59
|
+
def can_handle(self, response: str) -> bool:
|
60
|
+
"""判断响应中是否包含 REWRITE/REWRITE 指令"""
|
61
|
+
return bool(self.rewrite_pattern_file.search(response))
|
62
|
+
|
63
|
+
def handle(self, response: str, agent: Any) -> Tuple[bool, str]:
|
64
|
+
"""解析并执行整文件重写指令"""
|
65
|
+
rewrites = self._parse_rewrites(response)
|
66
|
+
if not rewrites:
|
67
|
+
return False, "未找到有效的文件重写指令"
|
68
|
+
|
69
|
+
# 记录 REWRITE 操作调用统计
|
70
|
+
try:
|
71
|
+
from jarvis.jarvis_stats.stats import StatsManager
|
72
|
+
|
73
|
+
StatsManager.increment("rewrite_file", group="tool")
|
74
|
+
except Exception:
|
75
|
+
# 统计失败不影响主流程
|
76
|
+
pass
|
77
|
+
|
78
|
+
results: List[str] = []
|
79
|
+
overall_success = True
|
80
|
+
|
81
|
+
for file_path, content in rewrites:
|
82
|
+
abs_path = os.path.abspath(file_path)
|
83
|
+
original_content = None
|
84
|
+
processed = False
|
85
|
+
try:
|
86
|
+
file_exists = os.path.exists(abs_path)
|
87
|
+
if file_exists:
|
88
|
+
with open(abs_path, "r", encoding="utf-8") as rf:
|
89
|
+
original_content = rf.read()
|
90
|
+
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
91
|
+
with open(abs_path, "w", encoding="utf-8") as wf:
|
92
|
+
wf.write(content)
|
93
|
+
processed = True
|
94
|
+
results.append(f"✅ 文件 {abs_path} 重写成功")
|
95
|
+
# 记录成功处理的文件(使用绝对路径)
|
96
|
+
if agent:
|
97
|
+
files = agent.get_user_data("files")
|
98
|
+
if files:
|
99
|
+
if abs_path not in files:
|
100
|
+
files.append(abs_path)
|
101
|
+
else:
|
102
|
+
files = [abs_path]
|
103
|
+
agent.set_user_data("files", files)
|
104
|
+
except Exception as e:
|
105
|
+
overall_success = False
|
106
|
+
# 回滚已修改内容
|
107
|
+
try:
|
108
|
+
if processed:
|
109
|
+
if original_content is None:
|
110
|
+
if os.path.exists(abs_path):
|
111
|
+
os.remove(abs_path)
|
112
|
+
else:
|
113
|
+
with open(abs_path, "w", encoding="utf-8") as wf:
|
114
|
+
wf.write(original_content)
|
115
|
+
except Exception:
|
116
|
+
pass
|
117
|
+
PrettyOutput.print(f"文件重写失败: {str(e)}", OutputType.ERROR)
|
118
|
+
results.append(f"❌ 文件 {abs_path} 重写失败: {str(e)}")
|
119
|
+
|
120
|
+
summary = "\n".join(results)
|
121
|
+
# 按现有 EditFileHandler 约定,始终返回 (False, summary) 以继续主循环
|
122
|
+
return False, summary
|
123
|
+
|
124
|
+
def _parse_rewrites(self, response: str) -> List[Tuple[str, str]]:
|
125
|
+
"""
|
126
|
+
解析响应中的 REWRITE/REWRITE 指令块。
|
127
|
+
返回列表 [(file_path, content), ...],按在响应中的出现顺序排序
|
128
|
+
"""
|
129
|
+
items: List[Tuple[str, str]] = []
|
130
|
+
matches: List[Tuple[int, Any]] = []
|
131
|
+
for m in self.rewrite_pattern_file.finditer(response):
|
132
|
+
matches.append((m.start(), m))
|
133
|
+
|
134
|
+
# 按出现顺序排序
|
135
|
+
matches.sort(key=lambda x: x[0])
|
136
|
+
|
137
|
+
for _, m in matches:
|
138
|
+
file_path = m.group(1) or m.group(2) or m.group(3) or ""
|
139
|
+
file_path = file_path.strip()
|
140
|
+
content = m.group(4)
|
141
|
+
if file_path:
|
142
|
+
items.append((file_path, content))
|
143
|
+
return items
|
jarvis/jarvis_agent/run_loop.py
CHANGED
@@ -81,12 +81,13 @@ class AgentRunLoop:
|
|
81
81
|
pass
|
82
82
|
need_return, tool_prompt = ag._call_tools(current_response)
|
83
83
|
|
84
|
-
#
|
85
|
-
ag.session.prompt = join_prompts([ag.session.prompt, tool_prompt])
|
86
|
-
|
84
|
+
# 如果工具要求立即返回结果(例如 SEND_MESSAGE 需要将字典返回给上层),直接返回该结果
|
87
85
|
if need_return:
|
88
|
-
return
|
86
|
+
return tool_prompt
|
89
87
|
|
88
|
+
# 将上一个提示和工具提示安全地拼接起来(仅当工具结果为字符串时)
|
89
|
+
safe_tool_prompt = tool_prompt if isinstance(tool_prompt, str) else ""
|
90
|
+
ag.session.prompt = join_prompts([ag.session.prompt, safe_tool_prompt])
|
90
91
|
|
91
92
|
# 广播工具调用后的事件(不影响主流程)
|
92
93
|
try:
|
@@ -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
|
jarvis/jarvis_agent/utils.py
CHANGED
@@ -16,7 +16,11 @@ def join_prompts(parts: Iterable[str]) -> str:
|
|
16
16
|
- 使用两个换行分隔
|
17
17
|
- 不进行额外 strip,保持调用方原样语义
|
18
18
|
"""
|
19
|
-
|
19
|
+
try:
|
20
|
+
non_empty: List[str] = [p for p in parts if isinstance(p, str) and p]
|
21
|
+
except Exception:
|
22
|
+
# 防御性处理:若 parts 不可迭代或出现异常,直接返回空字符串
|
23
|
+
return ""
|
20
24
|
return "\n\n".join(non_empty)
|
21
25
|
|
22
26
|
def is_auto_complete(response: str) -> bool:
|
@@ -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)
|