myagent-ai 1.2.2 → 1.3.1

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,908 @@
1
+ """
2
+ skills/gui_skill.py - 桌面 GUI 自动化技能
3
+ =========================================
4
+ 提供跨平台桌面 GUI 自动化能力,包括屏幕截图、鼠标操作、键盘输入、窗口管理等。
5
+ 支持 Windows 和 macOS。所有依赖均为可选,缺失时给出友好的安装提示。
6
+
7
+ Skills:
8
+ - ScreenShotSkill: 捕获屏幕或区域截图
9
+ - MouseClickSkill: 在屏幕坐标处点击
10
+ - MouseDragSkill: 从 A 点拖拽到 B 点
11
+ - TypeTextSkill: 在当前光标位置输入文本
12
+ - HotkeySkill: 按下键盘快捷键(自动适配平台)
13
+ - WindowListSkill: 列出所有打开的窗口
14
+ - WindowFocusSkill: 聚焦/置顶指定窗口
15
+ - ScreenElementSkill: 使用 VLM 从截图中定位元素坐标
16
+
17
+ Dependencies (all optional):
18
+ - mss: 屏幕截图 (跨平台, ~1MB)
19
+ - pynput: 鼠标/键盘控制 (跨平台, ~500KB)
20
+ - pygetwindow: 窗口管理 (Windows + macOS)
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import sys
25
+ import time
26
+ from typing import Any, Dict, List, Optional, Tuple
27
+
28
+ from core.logger import get_logger
29
+ from skills.base import Skill, SkillResult, SkillParameter
30
+
31
+ logger = get_logger("myagent.skills.gui")
32
+
33
+ # 平台检测 / Platform detection
34
+ IS_MACOS = sys.platform == "darwin"
35
+ IS_WINDOWS = sys.platform == "win32"
36
+ IS_LINUX = sys.platform.startswith("linux")
37
+
38
+
39
+ def _generate_screenshot_path() -> str:
40
+ """生成截图文件路径(带时间戳)"""
41
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
42
+ return f"/tmp/myagent_gui_screen_{timestamp}.png"
43
+
44
+
45
+ class ScreenShotSkill(Skill):
46
+ """
47
+ 屏幕截图 - 捕获整个屏幕或指定区域的截图。
48
+
49
+ 使用 mss 库实现跨平台高速截图,比 Pillow.grab 快 10 倍以上。
50
+ 截图保存为 PNG 文件,返回路径供 VLM 分析。
51
+ """
52
+
53
+ name = "screenshot"
54
+ description = (
55
+ "捕获屏幕截图,保存为 PNG 文件并返回路径。"
56
+ "可截取全屏或指定区域。截图可用于 VLM 视觉分析以定位 UI 元素。"
57
+ )
58
+ category = "gui"
59
+ parameters = [
60
+ SkillParameter("region", "string",
61
+ "截图区域坐标 'left,top,width,height'(留空则截取全屏)。"
62
+ "例如: '100,200,800,600' 表示从 (100,200) 开始截取 800x600 区域",
63
+ required=False, default=""),
64
+ SkillParameter("monitor", "integer",
65
+ "显示器编号(多显示器环境),0=全部, 1=主显示器, 2=副显示器...",
66
+ required=False, default=1),
67
+ ]
68
+
69
+ async def execute(
70
+ self,
71
+ region: str = "",
72
+ monitor: int = 1,
73
+ **kwargs,
74
+ ) -> SkillResult:
75
+ """执行:捕获屏幕截图"""
76
+ try:
77
+ import mss
78
+ except ImportError:
79
+ from core.deps_checker import ensure_skill_deps
80
+ if not ensure_skill_deps("gui"):
81
+ return SkillResult(
82
+ success=False,
83
+ error="mss 安装失败,请手动运行: pip install mss",
84
+ )
85
+ import mss
86
+
87
+ try:
88
+ screenshot_path = _generate_screenshot_path()
89
+
90
+ with mss.mss() as sct:
91
+ # 确定截图区域
92
+ if region:
93
+ # 解析区域: "left,top,width,height"
94
+ parts = region.split(",")
95
+ if len(parts) != 4:
96
+ return SkillResult(
97
+ success=False,
98
+ error=f"区域格式错误,应为 'left,top,width,height',得到: {region}",
99
+ )
100
+ try:
101
+ left, top, width, height = [int(p.strip()) for p in parts]
102
+ except ValueError:
103
+ return SkillResult(success=False, error=f"区域坐标必须是整数: {region}")
104
+
105
+ monitor_info = {"left": left, "top": top, "width": width, "height": height}
106
+ else:
107
+ # 截取指定显示器(默认主显示器)
108
+ monitors = sct.monitors
109
+ if monitor < 0 or monitor >= len(monitors):
110
+ return SkillResult(
111
+ success=False,
112
+ error=f"显示器编号 {monitor} 无效,可用范围: 0-{len(monitors) - 1}",
113
+ )
114
+ monitor_info = monitors[monitor]
115
+
116
+ # 捕获截图
117
+ sct_img = sct.grab(monitor_info)
118
+
119
+ # 保存为 PNG
120
+ from mss.tools import to_png
121
+ mss.tools.to_png(sct_img.rgb, sct_img.size, output=screenshot_path)
122
+
123
+ import os
124
+ file_size = os.path.getsize(screenshot_path)
125
+
126
+ return SkillResult(
127
+ success=True,
128
+ data={
129
+ "screenshot_path": screenshot_path,
130
+ "file_size_bytes": file_size,
131
+ "width": monitor_info.get("width"),
132
+ "height": monitor_info.get("height"),
133
+ "region": region or None,
134
+ "monitor": monitor,
135
+ },
136
+ message=f"屏幕截图已保存: {screenshot_path} ({file_size} 字节, {monitor_info.get('width')}x{monitor_info.get('height')})",
137
+ files=[screenshot_path],
138
+ )
139
+ except Exception as e:
140
+ logger.error(f"屏幕截图失败: {e}")
141
+ return SkillResult(success=False, error=f"屏幕截图失败: {e}")
142
+
143
+
144
+ class MouseClickSkill(Skill):
145
+ """
146
+ 鼠标点击 - 在指定的屏幕坐标处执行鼠标点击。
147
+
148
+ 支持单击、双击、右键点击。坐标以屏幕左上角为原点 (0, 0)。
149
+ 需要先用 screenshot 技能截图,然后用 VLM 分析获取坐标。
150
+ """
151
+
152
+ name = "mouse_click"
153
+ description = (
154
+ "在指定屏幕坐标处执行鼠标点击操作。"
155
+ "支持左键单击、左键双击、右键单击。"
156
+ "坐标以屏幕左上角为原点 (0,0)。"
157
+ )
158
+ category = "gui"
159
+ dangerous = True
160
+ parameters = [
161
+ SkillParameter("x", "integer", "目标 X 坐标(屏幕水平位置,左上角为 0)", required=True),
162
+ SkillParameter("y", "integer", "目标 Y 坐标(屏幕垂直位置,左上角为 0)", required=True),
163
+ SkillParameter("button", "string", "鼠标按钮: left=左键, right=右键, middle=中键", required=False,
164
+ default="left", enum=["left", "right", "middle"]),
165
+ SkillParameter("clicks", "integer", "点击次数: 1=单击, 2=双击, 3=三击", required=False, default=1),
166
+ ]
167
+
168
+ async def execute(
169
+ self,
170
+ x: int = 0,
171
+ y: int = 0,
172
+ button: str = "left",
173
+ clicks: int = 1,
174
+ **kwargs,
175
+ ) -> SkillResult:
176
+ """执行:鼠标点击"""
177
+ try:
178
+ from pynput.mouse import Controller, Button
179
+ except ImportError:
180
+ from core.deps_checker import ensure_skill_deps
181
+ if not ensure_skill_deps("gui"):
182
+ return SkillResult(
183
+ success=False,
184
+ error="pynput 安装失败,请手动运行: pip install pynput",
185
+ )
186
+ from pynput.mouse import Controller, Button
187
+
188
+ try:
189
+ mouse = Controller()
190
+
191
+ # 映射按钮名称
192
+ button_map = {
193
+ "left": Button.left,
194
+ "right": Button.right,
195
+ "middle": Button.middle,
196
+ }
197
+ btn = button_map.get(button, Button.left)
198
+
199
+ # 移动到目标位置
200
+ mouse.position = (x, y)
201
+ time.sleep(0.05) # 短暂等待鼠标移动到位
202
+
203
+ # 执行点击
204
+ if clicks == 1:
205
+ mouse.click(btn, 1)
206
+ elif clicks == 2:
207
+ mouse.click(btn, 2)
208
+ else:
209
+ mouse.click(btn, clicks)
210
+
211
+ click_desc = {"left": "左键", "right": "右键", "middle": "中键"}.get(button, button)
212
+ count_desc = "单击" if clicks == 1 else f"{clicks}连击"
213
+
214
+ return SkillResult(
215
+ success=True,
216
+ data={"x": x, "y": y, "button": button, "clicks": clicks},
217
+ message=f"已在 ({x}, {y}) 执行{click_desc}{count_desc}",
218
+ )
219
+ except Exception as e:
220
+ logger.error(f"鼠标点击失败: {e}")
221
+ return SkillResult(success=False, error=f"鼠标点击失败: {e}")
222
+
223
+
224
+ class MouseDragSkill(Skill):
225
+ """
226
+ 鼠标拖拽 - 从起点拖拽到终点。
227
+
228
+ 常用于文件拖放、滑动操作、选择区域等场景。
229
+ 可设置拖拽持续时间和步数,实现平滑拖拽效果。
230
+ """
231
+
232
+ name = "mouse_drag"
233
+ description = (
234
+ "从起点坐标拖拽到终点坐标。可用于文件拖放、区域选择、滑动条操作等。"
235
+ "支持设置拖拽持续时间以实现平滑拖拽效果。"
236
+ )
237
+ category = "gui"
238
+ dangerous = True
239
+ parameters = [
240
+ SkillParameter("start_x", "integer", "起点 X 坐标", required=True),
241
+ SkillParameter("start_y", "integer", "起点 Y 坐标", required=True),
242
+ SkillParameter("end_x", "integer", "终点 X 坐标", required=True),
243
+ SkillParameter("end_y", "integer", "终点 Y 坐标", required=True),
244
+ SkillParameter("button", "string", "鼠标按钮: left=左键(默认), right=右键", required=False,
245
+ default="left", enum=["left", "right"]),
246
+ SkillParameter("duration", "float", "拖拽持续时间(秒),默认 0.3 秒", required=False, default=0.3),
247
+ ]
248
+
249
+ async def execute(
250
+ self,
251
+ start_x: int = 0,
252
+ start_y: int = 0,
253
+ end_x: int = 0,
254
+ end_y: int = 0,
255
+ button: str = "left",
256
+ duration: float = 0.3,
257
+ **kwargs,
258
+ ) -> SkillResult:
259
+ """执行:鼠标拖拽"""
260
+ try:
261
+ from pynput.mouse import Controller, Button
262
+ except ImportError:
263
+ from core.deps_checker import ensure_skill_deps
264
+ if not ensure_skill_deps("gui"):
265
+ return SkillResult(
266
+ success=False,
267
+ error="pynput 安装失败,请手动运行: pip install pynput",
268
+ )
269
+ from pynput.mouse import Controller, Button
270
+
271
+ try:
272
+ mouse = Controller()
273
+ button_map = {
274
+ "left": Button.left,
275
+ "right": Button.right,
276
+ }
277
+ btn = button_map.get(button, Button.left)
278
+
279
+ # 移动到起点
280
+ mouse.position = (start_x, start_y)
281
+ time.sleep(0.05)
282
+
283
+ # 按下鼠标
284
+ mouse.press(btn)
285
+
286
+ # 平滑移动到终点
287
+ steps = max(int(duration * 60), 1) # 大约 60fps
288
+ dx = (end_x - start_x) / steps
289
+ dy = (end_y - start_y) / steps
290
+ step_delay = duration / steps
291
+
292
+ for i in range(steps):
293
+ mouse.move(int(dx), int(dy))
294
+ time.sleep(step_delay)
295
+
296
+ # 确保到达精确位置
297
+ mouse.position = (end_x, end_y)
298
+
299
+ # 释放鼠标
300
+ mouse.release(btn)
301
+
302
+ return SkillResult(
303
+ success=True,
304
+ data={
305
+ "start": {"x": start_x, "y": start_y},
306
+ "end": {"x": end_x, "y": end_y},
307
+ "button": button,
308
+ "duration": duration,
309
+ },
310
+ message=f"已从 ({start_x}, {start_y}) 拖拽到 ({end_x}, {end_y})",
311
+ )
312
+ except Exception as e:
313
+ logger.error(f"鼠标拖拽失败: {e}")
314
+ return SkillResult(success=False, error=f"鼠标拖拽失败: {e}")
315
+
316
+
317
+ class TypeTextSkill(Skill):
318
+ """
319
+ 输入文本 - 在当前光标位置输入文本。
320
+
321
+ 使用 pynput 键盘控制器模拟真实按键输入。
322
+ 支持普通文本和特殊按键。
323
+ 输入前确保目标输入框已获取焦点。
324
+ """
325
+
326
+ name = "type_text"
327
+ description = (
328
+ "在当前光标位置输入文本。模拟真实键盘按键输入。"
329
+ "输入前请确保目标输入框已获取焦点(可先用 mouse_click 点击输入框)。"
330
+ )
331
+ category = "gui"
332
+ dangerous = True
333
+ parameters = [
334
+ SkillParameter("text", "string", "要输入的文本内容", required=True),
335
+ SkillParameter("interval", "float", "按键间隔(秒),默认 0.02 秒。增大可模拟更慢的打字", required=False, default=0.02),
336
+ SkillParameter("clear_first", "boolean", "是否先用 Ctrl+A 全选再删除(清空已有内容)", required=False, default=False),
337
+ ]
338
+
339
+ async def execute(
340
+ self,
341
+ text: str = "",
342
+ interval: float = 0.02,
343
+ clear_first: bool = False,
344
+ **kwargs,
345
+ ) -> SkillResult:
346
+ """执行:输入文本"""
347
+ try:
348
+ from pynput.keyboard import Controller
349
+ except ImportError:
350
+ from core.deps_checker import ensure_skill_deps
351
+ if not ensure_skill_deps("gui"):
352
+ return SkillResult(
353
+ success=False,
354
+ error="pynput 安装失败,请手动运行: pip install pynput",
355
+ )
356
+ from pynput.keyboard import Controller
357
+
358
+ if not text:
359
+ return SkillResult(success=False, error="缺少必需参数: text")
360
+
361
+ try:
362
+ keyboard = Controller()
363
+
364
+ # 先清空已有内容(可选)
365
+ if clear_first:
366
+ if IS_MACOS:
367
+ # Mac: Cmd+A 全选
368
+ keyboard.press(Key.cmd)
369
+ keyboard.press('a')
370
+ keyboard.release('a')
371
+ keyboard.release(Key.cmd)
372
+ else:
373
+ # Windows/Linux: Ctrl+A 全选
374
+ keyboard.press(Key.ctrl)
375
+ keyboard.press('a')
376
+ keyboard.release('a')
377
+ keyboard.release(Key.ctrl)
378
+ time.sleep(0.05)
379
+ keyboard.press(Key.backspace)
380
+ keyboard.release(Key.backspace)
381
+ time.sleep(0.05)
382
+
383
+ # 逐字符输入
384
+ keyboard.type(text)
385
+
386
+ return SkillResult(
387
+ success=True,
388
+ data={"text_length": len(text), "cleared_first": clear_first},
389
+ message=f"已输入文本({len(text)} 个字符){f'(已先清空)' if clear_first else ''}",
390
+ )
391
+ except Exception as e:
392
+ logger.error(f"输入文本失败: {e}")
393
+ return SkillResult(success=False, error=f"输入文本失败: {e}")
394
+
395
+
396
+ class HotkeySkill(Skill):
397
+ """
398
+ 快捷键 - 按下键盘快捷键组合。
399
+
400
+ 自动根据平台适配修饰键:
401
+ - Windows/Linux: Ctrl 键
402
+ - macOS: Cmd (⌘) 键
403
+
404
+ 支持常用快捷键如 Ctrl+C (复制), Ctrl+V (粘贴), Alt+Tab 等。
405
+ 也可直接指定精确的按键组合。
406
+ """
407
+
408
+ name = "hotkey"
409
+ description = (
410
+ "按下键盘快捷键。自动适配平台修饰键(Windows 用 Ctrl,Mac 用 Cmd)。"
411
+ "支持预设快捷键(如 'copy', 'paste', 'select_all')或自定义按键组合(如 'ctrl+c', 'alt+tab')。"
412
+ )
413
+ category = "gui"
414
+ dangerous = True
415
+ parameters = [
416
+ SkillParameter("action", "string",
417
+ "快捷键动作或按键组合。预设值: copy, paste, cut, select_all, undo, redo, "
418
+ "save, close, tab, new_tab, find, refresh, fullscreen, "
419
+ "screenshot, quit, lock_screen。也可直接指定按键组合如 'ctrl+shift+i', 'alt+f4'",
420
+ required=True),
421
+ ]
422
+
423
+ # 预设快捷键映射 / Preset hotkey mappings
424
+ PRESET_HOTKEYS = {
425
+ "copy": ["ctrl", "c"],
426
+ "paste": ["ctrl", "v"],
427
+ "cut": ["ctrl", "x"],
428
+ "select_all": ["ctrl", "a"],
429
+ "undo": ["ctrl", "z"],
430
+ "redo": ["ctrl", "y"],
431
+ "save": ["ctrl", "s"],
432
+ "close": ["ctrl", "w"],
433
+ "tab": ["ctrl", "tab"],
434
+ "new_tab": ["ctrl", "t"],
435
+ "find": ["ctrl", "f"],
436
+ "refresh": ["ctrl", "r"],
437
+ "fullscreen": ["ctrl", "f11"] if IS_WINDOWS else ["ctrl", "cmd", "f"],
438
+ "screenshot": ["cmd", "shift", "3"] if IS_MACOS else ["ctrl", "print_screen"],
439
+ "quit": ["cmd", "q"] if IS_MACOS else ["alt", "f4"],
440
+ "lock_screen": ["ctrl", "cmd", "q"] if IS_MACOS else ["ctrl", "alt", "delete"],
441
+ }
442
+
443
+ async def execute(self, action: str = "", **kwargs) -> SkillResult:
444
+ """执行:按下快捷键"""
445
+ try:
446
+ from pynput.keyboard import Controller, Key, KeyCode
447
+ except ImportError:
448
+ from core.deps_checker import ensure_skill_deps
449
+ if not ensure_skill_deps("gui"):
450
+ return SkillResult(
451
+ success=False,
452
+ error="pynput 安装失败,请手动运行: pip install pynput",
453
+ )
454
+ from pynput.keyboard import Controller, Key, KeyCode
455
+
456
+ if not action:
457
+ return SkillResult(success=False, error="缺少必需参数: action")
458
+
459
+ try:
460
+ keyboard = Controller()
461
+
462
+ # 解析快捷键组合
463
+ if action.lower() in self.PRESET_HOTKEYS:
464
+ keys = self.PRESET_HOTKEYS[action.lower()]
465
+ else:
466
+ # 解析自定义按键组合,如 "ctrl+shift+i" -> ["ctrl", "shift", "i"]
467
+ keys = [k.strip().lower() for k in action.split("+")]
468
+
469
+ # 将键名映射到 pynput Key 或 KeyCode
470
+ pressed_keys = []
471
+ key_map = {
472
+ "ctrl": Key.ctrl,
473
+ "alt": Key.alt,
474
+ "shift": Key.shift,
475
+ "cmd": Key.cmd,
476
+ "super": Key.cmd,
477
+ "win": Key.cmd,
478
+ "tab": Key.tab,
479
+ "enter": Key.enter,
480
+ "return": Key.enter,
481
+ "space": Key.space,
482
+ "backspace": Key.backspace,
483
+ "delete": Key.delete,
484
+ "esc": Key.esc,
485
+ "escape": Key.esc,
486
+ "up": Key.up,
487
+ "down": Key.down,
488
+ "left": Key.left,
489
+ "right": Key.right,
490
+ "home": Key.home,
491
+ "end": Key.end,
492
+ "page_up": Key.page_up,
493
+ "page_down": Key.page_down,
494
+ "f1": Key.f1, "f2": Key.f2, "f3": Key.f3, "f4": Key.f4,
495
+ "f5": Key.f5, "f6": Key.f6, "f7": Key.f7, "f8": Key.f8,
496
+ "f9": Key.f9, "f10": Key.f10, "f11": Key.f11, "f12": Key.f12,
497
+ "print_screen": Key.print_screen,
498
+ "caps_lock": Key.caps_lock,
499
+ "num_lock": Key.num_lock,
500
+ "insert": Key.insert,
501
+ "pause": Key.pause,
502
+ }
503
+
504
+ # Mac 平台自动将 ctrl 替换为 cmd
505
+ if IS_MACOS:
506
+ keys = ["cmd" if k == "ctrl" else k for k in keys]
507
+
508
+ # 按下所有修饰键
509
+ for key_name in keys:
510
+ if key_name in key_map:
511
+ keyboard.press(key_map[key_name])
512
+ pressed_keys.append(key_map[key_name])
513
+ else:
514
+ # 单字符按键
515
+ if len(key_name) == 1:
516
+ keyboard.press(key_name)
517
+ pressed_keys.append(key_name)
518
+
519
+ # 以相反顺序释放
520
+ time.sleep(0.05)
521
+ for key in reversed(pressed_keys):
522
+ keyboard.release(key)
523
+
524
+ key_display = "+".join(keys)
525
+ platform_note = " (macOS: Ctrl→Cmd)" if IS_MACOS else ""
526
+ return SkillResult(
527
+ success=True,
528
+ data={"action": action, "keys": keys, "platform_adapted": IS_MACOS},
529
+ message=f"已按下快捷键: {key_display}{platform_note}",
530
+ )
531
+ except Exception as e:
532
+ logger.error(f"快捷键操作失败: {e}")
533
+ return SkillResult(success=False, error=f"快捷键操作失败: {e}")
534
+
535
+
536
+ class WindowListSkill(Skill):
537
+ """
538
+ 窗口列表 - 列出当前所有打开的窗口。
539
+
540
+ 返回每个窗口的标题、位置、大小等信息。
541
+ 可用于定位目标窗口以便后续操作。
542
+ """
543
+
544
+ name = "window_list"
545
+ description = (
546
+ "列出当前所有打开的窗口,包括标题、位置、大小等信息。"
547
+ "可用于查找目标窗口标题,供 window_focus 使用。"
548
+ )
549
+ category = "gui"
550
+ parameters = [
551
+ SkillParameter("filter", "string",
552
+ "窗口标题过滤关键词(留空则返回所有窗口)。仅返回标题包含该关键词的窗口",
553
+ required=False, default=""),
554
+ ]
555
+
556
+ async def execute(self, filter: str = "", **kwargs) -> SkillResult:
557
+ """执行:列出窗口"""
558
+ try:
559
+ import pygetwindow as gw
560
+ except ImportError:
561
+ from core.deps_checker import ensure_skill_deps
562
+ if not ensure_skill_deps("gui"):
563
+ return SkillResult(
564
+ success=False,
565
+ error="pygetwindow 安装失败,请手动运行: pip install pygetwindow",
566
+ )
567
+ import pygetwindow as gw
568
+
569
+ try:
570
+ windows = gw.getAllWindows()
571
+
572
+ # 过滤窗口
573
+ if filter:
574
+ windows = [w for w in windows if filter.lower() in w.title.lower()]
575
+
576
+ window_list = []
577
+ for w in windows:
578
+ window_list.append({
579
+ "title": w.title,
580
+ "left": w.left,
581
+ "top": w.top,
582
+ "width": w.width,
583
+ "height": w.height,
584
+ "visible": w.visible,
585
+ "active": w.isActive,
586
+ })
587
+
588
+ return SkillResult(
589
+ success=True,
590
+ data={
591
+ "windows": window_list,
592
+ "count": len(window_list),
593
+ "filter": filter or None,
594
+ "platform": sys.platform,
595
+ },
596
+ message=f"共 {len(window_list)} 个窗口{f'(过滤: {filter})' if filter else ''}",
597
+ )
598
+ except Exception as e:
599
+ logger.error(f"列出窗口失败: {e}")
600
+ return SkillResult(success=False, error=f"列出窗口失败: {e}")
601
+
602
+
603
+ class WindowFocusSkill(Skill):
604
+ """
605
+ 窗口聚焦 - 将指定窗口置顶并获取焦点。
606
+
607
+ 支持通过窗口标题(模糊匹配)或窗口标题序号来定位目标窗口。
608
+ 聚焦后可进行后续的鼠标/键盘操作。
609
+ """
610
+
611
+ name = "window_focus"
612
+ description = (
613
+ "将指定窗口置顶并获取焦点。支持通过窗口标题(模糊匹配)定位。"
614
+ "聚焦后可在该窗口内进行鼠标点击、键盘输入等操作。"
615
+ )
616
+ category = "gui"
617
+ dangerous = True
618
+ parameters = [
619
+ SkillParameter("title", "string", "目标窗口标题(模糊匹配,包含该文本即匹配)", required=True),
620
+ SkillParameter("activate", "boolean", "是否激活窗口(默认 true)", required=False, default=True),
621
+ SkillParameter("maximize", "boolean", "是否最大化窗口(默认 false)", required=False, default=False),
622
+ ]
623
+
624
+ async def execute(
625
+ self,
626
+ title: str = "",
627
+ activate: bool = True,
628
+ maximize: bool = False,
629
+ **kwargs,
630
+ ) -> SkillResult:
631
+ """执行:聚焦窗口"""
632
+ try:
633
+ import pygetwindow as gw
634
+ except ImportError:
635
+ from core.deps_checker import ensure_skill_deps
636
+ if not ensure_skill_deps("gui"):
637
+ return SkillResult(
638
+ success=False,
639
+ error="pygetwindow 安装失败,请手动运行: pip install pygetwindow",
640
+ )
641
+ import pygetwindow as gw
642
+
643
+ if not title:
644
+ return SkillResult(success=False, error="缺少必需参数: title")
645
+
646
+ try:
647
+ # 模糊匹配窗口标题
648
+ windows = gw.getWindowsWithTitle(title)
649
+ if not windows:
650
+ return SkillResult(
651
+ success=False,
652
+ error=f"未找到包含 '{title}' 的窗口",
653
+ )
654
+
655
+ window = windows[0]
656
+
657
+ if activate:
658
+ # 先尝试恢复最小化的窗口
659
+ if window.isMinimized:
660
+ window.restore()
661
+ time.sleep(0.2)
662
+
663
+ window.activate()
664
+ time.sleep(0.2)
665
+
666
+ if maximize:
667
+ window.maximize()
668
+ time.sleep(0.2)
669
+
670
+ return SkillResult(
671
+ success=True,
672
+ data={
673
+ "title": window.title,
674
+ "left": window.left,
675
+ "top": window.top,
676
+ "width": window.width,
677
+ "height": window.height,
678
+ "activated": activate,
679
+ "maximized": maximize,
680
+ },
681
+ message=f"已聚焦窗口: {window.title} ({window.width}x{window.height})",
682
+ )
683
+ except Exception as e:
684
+ logger.error(f"窗口聚焦失败: {e}")
685
+ return SkillResult(success=False, error=f"窗口聚焦失败: {e}")
686
+
687
+
688
+ class ScreenElementSkill(Skill):
689
+ """
690
+ 屏幕元素定位 - 使用视觉模型(VLM)从截图中定位 UI 元素坐标。
691
+
692
+ 工作流程:
693
+ 1. 自动截取屏幕截图
694
+ 2. 将截图发送到 VLM(视觉语言模型)进行分析
695
+ 3. VLM 返回目标元素的屏幕坐标
696
+ 4. 返回坐标供 mouse_click 技能使用
697
+
698
+ 依赖 LLM 客户端配置了支持视觉的模型(如 GPT-4o, Claude 3.5 Sonnet 等)。
699
+ """
700
+
701
+ name = "screen_element"
702
+ description = (
703
+ "使用视觉模型(VLM)从屏幕截图中定位 UI 元素的坐标。"
704
+ "先截图,再让 AI 视觉模型分析找到目标元素,返回屏幕坐标。"
705
+ "返回的坐标可直接用于 mouse_click 技能进行点击操作。"
706
+ )
707
+ category = "gui"
708
+ parameters = [
709
+ SkillParameter("description", "string",
710
+ "要查找的元素的文字描述(如 '保存按钮', '右上角的关闭按钮', '地址栏输入框')",
711
+ required=True),
712
+ SkillParameter("region", "string",
713
+ "截图区域 'left,top,width,height'(留空截取全屏)",
714
+ required=False, default=""),
715
+ SkillParameter("element_type", "string",
716
+ "元素类型提示: button/input/link/icon/menu/checkbox/text_area/其他",
717
+ required=False, default=""),
718
+ ]
719
+
720
+ async def execute(
721
+ self,
722
+ description: str = "",
723
+ region: str = "",
724
+ element_type: str = "",
725
+ **kwargs,
726
+ ) -> SkillResult:
727
+ """执行:使用 VLM 定位屏幕元素"""
728
+ # 第一步:截图
729
+ try:
730
+ import mss
731
+ except ImportError:
732
+ from core.deps_checker import ensure_skill_deps
733
+ if not ensure_skill_deps("gui"):
734
+ return SkillResult(
735
+ success=False,
736
+ error="mss 安装失败,请手动运行: pip install mss",
737
+ )
738
+ import mss
739
+
740
+ try:
741
+ screenshot_path = _generate_screenshot_path()
742
+
743
+ # 截图
744
+ with mss.mss() as sct:
745
+ if region:
746
+ parts = region.split(",")
747
+ if len(parts) == 4:
748
+ left, top, width, height = [int(p.strip()) for p in parts]
749
+ monitor_info = {"left": left, "top": top, "width": width, "height": height}
750
+ else:
751
+ monitor_info = sct.monitors[1]
752
+ else:
753
+ monitor_info = sct.monitors[1]
754
+
755
+ sct_img = sct.grab(monitor_info)
756
+ from mss.tools import to_png
757
+ to_png(sct_img.rgb, sct_img.size, output=screenshot_path)
758
+
759
+ import os
760
+ file_size = os.path.getsize(screenshot_path)
761
+
762
+ # 第二步:读取图片并编码为 base64
763
+ import base64
764
+ with open(screenshot_path, "rb") as f:
765
+ image_data = base64.b64encode(f.read()).decode("utf-8")
766
+
767
+ # 第三步:调用 VLM 分析截图
768
+ try:
769
+ from core.llm import LLMClient, Message
770
+
771
+ client = LLMClient()
772
+
773
+ # 构建 VLM 分析 prompt
774
+ type_hint = f"元素类型: {element_type}。" if element_type else ""
775
+ vlm_prompt = (
776
+ f"分析这张屏幕截图,找到以下 UI 元素并返回其中心坐标:\n"
777
+ f"目标: {description}\n"
778
+ f"{type_hint}\n\n"
779
+ f"请严格按以下 JSON 格式回复(不要包含其他内容):\n"
780
+ f'{{"found": true/false, "x": 中心X坐标, "y": 中心Y坐标, '
781
+ f'"confidence": 0.0-1.0, "description": "找到的元素的描述"}}\n\n'
782
+ f"注意: 坐标以截图左上角为原点 (0,0)。"
783
+ f"如果找不到元素,found 设为 false。"
784
+ )
785
+
786
+ # 使用 vision API
787
+ messages = [
788
+ Message(
789
+ role="user",
790
+ content=[
791
+ {"type": "text", "text": vlm_prompt},
792
+ {
793
+ "type": "image_url",
794
+ "image_url": {
795
+ "url": f"data:image/png;base64,{image_data}",
796
+ },
797
+ },
798
+ ],
799
+ ),
800
+ ]
801
+
802
+ response = await client.chat(
803
+ messages=messages,
804
+ temperature=0.1,
805
+ max_tokens=200,
806
+ )
807
+
808
+ if not response.success or not response.content:
809
+ return SkillResult(
810
+ success=True,
811
+ data={
812
+ "screenshot_path": screenshot_path,
813
+ "file_size_bytes": file_size,
814
+ "found": False,
815
+ "reason": "VLM 未返回有效结果",
816
+ },
817
+ message=f"截图已保存: {screenshot_path},但 VLM 分析失败",
818
+ files=[screenshot_path],
819
+ )
820
+
821
+ # 解析 VLM 返回的坐标
822
+ import json
823
+ import re
824
+ content = response.content.strip()
825
+
826
+ # 尝试从返回中提取 JSON
827
+ json_match = re.search(r'\{[^}]+\}', content, re.DOTALL)
828
+ if json_match:
829
+ result = json.loads(json_match.group())
830
+ else:
831
+ result = json.loads(content)
832
+
833
+ found = result.get("found", False)
834
+ x = result.get("x")
835
+ y = result.get("y")
836
+ confidence = result.get("confidence", 0)
837
+
838
+ if found and x is not None and y is not None:
839
+ # 如果截图是区域截图,需要加上偏移量
840
+ offset_x = monitor_info.get("left", 0)
841
+ offset_y = monitor_info.get("top", 0)
842
+ actual_x = int(x) + offset_x
843
+ actual_y = int(y) + offset_y
844
+
845
+ return SkillResult(
846
+ success=True,
847
+ data={
848
+ "found": True,
849
+ "x": actual_x,
850
+ "y": actual_y,
851
+ "confidence": confidence,
852
+ "description": result.get("description", description),
853
+ "screenshot_path": screenshot_path,
854
+ "element_type": element_type,
855
+ },
856
+ message=(
857
+ f"已定位元素 '{description}' 在坐标 ({actual_x}, {actual_y}),"
858
+ f"置信度: {confidence:.0%}。可使用 mouse_click 点击。"
859
+ ),
860
+ files=[screenshot_path],
861
+ )
862
+ else:
863
+ return SkillResult(
864
+ success=True,
865
+ data={
866
+ "found": False,
867
+ "screenshot_path": screenshot_path,
868
+ "vlm_response": content[:500],
869
+ },
870
+ message=f"未在截图中找到 '{description}'。截图已保存: {screenshot_path}",
871
+ files=[screenshot_path],
872
+ )
873
+
874
+ except ImportError:
875
+ return SkillResult(
876
+ success=True,
877
+ data={
878
+ "screenshot_path": screenshot_path,
879
+ "file_size_bytes": file_size,
880
+ "found": False,
881
+ "reason": "LLM 客户端不可用,无法进行 VLM 分析",
882
+ },
883
+ message=(
884
+ f"截图已保存: {screenshot_path},但无法调用 VLM 进行元素定位。"
885
+ "请检查 LLM 配置是否支持视觉模型,或手动查看截图确定坐标。"
886
+ ),
887
+ files=[screenshot_path],
888
+ )
889
+ except Exception as vlm_error:
890
+ logger.error(f"VLM 分析失败: {vlm_error}")
891
+ return SkillResult(
892
+ success=True,
893
+ data={
894
+ "screenshot_path": screenshot_path,
895
+ "file_size_bytes": file_size,
896
+ "found": False,
897
+ "reason": f"VLM 分析异常: {vlm_error}",
898
+ },
899
+ message=(
900
+ f"截图已保存: {screenshot_path},但 VLM 分析失败: {vlm_error}。"
901
+ "可手动查看截图确定坐标后使用 mouse_click。"
902
+ ),
903
+ files=[screenshot_path],
904
+ )
905
+
906
+ except Exception as e:
907
+ logger.error(f"屏幕元素定位失败: {e}")
908
+ return SkillResult(success=False, error=f"屏幕元素定位失败: {e}")