wechat_ai/agent/ax_probe.go

253 lines
7.4 KiB
Go

package main
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
type AXProbeResult struct {
AppName string `json:"app_name"`
PID int `json:"pid"`
Nodes []AXNode `json:"nodes"`
}
type AXNode struct {
Role string `json:"role"`
Title string `json:"title"`
Value string `json:"value"`
Description string `json:"description"`
Frame [4]float64 `json:"frame"`
Depth int `json:"depth"`
}
func runAXProbe() error {
logInfo("macOS Accessibility 微信 UI 树探测启动")
config, err := loadConfig("config.toml")
if err != nil {
return fmt.Errorf("读取 config.toml 失败:%w", err)
}
regionsPath := appDataPath(config, config.Agent.RegionsPath)
annotation, err := loadAnnotation(regionsPath)
if err != nil {
return fmt.Errorf("读取标注文件失败:%w", err)
}
summaries, warnings := inferMissingRegionTypes(summarizeRegions(annotation.Regions))
for _, warning := range warnings {
logWarning(warning)
}
probe, err := probeWeChatAXTree()
if err != nil {
return err
}
logInfo(fmt.Sprintf("AX 节点总数=%d pid=%d app=%s", len(probe.Nodes), probe.PID, probe.AppName))
textNodes := usefulAXTextNodes(probe.Nodes)
logInfo(fmt.Sprintf("可读文本/按钮节点=%d", len(textNodes)))
for index, node := range firstNodes(textNodes, 80) {
logJSON("ax", fmt.Sprintf("#%02d depth=%d role=%s frame=[%.0f,%.0f,%.0f,%.0f] text=%s", index+1, node.Depth, node.Role, node.Frame[0], node.Frame[1], node.Frame[2], node.Frame[3], axNodeText(node)))
}
if chat, ok := regionByType(summaries)["chat_content"]; ok {
chatNodes := nodesInsideBox(textNodes, chat.BBoxScreen)
sort.SliceStable(chatNodes, func(i, j int) bool {
if chatNodes[i].Frame[1] == chatNodes[j].Frame[1] {
return chatNodes[i].Frame[0] < chatNodes[j].Frame[0]
}
return chatNodes[i].Frame[1] < chatNodes[j].Frame[1]
})
logInfo(fmt.Sprintf("chat_content 区域内可读节点=%d", len(chatNodes)))
for index, node := range firstNodes(chatNodes, 80) {
logJSON("ax-chat", fmt.Sprintf("#%02d role=%s frame=[%.0f,%.0f,%.0f,%.0f] text=%s", index+1, node.Role, node.Frame[0], node.Frame[1], node.Frame[2], node.Frame[3], axNodeText(node)))
}
} else {
logWarning("未找到 chat_content 标注区域,跳过区域过滤")
}
return nil
}
func probeWeChatAXTree() (AXProbeResult, error) {
script := `
import AppKit
import ApplicationServices
import Foundation
struct Node: Encodable {
let role: String
let title: String
let value: String
let description: String
let frame: [Double]
let depth: Int
}
struct Result: Encodable {
let app_name: String
let pid: Int
let nodes: [Node]
}
func stringAttr(_ element: AXUIElement, _ key: String) -> String {
var value: CFTypeRef?
let error = AXUIElementCopyAttributeValue(element, key as CFString, &value)
if error != .success || value == nil { return "" }
if let str = value as? String { return clean(str) }
if let attr = value as? NSAttributedString { return clean(attr.string) }
return ""
}
func clean(_ value: String) -> String {
return value
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\r", with: " ")
.replacingOccurrences(of: "\t", with: " ")
}
func frameOf(_ element: AXUIElement) -> [Double] {
var positionValue: CFTypeRef?
var sizeValue: CFTypeRef?
var point = CGPoint.zero
var size = CGSize.zero
if AXUIElementCopyAttributeValue(element, kAXPositionAttribute as CFString, &positionValue) == .success,
let positionValue,
CFGetTypeID(positionValue) == AXValueGetTypeID() {
AXValueGetValue(positionValue as! AXValue, .cgPoint, &point)
}
if AXUIElementCopyAttributeValue(element, kAXSizeAttribute as CFString, &sizeValue) == .success,
let sizeValue,
CFGetTypeID(sizeValue) == AXValueGetTypeID() {
AXValueGetValue(sizeValue as! AXValue, .cgSize, &size)
}
return [Double(point.x), Double(point.y), Double(size.width), Double(size.height)]
}
func childrenOf(_ element: AXUIElement) -> [AXUIElement] {
var value: CFTypeRef?
let error = AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &value)
if error != .success || value == nil { return [] }
return value as? [AXUIElement] ?? []
}
func walk(_ element: AXUIElement, depth: Int, nodes: inout [Node], visited: inout Int) {
if visited > 2500 || depth > 20 { return }
visited += 1
let node = Node(
role: stringAttr(element, kAXRoleAttribute),
title: stringAttr(element, kAXTitleAttribute),
value: stringAttr(element, kAXValueAttribute),
description: stringAttr(element, kAXDescriptionAttribute),
frame: frameOf(element),
depth: depth
)
nodes.append(node)
for child in childrenOf(element) {
walk(child, depth: depth + 1, nodes: &nodes, visited: &visited)
}
}
let trusted = AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary)
if !trusted {
fputs("Accessibility permission is not granted\n", stderr)
exit(10)
}
let apps = NSWorkspace.shared.runningApplications.filter { app in
let name = app.localizedName ?? ""
return name == "微信" || name == "WeChat" || name.lowercased().contains("wechat")
}
guard let app = apps.first else {
fputs("WeChat app not found\n", stderr)
exit(2)
}
app.activate(options: [.activateIgnoringOtherApps])
usleep(300000)
let root = AXUIElementCreateApplication(app.processIdentifier)
var nodes: [Node] = []
var visited = 0
walk(root, depth: 0, nodes: &nodes, visited: &visited)
let result = Result(app_name: app.localizedName ?? "", pid: Int(app.processIdentifier), nodes: nodes)
let data = try JSONEncoder().encode(result)
print(String(data: data, encoding: .utf8)!)
`
output, err := runSwift(script)
if err != nil {
return AXProbeResult{}, fmt.Errorf("AX probe failed: %w", err)
}
var result AXProbeResult
if err := json.Unmarshal([]byte(output), &result); err != nil {
preview := output
if len(preview) > 300 {
preview = preview[:300]
}
return AXProbeResult{}, fmt.Errorf("parse AX JSON failed: %w output_prefix=%q", err, preview)
}
return result, nil
}
func usefulAXTextNodes(nodes []AXNode) []AXNode {
result := make([]AXNode, 0)
seen := map[string]bool{}
for _, node := range nodes {
text := strings.TrimSpace(axNodeText(node))
if text == "" {
continue
}
if node.Frame[2] <= 0 || node.Frame[3] <= 0 {
continue
}
key := fmt.Sprintf("%s|%s|%.0f|%.0f|%.0f|%.0f", node.Role, text, node.Frame[0], node.Frame[1], node.Frame[2], node.Frame[3])
if seen[key] {
continue
}
seen[key] = true
result = append(result, node)
}
sort.SliceStable(result, func(i, j int) bool {
if result[i].Frame[1] == result[j].Frame[1] {
return result[i].Frame[0] < result[j].Frame[0]
}
return result[i].Frame[1] < result[j].Frame[1]
})
return result
}
func axNodeText(node AXNode) string {
parts := []string{node.Value, node.Title, node.Description}
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
return part
}
}
return ""
}
func nodesInsideBox(nodes []AXNode, box [4]float64) []AXNode {
result := make([]AXNode, 0)
for _, node := range nodes {
cx := node.Frame[0] + node.Frame[2]/2
cy := node.Frame[1] + node.Frame[3]/2
if cx >= box[0] && cx <= box[2] && cy >= box[1] && cy <= box[3] {
result = append(result, node)
}
}
return result
}
func firstNodes(nodes []AXNode, limit int) []AXNode {
if len(nodes) <= limit {
return nodes
}
return nodes[:limit]
}