File size: 17,913 Bytes
2695555 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 | # openTARS — nucleo/elementos.py
# Clicar e escrever pelo nome: acessibilidade e, nos apps que não a oferecem, a visão da tela (o Gargantua 2).
#
# Parte do núcleo: o tars.py executa este arquivo DENTRO do próprio
# namespace (ver _carregar_nucleo), então tudo aqui é tars.<nome> e as
# partes enxergam umas às outras sem import. Os testes que trocam
# tars.<função> por uma falsa continuam valendo pra todas as partes.
# flake8: noqa
# ============================================================
# ELEMENTOS DA JANELA (árvore de acessibilidade)
# ============================================================
#
# Clicar num botão pelo NOME ("7", "=", "Salvar") em vez de print +
# coordenada: ver tars_acessibilidade.py. O print continua como plano B
# pra app que não expõe a árvore.
_acessibilidade_ligada = {"feito": False}
AVISO_SEM_ACESSIBILIDADE = (
"The accessibility interface is not available ({motivo}). Use take_screenshot "
"with window=<app> and click by coordinates. (To enable it: sudo apt install "
"gir1.2-atspi-2.0 python3-gi at-spi2-core)"
)
def _dica_clique_por_nome():
if acess.indisponivel():
return ""
return " To press its buttons, use click_element with the text shown on each button."
def _janela_x_do_pid(pid):
if not pid:
return None
return next((j for j in listar_janelas_x() or [] if j["pid"] == pid), None)
TIMEOUT_JANELA_ACESSIVEL_SEG = 5
_abertos_sozinhos = {} # app normalizado -> quando o openTARS tentou abrir
def _pode_abrir_sozinho(nome):
"""Abre no máximo uma vez a cada 30 s por app (uma fila de cliques não
abre cinco calculadoras), nunca o próprio openTARS, e só se o nome
for de um app instalado."""
chave = normalizar(nome)
if not chave or "tars" in chave:
return False
agora = time.time()
if agora - _abertos_sozinhos.get(chave, 0) < 30:
return False
_abertos_sozinhos[chave] = agora
return bool(encontrar_app(nome))
def _nota_aberto(alvo):
return " (it was not open, so openTARS opened it first)" if alvo.get("aberto_agora") else ""
# Apps abertos que não expõem os botões (Electron/Chromium: Claude,
# Discord, VS Code, Slack...). Depois da primeira falha, click_element e
# list_elements respondem na hora com o caminho certo, em vez de a IA
# tentar nome por nome (log real: 14 cliques falhos seguidos no Claude).
_SEM_ARVORE = {} # nome normalizado -> quando descobrimos
VALIDADE_SEM_ARVORE_SEG = 600
def _capturar_janela(nome):
"""(imagem, ox, oy, janela_x) da janela pedida, já na frente, ou None."""
janela_x = encontrar_janela_por_nome(nome) if nome else None
if not janela_x:
return None
try:
if janela_x["id"] != janela_ativa_x():
focar_janela_x(janela_x["id"])
time.sleep(0.3)
janela_x = next((j for j in listar_janelas_x() or [] if j["id"] == janela_x["id"]), janela_x)
except Exception:
pass
captura = _capturar_tela()
x0, y0 = max(0, janela_x["x"]), max(0, janela_x["y"])
x1 = min(captura.width, janela_x["x"] + janela_x["largura"])
y1 = min(captura.height, janela_x["y"] + janela_x["altura"])
if x1 - x0 < 10 or y1 - y0 < 10:
return None
return captura.crop((x0, y0, x1, y1)), x0, y0, janela_x
# Clique pela visão (ícone sem texto): TARS_CLIQUE_POR_VISAO=0 desliga a visão nos cliques.
CLIQUE_POR_VISAO = os.environ.get("TARS_CLIQUE_POR_VISAO", "1") not in ("0", "off", "nao", "no")
def clicar_pela_visao(nome, janela):
"""Plano B do click_element: o modelo de visão olha a janela e aponta onde está 'nome'
(botão, campo ou ícone); o openTARS clica ali. Funciona em app que não expõe
seus botões (Electron...). None se não deu pra olhar (sem modelo de visão, sem janela)."""
if not CLIQUE_POR_VISAO or ERRO_PYAUTOGUI or not janela:
return None
capturada = _capturar_janela(janela)
if not capturada:
return None
imagem, ox, oy, janela_x = capturada
ponto, _tag = localizar_com_visao(nome, imagem)
if ponto == "ausente":
return {"sucesso": False, "janela": janela_x["titulo"],
"mensagem": f"Looked at '{janela_x['titulo']}' and '{nome}' is not there."}
if not isinstance(ponto, tuple):
_avisar_falha_da_visao()
return None
try:
pyautogui.click(x=ox + ponto[0], y=oy + ponto[1])
except Exception as e:
return {"sucesso": False, "mensagem": f"Located '{nome}' but the click failed: {e}"}
time.sleep(0.35)
return {"sucesso": True, "janela": janela_x["titulo"], "elemento": nome, "mensagem": (
f"SUCCESS: clicked where '{nome}' was located in '{janela_x['titulo']}' (x={ponto[0]}, y={ponto[1]} in "
"the window). It was found by LOOKING at the screen, so it can be a little off: check the result "
"(take_screenshot) before going on.")}
def _mensagem_sem_arvore(nome):
return (
f"'{nome}' is open, but it does not expose its buttons to the accessibility interface "
"(common in Electron/Chromium apps such as Claude, Discord, VS Code, Slack). You CAN "
"still use it. To write and send a message (its text box usually already has the "
f"keyboard focus): type_in_element(text=<your text>, window='{nome}', submit=true). "
f"To click something else: take_screenshot(window='{nome}') and click_mouse with x/y of "
"that image plus target='<what it is>'. Do NOT use click_element or list_elements on it again, and don't ask the "
"user to do it: do it yourself with those tools."
)
def _digitar_na_janela(janela, texto, enviar=False):
"""Traz a janela pra frente e digita no campo que tem o foco (num app
de chat, a caixa de mensagem). enviar=True aperta Enter no fim."""
focada = focar_janela(janela)
if not focada.get("sucesso"):
return focada
time.sleep(0.25) # o app redesenha e devolve o foco à caixa de texto
resultado = digitar_texto(texto or "")
if not resultado.get("sucesso"):
return resultado
if enviar:
tecla = pressionar_tecla("enter")
if not tecla.get("sucesso"):
return tecla
titulo = (focada.get("janela") or {}).get("titulo") or janela
return {"sucesso": True, "janela": titulo, "mensagem": (
f"SUCCESS: typed the text into the focused box of '{titulo}'"
+ (" and pressed Enter to send it." if enviar else ". Use press_key('enter') to send it.")
+ " If it may have gone to the wrong place, check with take_screenshot.")}
def _marcar_sem_arvore(nome):
if nome:
_SEM_ARVORE[normalizar(nome)] = time.time()
def _sem_arvore(nome):
quando = _SEM_ARVORE.get(normalizar(nome or ""))
return quando is not None and time.time() - quando < VALIDADE_SEM_ARVORE_SEG
def _janela_acessivel(nome):
"""A janela (na árvore de acessibilidade) que a IA pediu, ou a ativa.
Devolve (janela, erro)."""
motivo = acess.indisponivel()
if motivo:
aviso = (plataforma.dica_instalar(AVISO_SEM_ACESSIBILIDADE) if plataforma.sistema() == "linux"
else AVISO_SEM_ACESSIBILIDADE.split(" (To enable")[0])
return None, aviso.format(motivo=motivo)
if nome and _sem_arvore(nome):
return None, _mensagem_sem_arvore(nome)
def procurar():
janela_x, termos = None, ()
if nome:
termos = _termos_para_janela(nome)
janela_x = encontrar_janela_por_nome(nome)
# Só o título batendo (uma aba "Calculadora online" no
# navegador) não basta pra dizer que é o app pedido.
if janela_x and _pontuar_janela(janela_x, termos) < 2:
janela_x = None
else:
ativa = janela_ativa_x()
if ativa:
janela_x = next((j for j in listar_janelas_x() or [] if j["id"] == ativa), None)
return acess.achar_janela(
pid=janela_x["pid"] if janela_x else None,
titulo=janela_x["titulo"] if janela_x else None,
termos=termos,
ativa=not nome,
ignorar_pids=_pids_protegidos(),
)
alvo = procurar()
# Apps Qt/KDE (e alguns outros) só expõem a árvore com a
# acessibilidade da sessão ligada: liga uma vez e procura de novo.
if alvo is None and not _acessibilidade_ligada["feito"]:
_acessibilidade_ligada["feito"] = True
if acess.ativar():
time.sleep(0.6)
alvo = procurar()
# A IA pulou o open_application ("clique no 1 da calculadora" com a
# calculadora fechada): abre o app e espera a janela aparecer na
# árvore, uma vez por app. Antes os cinco cliques falhavam em fila.
aberta_no_x = bool(nome) and alvo is None and encontrar_janela_por_nome(nome) is not None
if alvo is None and nome and not aberta_no_x and _pode_abrir_sozinho(nome):
aberto = abrir_aplicativo(nome)
if aberto.get("sucesso"):
limite = time.time() + TIMEOUT_JANELA_ACESSIVEL_SEG
while alvo is None and time.time() < limite:
time.sleep(0.25)
alvo = procurar()
if alvo is not None:
alvo = dict(alvo, aberto_agora=True)
if alvo is None:
if nome and (aberta_no_x or encontrar_janela_por_nome(nome) is not None):
_marcar_sem_arvore(nome)
return None, _mensagem_sem_arvore(nome)
if nome:
return None, (
f"'{nome}' is not open, or does not expose its elements to the accessibility "
"interface. If it is open, use take_screenshot with window=<app> and click by coordinates."
)
return None, (
"No accessible window is in focus. Pass window=<app name>, or use "
"take_screenshot and click by coordinates."
)
return alvo, None
def listar_elementos(janela=None, filtro=None):
"""Handler da ferramenta list_elements."""
alvo, erro = _janela_acessivel(janela)
if erro:
return {"sucesso": False, "mensagem": erro}
r = acess.listar(alvo, filtro)
if not r["total"] and not r["textos"] and not filtro and janela:
_marcar_sem_arvore(janela)
return {"sucesso": False, "janela": alvo["titulo"], "mensagem": _mensagem_sem_arvore(janela)}
if not r["total"] and not r["textos"]:
return {
"sucesso": False,
"janela": alvo["titulo"],
"mensagem": (
f"'{alvo['titulo']}' exposes no clickable elements"
+ (f" matching '{filtro}'" if filtro else "")
+ ". Use take_screenshot with window=<app> and click by coordinates."
),
}
return {
"sucesso": True,
"janela": alvo["titulo"],
"elementos": r["elementos"],
"textos": r["textos"],
"mensagem": (
f"{r['total']} element(s) in '{alvo['titulo']}'{_nota_aberto(alvo)} (grouped by role) and the texts "
"it shows. Press one with click_element(name=...)."
),
}
def _clicar_pelo_retangulo(alvo, retangulo):
"""Plano B de click_element: elemento sem ação de acessibilidade, mas
com posição conhecida. Só com X11 e só se a posição cair dentro da
janela (GTK 4 informa posições relativas, que clicariam no lugar errado)."""
if SESSAO_GRAFICA not in ("x11", "windows", "aqua") or ERRO_PYAUTOGUI or not retangulo:
return False
if not acess.coordenadas_confiaveis(alvo):
return False
x, y, largura, altura = retangulo
janela_x = _janela_x_do_pid(alvo.get("pid"))
if largura <= 0 or altura <= 0 or not janela_x:
return False
cx, cy = x + largura // 2, y + altura // 2
dentro = (janela_x["x"] <= cx < janela_x["x"] + janela_x["largura"]
and janela_x["y"] <= cy < janela_x["y"] + janela_x["altura"])
if not dentro:
return False
try:
pyautogui.click(x=cx, y=cy)
return True
except Exception:
return False
def clicar_elemento(nome, janela=None, papel=None):
"""Handler da ferramenta click_element."""
nome = (nome or "").strip()
if not nome:
return {"sucesso": False, "mensagem": "ERROR: 'name' is empty: pass the text shown on the element."}
alvo, erro = _janela_acessivel(janela)
if erro:
if janela and _sem_arvore(janela):
por_texto = clicar_pela_visao(nome, janela)
if por_texto:
return por_texto
return {"sucesso": False, "mensagem": erro}
r = acess.clicar(alvo, nome, papel)
if r["sucesso"]:
textos = r.get("textos") or []
mensagem = f"SUCCESS: clicked {r['elemento']} in '{alvo['titulo']}'{_nota_aberto(alvo)}."
if textos:
mensagem += " The window now shows: " + " | ".join(textos[:8])
return {"sucesso": True, "janela": alvo["titulo"], "elemento": r["elemento"],
"textos": textos[:8], "mensagem": mensagem}
if not r["achou"]:
parecidos = acess.parecidos(alvo, nome)
if not parecidos and janela and not acess.listar(alvo, None)["total"]:
_marcar_sem_arvore(janela)
por_texto = clicar_pela_visao(nome, janela)
if por_texto and por_texto["sucesso"]:
return por_texto
return {"sucesso": False, "janela": alvo["titulo"], "mensagem": _mensagem_sem_arvore(janela)}
# A árvore existe mas não tem esse nome (botão desenhado à mão,
# canvas...): tenta achar o texto na tela antes de desistir.
por_texto = clicar_pela_visao(nome, janela or alvo.get("titulo"))
if por_texto and por_texto["sucesso"]:
return por_texto
return {
"sucesso": False,
"janela": alvo["titulo"],
"mensagem": (
f"No element named '{nome}' in '{alvo['titulo']}'."
+ (f" Some of its elements: {', '.join(parecidos)}." if parecidos else "")
+ " Use list_elements to see them all, or take_screenshot to click by coordinates."
),
}
if _clicar_pelo_retangulo(alvo, r.get("retangulo")):
return {"sucesso": True, "janela": alvo["titulo"], "elemento": r["elemento"],
"mensagem": f"SUCCESS: clicked {r['elemento']} in '{alvo['titulo']}' (with the mouse)."}
return {
"sucesso": False,
"janela": alvo["titulo"],
"mensagem": (
f"Found {r['elemento']} in '{alvo['titulo']}', but it cannot be pressed through "
"accessibility. Use take_screenshot with window=<app> and click by coordinates."
),
}
def escrever_em_elemento(nome, texto, janela=None, enviar=False):
"""Handler da ferramenta type_in_element."""
barrado = _barrar_texto_para_terminal(texto, janela)
if barrado:
return barrado
alvo, erro = _janela_acessivel(janela)
if erro:
# App aberto sem árvore (Electron). Com o nome do campo ("Reply to
# Claude", "Pesquisar"), o Cooper acha o campo e o openTARS clica nele; sem nome,
# ou se não achar, digita no campo que já tem o foco.
if janela and _sem_arvore(janela):
if (nome or "").strip():
clicado = clicar_pela_visao(nome.strip(), janela)
if clicado and clicado.get("sucesso"):
time.sleep(0.15)
resultado = digitar_texto(texto or "")
if resultado.get("sucesso") and enviar:
pressionar_tecla("enter")
if resultado.get("sucesso"):
resultado["mensagem"] = (
f"SUCCESS: clicked the field '{clicado['elemento']}' in '{clicado['janela']}' "
"(found by looking at the screen) and typed the text"
+ (" and pressed Enter." if enviar else "."))
return resultado
return _digitar_na_janela(janela, texto, enviar)
return {"sucesso": False, "mensagem": erro}
r = acess.preencher(alvo, (nome or "").strip(), texto or "")
if r["sucesso"]:
if enviar:
janela_x = _janela_x_do_pid(alvo.get("pid"))
if janela_x:
try:
focar_janela_x(janela_x["id"])
time.sleep(0.15)
except Exception:
pass
pressionar_tecla("enter")
return {"sucesso": True, "janela": alvo["titulo"],
"mensagem": f"SUCCESS: wrote the text into {r['elemento']} in '{alvo['titulo']}'"
+ (" and pressed Enter." if enviar else ".")}
if r.get("precisa_digitar"):
# O campo não aceita texto pela acessibilidade: traz a janela pra
# frente (o foco do teclado é da janela ativa) e digita.
janela_x = _janela_x_do_pid(alvo.get("pid"))
if janela_x:
try:
focar_janela_x(janela_x["id"])
time.sleep(0.2)
except Exception:
pass
resultado = digitar_texto(texto or "")
if resultado.get("sucesso"):
if enviar:
pressionar_tecla("enter")
resultado["mensagem"] = (f"SUCCESS: focused {r['elemento']} in '{alvo['titulo']}' and typed the text"
+ (" and pressed Enter." if enviar else "."))
return resultado
campos = r.get("campos") or []
return {
"sucesso": False,
"janela": alvo["titulo"],
"mensagem": (
f"No text field named '{nome}' in '{alvo['titulo']}'."
+ (f" Its fields: {', '.join(campos)}." if campos else " It has no text field exposed.")
+ " You can also click the field and use type_text."
),
}
|