seedcode-cli 6.1.5__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.
- seedcode/__init__.py +14 -0
- seedcode/__main__.py +12 -0
- seedcode/app.py +508 -0
- seedcode/apps/__init__.py +32 -0
- seedcode/apps/discovery.py +241 -0
- seedcode/apps/installer.py +164 -0
- seedcode/apps/launcher.py +156 -0
- seedcode/apps/verifier.py +119 -0
- seedcode/assets/logo.txt +15 -0
- seedcode/cli.py +95 -0
- seedcode/commands/__init__.py +81 -0
- seedcode/commands/about.py +34 -0
- seedcode/commands/agent.py +94 -0
- seedcode/commands/assist.py +201 -0
- seedcode/commands/clear.py +20 -0
- seedcode/commands/desktop.py +104 -0
- seedcode/commands/doctor.py +152 -0
- seedcode/commands/help.py +61 -0
- seedcode/commands/history.py +365 -0
- seedcode/commands/palette.py +100 -0
- seedcode/commands/provider.py +451 -0
- seedcode/commands/theme.py +76 -0
- seedcode/computer/__init__.py +98 -0
- seedcode/computer/browser.py +276 -0
- seedcode/computer/browser_cdp.py +567 -0
- seedcode/computer/browser_engine.py +546 -0
- seedcode/computer/browser_extract.py +301 -0
- seedcode/computer/browser_popups.py +329 -0
- seedcode/computer/browser_selenium.py +209 -0
- seedcode/computer/browser_skills.py +245 -0
- seedcode/computer/catalog.py +200 -0
- seedcode/computer/controller.py +324 -0
- seedcode/computer/dispatcher.py +272 -0
- seedcode/computer/dpi.py +185 -0
- seedcode/computer/engine.py +105 -0
- seedcode/computer/keyboard.py +101 -0
- seedcode/computer/logbook.py +104 -0
- seedcode/computer/mouse.py +48 -0
- seedcode/computer/ocr.py +213 -0
- seedcode/computer/operator_skills.py +577 -0
- seedcode/computer/permissions.py +203 -0
- seedcode/computer/recovery.py +115 -0
- seedcode/computer/registry.py +107 -0
- seedcode/computer/resolver.py +434 -0
- seedcode/computer/screen.py +130 -0
- seedcode/computer/screen_state.py +412 -0
- seedcode/computer/selfguard.py +197 -0
- seedcode/computer/semantic.py +100 -0
- seedcode/computer/skills.py +139 -0
- seedcode/computer/state.py +199 -0
- seedcode/computer/verifier.py +177 -0
- seedcode/computer/vision.py +327 -0
- seedcode/computer/windows.py +217 -0
- seedcode/config/__init__.py +8 -0
- seedcode/config/defaults.py +22 -0
- seedcode/config/manager.py +62 -0
- seedcode/core/__init__.py +31 -0
- seedcode/core/agent.py +534 -0
- seedcode/core/chat.py +128 -0
- seedcode/core/client.py +9 -0
- seedcode/core/errors.py +199 -0
- seedcode/core/identity.py +66 -0
- seedcode/core/identity_store.py +119 -0
- seedcode/core/lifecycle.py +240 -0
- seedcode/core/limits.py +35 -0
- seedcode/core/models.py +347 -0
- seedcode/core/project.py +96 -0
- seedcode/core/providers/__init__.py +58 -0
- seedcode/core/providers/aerolink.py +324 -0
- seedcode/core/providers/base.py +230 -0
- seedcode/core/providers/freemodel.py +931 -0
- seedcode/core/providers/ollama.py +262 -0
- seedcode/core/providers/openrouter.py +393 -0
- seedcode/core/streaming.py +21 -0
- seedcode/memory/__init__.py +8 -0
- seedcode/memory/manager.py +47 -0
- seedcode/memory/storage.py +38 -0
- seedcode/memory/store.py +257 -0
- seedcode/tools/__init__.py +35 -0
- seedcode/tools/base.py +179 -0
- seedcode/tools/desktop.py +371 -0
- seedcode/tools/filesystem.py +309 -0
- seedcode/tools/git.py +72 -0
- seedcode/tools/patch.py +170 -0
- seedcode/tools/permissions.py +288 -0
- seedcode/tools/search.py +137 -0
- seedcode/tools/terminal.py +200 -0
- seedcode/tools/textio.py +59 -0
- seedcode/ui/__init__.py +164 -0
- seedcode/ui/badges.py +64 -0
- seedcode/ui/banner.py +78 -0
- seedcode/ui/dashboard.py +197 -0
- seedcode/ui/dialog.py +62 -0
- seedcode/ui/fuzzy.py +128 -0
- seedcode/ui/layout.py +54 -0
- seedcode/ui/menu.py +61 -0
- seedcode/ui/palette.py +40 -0
- seedcode/ui/progress.py +41 -0
- seedcode/ui/prompts.py +16 -0
- seedcode/ui/renderer.py +36 -0
- seedcode/ui/searchbox.py +70 -0
- seedcode/ui/selector.py +514 -0
- seedcode/ui/statusbar.py +38 -0
- seedcode/ui/textbox.py +61 -0
- seedcode/ui/theme.py +204 -0
- seedcode/ui/tree.py +91 -0
- seedcode/utils/__init__.py +22 -0
- seedcode/utils/helpers.py +97 -0
- seedcode/utils/logger.py +65 -0
- seedcode_cli-6.1.5.dist-info/METADATA +368 -0
- seedcode_cli-6.1.5.dist-info/RECORD +114 -0
- seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
- seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
- seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""Element resolver: descriptions in, coordinates out.
|
|
2
|
+
|
|
3
|
+
This is the boundary that keeps coordinates away from the AI. Every semantic UI
|
|
4
|
+
action (``ui_click("Sign in button")``, ``ui_type("search box", ...)``) hands
|
|
5
|
+
the resolver a *description*; the resolver finds the matching on-screen element
|
|
6
|
+
and returns its clickable point. Resolution walks a fixed strategy ladder,
|
|
7
|
+
cheapest and most reliable first, and stops at the first confident hit:
|
|
8
|
+
|
|
9
|
+
1. **Accessibility tree** (UI Automation) — the primary, exact source: fuzzy
|
|
10
|
+
match the description against element role + name.
|
|
11
|
+
2. **UIA properties** — a second UIA pass matching automation-id / value /
|
|
12
|
+
help-text for elements a name match missed (unnamed inputs, icon buttons).
|
|
13
|
+
3. **DOM inspection** — when the target is a web page and the browser exposes a
|
|
14
|
+
DevTools endpoint, ask the *page* where the element is. Exact, and immune to
|
|
15
|
+
theme/zoom/scroll. Skipped silently when no browser is attached.
|
|
16
|
+
4. **OCR** — for windows that expose no automation tree (games, custom-drawn
|
|
17
|
+
apps): locate the text on a screenshot and click its center.
|
|
18
|
+
5. **Computer vision** — snap an OCR hit out to the clickable control that
|
|
19
|
+
encloses it, so the click lands on the button body, not the glyphs.
|
|
20
|
+
6. **Image matching** — a caller-supplied reference image (an icon/logo with no
|
|
21
|
+
accessibility name), located by normalized-correlation template match.
|
|
22
|
+
7. **Relative positioning** — "the field below Username", "the button next to
|
|
23
|
+
Search": resolve the *anchor* by the tiers above, then offset from it.
|
|
24
|
+
8. **Absolute coordinates** — last resort, internal only (recovery replays a
|
|
25
|
+
known point); the AI never supplies coordinates.
|
|
26
|
+
|
|
27
|
+
The AI is never shown, and never supplies, the resulting coordinates. If
|
|
28
|
+
nothing resolves, a :class:`ResolveError` flows back so the dispatcher's
|
|
29
|
+
recovery engine can retry (refocus, re-snapshot) or, as a last resort, ask the
|
|
30
|
+
AI to replan against a fresh semantic snapshot.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import re
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from ..ui.fuzzy import fuzzy_match
|
|
40
|
+
|
|
41
|
+
# A fuzzy score below this means "no confident match" — better to fail and let
|
|
42
|
+
# recovery re-snapshot than to click the wrong thing.
|
|
43
|
+
_MIN_SCORE = 200.0
|
|
44
|
+
|
|
45
|
+
# Role words the AI naturally appends to a description ("Save button", "search
|
|
46
|
+
# box"); stripped before matching so they don't fight the element's own role.
|
|
47
|
+
_ROLE_HINTS = {
|
|
48
|
+
"button": "button",
|
|
49
|
+
"btn": "button",
|
|
50
|
+
"input": "input",
|
|
51
|
+
"field": "input",
|
|
52
|
+
"box": "input",
|
|
53
|
+
"textbox": "input",
|
|
54
|
+
"link": "link",
|
|
55
|
+
"menu": "menu",
|
|
56
|
+
"menuitem": "menuitem",
|
|
57
|
+
"item": "listitem",
|
|
58
|
+
"tab": "tab",
|
|
59
|
+
"checkbox": "checkbox",
|
|
60
|
+
"check": "checkbox",
|
|
61
|
+
"radio": "radio",
|
|
62
|
+
"combobox": "combobox",
|
|
63
|
+
"dropdown": "combobox",
|
|
64
|
+
"icon": "button",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Direction phrases for the relative-positioning tier, mapped to a unit vector
|
|
68
|
+
# in screen space (x right, y down). Longest phrases first so "to the right of"
|
|
69
|
+
# is matched before "right".
|
|
70
|
+
_DIRECTIONS: list[tuple[str, tuple[int, int]]] = [
|
|
71
|
+
("to the right of", (1, 0)),
|
|
72
|
+
("to the left of", (-1, 0)),
|
|
73
|
+
("right of", (1, 0)),
|
|
74
|
+
("left of", (-1, 0)),
|
|
75
|
+
("underneath", (0, 1)),
|
|
76
|
+
("beneath", (0, 1)),
|
|
77
|
+
("below", (0, 1)),
|
|
78
|
+
("under", (0, 1)),
|
|
79
|
+
("above", (0, -1)),
|
|
80
|
+
("over", (0, -1)),
|
|
81
|
+
("next to", (1, 0)),
|
|
82
|
+
("beside", (1, 0)),
|
|
83
|
+
("near", (0, 0)),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ResolveError(Exception):
|
|
88
|
+
"""A described element could not be located on screen."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(slots=True)
|
|
92
|
+
class ResolvedElement:
|
|
93
|
+
"""An element the resolver located, with the point to act on."""
|
|
94
|
+
|
|
95
|
+
x: int
|
|
96
|
+
y: int
|
|
97
|
+
role: str
|
|
98
|
+
name: str
|
|
99
|
+
score: float
|
|
100
|
+
# Which ladder tier produced the hit: accessibility | uia | dom | ocr | cv |
|
|
101
|
+
# image | relative | absolute.
|
|
102
|
+
source: str
|
|
103
|
+
# The ordered list of tiers attempted before this one succeeded (diagnostic
|
|
104
|
+
# trail, surfaced in logs and to recovery).
|
|
105
|
+
tried: list[str] = field(default_factory=list)
|
|
106
|
+
|
|
107
|
+
def describe(self) -> str:
|
|
108
|
+
return f'{self.role} "{self.name or "(unnamed)"}" via {self.source}'
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ElementResolver:
|
|
112
|
+
"""Turns element descriptions into concrete screen points.
|
|
113
|
+
|
|
114
|
+
Deterministic and offline. Drivers are injectable so the resolution logic
|
|
115
|
+
can be unit-tested without a live desktop.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(self, vision: Any = None, screen: Any = None, dom: Any = None) -> None:
|
|
119
|
+
if vision is None:
|
|
120
|
+
from . import vision as vision # type: ignore
|
|
121
|
+
if screen is None:
|
|
122
|
+
from . import screen as screen # type: ignore
|
|
123
|
+
if dom is None:
|
|
124
|
+
from . import browser_cdp as dom # type: ignore
|
|
125
|
+
self._vision = vision
|
|
126
|
+
self._screen = screen
|
|
127
|
+
# The DOM tier's provider. Injectable so resolution can be tested
|
|
128
|
+
# without a live browser, and so a caller can disable the tier by
|
|
129
|
+
# passing a stub.
|
|
130
|
+
self._dom = dom
|
|
131
|
+
|
|
132
|
+
def resolve(
|
|
133
|
+
self,
|
|
134
|
+
description: str,
|
|
135
|
+
window_title: str | None = None,
|
|
136
|
+
*,
|
|
137
|
+
template: str | None = None,
|
|
138
|
+
) -> ResolvedElement:
|
|
139
|
+
"""Locate the element matching ``description`` and return its point.
|
|
140
|
+
|
|
141
|
+
``template`` optionally points at a reference image for the
|
|
142
|
+
image-matching tier (skills supply it; the AI never does).
|
|
143
|
+
"""
|
|
144
|
+
desc = (description or "").strip()
|
|
145
|
+
if not desc:
|
|
146
|
+
raise ResolveError("No element description was given.")
|
|
147
|
+
|
|
148
|
+
tried: list[str] = []
|
|
149
|
+
|
|
150
|
+
# Snapshot the accessibility tree once; tiers 1–2 reuse it.
|
|
151
|
+
try:
|
|
152
|
+
_title, elements = self._vision.snapshot(window_title)
|
|
153
|
+
except Exception:
|
|
154
|
+
elements = []
|
|
155
|
+
|
|
156
|
+
# A spatial phrase ("the field below Username") is an explicit
|
|
157
|
+
# instruction, not a fallback: honour it before fuzzy matching, which
|
|
158
|
+
# would otherwise match the *anchor* itself and click the wrong control.
|
|
159
|
+
# The anchor is still located via the accessibility tiers below, so
|
|
160
|
+
# mechanism preference is preserved.
|
|
161
|
+
anchor_desc, vector = self._split_relative(desc)
|
|
162
|
+
if anchor_desc is not None:
|
|
163
|
+
tried.append("relative")
|
|
164
|
+
rel = self._resolve_relative(anchor_desc, vector, elements, window_title, tried)
|
|
165
|
+
if rel is not None:
|
|
166
|
+
return rel
|
|
167
|
+
|
|
168
|
+
# 1) Accessibility — exact, preferred.
|
|
169
|
+
tried.append("accessibility")
|
|
170
|
+
hit = self._match_elements(desc, elements)
|
|
171
|
+
if hit is not None:
|
|
172
|
+
return self._tag(hit, tried)
|
|
173
|
+
|
|
174
|
+
# 2) UIA properties — automation-id / value / help-text.
|
|
175
|
+
tried.append("uia")
|
|
176
|
+
hit = self._match_uia_properties(desc, elements)
|
|
177
|
+
if hit is not None:
|
|
178
|
+
return self._tag(hit, tried)
|
|
179
|
+
|
|
180
|
+
# 3) DOM — ask the page itself, when one is attached.
|
|
181
|
+
tried.append("dom")
|
|
182
|
+
dom_hit = self._locate_dom(desc)
|
|
183
|
+
if dom_hit is not None:
|
|
184
|
+
return self._tag(dom_hit, tried)
|
|
185
|
+
|
|
186
|
+
# 4) OCR — text on a screenshot (+ 5) CV refinement of the hit box).
|
|
187
|
+
tried.append("ocr")
|
|
188
|
+
ocr_box = self._locate_ocr(desc, window_title)
|
|
189
|
+
if ocr_box is not None:
|
|
190
|
+
tried.append("cv")
|
|
191
|
+
box, source = self._refine_with_cv(ocr_box, window_title)
|
|
192
|
+
left, top, width, height = box
|
|
193
|
+
return ResolvedElement(
|
|
194
|
+
x=int(left + width // 2), y=int(top + height // 2),
|
|
195
|
+
role="text", name=desc, score=_MIN_SCORE, source=source, tried=list(tried),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# 6) Image matching — a caller-supplied reference image.
|
|
199
|
+
if template:
|
|
200
|
+
tried.append("image")
|
|
201
|
+
img_hit = self._match_template(template, window_title)
|
|
202
|
+
if img_hit is not None:
|
|
203
|
+
return self._tag(img_hit, tried)
|
|
204
|
+
|
|
205
|
+
raise ResolveError(
|
|
206
|
+
f'Could not find "{desc}" on screen (tried: {", ".join(tried)}). '
|
|
207
|
+
"Take a fresh computer_see snapshot and describe a visible element."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def resolve_point(self, x: int, y: int) -> ResolvedElement:
|
|
211
|
+
"""Absolute-coordinate tier — internal last resort (never AI-driven)."""
|
|
212
|
+
return ResolvedElement(
|
|
213
|
+
x=int(x), y=int(y), role="point", name=f"({x}, {y})",
|
|
214
|
+
score=_MIN_SCORE, source="absolute", tried=["absolute"],
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# --- accessibility matching ---------------------------------------------
|
|
218
|
+
def _match_elements(self, desc: str, elements: list) -> ResolvedElement | None:
|
|
219
|
+
if not elements:
|
|
220
|
+
return None
|
|
221
|
+
query, wanted_role = self._split_role_hint(desc)
|
|
222
|
+
best: ResolvedElement | None = None
|
|
223
|
+
for el in elements:
|
|
224
|
+
# Score the query against the element name (primary) and, more
|
|
225
|
+
# weakly, its role, so "search box" still finds an unnamed input.
|
|
226
|
+
name_score = fuzzy_match(query, el.name).score if query else 0.0
|
|
227
|
+
role_score = 0.0
|
|
228
|
+
if wanted_role and el.role == wanted_role:
|
|
229
|
+
role_score = 150.0
|
|
230
|
+
elif wanted_role and el.role != wanted_role:
|
|
231
|
+
role_score = -60.0 # penalise a role mismatch, don't exclude
|
|
232
|
+
score = name_score + role_score
|
|
233
|
+
# An unnamed element that matches only by role still counts when
|
|
234
|
+
# the description was essentially just a role ("the input").
|
|
235
|
+
if not query and wanted_role and el.role == wanted_role:
|
|
236
|
+
score = _MIN_SCORE + 1.0
|
|
237
|
+
if not getattr(el, "enabled", True):
|
|
238
|
+
score -= 80.0
|
|
239
|
+
if best is None or score > best.score:
|
|
240
|
+
best = ResolvedElement(
|
|
241
|
+
x=el.x, y=el.y, role=el.role, name=el.name,
|
|
242
|
+
score=score, source="accessibility",
|
|
243
|
+
)
|
|
244
|
+
if best is not None and best.score >= _MIN_SCORE:
|
|
245
|
+
return best
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
def _match_uia_properties(self, desc: str, elements: list) -> ResolvedElement | None:
|
|
249
|
+
"""Second UIA pass: match automation-id / value / help-text.
|
|
250
|
+
|
|
251
|
+
Buttons drawn with only an icon, and inputs whose accessible name is
|
|
252
|
+
empty, often still carry an AutomationId ("searchBox"), a Value, or
|
|
253
|
+
help text. Name-based matching misses those; this catches them.
|
|
254
|
+
Defensive: elements without these attributes (test fakes, minimal
|
|
255
|
+
drivers) simply contribute nothing.
|
|
256
|
+
"""
|
|
257
|
+
query, _role = self._split_role_hint(desc)
|
|
258
|
+
needle = " ".join(query.lower().split())
|
|
259
|
+
if not needle:
|
|
260
|
+
return None
|
|
261
|
+
best: ResolvedElement | None = None
|
|
262
|
+
for el in elements:
|
|
263
|
+
haystacks = [
|
|
264
|
+
str(getattr(el, "automation_id", "") or ""),
|
|
265
|
+
str(getattr(el, "value", "") or ""),
|
|
266
|
+
str(getattr(el, "help_text", "") or ""),
|
|
267
|
+
]
|
|
268
|
+
score = 0.0
|
|
269
|
+
for hay in haystacks:
|
|
270
|
+
if not hay:
|
|
271
|
+
continue
|
|
272
|
+
hay_norm = " ".join(hay.lower().split())
|
|
273
|
+
if needle in hay_norm or hay_norm in needle:
|
|
274
|
+
score = max(score, _MIN_SCORE + 20.0)
|
|
275
|
+
else:
|
|
276
|
+
score = max(score, fuzzy_match(query, hay).score)
|
|
277
|
+
if not getattr(el, "enabled", True):
|
|
278
|
+
score -= 80.0
|
|
279
|
+
if score >= _MIN_SCORE and (best is None or score > best.score):
|
|
280
|
+
best = ResolvedElement(
|
|
281
|
+
x=el.x, y=el.y, role=getattr(el, "role", "element"),
|
|
282
|
+
name=getattr(el, "name", "") or needle, score=score, source="uia",
|
|
283
|
+
)
|
|
284
|
+
return best
|
|
285
|
+
|
|
286
|
+
def _split_role_hint(self, desc: str) -> tuple[str, str | None]:
|
|
287
|
+
"""Peel a trailing role word off the description, if present."""
|
|
288
|
+
words = desc.split()
|
|
289
|
+
if len(words) >= 2:
|
|
290
|
+
role = _ROLE_HINTS.get(words[-1].lower())
|
|
291
|
+
if role is not None:
|
|
292
|
+
return " ".join(words[:-1]).strip(), role
|
|
293
|
+
# A one-word role-only description ("button") still carries the hint.
|
|
294
|
+
if len(words) == 1:
|
|
295
|
+
role = _ROLE_HINTS.get(words[0].lower())
|
|
296
|
+
if role is not None:
|
|
297
|
+
return "", role
|
|
298
|
+
return desc, None
|
|
299
|
+
|
|
300
|
+
# --- DOM -----------------------------------------------------------------
|
|
301
|
+
def _locate_dom(self, desc: str) -> ResolvedElement | None:
|
|
302
|
+
"""Ask the attached web page where the described element is.
|
|
303
|
+
|
|
304
|
+
Returns None — cheaply and silently — whenever no browser is attached,
|
|
305
|
+
which is the common case for native-app automation. The page reports
|
|
306
|
+
screen coordinates directly, so no conversion is needed here.
|
|
307
|
+
"""
|
|
308
|
+
dom = self._dom
|
|
309
|
+
if dom is None:
|
|
310
|
+
return None
|
|
311
|
+
try:
|
|
312
|
+
if not dom.is_available():
|
|
313
|
+
return None
|
|
314
|
+
box = dom.locate_text(self._strip_role(desc))
|
|
315
|
+
except Exception:
|
|
316
|
+
return None
|
|
317
|
+
if box is None:
|
|
318
|
+
return None
|
|
319
|
+
return ResolvedElement(
|
|
320
|
+
x=int(box.x), y=int(box.y), role="dom",
|
|
321
|
+
name=getattr(box, "text", "") or desc,
|
|
322
|
+
score=_MIN_SCORE, source="dom",
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def _strip_role(self, desc: str) -> str:
|
|
326
|
+
"""Drop a trailing role word — the DOM matches on visible text."""
|
|
327
|
+
query, _role = self._split_role_hint(desc)
|
|
328
|
+
return query or desc
|
|
329
|
+
|
|
330
|
+
# --- OCR + CV ------------------------------------------------------------
|
|
331
|
+
def _locate_ocr(self, desc: str, window_title: str | None) -> tuple[int, int, int, int] | None:
|
|
332
|
+
"""Find text on a screenshot; return its (left, top, width, height)."""
|
|
333
|
+
locate = getattr(self._vision, "ocr_locate", None)
|
|
334
|
+
if locate is None or self._screen is None or not self._vision.ocr_available():
|
|
335
|
+
return None
|
|
336
|
+
try:
|
|
337
|
+
path = self._screen.capture()
|
|
338
|
+
return locate(path, desc) # (left, top, width, height) or None
|
|
339
|
+
except Exception:
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
def _refine_with_cv(
|
|
343
|
+
self, box: tuple[int, int, int, int], window_title: str | None
|
|
344
|
+
) -> tuple[tuple[int, int, int, int], str]:
|
|
345
|
+
"""Snap an OCR box to its enclosing control via CV; report the tier."""
|
|
346
|
+
refine = getattr(self._vision, "refine_region_cv", None)
|
|
347
|
+
if refine is None or self._screen is None or not getattr(self._vision, "cv_available", lambda: False)():
|
|
348
|
+
return box, "ocr"
|
|
349
|
+
try:
|
|
350
|
+
path = self._screen.capture()
|
|
351
|
+
refined = refine(path, box)
|
|
352
|
+
return (refined, "cv") if refined != box else (box, "ocr")
|
|
353
|
+
except Exception:
|
|
354
|
+
return box, "ocr"
|
|
355
|
+
|
|
356
|
+
# --- image matching ------------------------------------------------------
|
|
357
|
+
def _match_template(self, template: str, window_title: str | None) -> ResolvedElement | None:
|
|
358
|
+
locate = getattr(self._vision, "template_locate", None)
|
|
359
|
+
if locate is None or self._screen is None:
|
|
360
|
+
return None
|
|
361
|
+
try:
|
|
362
|
+
path = self._screen.capture()
|
|
363
|
+
box = locate(path, template)
|
|
364
|
+
except Exception:
|
|
365
|
+
return None
|
|
366
|
+
if not box:
|
|
367
|
+
return None
|
|
368
|
+
left, top, width, height = box
|
|
369
|
+
return ResolvedElement(
|
|
370
|
+
x=int(left + width // 2), y=int(top + height // 2),
|
|
371
|
+
role="image", name=str(template), score=_MIN_SCORE, source="image",
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
# --- relative positioning ------------------------------------------------
|
|
375
|
+
def _split_relative(self, desc: str) -> tuple[str | None, tuple[int, int]]:
|
|
376
|
+
"""Split "<target> <direction> <anchor>" into (anchor, unit vector).
|
|
377
|
+
|
|
378
|
+
Returns (None, (0, 0)) when the description carries no spatial phrase.
|
|
379
|
+
"""
|
|
380
|
+
low = desc.lower()
|
|
381
|
+
for phrase, vector in _DIRECTIONS:
|
|
382
|
+
m = re.search(rf"\b{re.escape(phrase)}\b", low)
|
|
383
|
+
if m:
|
|
384
|
+
anchor = desc[m.end():].strip(" .,:") or desc[: m.start()].strip(" .,:")
|
|
385
|
+
if anchor:
|
|
386
|
+
return anchor, vector
|
|
387
|
+
return None, (0, 0)
|
|
388
|
+
|
|
389
|
+
def _resolve_relative(
|
|
390
|
+
self,
|
|
391
|
+
anchor_desc: str,
|
|
392
|
+
vector: tuple[int, int],
|
|
393
|
+
elements: list,
|
|
394
|
+
window_title: str | None,
|
|
395
|
+
tried: list[str],
|
|
396
|
+
) -> ResolvedElement | None:
|
|
397
|
+
"""Resolve the anchor element, then offset by the direction vector."""
|
|
398
|
+
anchor = self._match_elements(anchor_desc, elements)
|
|
399
|
+
if anchor is None:
|
|
400
|
+
anchor = self._match_uia_properties(anchor_desc, elements)
|
|
401
|
+
if anchor is None:
|
|
402
|
+
return None
|
|
403
|
+
# Offset by roughly the element's own extent so we land on the adjacent
|
|
404
|
+
# control, not still inside the anchor. Fall back to a sensible step.
|
|
405
|
+
el = self._element_named(elements, anchor.name)
|
|
406
|
+
step_x = (getattr(el, "width", 0) or 120) if vector[0] else 0
|
|
407
|
+
step_y = (getattr(el, "height", 0) or 32) if vector[1] else 0
|
|
408
|
+
# A little breathing room past the edge of the anchor.
|
|
409
|
+
gap_x = int(vector[0] * (step_x // 2 + 16))
|
|
410
|
+
gap_y = int(vector[1] * (step_y // 2 + 16))
|
|
411
|
+
return ResolvedElement(
|
|
412
|
+
x=anchor.x + gap_x, y=anchor.y + gap_y,
|
|
413
|
+
role="relative", name=f"{_vec_name(vector)} {anchor.name}".strip(),
|
|
414
|
+
score=_MIN_SCORE, source="relative", tried=list(tried),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
@staticmethod
|
|
418
|
+
def _element_named(elements: list, name: str):
|
|
419
|
+
for el in elements:
|
|
420
|
+
if getattr(el, "name", None) == name:
|
|
421
|
+
return el
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
@staticmethod
|
|
425
|
+
def _tag(hit: ResolvedElement, tried: list[str]) -> ResolvedElement:
|
|
426
|
+
hit.tried = list(tried)
|
|
427
|
+
return hit
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _vec_name(vector: tuple[int, int]) -> str:
|
|
431
|
+
return {
|
|
432
|
+
(1, 0): "right of", (-1, 0): "left of",
|
|
433
|
+
(0, 1): "below", (0, -1): "above", (0, 0): "near",
|
|
434
|
+
}.get(vector, "near")
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Screen driver: screenshots, resolution, and multi-monitor geometry.
|
|
2
|
+
|
|
3
|
+
Uses ``mss`` for capture (fast, multi-monitor aware) and Pillow only to
|
|
4
|
+
encode PNGs. All imports are lazy so the rest of Seed Code loads without the
|
|
5
|
+
desktop extra installed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ..utils.helpers import app_dir
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class MonitorInfo:
|
|
19
|
+
"""One physical monitor in virtual-desktop coordinates."""
|
|
20
|
+
|
|
21
|
+
index: int # 1-based, matching mss numbering (0 is the combined desktop)
|
|
22
|
+
left: int
|
|
23
|
+
top: int
|
|
24
|
+
width: int
|
|
25
|
+
height: int
|
|
26
|
+
primary: bool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class ScreenGeometry:
|
|
31
|
+
"""The combined virtual desktop plus its monitors."""
|
|
32
|
+
|
|
33
|
+
left: int
|
|
34
|
+
top: int
|
|
35
|
+
width: int
|
|
36
|
+
height: int
|
|
37
|
+
monitors: list[MonitorInfo]
|
|
38
|
+
|
|
39
|
+
def contains(self, x: int, y: int) -> bool:
|
|
40
|
+
return (
|
|
41
|
+
self.left <= x < self.left + self.width
|
|
42
|
+
and self.top <= y < self.top + self.height
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def screenshots_dir() -> Path:
|
|
47
|
+
"""Directory screenshots are saved to (created on demand)."""
|
|
48
|
+
path = app_dir() / "screenshots"
|
|
49
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
return path
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def geometry() -> ScreenGeometry:
|
|
54
|
+
"""Current virtual-desktop geometry (all monitors)."""
|
|
55
|
+
import mss
|
|
56
|
+
|
|
57
|
+
with mss.mss() as sct:
|
|
58
|
+
combined = sct.monitors[0]
|
|
59
|
+
monitors = [
|
|
60
|
+
MonitorInfo(
|
|
61
|
+
index=i,
|
|
62
|
+
left=m["left"],
|
|
63
|
+
top=m["top"],
|
|
64
|
+
width=m["width"],
|
|
65
|
+
height=m["height"],
|
|
66
|
+
primary=(m["left"] == 0 and m["top"] == 0),
|
|
67
|
+
)
|
|
68
|
+
for i, m in enumerate(sct.monitors[1:], start=1)
|
|
69
|
+
]
|
|
70
|
+
return ScreenGeometry(
|
|
71
|
+
left=combined["left"],
|
|
72
|
+
top=combined["top"],
|
|
73
|
+
width=combined["width"],
|
|
74
|
+
height=combined["height"],
|
|
75
|
+
monitors=monitors,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def capture(
|
|
80
|
+
region: tuple[int, int, int, int] | None = None,
|
|
81
|
+
monitor: int | None = None,
|
|
82
|
+
save_to: Path | None = None,
|
|
83
|
+
) -> Path:
|
|
84
|
+
"""Capture the screen to a PNG file and return its path.
|
|
85
|
+
|
|
86
|
+
``region`` is (left, top, width, height) in virtual-desktop coordinates;
|
|
87
|
+
``monitor`` is a 1-based monitor index; neither means the whole desktop.
|
|
88
|
+
"""
|
|
89
|
+
import mss
|
|
90
|
+
import mss.tools
|
|
91
|
+
|
|
92
|
+
with mss.mss() as sct:
|
|
93
|
+
if region is not None:
|
|
94
|
+
left, top, width, height = region
|
|
95
|
+
grab_area = {"left": left, "top": top, "width": width, "height": height}
|
|
96
|
+
elif monitor is not None:
|
|
97
|
+
if not 1 <= monitor < len(sct.monitors):
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"Monitor {monitor} does not exist (found {len(sct.monitors) - 1})."
|
|
100
|
+
)
|
|
101
|
+
grab_area = sct.monitors[monitor]
|
|
102
|
+
else:
|
|
103
|
+
grab_area = sct.monitors[0]
|
|
104
|
+
shot = sct.grab(grab_area)
|
|
105
|
+
|
|
106
|
+
if save_to is None:
|
|
107
|
+
stamp = time.strftime("%Y%m%d-%H%M%S", time.localtime())
|
|
108
|
+
save_to = screenshots_dir() / f"screenshot-{stamp}.png"
|
|
109
|
+
save_to.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
mss.tools.to_png(shot.rgb, shot.size, output=str(save_to))
|
|
111
|
+
return save_to
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def encode_png_base64(path: Path, max_dim: int = 1568) -> str:
|
|
115
|
+
"""Base64-encode a screenshot PNG, downscaling large captures first.
|
|
116
|
+
|
|
117
|
+
Vision models cap useful input resolution; downscaling keeps payloads
|
|
118
|
+
small without losing the layout the model needs.
|
|
119
|
+
"""
|
|
120
|
+
import base64
|
|
121
|
+
import io
|
|
122
|
+
|
|
123
|
+
from PIL import Image
|
|
124
|
+
|
|
125
|
+
with Image.open(path) as img:
|
|
126
|
+
if max(img.size) > max_dim:
|
|
127
|
+
img.thumbnail((max_dim, max_dim))
|
|
128
|
+
buffer = io.BytesIO()
|
|
129
|
+
img.save(buffer, format="PNG")
|
|
130
|
+
return base64.b64encode(buffer.getvalue()).decode("ascii")
|