auto-coder 0.1.287__py3-none-any.whl → 0.1.288__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.
Potentially problematic release.
This version of auto-coder might be problematic. Click here for more details.
- {auto_coder-0.1.287.dist-info → auto_coder-0.1.288.dist-info}/METADATA +1 -1
- {auto_coder-0.1.287.dist-info → auto_coder-0.1.288.dist-info}/RECORD +21 -13
- autocoder/chat_auto_coder.py +265 -82
- autocoder/chat_auto_coder_lang.py +9 -5
- autocoder/commands/auto_web.py +1062 -0
- autocoder/common/__init__.py +1 -2
- autocoder/common/anything2img.py +113 -43
- autocoder/common/auto_coder_lang.py +27 -0
- autocoder/common/computer_use.py +931 -0
- autocoder/plugins/__init__.py +1123 -0
- autocoder/plugins/dynamic_completion_example.py +148 -0
- autocoder/plugins/git_helper_plugin.py +252 -0
- autocoder/plugins/sample_plugin.py +160 -0
- autocoder/plugins/token_helper_plugin.py +343 -0
- autocoder/plugins/utils.py +9 -0
- autocoder/rag/relevant_utils.py +1 -1
- autocoder/version.py +1 -1
- {auto_coder-0.1.287.dist-info → auto_coder-0.1.288.dist-info}/LICENSE +0 -0
- {auto_coder-0.1.287.dist-info → auto_coder-0.1.288.dist-info}/WHEEL +0 -0
- {auto_coder-0.1.287.dist-info → auto_coder-0.1.288.dist-info}/entry_points.txt +0 -0
- {auto_coder-0.1.287.dist-info → auto_coder-0.1.288.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import json
|
|
4
|
+
import pyautogui
|
|
5
|
+
import numpy as np
|
|
6
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
7
|
+
import byzerllm
|
|
8
|
+
from loguru import logger
|
|
9
|
+
from typing import List, Dict, Tuple, Optional, Any, Union
|
|
10
|
+
import platform
|
|
11
|
+
from autocoder.common import AutoCoderArgs
|
|
12
|
+
from byzerllm.utils.client import code_utils
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.panel import Panel
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
import pydantic
|
|
17
|
+
|
|
18
|
+
class LoadingStatus(pydantic.BaseModel):
|
|
19
|
+
should_wait: bool
|
|
20
|
+
reason: str
|
|
21
|
+
class ComputerUse:
|
|
22
|
+
"""
|
|
23
|
+
计算机交互工具类,提供以下功能:
|
|
24
|
+
1. 屏幕截图
|
|
25
|
+
2. 检测屏幕上的物体
|
|
26
|
+
3. 鼠标点击
|
|
27
|
+
4. 鼠标滑动
|
|
28
|
+
5. 键盘输入
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
llm: byzerllm.ByzerLLM,
|
|
34
|
+
args: AutoCoderArgs,
|
|
35
|
+
screenshot_dir: str = None,
|
|
36
|
+
pause: float = 0.5 # 每次操作后的暂停时间
|
|
37
|
+
):
|
|
38
|
+
"""
|
|
39
|
+
初始化计算机交互工具
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
llm: ByzerLLM 实例,用于视觉分析
|
|
43
|
+
args: AutoCoderArgs 配置
|
|
44
|
+
screenshot_dir: 截图保存目录,如果为 None 则使用 args.output
|
|
45
|
+
pause: 每次操作后的暂停时间,单位为秒
|
|
46
|
+
"""
|
|
47
|
+
self.llm = llm
|
|
48
|
+
# 获取视觉模型子客户端
|
|
49
|
+
if llm.get_sub_client("vl_model"):
|
|
50
|
+
self.vl_model = llm.get_sub_client("vl_model")
|
|
51
|
+
else:
|
|
52
|
+
self.vl_model = self.llm
|
|
53
|
+
|
|
54
|
+
self.args = args
|
|
55
|
+
self.screenshot_dir = screenshot_dir or os.path.join(args.output, "screenshots")
|
|
56
|
+
os.makedirs(self.screenshot_dir, exist_ok=True)
|
|
57
|
+
|
|
58
|
+
# 设置 pyautogui 暂停时间
|
|
59
|
+
pyautogui.PAUSE = pause
|
|
60
|
+
|
|
61
|
+
# 获取系统信息
|
|
62
|
+
self.system = platform.system()
|
|
63
|
+
logger.info(f"运行于 {self.system} 系统")
|
|
64
|
+
|
|
65
|
+
# 获取屏幕尺寸
|
|
66
|
+
self.screen_width, self.screen_height = pyautogui.size()
|
|
67
|
+
logger.info(f"屏幕尺寸: {self.screen_width}x{self.screen_height}")
|
|
68
|
+
|
|
69
|
+
# 设置 pyautogui 的失败安全机制
|
|
70
|
+
pyautogui.FAILSAFE = True # 将鼠标移动到屏幕左上角会触发异常,中断程序
|
|
71
|
+
|
|
72
|
+
def screenshot(self, filename: str = None) -> str:
|
|
73
|
+
"""
|
|
74
|
+
截取屏幕并保存为图片
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
filename: 保存的文件名,如果为 None 则自动生成
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
截图的保存路径
|
|
81
|
+
"""
|
|
82
|
+
# 生成文件名
|
|
83
|
+
if filename is None:
|
|
84
|
+
timestamp = time.strftime("%Y%m%d_%H%M%S", time.localtime())
|
|
85
|
+
filename = f"screenshot_{timestamp}.png"
|
|
86
|
+
|
|
87
|
+
# 确保文件名有扩展名
|
|
88
|
+
if not filename.lower().endswith(('.png', '.jpg', '.jpeg')):
|
|
89
|
+
filename += '.png'
|
|
90
|
+
|
|
91
|
+
# 完整路径
|
|
92
|
+
filepath = os.path.join(self.screenshot_dir, filename)
|
|
93
|
+
|
|
94
|
+
# 截屏
|
|
95
|
+
screenshot = pyautogui.screenshot()
|
|
96
|
+
screenshot.save(filepath)
|
|
97
|
+
logger.info(f"屏幕截图已保存到 {filepath}")
|
|
98
|
+
|
|
99
|
+
return filepath
|
|
100
|
+
|
|
101
|
+
@byzerllm.prompt()
|
|
102
|
+
def detect_objects(self, image_path: str) -> str:
|
|
103
|
+
"""
|
|
104
|
+
{{ image }}
|
|
105
|
+
请分析这张屏幕截图,识别出其中的各种界面元素(如按钮、输入框、菜单、图标等),并给出每个元素的bounding box坐标。
|
|
106
|
+
|
|
107
|
+
bouding box 使用 (xmin, ymin, xmax, ymax) 来表示,其中xmin, ymin: 表示矩形左上角的坐标
|
|
108
|
+
xmax, ymax: 表示矩形右下角的坐标
|
|
109
|
+
|
|
110
|
+
最后按如下格式返回:
|
|
111
|
+
```json
|
|
112
|
+
{
|
|
113
|
+
"objects": [
|
|
114
|
+
{
|
|
115
|
+
"type": "button", # 元素类型,如 button, input, icon, menu, text 等
|
|
116
|
+
"bounding_box": [xmin, ymin, xmax, ymax],
|
|
117
|
+
"text": "按钮上的文字或元素描述",
|
|
118
|
+
"confidence": 0.95 # 可选,置信度
|
|
119
|
+
},
|
|
120
|
+
...
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
"""
|
|
125
|
+
image = byzerllm.Image.load_image_from_path(image_path)
|
|
126
|
+
return {"image": image}
|
|
127
|
+
|
|
128
|
+
@byzerllm.prompt()
|
|
129
|
+
def find_elements(self, image_path: str, element_desc: str) -> str:
|
|
130
|
+
"""
|
|
131
|
+
{{ image }}
|
|
132
|
+
|
|
133
|
+
请在屏幕截图中找到所有符合以下描述的用户界面元素:
|
|
134
|
+
<element_desc>
|
|
135
|
+
{{ element_desc }}
|
|
136
|
+
</element_desc>
|
|
137
|
+
|
|
138
|
+
请考虑各种类型的UI元素,包括但不限于:
|
|
139
|
+
- 按钮(button)
|
|
140
|
+
- 输入框(input)
|
|
141
|
+
- 下拉菜单(dropdown)
|
|
142
|
+
- 复选框(checkbox)
|
|
143
|
+
- 单选按钮(radio)
|
|
144
|
+
- 标签(label)
|
|
145
|
+
- 图标(icon)
|
|
146
|
+
- 菜单项(menu item)
|
|
147
|
+
- 导航栏(navigation)
|
|
148
|
+
- 滑块(slider)
|
|
149
|
+
|
|
150
|
+
请注意以下几点:
|
|
151
|
+
1. 即使元素仅部分匹配描述,也请返回
|
|
152
|
+
2. 对于文本元素,请尝试识别包含相似或相关文本的元素
|
|
153
|
+
3. 考虑元素在界面中的上下文和位置关系
|
|
154
|
+
4. 适应不同的视觉样式(包括深色/浅色主题)
|
|
155
|
+
5. 如果找到多个匹配项,请全部返回并按置信度排序
|
|
156
|
+
|
|
157
|
+
对于每个找到的元素,请提供以下信息:
|
|
158
|
+
- bounding box坐标 (xmin, ymin, xmax, ymax),其中 xmin,ymin表示左上角坐标,xmax,ymax表示右下角坐标。
|
|
159
|
+
- 元素类型(如按钮、输入框等)
|
|
160
|
+
- 元素上的文本或描述
|
|
161
|
+
- 匹配的置信度(0-1之间)
|
|
162
|
+
|
|
163
|
+
最后按如下JSON格式返回:
|
|
164
|
+
```json
|
|
165
|
+
{
|
|
166
|
+
"objects": [
|
|
167
|
+
{
|
|
168
|
+
"type": "button", // 元素类型
|
|
169
|
+
"bounding_box": [xmin, ymin, xmax, ymax],
|
|
170
|
+
"text": "元素上的文字或描述",
|
|
171
|
+
"confidence": 0.95 // 置信度,值越高表示匹配越确定
|
|
172
|
+
},
|
|
173
|
+
// 更多元素...
|
|
174
|
+
]
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
如果完全没有找到匹配的元素,请返回空objects数组:
|
|
179
|
+
```json
|
|
180
|
+
{
|
|
181
|
+
"objects": []
|
|
182
|
+
}
|
|
183
|
+
"""
|
|
184
|
+
image = byzerllm.Image.load_image_from_path(image_path)
|
|
185
|
+
return {"image": image, "element_desc": element_desc}
|
|
186
|
+
|
|
187
|
+
def click(self, x: int, y: int, button: str = 'left', clicks: int = 1) -> None:
|
|
188
|
+
"""
|
|
189
|
+
在指定位置执行鼠标点击
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
x: 横坐标
|
|
193
|
+
y: 纵坐标
|
|
194
|
+
button: 鼠标按钮,可选 'left', 'right', 'middle'
|
|
195
|
+
clicks: 点击次数
|
|
196
|
+
"""
|
|
197
|
+
# 确保坐标在屏幕范围内
|
|
198
|
+
x = max(0, min(x, self.screen_width - 1))
|
|
199
|
+
y = max(0, min(y, self.screen_height - 1))
|
|
200
|
+
|
|
201
|
+
logger.info(f"鼠标点击: 坐标({x}, {y}), 按钮: {button}, 次数: {clicks}")
|
|
202
|
+
pyautogui.click(x=x, y=y, button=button, clicks=clicks)
|
|
203
|
+
|
|
204
|
+
def draw_bounding_box(self, image_path: str, bbox: List[int], element_desc: str,
|
|
205
|
+
center_point: Optional[Tuple[int, int]] = None,
|
|
206
|
+
confidence: Optional[float] = None) -> str:
|
|
207
|
+
"""
|
|
208
|
+
在图像上绘制边界框、标签和中心点
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
image_path: 原始图像路径
|
|
212
|
+
bbox: 边界框坐标 [x1, y1, x2, y2]
|
|
213
|
+
element_desc: 元素描述
|
|
214
|
+
center_point: 可选,中心点坐标 (x, y)
|
|
215
|
+
confidence: 可选,置信度
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
保存的带边界框图像路径
|
|
219
|
+
"""
|
|
220
|
+
try:
|
|
221
|
+
# 创建临时目录存储带框图片
|
|
222
|
+
bbox_dir = os.path.join(self.screenshot_dir, "bboxes")
|
|
223
|
+
os.makedirs(bbox_dir, exist_ok=True)
|
|
224
|
+
|
|
225
|
+
# 生成新文件名
|
|
226
|
+
timestamp = time.strftime("%Y%m%d_%H%M%S", time.localtime())
|
|
227
|
+
bbox_image_path = os.path.join(bbox_dir, f"bbox_{timestamp}.png")
|
|
228
|
+
|
|
229
|
+
# 加载原始图片
|
|
230
|
+
img = Image.open(image_path)
|
|
231
|
+
draw = ImageDraw.Draw(img)
|
|
232
|
+
|
|
233
|
+
# 绘制矩形框
|
|
234
|
+
draw.rectangle(
|
|
235
|
+
[(bbox[0], bbox[1]), (bbox[2], bbox[3])],
|
|
236
|
+
outline="red",
|
|
237
|
+
width=3
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# 如果提供了中心点,绘制中心点
|
|
241
|
+
if center_point:
|
|
242
|
+
cx, cy = center_point
|
|
243
|
+
draw.ellipse(
|
|
244
|
+
[(cx-5, cy-5), (cx+5, cy+5)],
|
|
245
|
+
fill="blue"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
# 准备标签文本
|
|
249
|
+
if confidence is not None and isinstance(confidence, (int, float)):
|
|
250
|
+
label_text = f"{element_desc} ({confidence:.2f})"
|
|
251
|
+
else:
|
|
252
|
+
label_text = element_desc
|
|
253
|
+
|
|
254
|
+
# 设置文本位置
|
|
255
|
+
text_x = bbox[0]
|
|
256
|
+
text_y = max(0, bbox[1] - 20) # 文本位于框上方
|
|
257
|
+
|
|
258
|
+
# 绘制文本背景
|
|
259
|
+
text_width = len(label_text) * 8 # 估算文本宽度
|
|
260
|
+
draw.rectangle(
|
|
261
|
+
[(text_x, text_y), (text_x + text_width, text_y + 20)],
|
|
262
|
+
fill="yellow"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# 绘制文本
|
|
266
|
+
draw.text(
|
|
267
|
+
(text_x, text_y),
|
|
268
|
+
label_text,
|
|
269
|
+
fill="black"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# 在图片上添加时间戳和文件信息
|
|
273
|
+
footer_text = f"Time: {time.strftime('%Y-%m-%d %H:%M:%S')} | File: {os.path.basename(image_path)}"
|
|
274
|
+
img_width, img_height = img.size
|
|
275
|
+
footer_y = img_height - 20
|
|
276
|
+
|
|
277
|
+
# 添加底部信息栏
|
|
278
|
+
draw.rectangle(
|
|
279
|
+
[(0, footer_y - 5), (img_width, img_height)],
|
|
280
|
+
fill="black"
|
|
281
|
+
)
|
|
282
|
+
draw.text(
|
|
283
|
+
(10, footer_y),
|
|
284
|
+
footer_text,
|
|
285
|
+
fill="white"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
# 保存图片
|
|
289
|
+
img.save(bbox_image_path)
|
|
290
|
+
logger.info(f"已保存带边界框的图片: {bbox_image_path}")
|
|
291
|
+
print(f"识别到元素 '{element_desc}' - 已保存边界框图片: {bbox_image_path}")
|
|
292
|
+
|
|
293
|
+
return bbox_image_path
|
|
294
|
+
|
|
295
|
+
except Exception as e:
|
|
296
|
+
logger.error(f"绘制边界框时出错: {str(e)}")
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def click_element(self, screenshot_path: str, element_desc: str) -> bool:
|
|
301
|
+
"""
|
|
302
|
+
查找并点击指定描述的元素
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
screenshot_path: 屏幕截图路径
|
|
306
|
+
element_desc: 元素描述
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
是否成功点击
|
|
310
|
+
"""
|
|
311
|
+
# 查找元素
|
|
312
|
+
response = self.find_elements.with_llm(self.vl_model).run(screenshot_path, element_desc)
|
|
313
|
+
logger.info(f"查找元素结果: {response}")
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
# 解析JSON结果
|
|
317
|
+
result_json = code_utils.extract_code(response)[-1][1]
|
|
318
|
+
result = json.loads(result_json)
|
|
319
|
+
objects = result.get("objects", [])
|
|
320
|
+
sorted_objects = sorted(objects, key=lambda x: x.get("confidence", 0), reverse=True)
|
|
321
|
+
|
|
322
|
+
for element in sorted_objects:
|
|
323
|
+
bbox = element.get("bounding_box", [])
|
|
324
|
+
|
|
325
|
+
if len(bbox) == 4:
|
|
326
|
+
# 计算中心点
|
|
327
|
+
center_x = int((bbox[0] + bbox[2]) / 2)
|
|
328
|
+
center_y = int((bbox[1] + bbox[3]) / 2)
|
|
329
|
+
|
|
330
|
+
# 绘制边界框并保存图像
|
|
331
|
+
element_text = element.get('text', element_desc)
|
|
332
|
+
confidence = element.get('confidence', None)
|
|
333
|
+
|
|
334
|
+
self.draw_bounding_box(
|
|
335
|
+
image_path=screenshot_path,
|
|
336
|
+
bbox=bbox,
|
|
337
|
+
element_desc=element_text,
|
|
338
|
+
center_point=(center_x, center_y),
|
|
339
|
+
confidence=confidence
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# 点击中心点
|
|
343
|
+
self.click(center_x, center_y)
|
|
344
|
+
logger.info(f"点击元素: {element_text}")
|
|
345
|
+
return True
|
|
346
|
+
|
|
347
|
+
logger.warning(f"未找到元素: {element_desc}")
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
except Exception as e:
|
|
351
|
+
logger.error(f"解析查找结果时出错: {str(e)}")
|
|
352
|
+
return False
|
|
353
|
+
|
|
354
|
+
def drag(self, start_x: int, start_y: int, end_x: int, end_y: int, duration: float = 0.5) -> None:
|
|
355
|
+
"""
|
|
356
|
+
从一个位置拖动到另一个位置
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
start_x: 起始位置X坐标
|
|
360
|
+
start_y: 起始位置Y坐标
|
|
361
|
+
end_x: 结束位置X坐标
|
|
362
|
+
end_y: 结束位置Y坐标
|
|
363
|
+
duration: 拖动持续时间,单位为秒
|
|
364
|
+
"""
|
|
365
|
+
# 确保坐标在屏幕范围内
|
|
366
|
+
start_x = max(0, min(start_x, self.screen_width - 1))
|
|
367
|
+
start_y = max(0, min(start_y, self.screen_height - 1))
|
|
368
|
+
end_x = max(0, min(end_x, self.screen_width - 1))
|
|
369
|
+
end_y = max(0, min(end_y, self.screen_height - 1))
|
|
370
|
+
|
|
371
|
+
logger.info(f"鼠标拖动: 从({start_x}, {start_y})到({end_x}, {end_y}), 持续时间: {duration}秒")
|
|
372
|
+
|
|
373
|
+
# 移动到起始位置
|
|
374
|
+
pyautogui.moveTo(start_x, start_y)
|
|
375
|
+
# 拖动到目标位置
|
|
376
|
+
pyautogui.dragTo(end_x, end_y, duration=duration)
|
|
377
|
+
|
|
378
|
+
def scroll(self, x: int, y: int, clicks: int) -> None:
|
|
379
|
+
"""
|
|
380
|
+
在指定位置滚动鼠标滚轮
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
x: 横坐标
|
|
384
|
+
y: 纵坐标
|
|
385
|
+
clicks: 滚动量,正数向上滚动,负数向下滚动
|
|
386
|
+
"""
|
|
387
|
+
# 确保坐标在屏幕范围内
|
|
388
|
+
x = max(0, min(x, self.screen_width - 1))
|
|
389
|
+
y = max(0, min(y, self.screen_height - 1))
|
|
390
|
+
|
|
391
|
+
direction = "上" if clicks > 0 else "下"
|
|
392
|
+
logger.info(f"鼠标滚动: 坐标({x}, {y}), 方向: {direction}, 量: {abs(clicks)}")
|
|
393
|
+
|
|
394
|
+
# 移动到指定位置
|
|
395
|
+
pyautogui.moveTo(x, y)
|
|
396
|
+
# 滚动
|
|
397
|
+
pyautogui.scroll(clicks)
|
|
398
|
+
|
|
399
|
+
def type_text(self, text: str, interval: float = 0.05) -> None:
|
|
400
|
+
"""
|
|
401
|
+
模拟键盘输入文本
|
|
402
|
+
|
|
403
|
+
Args:
|
|
404
|
+
text: 要输入的文本
|
|
405
|
+
interval: 按键间隔时间,单位为秒
|
|
406
|
+
"""
|
|
407
|
+
logger.info(f"键盘输入: '{text}'")
|
|
408
|
+
pyautogui.typewrite(text, interval=interval)
|
|
409
|
+
|
|
410
|
+
def press_key(self, key):
|
|
411
|
+
"""
|
|
412
|
+
按下指定的键或键组合
|
|
413
|
+
|
|
414
|
+
Args:
|
|
415
|
+
key: 键名或键组合列表,如'enter'或['ctrl', 'c']
|
|
416
|
+
"""
|
|
417
|
+
try:
|
|
418
|
+
if isinstance(key, list):
|
|
419
|
+
# 处理组合键
|
|
420
|
+
logger.info(f"按下组合键: {'+'.join(key)}")
|
|
421
|
+
pyautogui.hotkey(*key) # 使用hotkey方法处理组合键
|
|
422
|
+
else:
|
|
423
|
+
# 处理单个键
|
|
424
|
+
logger.info(f"按下按键: {key}")
|
|
425
|
+
pyautogui.press(key)
|
|
426
|
+
except Exception as e:
|
|
427
|
+
logger.error(f"按键操作失败: {str(e)}")
|
|
428
|
+
raise
|
|
429
|
+
|
|
430
|
+
def extract_text(self, screenshot_path: str, region: List[int] = None) -> str:
|
|
431
|
+
"""
|
|
432
|
+
从屏幕截图中提取文本
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
screenshot_path: 屏幕截图路径
|
|
436
|
+
region: 区域坐标 [xmin, ymin, xmax, ymax],如果为None则处理整个截图
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
提取的文本
|
|
440
|
+
"""
|
|
441
|
+
if region is not None:
|
|
442
|
+
# 裁剪指定区域
|
|
443
|
+
img = Image.open(screenshot_path)
|
|
444
|
+
cropped = img.crop(region)
|
|
445
|
+
|
|
446
|
+
# 保存临时文件
|
|
447
|
+
temp_path = os.path.join(self.screenshot_dir, "temp_region.png")
|
|
448
|
+
cropped.save(temp_path)
|
|
449
|
+
screenshot_path = temp_path
|
|
450
|
+
|
|
451
|
+
@byzerllm.prompt()
|
|
452
|
+
def extract_text_from_image(image_path: str) -> str:
|
|
453
|
+
"""
|
|
454
|
+
{{ image }}
|
|
455
|
+
请提取这张图片中的所有文本内容,保持原有布局和顺序。
|
|
456
|
+
"""
|
|
457
|
+
image = byzerllm.Image.load_image_from_path(image_path)
|
|
458
|
+
return {"image": image}
|
|
459
|
+
|
|
460
|
+
# 调用VL模型提取文本
|
|
461
|
+
result = extract_text_from_image.with_llm(self.vl_model).run(screenshot_path)
|
|
462
|
+
return result
|
|
463
|
+
|
|
464
|
+
def run_workflow(self, steps: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
465
|
+
"""
|
|
466
|
+
执行一系列操作步骤
|
|
467
|
+
|
|
468
|
+
Args:
|
|
469
|
+
steps: 操作步骤列表,每个步骤是一个字典,包含 'action' 和相关参数
|
|
470
|
+
支持的 action: screenshot, detect, click, find_and_click, type, press,
|
|
471
|
+
drag, scroll, extract_text, ask_user, response_user, open_browser, should_wait_loading
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
执行结果列表
|
|
475
|
+
"""
|
|
476
|
+
results = []
|
|
477
|
+
|
|
478
|
+
for i, step in enumerate(steps):
|
|
479
|
+
try:
|
|
480
|
+
action = step.get('action', '')
|
|
481
|
+
logger.info(f"执行步骤 {i+1}: {action}")
|
|
482
|
+
|
|
483
|
+
result = {'step': i+1, 'action': action, 'success': True}
|
|
484
|
+
|
|
485
|
+
if action == 'screenshot':
|
|
486
|
+
filename = step.get('filename')
|
|
487
|
+
path = self.screenshot(filename)
|
|
488
|
+
result['path'] = path
|
|
489
|
+
result["should_verify"] = False
|
|
490
|
+
|
|
491
|
+
elif action == 'focus_app':
|
|
492
|
+
app_name = step.get('app_name')
|
|
493
|
+
retry_count = step.get('retry_count', 3)
|
|
494
|
+
success = self.focus_app(app_name, retry_count)
|
|
495
|
+
result['success'] = success
|
|
496
|
+
result['app_name'] = app_name
|
|
497
|
+
|
|
498
|
+
elif action == 'detect':
|
|
499
|
+
image_path = step.get('image_path')
|
|
500
|
+
if not image_path:
|
|
501
|
+
# 自动截图
|
|
502
|
+
image_path = self.screenshot(f"auto_step_{i+1}.png")
|
|
503
|
+
|
|
504
|
+
detection = self.detect_objects.with_llm(self.vl_model).run(image_path)
|
|
505
|
+
result_json = code_utils.extract_code(detection)[-1][1]
|
|
506
|
+
result['objects'] = json.loads(result_json)
|
|
507
|
+
|
|
508
|
+
elif action == 'click':
|
|
509
|
+
x = step.get('x')
|
|
510
|
+
y = step.get('y')
|
|
511
|
+
button = step.get('button', 'left')
|
|
512
|
+
clicks = step.get('clicks', 1)
|
|
513
|
+
|
|
514
|
+
self.click(x, y, button, clicks)
|
|
515
|
+
result['coordinates'] = [x, y]
|
|
516
|
+
|
|
517
|
+
elif action == 'find_and_click':
|
|
518
|
+
element_desc = step.get('element_desc')
|
|
519
|
+
image_path = step.get('image_path')
|
|
520
|
+
if not image_path:
|
|
521
|
+
# 自动截图
|
|
522
|
+
image_path = self.screenshot(f"auto_step_{i+1}.png")
|
|
523
|
+
|
|
524
|
+
success = self.click_element(image_path, element_desc)
|
|
525
|
+
result['success'] = success
|
|
526
|
+
result['element_desc'] = element_desc
|
|
527
|
+
|
|
528
|
+
elif action == 'drag':
|
|
529
|
+
start_x = step.get('start_x')
|
|
530
|
+
start_y = step.get('start_y')
|
|
531
|
+
end_x = step.get('end_x')
|
|
532
|
+
end_y = step.get('end_y')
|
|
533
|
+
duration = step.get('duration', 0.5)
|
|
534
|
+
|
|
535
|
+
self.drag(start_x, start_y, end_x, end_y, duration)
|
|
536
|
+
result['from'] = [start_x, start_y]
|
|
537
|
+
result['to'] = [end_x, end_y]
|
|
538
|
+
|
|
539
|
+
elif action == 'type':
|
|
540
|
+
text = step.get('text', '')
|
|
541
|
+
interval = step.get('interval', 0.05)
|
|
542
|
+
|
|
543
|
+
self.type_text(text, interval)
|
|
544
|
+
result['text'] = text
|
|
545
|
+
|
|
546
|
+
elif action == 'press':
|
|
547
|
+
key = step.get('key')
|
|
548
|
+
self.press_key(key)
|
|
549
|
+
result['key'] = key
|
|
550
|
+
|
|
551
|
+
elif action == 'scroll':
|
|
552
|
+
clicks = step.get('clicks')
|
|
553
|
+
x = step.get('x', None)
|
|
554
|
+
y = step.get('y', None)
|
|
555
|
+
|
|
556
|
+
# 如果没有提供坐标,使用当前鼠标位置
|
|
557
|
+
current_pos = pyautogui.position()
|
|
558
|
+
if x is None:
|
|
559
|
+
x = current_pos.x
|
|
560
|
+
if y is None:
|
|
561
|
+
y = current_pos.y
|
|
562
|
+
|
|
563
|
+
self.scroll(x, y, clicks)
|
|
564
|
+
result['coordinates'] = [x, y, clicks]
|
|
565
|
+
|
|
566
|
+
elif action == 'extract_text':
|
|
567
|
+
image_path = step.get('image_path')
|
|
568
|
+
region = step.get('region')
|
|
569
|
+
|
|
570
|
+
if not image_path:
|
|
571
|
+
# 自动截图
|
|
572
|
+
image_path = self.screenshot(f"auto_step_{i+1}.png")
|
|
573
|
+
|
|
574
|
+
text = self.extract_text(image_path, region)
|
|
575
|
+
result['text'] = text
|
|
576
|
+
|
|
577
|
+
elif action == 'ask_user':
|
|
578
|
+
logger.info(f"需要询问用户: {question}")
|
|
579
|
+
question = step.get('question', '')
|
|
580
|
+
response = self.ask_user(question)
|
|
581
|
+
result['question'] = question
|
|
582
|
+
result['action_required'] = 'ask_user'
|
|
583
|
+
result['response'] = response
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
elif action == 'response_user':
|
|
587
|
+
logger.info(f"需要向用户显示: {response}")
|
|
588
|
+
response = step.get('response', '')
|
|
589
|
+
self.response_user(response)
|
|
590
|
+
result['response'] = response
|
|
591
|
+
result['action_required'] = 'response_user'
|
|
592
|
+
|
|
593
|
+
elif action == 'open_browser':
|
|
594
|
+
browser_name = step.get('browser_name', 'chrome')
|
|
595
|
+
browser_result = self.open_browser(browser_name)
|
|
596
|
+
result.update(browser_result)
|
|
597
|
+
|
|
598
|
+
elif action == "wait_loading":
|
|
599
|
+
target = step.get('target')
|
|
600
|
+
|
|
601
|
+
# 调用_should_wait_loading方法分析是否需要等待加载
|
|
602
|
+
wait_analysis = self.should_wait_loading(
|
|
603
|
+
target=target
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
# 如果需要等待,自动等待一段时间
|
|
607
|
+
wait_times = 0
|
|
608
|
+
wait_time = 10
|
|
609
|
+
|
|
610
|
+
while wait_analysis.should_wait and wait_times < 10:
|
|
611
|
+
wait_times += 1
|
|
612
|
+
logger.info(f"系统正在加载中,自动等待 {wait_time} 秒...")
|
|
613
|
+
time.sleep(wait_time)
|
|
614
|
+
wait_analysis = self.should_wait_loading(
|
|
615
|
+
target=target
|
|
616
|
+
)
|
|
617
|
+
result['should_wait'] = wait_analysis.should_wait
|
|
618
|
+
result['reason'] = wait_analysis.reason
|
|
619
|
+
result['should_wait'] = wait_analysis.should_wait
|
|
620
|
+
result['reason'] = wait_analysis.reason
|
|
621
|
+
result["should_verify"] = False
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
else:
|
|
625
|
+
logger.warning(f"未知操作: {action}")
|
|
626
|
+
result['success'] = False
|
|
627
|
+
result['error'] = f"未知操作: {action}"
|
|
628
|
+
|
|
629
|
+
# 添加执行结果
|
|
630
|
+
results.append(result)
|
|
631
|
+
|
|
632
|
+
# 如果设置了等待时间
|
|
633
|
+
if 'wait' in step:
|
|
634
|
+
wait_time = float(step['wait'])
|
|
635
|
+
logger.info(f"等待 {wait_time} 秒")
|
|
636
|
+
time.sleep(wait_time)
|
|
637
|
+
|
|
638
|
+
except Exception as e:
|
|
639
|
+
logger.error(f"执行步骤 {i+1} 时出错: {str(e)}")
|
|
640
|
+
results.append({
|
|
641
|
+
'step': i+1,
|
|
642
|
+
'action': step.get('action', 'unknown'),
|
|
643
|
+
'success': False,
|
|
644
|
+
'error': str(e)
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
# 如果设置了出错继续
|
|
648
|
+
if not step.get('continue_on_error', False):
|
|
649
|
+
break
|
|
650
|
+
|
|
651
|
+
return results
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def ask_user(self, question: str) -> str:
|
|
655
|
+
"""
|
|
656
|
+
向用户提问,获取用户输入
|
|
657
|
+
|
|
658
|
+
Args:
|
|
659
|
+
question: 问题内容
|
|
660
|
+
|
|
661
|
+
Returns:
|
|
662
|
+
用户的回答
|
|
663
|
+
"""
|
|
664
|
+
console = Console()
|
|
665
|
+
|
|
666
|
+
# 创建一个醒目的问题面板
|
|
667
|
+
question_text = Text(question, style="bold cyan")
|
|
668
|
+
question_panel = Panel(
|
|
669
|
+
question_text,
|
|
670
|
+
title="[bold yellow]Web Automation Question[/bold yellow]",
|
|
671
|
+
border_style="blue",
|
|
672
|
+
expand=False
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
# 显示问题面板
|
|
676
|
+
console.print(question_panel)
|
|
677
|
+
|
|
678
|
+
# 获取用户输入
|
|
679
|
+
try:
|
|
680
|
+
from prompt_toolkit import PromptSession
|
|
681
|
+
session = PromptSession(message=self.printer.get_message_from_key('web_automation_ask_user', default="Your answer: "))
|
|
682
|
+
answer = session.prompt()
|
|
683
|
+
except (ImportError, KeyboardInterrupt):
|
|
684
|
+
# 降级到标准输入或处理中断
|
|
685
|
+
answer = input("Your answer: ")
|
|
686
|
+
|
|
687
|
+
# 记录交互
|
|
688
|
+
self.save_to_memory_file(
|
|
689
|
+
query=question,
|
|
690
|
+
response=answer
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
return answer
|
|
694
|
+
|
|
695
|
+
def response_user(self, response: str) -> str:
|
|
696
|
+
"""
|
|
697
|
+
直接向用户显示消息,无需等待用户输入
|
|
698
|
+
|
|
699
|
+
Args:
|
|
700
|
+
response: 要显示的消息内容
|
|
701
|
+
|
|
702
|
+
Returns:
|
|
703
|
+
显示的消息内容
|
|
704
|
+
"""
|
|
705
|
+
console = Console()
|
|
706
|
+
|
|
707
|
+
# 创建一个醒目的消息面板
|
|
708
|
+
message_text = Text(response, style="italic")
|
|
709
|
+
message_panel = Panel(
|
|
710
|
+
message_text,
|
|
711
|
+
title="",
|
|
712
|
+
border_style="green",
|
|
713
|
+
expand=False
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
# 显示消息面板
|
|
717
|
+
console.print(message_panel)
|
|
718
|
+
|
|
719
|
+
# 记录交互
|
|
720
|
+
self.save_to_memory_file(
|
|
721
|
+
query="system_message",
|
|
722
|
+
response=response
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
return response
|
|
726
|
+
|
|
727
|
+
def open_browser(self, browser_name="chrome", url=None):
|
|
728
|
+
"""
|
|
729
|
+
打开指定的浏览器并可选择导航到URL。比直接使用系统命令更可靠的浏览器启动方式。
|
|
730
|
+
|
|
731
|
+
Args:
|
|
732
|
+
browser_name: 浏览器名称,默认为"chrome",也支持"firefox"和"edge"等
|
|
733
|
+
url: 可选参数,启动后要导航到的网址
|
|
734
|
+
|
|
735
|
+
Returns:
|
|
736
|
+
操作结果字典
|
|
737
|
+
"""
|
|
738
|
+
import subprocess
|
|
739
|
+
import platform
|
|
740
|
+
import time
|
|
741
|
+
|
|
742
|
+
system = platform.system()
|
|
743
|
+
result = {"success": False, "message": ""}
|
|
744
|
+
|
|
745
|
+
try:
|
|
746
|
+
# 根据浏览器名称和操作系统打开浏览器
|
|
747
|
+
if browser_name.lower() == "chrome":
|
|
748
|
+
if system == 'Windows':
|
|
749
|
+
subprocess.Popen(['start', 'chrome'], shell=True)
|
|
750
|
+
elif system == 'Darwin': # macOS
|
|
751
|
+
subprocess.Popen(['open', '-a', 'Google Chrome'])
|
|
752
|
+
elif system == 'Linux':
|
|
753
|
+
subprocess.Popen(['google-chrome'])
|
|
754
|
+
elif browser_name.lower() == "firefox":
|
|
755
|
+
if system == 'Windows':
|
|
756
|
+
subprocess.Popen(['start', 'firefox'], shell=True)
|
|
757
|
+
elif system == 'Darwin': # macOS
|
|
758
|
+
subprocess.Popen(['open', '-a', 'Firefox'])
|
|
759
|
+
elif system == 'Linux':
|
|
760
|
+
subprocess.Popen(['firefox'])
|
|
761
|
+
elif browser_name.lower() == "edge":
|
|
762
|
+
if system == 'Windows':
|
|
763
|
+
subprocess.Popen(['start', 'msedge'], shell=True)
|
|
764
|
+
elif system == 'Darwin': # macOS
|
|
765
|
+
subprocess.Popen(['open', '-a', 'Microsoft Edge'])
|
|
766
|
+
elif system == 'Linux':
|
|
767
|
+
subprocess.Popen(['microsoft-edge'])
|
|
768
|
+
|
|
769
|
+
result["success"] = True
|
|
770
|
+
return result
|
|
771
|
+
|
|
772
|
+
except Exception as e:
|
|
773
|
+
result["message"] = f"启动{browser_name}浏览器时出错: {str(e)}"
|
|
774
|
+
logger.error(f"启动{browser_name}浏览器时出错: {str(e)}")
|
|
775
|
+
return result
|
|
776
|
+
|
|
777
|
+
def element_exists(self, screenshot_path: str, element_desc: str) -> bool:
|
|
778
|
+
"""
|
|
779
|
+
检查屏幕上是否存在指定描述的元素
|
|
780
|
+
|
|
781
|
+
Args:
|
|
782
|
+
screenshot_path: 屏幕截图路径
|
|
783
|
+
element_desc: 元素描述
|
|
784
|
+
|
|
785
|
+
Returns:
|
|
786
|
+
元素是否存在
|
|
787
|
+
"""
|
|
788
|
+
# 查找元素
|
|
789
|
+
response = self.find_elements.with_llm(self.vl_model).run(screenshot_path, element_desc)
|
|
790
|
+
logger.info(f"检查元素是否存在: {element_desc}")
|
|
791
|
+
|
|
792
|
+
try:
|
|
793
|
+
# 解析JSON结果
|
|
794
|
+
result_json = code_utils.extract_code(response)[-1][1]
|
|
795
|
+
result = json.loads(result_json)
|
|
796
|
+
objects = result.get("objects", [])
|
|
797
|
+
|
|
798
|
+
# 如果objects列表不为空,表示找到了元素
|
|
799
|
+
exists = len(objects) > 0
|
|
800
|
+
|
|
801
|
+
if exists:
|
|
802
|
+
# 找到了元素,提供第一个匹配元素的信息
|
|
803
|
+
best_match = sorted(objects, key=lambda x: x.get("confidence", 0), reverse=True)[0]
|
|
804
|
+
confidence = best_match.get("confidence", 0)
|
|
805
|
+
text = best_match.get("text", "")
|
|
806
|
+
|
|
807
|
+
# 可选:绘制边界框并保存图片
|
|
808
|
+
bbox = best_match.get("bounding_box", [])
|
|
809
|
+
if len(bbox) == 4:
|
|
810
|
+
try:
|
|
811
|
+
# 计算中心点坐标
|
|
812
|
+
center_x = int((bbox[0] + bbox[2]) / 2)
|
|
813
|
+
center_y = int((bbox[1] + bbox[3]) / 2)
|
|
814
|
+
|
|
815
|
+
# 绘制边界框
|
|
816
|
+
self.draw_bounding_box(
|
|
817
|
+
image_path=screenshot_path,
|
|
818
|
+
bbox=bbox,
|
|
819
|
+
element_desc=text or element_desc,
|
|
820
|
+
center_point=(center_x, center_y),
|
|
821
|
+
confidence=confidence
|
|
822
|
+
)
|
|
823
|
+
except Exception as e:
|
|
824
|
+
logger.error(f"绘制边界框时出错: {str(e)}")
|
|
825
|
+
|
|
826
|
+
logger.info(f"找到元素 '{element_desc}', 置信度: {confidence}, 文本: '{text}'")
|
|
827
|
+
else:
|
|
828
|
+
logger.info(f"未找到元素 '{element_desc}'")
|
|
829
|
+
|
|
830
|
+
return exists
|
|
831
|
+
|
|
832
|
+
except Exception as e:
|
|
833
|
+
logger.error(f"检查元素是否存在时出错: {str(e)}")
|
|
834
|
+
return False
|
|
835
|
+
|
|
836
|
+
@byzerllm.prompt()
|
|
837
|
+
def _should_wait_loading(self, target: str) ->str:
|
|
838
|
+
"""
|
|
839
|
+
{{ current_screenshot }}
|
|
840
|
+
|
|
841
|
+
考虑到诸如点击链接等动作,点击后不会立马发生屏幕变化,而用户期待看到的内容为:
|
|
842
|
+
|
|
843
|
+
{{ target }}
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
我们需要判断用户期待的内容是否已经出现。如果系统还处于加载状态,并且用户期待的内容还没有出现,请返回如下JSON格式:
|
|
847
|
+
```json
|
|
848
|
+
{
|
|
849
|
+
"should_wait": true,
|
|
850
|
+
"reason": "判断是否处于加载状态分析理由"
|
|
851
|
+
}
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
如果用户期待的内容已经出现,请返回如下JSON格式:
|
|
855
|
+
```json
|
|
856
|
+
{
|
|
857
|
+
"should_wait": false,
|
|
858
|
+
"reason": "判断是否处于加载状态分析理由"
|
|
859
|
+
}
|
|
860
|
+
```
|
|
861
|
+
"""
|
|
862
|
+
current_screenshot_path = self.screenshot(f"current_screenshot_{int(time.time())}.png")
|
|
863
|
+
current_screenshot = byzerllm.Image.load_image_from_path(current_screenshot_path)
|
|
864
|
+
return {
|
|
865
|
+
"current_screenshot":current_screenshot,
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def should_wait_loading(self, target: str) -> LoadingStatus:
|
|
870
|
+
"""
|
|
871
|
+
判断是否需要等待加载完成
|
|
872
|
+
"""
|
|
873
|
+
response = self._should_wait_loading.with_llm(self.vl_model).with_return_type(LoadingStatus).run(target)
|
|
874
|
+
return response
|
|
875
|
+
|
|
876
|
+
def focus_app(self, app_name: str, retry_count: int = 3) -> bool:
|
|
877
|
+
"""
|
|
878
|
+
查找并聚焦指定的应用程序窗口
|
|
879
|
+
|
|
880
|
+
Args:
|
|
881
|
+
app_name: 应用程序名称或窗口标题的一部分
|
|
882
|
+
retry_count: 重试次数,默认为3
|
|
883
|
+
|
|
884
|
+
Returns:
|
|
885
|
+
是否成功聚焦应用
|
|
886
|
+
"""
|
|
887
|
+
logger.info(f"尝试聚焦应用: {app_name}")
|
|
888
|
+
|
|
889
|
+
for attempt in range(retry_count):
|
|
890
|
+
# 截取当前屏幕
|
|
891
|
+
screenshot_path = self.screenshot(f"focus_app_{int(time.time())}.png")
|
|
892
|
+
|
|
893
|
+
# 查找元素并获取位置
|
|
894
|
+
response = self.find_elements.with_llm(self.vl_model).run(screenshot_path, f"应用 {app_name},是否在屏幕上,比如标题栏,任务栏亦或者当前活动窗口")
|
|
895
|
+
try:
|
|
896
|
+
result_json = code_utils.extract_code(response)[-1][1]
|
|
897
|
+
result = json.loads(result_json)
|
|
898
|
+
objects = result.get("objects", [])
|
|
899
|
+
|
|
900
|
+
if objects:
|
|
901
|
+
# 找到匹配度最高的元素
|
|
902
|
+
best_match = sorted(objects, key=lambda x: x.get("confidence", 0), reverse=True)[0]
|
|
903
|
+
bbox = best_match.get("bounding_box", [])
|
|
904
|
+
|
|
905
|
+
if len(bbox) == 4:
|
|
906
|
+
# 计算中心点
|
|
907
|
+
center_x = int((bbox[0] + bbox[2]) / 2)
|
|
908
|
+
center_y = int((bbox[1] + bbox[3]) / 2)
|
|
909
|
+
|
|
910
|
+
# 点击应用窗口来聚焦
|
|
911
|
+
self.click(center_x, center_y)
|
|
912
|
+
logger.info(f"已点击 {app_name} 窗口,尝试聚焦")
|
|
913
|
+
|
|
914
|
+
# 等待窗口获得焦点
|
|
915
|
+
time.sleep(0.5)
|
|
916
|
+
|
|
917
|
+
# 再次截图以验证是否聚焦成功
|
|
918
|
+
verification_screenshot = self.screenshot(f"focus_app_verification_{int(time.time())}.png")
|
|
919
|
+
if self.element_exists(verification_screenshot, f"应用 {app_name} 是否在当前桌面"):
|
|
920
|
+
logger.info(f"成功聚焦应用: {app_name}")
|
|
921
|
+
return True
|
|
922
|
+
except Exception as e:
|
|
923
|
+
logger.error(f"聚焦应用时出错: {str(e)}")
|
|
924
|
+
|
|
925
|
+
# 如果所有尝试都失败,等待一段时间后重试
|
|
926
|
+
if attempt < retry_count - 1:
|
|
927
|
+
logger.warning(f"聚焦 {app_name} 失败,将在1秒后重试 ({attempt+1}/{retry_count})")
|
|
928
|
+
time.sleep(1)
|
|
929
|
+
|
|
930
|
+
logger.error(f"无法聚焦应用: {app_name},已达到最大重试次数")
|
|
931
|
+
return False
|