diff --git a/.gitignore b/.gitignore
index 3327715..0a77644 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,15 @@ src-tauri/gen/
agent/agent
agent/agent-*
+agent/config.toml
+
+wechat_vision/venv/
+wechat_vision/__pycache__/
+wechat_vision/ouptsw/
+wechat_vision/test/
+
+.codegraph/daemon.pid
+.vscode/
.DS_Store
*.log
diff --git a/agent/ax_probe.go b/agent/ax_probe.go
new file mode 100644
index 0000000..5c04464
--- /dev/null
+++ b/agent/ax_probe.go
@@ -0,0 +1,252 @@
+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]
+}
diff --git a/agent/chat_sync.go b/agent/chat_sync.go
new file mode 100644
index 0000000..405e668
--- /dev/null
+++ b/agent/chat_sync.go
@@ -0,0 +1,274 @@
+package main
+
+import (
+ "crypto/sha1"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+func runChatSync() error {
+ logInfo("聊天记录同步 demo 启动")
+
+ config, err := loadConfig("config.toml")
+ if err != nil {
+ return fmt.Errorf("读取 config.toml 失败:%w", err)
+ }
+
+ regionsPath := appDataPath(config, config.Agent.RegionsPath)
+ screenshotPath := appDataPath(config, config.Agent.ScreenshotPath)
+ archivePath := appDataPath(config, config.Agent.ChatRecordsPath)
+
+ annotation, err := loadAnnotation(regionsPath)
+ if err != nil {
+ return fmt.Errorf("读取标注文件失败:%w", err)
+ }
+ summaries, warnings := inferMissingRegionTypes(summarizeRegions(annotation.Regions))
+ for _, warning := range warnings {
+ logWarning(warning)
+ }
+ if err := validateChatSyncRegions(summaries); err != nil {
+ return err
+ }
+
+ archive, existed, err := loadChatArchive(archivePath)
+ if err != nil {
+ return err
+ }
+ if !existed || len(archive.Messages) == 0 {
+ logInfo("本地没有聊天记录,先滚动到聊天记录顶部")
+ if err := scrollChatToTop(config, summaries, screenshotPath); err != nil {
+ return err
+ }
+ } else {
+ logInfo(fmt.Sprintf("已读取本地聊天记录 messages=%d,将从当前视图继续向下读取", len(archive.Messages)))
+ }
+
+ if archive.App == "" {
+ archive.App = "wechat"
+ archive.CreatedAt = time.Now().Format(time.RFC3339)
+ }
+ archive.Source = TaskSource{RegionsPath: regionsPath, ScreenshotPath: screenshotPath}
+
+ seenNoNewPages := 0
+ for pageIndex := 0; pageIndex < config.Agent.ChatSyncMaxPages; pageIndex++ {
+ window, err := findWeChatWindow()
+ if err != nil {
+ return err
+ }
+ if err := captureWindow(window, screenshotPath); err != nil {
+ return err
+ }
+
+ result, err := extractChatPage(config, summaries, screenshotPath)
+ if err != nil {
+ return err
+ }
+ if result.ContactName != "" && archive.Contact == "" {
+ archive.Contact = result.ContactName
+ }
+
+ newCount := appendChatMessages(&archive, result.Messages, pageIndex)
+ archive.Pages = append(archive.Pages, ChatPage{
+ PageIndex: pageIndex,
+ Direction: "down",
+ NewCount: newCount,
+ SeenAt: time.Now().Format(time.RFC3339),
+ })
+ logTask(fmt.Sprintf("聊天页 page=%d 提取 messages=%d new=%d summary=%s", pageIndex+1, len(result.Messages), newCount, result.PageSummary))
+
+ if newCount == 0 {
+ seenNoNewPages++
+ } else {
+ seenNoNewPages = 0
+ }
+ if seenNoNewPages >= 2 {
+ logInfo("连续页面没有新增聊天记录,停止同步")
+ break
+ }
+
+ if pageIndex < config.Agent.ChatSyncMaxPages-1 {
+ if err := scrollChat(config, summaries, -absInt(config.Agent.ScrollDeltaY)); err != nil {
+ return err
+ }
+ time.Sleep(800 * time.Millisecond)
+ }
+ }
+
+ archive.UpdatedAt = time.Now().Format(time.RFC3339)
+ if err := saveChatArchive(archivePath, archive); err != nil {
+ return err
+ }
+ logInfo(fmt.Sprintf("聊天记录同步完成,messages=%d path=%s", len(archive.Messages), archivePath))
+
+ return nil
+}
+
+func validateChatSyncRegions(summaries []RegionSummary) error {
+ index := regionByType(summaries)
+ if _, exists := index["chat_content"]; !exists {
+ return fmt.Errorf("missing required region: chat_content")
+ }
+ return nil
+}
+
+func scrollChatToTop(config Config, summaries []RegionSummary, screenshotPath string) error {
+ for index := 0; index < config.Agent.ChatSyncTopScrolls; index++ {
+ if err := scrollChat(config, summaries, absInt(config.Agent.ScrollDeltaY)); err != nil {
+ return err
+ }
+ logTask(fmt.Sprintf("向上滚动到历史顶部 round=%d/%d", index+1, config.Agent.ChatSyncTopScrolls))
+ time.Sleep(350 * time.Millisecond)
+ }
+
+ window, err := findWeChatWindow()
+ if err != nil {
+ return err
+ }
+ return captureWindow(window, screenshotPath)
+}
+
+func scrollChat(config Config, summaries []RegionSummary, deltaY int) error {
+ chat, ok := regionByType(summaries)["chat_content"]
+ if !ok {
+ return fmt.Errorf("chat_content region not found")
+ }
+ window, err := findWeChatWindow()
+ if err != nil {
+ return err
+ }
+ pointX := window.X + chat.CenterSource[0]
+ pointY := window.Y + chat.CenterSource[1]
+ logTask(fmt.Sprintf("滚动聊天区域 point=[%.1f, %.1f] delta_y=%d", pointX, pointY, deltaY))
+ return scrollAt(pointX, pointY, deltaY)
+}
+
+func extractChatPage(config Config, summaries []RegionSummary, screenshotPath string) (ChatExtractResult, error) {
+ imageDataURL, err := loadImageDataURL(screenshotPath)
+ if err != nil {
+ return ChatExtractResult{}, err
+ }
+ prompt, err := buildChatExtractPrompt(summaries)
+ if err != nil {
+ return ChatExtractResult{}, err
+ }
+ logThink("正在用豆包多模态抽取当前聊天区域消息")
+ raw, err := requestLLM(config, prompt, imageDataURL)
+ if err != nil {
+ return ChatExtractResult{}, err
+ }
+ jsonText, err := extractJSON(raw)
+ if err != nil {
+ _ = saveRawResponse(config, raw)
+ return ChatExtractResult{}, err
+ }
+ var result ChatExtractResult
+ if err := json.Unmarshal([]byte(jsonText), &result); err != nil {
+ _ = saveRawResponse(config, raw)
+ return ChatExtractResult{}, err
+ }
+ return result, nil
+}
+
+func buildChatExtractPrompt(summaries []RegionSummary) (string, error) {
+ regionsJSON, err := json.MarshalIndent(summaries, "", " ")
+ if err != nil {
+ return "", err
+ }
+ return `你是微信聊天记录抽取器。
+
+请只识别截图中 chat_content 标注区域里的聊天消息,不要识别联系人列表、输入框或其它区域。
+
+要求:
+- 按消息在页面中的从上到下顺序输出。
+- sender 可填 "me"、"other" 或能识别到的昵称;无法判断填 "unknown"。
+- role 可填 "me"、"other"、"system"。
+- time 如果画面中没有明确时间则留空字符串。
+- content 必须是消息文本,不能编造。
+- 只能返回合法 JSON,不要 Markdown,不要解释。
+
+标注区域:
+` + string(regionsJSON) + `
+
+返回结构:
+{
+ "contact_name": "unknown",
+ "page_summary": "当前页聊天内容摘要",
+ "messages": [
+ {"sender":"other","role":"other","time":"","content":"消息文本"}
+ ]
+}`, nil
+}
+
+func loadChatArchive(path string) (ChatArchive, bool, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return ChatArchive{}, false, nil
+ }
+ return ChatArchive{}, false, err
+ }
+ var archive ChatArchive
+ if err := json.Unmarshal(data, &archive); err != nil {
+ return ChatArchive{}, false, err
+ }
+ return archive, true, nil
+}
+
+func saveChatArchive(path string, archive ChatArchive) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(archive, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, data, 0644)
+}
+
+func appendChatMessages(archive *ChatArchive, messages []ChatMessage, pageIndex int) int {
+ existing := make(map[string]bool, len(archive.Messages))
+ for _, message := range archive.Messages {
+ existing[message.ID] = true
+ }
+
+ newCount := 0
+ for _, message := range messages {
+ message.Content = strings.TrimSpace(message.Content)
+ if message.Content == "" {
+ continue
+ }
+ message.PageIndex = pageIndex
+ message.SeenAt = time.Now().Format(time.RFC3339)
+ message.ID = chatMessageID(message)
+ if existing[message.ID] {
+ continue
+ }
+ existing[message.ID] = true
+ archive.Messages = append(archive.Messages, message)
+ newCount++
+ }
+ return newCount
+}
+
+func chatMessageID(message ChatMessage) string {
+ key := strings.Join([]string{
+ strings.TrimSpace(message.Sender),
+ strings.TrimSpace(message.Role),
+ strings.TrimSpace(message.Time),
+ strings.TrimSpace(message.Content),
+ }, "|")
+ sum := sha1.Sum([]byte(key))
+ return hex.EncodeToString(sum[:])
+}
+
+func absInt(value int) int {
+ if value < 0 {
+ return -value
+ }
+ return value
+}
diff --git a/agent/config.example.toml b/agent/config.example.toml
index f82ee6a..6c2a729 100644
--- a/agent/config.example.toml
+++ b/agent/config.example.toml
@@ -11,3 +11,9 @@ screenshot_path = "data/screenshots/WeChat.jpg"
task_output_path = "data/tasks/latest_task.json"
task_history_dir = "data/tasks/history"
dry_run = true
+observe_mode = "normal" # normal | scroll_first
+max_scrolls = 1
+scroll_delta_y = 6
+chat_records_path = "data/chats/wechat_chat_records.json"
+chat_sync_max_pages = 3
+chat_sync_top_scrolls = 8
diff --git a/agent/config.go b/agent/config.go
index b73b5a5..297f1b6 100644
--- a/agent/config.go
+++ b/agent/config.go
@@ -50,6 +50,24 @@ func applyConfigDefaults(config *Config) {
if config.Agent.TaskHistoryDir == "" {
config.Agent.TaskHistoryDir = "data/tasks/history"
}
+ if config.Agent.ObserveMode == "" {
+ config.Agent.ObserveMode = "normal"
+ }
+ if config.Agent.MaxScrolls <= 0 {
+ config.Agent.MaxScrolls = 1
+ }
+ if config.Agent.ScrollDeltaY == 0 {
+ config.Agent.ScrollDeltaY = 6
+ }
+ if config.Agent.ChatRecordsPath == "" {
+ config.Agent.ChatRecordsPath = "data/chats/wechat_chat_records.json"
+ }
+ if config.Agent.ChatSyncMaxPages <= 0 {
+ config.Agent.ChatSyncMaxPages = 3
+ }
+ if config.Agent.ChatSyncTopScrolls <= 0 {
+ config.Agent.ChatSyncTopScrolls = 8
+ }
config.Agent.DryRun = true
}
diff --git a/agent/executor.go b/agent/executor.go
new file mode 100644
index 0000000..57c1ff7
--- /dev/null
+++ b/agent/executor.go
@@ -0,0 +1,178 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "math"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "time"
+)
+
+func maybeScrollBeforeObserve(config Config, summaries []RegionSummary, screenshotPath string) error {
+ if config.Agent.ObserveMode != "scroll_first" {
+ return nil
+ }
+ if runtime.GOOS != "darwin" {
+ return fmt.Errorf("observe_mode=scroll_first currently supports macOS only")
+ }
+
+ chat, ok := regionByType(summaries)["chat_content"]
+ if !ok {
+ return fmt.Errorf("cannot scroll before observe: chat_content region not found")
+ }
+
+ maxScrolls := config.Agent.MaxScrolls
+ if maxScrolls <= 0 {
+ maxScrolls = 1
+ }
+
+ for index := 0; index < maxScrolls; index++ {
+ window, err := findWeChatWindow()
+ if err != nil {
+ return err
+ }
+
+ pointX := window.X + chat.CenterSource[0]
+ pointY := window.Y + chat.CenterSource[1]
+ logTask(fmt.Sprintf("观察前滚动聊天区域:round=%d point=[%.1f, %.1f] delta_y=%d window=[%.0f, %.0f, %.0f, %.0f]", index+1, pointX, pointY, config.Agent.ScrollDeltaY, window.X, window.Y, window.Width, window.Height))
+ if err := scrollAt(pointX, pointY, config.Agent.ScrollDeltaY); err != nil {
+ return err
+ }
+ time.Sleep(700 * time.Millisecond)
+
+ if err := captureWindow(window, screenshotPath); err != nil {
+ return err
+ }
+ logInfo("已滚动并覆盖更新截图 WeChat.jpg")
+ }
+
+ return nil
+}
+
+func findWeChatWindow() (WindowBounds, error) {
+ script := `
+import CoreGraphics
+import Foundation
+
+let options = CGWindowListOption(arrayLiteral: [.optionOnScreenOnly, .excludeDesktopElements])
+guard let infos = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else {
+ exit(1)
+}
+
+for info in infos {
+ let owner = info[kCGWindowOwnerName as String] as? String ?? ""
+ let title = info[kCGWindowName as String] as? String ?? ""
+ let layer = info[kCGWindowLayer as String] as? Int ?? -1
+ if layer == 0 && (owner.contains("微信") || owner.lowercased().contains("wechat") || title == "微信") {
+ if let bounds = info[kCGWindowBounds as String] as? [String: Any],
+ let x = bounds["X"] as? Double,
+ let y = bounds["Y"] as? Double,
+ let w = bounds["Width"] as? Double,
+ let h = bounds["Height"] as? Double {
+ let id = info[kCGWindowNumber as String] as? Int ?? 0
+ let result: [String: Any] = ["x": x, "y": y, "width": w, "height": h, "owner": owner, "title": title, "id": id]
+ let data = try! JSONSerialization.data(withJSONObject: result)
+ print(String(data: data, encoding: .utf8)!)
+ exit(0)
+ }
+ }
+}
+
+exit(2)
+`
+
+ output, err := runSwift(script)
+ if err != nil {
+ return WindowBounds{}, fmt.Errorf("find WeChat window failed: %w", err)
+ }
+
+ var raw struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+ Width float64 `json:"width"`
+ Height float64 `json:"height"`
+ Owner string `json:"owner"`
+ Title string `json:"title"`
+ ID int `json:"id"`
+ }
+ if err := json.Unmarshal([]byte(output), &raw); err != nil {
+ return WindowBounds{}, err
+ }
+
+ return WindowBounds(raw), nil
+}
+
+func scrollAt(x float64, y float64, deltaY int) error {
+ if deltaY == 0 {
+ deltaY = 6
+ }
+
+ script := fmt.Sprintf(`
+import CoreGraphics
+import Foundation
+import AppKit
+
+NSWorkspace.shared.runningApplications
+ .first { $0.localizedName == "微信" || $0.localizedName == "WeChat" }?
+ .activate(options: [.activateIgnoringOtherApps])
+usleep(300000)
+
+let point = CGPoint(x: %.3f, y: %.3f)
+let move = CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: point, mouseButton: .left)
+move?.post(tap: .cghidEventTap)
+usleep(80000)
+let down = CGEvent(mouseEventSource: nil, mouseType: .leftMouseDown, mouseCursorPosition: point, mouseButton: .left)
+let up = CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, mouseCursorPosition: point, mouseButton: .left)
+down?.post(tap: .cghidEventTap)
+usleep(60000)
+up?.post(tap: .cghidEventTap)
+usleep(120000)
+
+let delta = Int32(%d)
+for _ in 0..<8 {
+ let lineScroll = CGEvent(scrollWheelEvent2Source: nil, units: .line, wheelCount: 1, wheel1: delta, wheel2: 0, wheel3: 0)
+ lineScroll?.post(tap: .cghidEventTap)
+ usleep(45000)
+}
+
+for _ in 0..<4 {
+ let pixelScroll = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 1, wheel1: delta * 80, wheel2: 0, wheel3: 0)
+ pixelScroll?.post(tap: .cghidEventTap)
+ usleep(45000)
+}
+`, x, y, deltaY)
+
+ _, err := runSwift(script)
+ return err
+}
+
+func captureWindow(window WindowBounds, screenshotPath string) error {
+ if err := os.MkdirAll(filepath.Dir(screenshotPath), 0755); err != nil {
+ return err
+ }
+
+ rect := fmt.Sprintf("%d,%d,%d,%d", int(math.Round(window.X)), int(math.Round(window.Y)), int(math.Round(window.Width)), int(math.Round(window.Height)))
+ command := exec.Command("screencapture", "-x", "-R", rect, screenshotPath)
+ if output, err := command.CombinedOutput(); err != nil {
+ return fmt.Errorf("screencapture failed: %w output=%s", err, string(output))
+ }
+
+ return nil
+}
+
+func runSwift(script string) (string, error) {
+ command := exec.Command("swift", "-")
+ command.Stdin = strings.NewReader(script)
+ var stderr bytes.Buffer
+ command.Stderr = &stderr
+ output, err := command.Output()
+ if err != nil {
+ return "", fmt.Errorf("%w stderr=%s", err, stderr.String())
+ }
+ return strings.TrimSpace(string(output)), nil
+}
diff --git a/agent/main.go b/agent/main.go
index 126a1a9..3c06aa7 100644
--- a/agent/main.go
+++ b/agent/main.go
@@ -6,6 +6,22 @@ import (
)
func main() {
+ if len(os.Args) > 1 && os.Args[1] == "ax-probe" {
+ if err := runAXProbe(); err != nil {
+ logError(err.Error())
+ os.Exit(1)
+ }
+ return
+ }
+
+ if len(os.Args) > 1 && os.Args[1] == "sync-chat" {
+ if err := runChatSync(); err != nil {
+ logError(err.Error())
+ os.Exit(1)
+ }
+ return
+ }
+
if err := runOnce(); err != nil {
logError(err.Error())
os.Exit(1)
@@ -41,6 +57,10 @@ func runOnce() error {
logThink(fmt.Sprintf("区域识别:%s center=[%.1f, %.1f]", summary.Type, summary.CenterScreen[0], summary.CenterScreen[1]))
}
+ if err := maybeScrollBeforeObserve(config, summaries, screenshotPath); err != nil {
+ return fmt.Errorf("滚动获取更多聊天记录失败:%w", err)
+ }
+
imageDataURL, err := loadImageDataURL(screenshotPath)
if err != nil {
return fmt.Errorf("读取截图失败:%w", err)
diff --git a/agent/planner.go b/agent/planner.go
index 0fd1cfe..e6c42bc 100644
--- a/agent/planner.go
+++ b/agent/planner.go
@@ -19,7 +19,8 @@ func buildPrompt(summaries []RegionSummary) (string, error) {
2. 识别联系人或会话对象,如果无法识别则填 unknown。
3. 判断是否需要回复。
4. 如果需要回复,生成 reply_text。
-5. 生成 dry-run 动作计划。
+5. 如果聊天上下文不足以判断是否回复,可以规划滚动聊天区域查看更多记录。
+6. 生成 dry-run 动作计划。
限制:
- 只能返回合法 JSON,不要 Markdown,不要解释。
@@ -28,7 +29,13 @@ func buildPrompt(summaries []RegionSummary) (string, error) {
- 如果区域 type 是 custom,请重点参考 description 判断该区域的用途和可执行动作。
- description 是用户标注时填写的用途说明,优先级高于模型自行猜测。
- 微信发送消息使用键入回车键,不要规划点击发送按钮。
-- action.type 只能是 click、type_text、press_enter、scroll、wait、noop。
+- 如果当前聊天内容不足以判断是否回复,可以返回 scroll 动作。
+- scroll 只能作用于 chat_content 区域。
+- delta_y < 0 表示向上滚动查看更多历史消息;delta_y > 0 表示向下滚动回到最新消息。
+- 每次最多返回一个 scroll 动作,abs(delta_y) 不要超过 5。
+- 如果滚动后需要重新识别,请追加 observe_again 动作。
+- 不要在同一个任务里同时包含 scroll 和 type_text/press_enter;上下文不足时先滚动观察,不要直接回复。
+- action.type 只能是 click、type_text、press_enter、scroll、observe_again、wait、noop。
- 当前是 dry_run,只生成计划,不要假设动作已执行。
- 如果无需回复,task_type 使用 wechat_observe 或 noop,并给出 observations。
diff --git a/agent/task.go b/agent/task.go
index c97aa13..b172b2f 100644
--- a/agent/task.go
+++ b/agent/task.go
@@ -16,12 +16,13 @@ var allowedTaskTypes = map[string]bool{
}
var allowedActionTypes = map[string]bool{
- "click": true,
- "type_text": true,
- "press_enter": true,
- "scroll": true,
- "wait": true,
- "noop": true,
+ "click": true,
+ "type_text": true,
+ "press_enter": true,
+ "observe_again": true,
+ "scroll": true,
+ "wait": true,
+ "noop": true,
}
func finalizeTask(task Task, summaries []RegionSummary, config Config, regionsPath string, screenshotPath string) Task {
@@ -68,11 +69,26 @@ func finalizeTask(task Task, summaries []RegionSummary, config Config, regionsPa
if summary, exists := regionIndex[action.TargetRegion]; exists {
point := summary.CenterScreen
action.Point = &point
- } else if action.Type == "click" {
+ } else if action.Type == "click" || action.Type == "scroll" {
action.Type = "noop"
action.Reason = "target_region not found"
}
}
+ if action.Type == "scroll" {
+ if action.TargetRegion != "chat_content" {
+ action.Type = "noop"
+ action.Reason = "scroll only allowed on chat_content"
+ }
+ if action.DeltaY > 5 {
+ action.DeltaY = 5
+ }
+ if action.DeltaY < -5 {
+ action.DeltaY = -5
+ }
+ if action.DeltaY == 0 {
+ action.DeltaY = -3
+ }
+ }
if action.Type == "type_text" && len([]rune(action.Text)) > 500 {
runes := []rune(action.Text)
action.Text = string(runes[:500])
@@ -119,6 +135,8 @@ func executionMessage(action Action) string {
return "dry_run: 跳过按回车发送"
case "scroll":
return fmt.Sprintf("dry_run: 跳过滑动 %s delta_y=%d", action.TargetRegion, action.DeltaY)
+ case "observe_again":
+ return "dry_run: 跳过重新截图并识别"
case "wait":
return fmt.Sprintf("dry_run: 跳过等待 %dms", action.DurationMs)
default:
diff --git a/agent/types.go b/agent/types.go
index dd70ed3..98800b2 100644
--- a/agent/types.go
+++ b/agent/types.go
@@ -12,12 +12,28 @@ type VolcengineConfig struct {
}
type AgentConfig struct {
- AppDataDir string `toml:"app_data_dir"`
- RegionsPath string `toml:"regions_path"`
- ScreenshotPath string `toml:"screenshot_path"`
- TaskOutputPath string `toml:"task_output_path"`
- TaskHistoryDir string `toml:"task_history_dir"`
- DryRun bool `toml:"dry_run"`
+ AppDataDir string `toml:"app_data_dir"`
+ RegionsPath string `toml:"regions_path"`
+ ScreenshotPath string `toml:"screenshot_path"`
+ TaskOutputPath string `toml:"task_output_path"`
+ TaskHistoryDir string `toml:"task_history_dir"`
+ DryRun bool `toml:"dry_run"`
+ ObserveMode string `toml:"observe_mode"`
+ MaxScrolls int `toml:"max_scrolls"`
+ ScrollDeltaY int `toml:"scroll_delta_y"`
+ ChatRecordsPath string `toml:"chat_records_path"`
+ ChatSyncMaxPages int `toml:"chat_sync_max_pages"`
+ ChatSyncTopScrolls int `toml:"chat_sync_top_scrolls"`
+}
+
+type WindowBounds struct {
+ X float64
+ Y float64
+ Width float64
+ Height float64
+ Owner string
+ Title string
+ ID int
}
type LogEntry struct {
@@ -138,3 +154,36 @@ type openAIResponse struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
+
+type ChatArchive struct {
+ App string `json:"app"`
+ Contact string `json:"contact"`
+ Source TaskSource `json:"source"`
+ Messages []ChatMessage `json:"messages"`
+ Pages []ChatPage `json:"pages"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+type ChatMessage struct {
+ ID string `json:"id"`
+ Sender string `json:"sender"`
+ Role string `json:"role"`
+ Time string `json:"time,omitempty"`
+ Content string `json:"content"`
+ PageIndex int `json:"page_index"`
+ SeenAt string `json:"seen_at"`
+}
+
+type ChatPage struct {
+ PageIndex int `json:"page_index"`
+ Direction string `json:"direction"`
+ NewCount int `json:"new_count"`
+ SeenAt string `json:"seen_at"`
+}
+
+type ChatExtractResult struct {
+ ContactName string `json:"contact_name"`
+ PageSummary string `json:"page_summary"`
+ Messages []ChatMessage `json:"messages"`
+}
diff --git a/wechat_vision/app.py b/wechat_vision/app.py
new file mode 100644
index 0000000..c556100
--- /dev/null
+++ b/wechat_vision/app.py
@@ -0,0 +1,163 @@
+from pathlib import Path
+
+import numpy as np
+import onnxruntime as ort
+from PIL import Image, ImageDraw
+
+
+MODEL_PATH = Path(__file__).with_name("best.onnx")
+TEST_DIR = Path(__file__).with_name("test")
+OUTPUT_DIR = Path(__file__).with_name("ouptsw")
+IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
+CONFIDENCE_THRESHOLD = 0.1
+IOU_THRESHOLD = 0.5
+
+
+def _dtype(onnx_type):
+ if onnx_type == "tensor(float)":
+ return np.float32
+ if onnx_type == "tensor(float16)":
+ return np.float16
+ if onnx_type == "tensor(double)":
+ return np.float64
+ if onnx_type in {"tensor(int64)", "tensor(uint64)"}:
+ return np.int64
+ if onnx_type in {"tensor(int32)", "tensor(uint32)"}:
+ return np.int32
+ raise ValueError(f"Unsupported input type: {onnx_type}")
+
+
+def _shape(input_meta):
+ return [dim if isinstance(dim, int) else 1 for dim in input_meta.shape]
+
+
+def _iou(box, boxes):
+ x1 = np.maximum(box[0], boxes[:, 0])
+ y1 = np.maximum(box[1], boxes[:, 1])
+ x2 = np.minimum(box[2], boxes[:, 2])
+ y2 = np.minimum(box[3], boxes[:, 3])
+
+ intersection = np.maximum(0, x2 - x1) * np.maximum(0, y2 - y1)
+ box_area = np.maximum(0, box[2] - box[0]) * np.maximum(0, box[3] - box[1])
+ boxes_area = np.maximum(0, boxes[:, 2] - boxes[:, 0]) * np.maximum(
+ 0,
+ boxes[:, 3] - boxes[:, 1],
+ )
+ return intersection / np.maximum(box_area + boxes_area - intersection, 1e-6)
+
+
+def _nms(detections):
+ detections = detections[detections[:, 4] >= CONFIDENCE_THRESHOLD]
+ if len(detections) == 0:
+ return detections
+
+ detections = detections[np.argsort(detections[:, 4])[::-1]]
+ kept = []
+ while len(detections) > 0:
+ best = detections[0]
+ kept.append(best)
+ if len(detections) == 1:
+ break
+ detections = detections[1:][_iou(best[:4], detections[1:, :4]) < IOU_THRESHOLD]
+
+ return np.array(kept)
+
+
+def _preprocess(image, input_shape):
+ _, _, height, width = input_shape
+ resized = image.resize((width, height))
+ array = np.asarray(resized, dtype=np.float32) / 255.0
+ return np.transpose(array, (2, 0, 1))[None]
+
+
+def _scale_detections(detections, original_size, input_shape):
+ _, _, input_height, input_width = input_shape
+ original_width, original_height = original_size
+ scaled = detections.copy()
+ scaled[:, [0, 2]] *= original_width / input_width
+ scaled[:, [1, 3]] *= original_height / input_height
+ scaled[:, [0, 2]] = np.clip(scaled[:, [0, 2]], 0, original_width)
+ scaled[:, [1, 3]] = np.clip(scaled[:, [1, 3]], 0, original_height)
+ return scaled
+
+
+def _draw_detections(image, detections):
+ draw = ImageDraw.Draw(image)
+ for detection in detections:
+ x1, y1, x2, y2, score, class_id = detection
+ cx = (x1 + x2) / 2
+ cy = (y1 + y2) / 2
+ label = f"({cx:.1f}, {cy:.1f}) {score:.2f}"
+
+ draw.rectangle((x1, y1, x2, y2), outline="red", width=3)
+ draw.ellipse((cx - 5, cy - 5, cx + 5, cy + 5), fill="yellow", outline="red")
+ draw.line((cx - 10, cy, cx + 10, cy), fill="red", width=2)
+ draw.line((cx, cy - 10, cx, cy + 10), fill="red", width=2)
+ draw.text((x1, max(0, y1 - 14)), label, fill="yellow")
+
+
+def main():
+ session = ort.InferenceSession(str(MODEL_PATH))
+ input_meta = session.get_inputs()[0]
+ input_shape = _shape(input_meta)
+
+ print(f"model: {MODEL_PATH}")
+ print(f"providers: {session.get_providers()}")
+
+ print("inputs:")
+ feeds = {}
+ for input_meta in session.get_inputs():
+ print(
+ f"- name={input_meta.name}, type={input_meta.type}, "
+ f"shape={input_meta.shape}"
+ )
+ feeds[input_meta.name] = np.zeros(
+ _shape(input_meta),
+ dtype=_dtype(input_meta.type),
+ )
+
+ print("outputs:")
+ for output_meta in session.get_outputs():
+ print(
+ f"- name={output_meta.name}, type={output_meta.type}, "
+ f"shape={output_meta.shape}"
+ )
+
+ outputs = session.run(None, feeds)
+ print("dummy inference: ok")
+ for index, output in enumerate(outputs):
+ print(f"- output[{index}]: shape={output.shape}, dtype={output.dtype}")
+
+ OUTPUT_DIR.mkdir(exist_ok=True)
+ result_lines = ["image,class_id,score,center_x,center_y,x1,y1,x2,y2"]
+
+ for image_path in sorted(TEST_DIR.iterdir()):
+ if image_path.suffix.lower() not in IMAGE_SUFFIXES:
+ continue
+
+ image = Image.open(image_path).convert("RGB")
+ tensor = _preprocess(image, input_shape)
+ detections = session.run(None, {input_meta.name: tensor})[0][0]
+ detections = _scale_detections(_nms(detections), image.size, input_shape)
+
+ annotated = image.copy()
+ _draw_detections(annotated, detections)
+ output_path = OUTPUT_DIR / image_path.name
+ annotated.save(output_path)
+
+ print(f"{image_path.name}: {len(detections)} target(s) -> {output_path}")
+ for detection in detections:
+ x1, y1, x2, y2, score, class_id = detection
+ cx = (x1 + x2) / 2
+ cy = (y1 + y2) / 2
+ print(f" center=({cx:.1f}, {cy:.1f}), score={score:.3f}")
+ result_lines.append(
+ f"{image_path.name},{int(class_id)},{score:.6f},{cx:.2f},{cy:.2f},"
+ f"{x1:.2f},{y1:.2f},{x2:.2f},{y2:.2f}"
+ )
+
+ (OUTPUT_DIR / "coordinates.csv").write_text("\n".join(result_lines) + "\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/wechat_vision/best.onnx b/wechat_vision/best.onnx
new file mode 100644
index 0000000..4e3a7b5
Binary files /dev/null and b/wechat_vision/best.onnx differ
diff --git a/wechat_vision/ffmpeg_realtime_detect.py b/wechat_vision/ffmpeg_realtime_detect.py
new file mode 100644
index 0000000..b0bfae8
--- /dev/null
+++ b/wechat_vision/ffmpeg_realtime_detect.py
@@ -0,0 +1,898 @@
+import argparse
+import json
+import signal
+import subprocess
+import sys
+import threading
+import time
+import webbrowser
+from datetime import datetime, timezone
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from io import BytesIO
+from pathlib import Path
+
+import numpy as np
+import onnxruntime as ort
+from PIL import Image, ImageDraw
+
+
+BASE_DIR = Path(__file__).resolve().parent
+MODEL_PATH = BASE_DIR / "best.onnx"
+DEBUG_DIR = BASE_DIR / "ouptsw" / "monitor"
+VISUALIZE_DIR = BASE_DIR / "ouptsw" / "realtime_view"
+CLASS_NAMES = {
+ 0: "input_box",
+}
+IOU_THRESHOLD = 0.5
+
+
+class StreamState:
+ def __init__(self):
+ self.condition = threading.Condition()
+ self.jpeg = None
+ self.event = None
+ self.sequence = 0
+
+ def update(self, jpeg, event):
+ with self.condition:
+ self.jpeg = jpeg
+ self.event = event
+ self.sequence += 1
+ self.condition.notify_all()
+
+ def snapshot(self):
+ with self.condition:
+ return self.jpeg, self.event, self.sequence
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(
+ description="Realtime FFmpeg screen capture + ONNX detection demo."
+ )
+ parser.add_argument("--model", default=str(MODEL_PATH), help="ONNX model path")
+ parser.add_argument(
+ "--input",
+ default="3:none",
+ help="FFmpeg avfoundation input, e.g. '3:none'. Use --list-devices to inspect.",
+ )
+ parser.add_argument("--fps", type=float, default=2.0, help="capture FPS")
+ parser.add_argument("--confidence", type=float, default=0.1, help="minimum score")
+ parser.add_argument("--target-class", type=int, default=0, help="target class id")
+ parser.add_argument(
+ "--max-frames",
+ type=int,
+ default=0,
+ help="stop after N frames; 0 means run until interrupted",
+ )
+ parser.add_argument(
+ "--emit-empty",
+ action="store_true",
+ help="emit no_detection events when target is not found",
+ )
+ parser.add_argument(
+ "--debug-dir",
+ default="",
+ help="save annotated frames into this directory",
+ )
+ parser.add_argument(
+ "--debug-every",
+ type=int,
+ default=0,
+ help="save one debug image every N frames; 0 disables debug images",
+ )
+ parser.add_argument(
+ "--visualize-dir",
+ default="",
+ help="write latest.jpg/latest.json/index.html for browser visualization",
+ )
+ parser.add_argument(
+ "--visualize-every",
+ type=int,
+ default=1,
+ help="update visualization every N frames when --visualize-dir is enabled",
+ )
+ parser.add_argument(
+ "--open-visualizer",
+ action="store_true",
+ help="open the visualization HTML in the default browser",
+ )
+ parser.add_argument(
+ "--serve",
+ action="store_true",
+ help="serve a realtime MJPEG stream and dashboard over HTTP",
+ )
+ parser.add_argument("--host", default="127.0.0.1", help="HTTP server host")
+ parser.add_argument("--port", type=int, default=8765, help="HTTP server port")
+ parser.add_argument(
+ "--display",
+ action="store_true",
+ help="open an ffplay window with the annotated realtime video",
+ )
+ parser.add_argument(
+ "--display-title",
+ default="WeChat Vision ONNX Preview",
+ help="ffplay window title when --display is enabled",
+ )
+ parser.add_argument(
+ "--display-width",
+ type=int,
+ default=720,
+ help="ffplay window width when --display is enabled",
+ )
+ parser.add_argument(
+ "--display-height",
+ type=int,
+ default=450,
+ help="ffplay window height when --display is enabled",
+ )
+ parser.add_argument(
+ "--display-left",
+ type=int,
+ default=0,
+ help="ffplay window left position when --display is enabled",
+ )
+ parser.add_argument(
+ "--display-top",
+ type=int,
+ default=0,
+ help="ffplay window top position when --display is enabled",
+ )
+ parser.add_argument(
+ "--list-devices",
+ action="store_true",
+ help="list FFmpeg avfoundation devices and exit",
+ )
+ return parser.parse_args()
+
+
+def log_error(message):
+ print(message, file=sys.stderr, flush=True)
+
+
+def utc_now():
+ return datetime.now(timezone.utc).isoformat()
+
+
+def input_shape(input_meta):
+ return [dim if isinstance(dim, int) else 1 for dim in input_meta.shape]
+
+
+def preprocess(image, shape):
+ _, _, height, width = shape
+ resized = image.convert("RGB").resize((width, height))
+ array = np.asarray(resized, dtype=np.float32) / 255.0
+ return np.transpose(array, (2, 0, 1))[None]
+
+
+def iou(box, boxes):
+ x1 = np.maximum(box[0], boxes[:, 0])
+ y1 = np.maximum(box[1], boxes[:, 1])
+ x2 = np.minimum(box[2], boxes[:, 2])
+ y2 = np.minimum(box[3], boxes[:, 3])
+ intersection = np.maximum(0, x2 - x1) * np.maximum(0, y2 - y1)
+ box_area = np.maximum(0, box[2] - box[0]) * np.maximum(0, box[3] - box[1])
+ boxes_area = np.maximum(0, boxes[:, 2] - boxes[:, 0]) * np.maximum(
+ 0,
+ boxes[:, 3] - boxes[:, 1],
+ )
+ return intersection / np.maximum(box_area + boxes_area - intersection, 1e-6)
+
+
+def nms(detections, confidence):
+ detections = detections[detections[:, 4] >= confidence]
+ if len(detections) == 0:
+ return detections
+
+ detections = detections[np.argsort(detections[:, 4])[::-1]]
+ kept = []
+ while len(detections) > 0:
+ best = detections[0]
+ kept.append(best)
+ if len(detections) == 1:
+ break
+ detections = detections[1:][iou(best[:4], detections[1:, :4]) < IOU_THRESHOLD]
+ return np.array(kept)
+
+
+def scale_detections(detections, original_size, shape):
+ if len(detections) == 0:
+ return detections
+
+ _, _, input_height, input_width = shape
+ original_width, original_height = original_size
+ scaled = detections.copy()
+ scaled[:, [0, 2]] *= original_width / input_width
+ scaled[:, [1, 3]] *= original_height / input_height
+ scaled[:, [0, 2]] = np.clip(scaled[:, [0, 2]], 0, original_width)
+ scaled[:, [1, 3]] = np.clip(scaled[:, [1, 3]], 0, original_height)
+ return scaled
+
+
+def detect(session, input_meta, image, target_class, confidence):
+ shape = input_shape(input_meta)
+ tensor = preprocess(image, shape)
+ raw = session.run(None, {input_meta.name: tensor})[0][0]
+ detections = raw[raw[:, 5].astype(int) == target_class]
+ detections = nms(detections, confidence)
+ return scale_detections(detections, image.size, shape)
+
+
+def emit(event):
+ print(json.dumps(event, ensure_ascii=False, separators=(",", ":")), flush=True)
+
+
+def write_json_atomic(path, data):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temp_path = path.with_suffix(path.suffix + ".tmp")
+ temp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
+ temp_path.replace(path)
+
+
+def save_image_atomic(image, path):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temp_path = path.with_suffix(path.suffix + ".tmp")
+ image.save(temp_path, format="JPEG", quality=85)
+ temp_path.replace(path)
+
+
+def image_to_jpeg_bytes(image):
+ buffer = BytesIO()
+ image.save(buffer, format="JPEG", quality=85)
+ return buffer.getvalue()
+
+
+def detection_event(detection, frame_index, frame_size, class_name):
+ x1, y1, x2, y2, score, class_id = detection
+ cx = (x1 + x2) / 2
+ cy = (y1 + y2) / 2
+ return {
+ "type": "detection",
+ "timestamp": utc_now(),
+ "frame_index": frame_index,
+ "class_id": int(class_id),
+ "class_name": class_name,
+ "confidence": round(float(score), 6),
+ "bbox_screen": [round(float(x1), 2), round(float(y1), 2), round(float(x2), 2), round(float(y2), 2)],
+ "center_screen": [round(float(cx), 2), round(float(cy), 2)],
+ "frame_size": [frame_size[0], frame_size[1]],
+ }
+
+
+def no_detection_event(frame_index, frame_size, target_class, class_name):
+ return {
+ "type": "no_detection",
+ "timestamp": utc_now(),
+ "frame_index": frame_index,
+ "class_id": target_class,
+ "class_name": class_name,
+ "frame_size": [frame_size[0], frame_size[1]],
+ }
+
+
+def save_debug_image(image, detections, path, class_name):
+ debug = draw_detections(image, detections, class_name)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ debug.save(path)
+
+
+def draw_detections(image, detections, class_name):
+ debug = image.convert("RGB")
+ draw = ImageDraw.Draw(debug)
+ for detection in detections:
+ x1, y1, x2, y2, score, _ = detection
+ cx = (x1 + x2) / 2
+ cy = (y1 + y2) / 2
+ draw.rectangle((x1, y1, x2, y2), outline="red", width=3)
+ draw.ellipse((cx - 5, cy - 5, cx + 5, cy + 5), fill="yellow", outline="red")
+ draw.text(
+ (x1, max(0, y1 - 14)),
+ f"{class_name} ({cx:.1f}, {cy:.1f}) {score:.2f}",
+ fill="yellow",
+ )
+ return debug
+
+
+def draw_frame_info(image, event):
+ draw = ImageDraw.Draw(image)
+ frame_index = event.get("frame_index", "-")
+ timestamp = event.get("timestamp", "")
+ text = f"frame={frame_index} {timestamp}"
+ draw.rectangle((12, 12, 620, 52), fill=(0, 0, 0))
+ draw.text((22, 22), text, fill=(80, 255, 120))
+ return image
+
+
+def write_visualizer_html(path):
+ path.parent.mkdir(parents=True, exist_ok=True)
+ html = """
+
+
+
+
+ WeChat Vision Realtime
+
+
+
+
+
![latest detection frame]()
+
+
+
+
+
+"""
+ path.write_text(html, encoding="utf-8")
+
+
+def dashboard_html(frame_url="frame.jpg", json_url="latest.json"):
+ return f"""
+
+
+
+
+ WeChat Vision Live Stream
+
+
+
+
+
+
+ waiting for frames
+
+
+
+
+
+
+"""
+
+
+def make_stream_handler(state):
+ class Handler(BaseHTTPRequestHandler):
+ protocol_version = "HTTP/1.1"
+
+ def log_message(self, format, *args):
+ return
+
+ def do_HEAD(self):
+ if self.path == "/" or self.path.startswith("/index.html"):
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ return
+ if self.path.startswith("/latest.json"):
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ return
+ if self.path.startswith("/frame.jpg"):
+ self.send_response(200)
+ self.send_header("Content-Type", "image/jpeg")
+ self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
+ self.end_headers()
+ return
+ if self.path.startswith("/stream.mjpg"):
+ self.send_response(200)
+ self.send_header("Cache-Control", "no-cache, private")
+ self.send_header("Pragma", "no-cache")
+ self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
+ self.end_headers()
+ return
+ self.send_error(404)
+
+ def do_GET(self):
+ if self.path == "/" or self.path.startswith("/index.html"):
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ self.wfile.write(dashboard_html().encode("utf-8"))
+ return
+
+ if self.path.startswith("/latest.json"):
+ _, event, sequence = state.snapshot()
+ payload = {
+ "sequence": sequence,
+ "updated_at": utc_now(),
+ "event": event or {"type": "waiting", "timestamp": utc_now()},
+ }
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Cache-Control", "no-store")
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+ return
+
+ if self.path.startswith("/frame.jpg"):
+ jpeg, _, sequence = state.snapshot()
+ if not jpeg:
+ self.send_response(503)
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ return
+ self.send_response(200)
+ self.send_header("Content-Type", "image/jpeg")
+ self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
+ self.send_header("Pragma", "no-cache")
+ self.send_header("X-Frame-Sequence", str(sequence))
+ self.send_header("Content-Length", str(len(jpeg)))
+ self.end_headers()
+ self.wfile.write(jpeg)
+ return
+
+ if self.path.startswith("/stream.mjpg"):
+ self.send_response(200)
+ self.send_header("Age", "0")
+ self.send_header("Cache-Control", "no-cache, private")
+ self.send_header("Pragma", "no-cache")
+ self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
+ self.end_headers()
+
+ last_sequence = -1
+ while True:
+ with state.condition:
+ state.condition.wait_for(lambda: state.sequence != last_sequence, timeout=5)
+ jpeg = state.jpeg
+ last_sequence = state.sequence
+ if not jpeg:
+ continue
+ try:
+ self.wfile.write(b"--frame\r\n")
+ self.wfile.write(b"Content-Type: image/jpeg\r\n")
+ self.wfile.write(f"Content-Length: {len(jpeg)}\r\n\r\n".encode("ascii"))
+ self.wfile.write(jpeg)
+ self.wfile.write(b"\r\n")
+ except (BrokenPipeError, ConnectionResetError):
+ break
+ return
+
+ self.send_error(404)
+
+ return Handler
+
+
+def start_stream_server(host, port, state):
+ server = ThreadingHTTPServer((host, port), make_stream_handler(state))
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ return server
+
+
+def update_visualizer(image, detections, event, output_dir, class_name):
+ annotated = draw_detections(image, detections, class_name)
+ annotated = draw_frame_info(annotated, event)
+ save_image_atomic(annotated, output_dir / "latest.jpg")
+ payload = {
+ "updated_at": utc_now(),
+ "event": event,
+ "frame_index": event.get("frame_index"),
+ "class_id": event.get("class_id"),
+ "class_name": event.get("class_name"),
+ "confidence": event.get("confidence"),
+ "bbox_screen": event.get("bbox_screen"),
+ "center_screen": event.get("center_screen"),
+ "frame_size": event.get("frame_size"),
+ "timestamp": event.get("timestamp"),
+ }
+ write_json_atomic(output_dir / "latest.json", payload)
+
+
+def ffmpeg_command(args):
+ return [
+ "ffmpeg",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-f",
+ "avfoundation",
+ "-framerate",
+ str(args.fps),
+ "-i",
+ args.input,
+ "-f",
+ "image2pipe",
+ "-vcodec",
+ "mjpeg",
+ "-",
+ ]
+
+
+def ffplay_command(args):
+ return [
+ "ffplay",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-fflags",
+ "nobuffer",
+ "-flags",
+ "low_delay",
+ "-framedrop",
+ "-sync",
+ "ext",
+ "-window_title",
+ args.display_title,
+ "-left",
+ str(args.display_left),
+ "-top",
+ str(args.display_top),
+ "-x",
+ str(args.display_width),
+ "-y",
+ str(args.display_height),
+ "-f",
+ "mjpeg",
+ "-i",
+ "pipe:0",
+ ]
+
+
+def start_ffplay(args):
+ return subprocess.Popen(
+ ffplay_command(args),
+ stdin=subprocess.PIPE,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ bufsize=0,
+ )
+
+
+def write_display_frame(display_process, jpeg):
+ if display_process is None or display_process.stdin is None:
+ return False
+ if display_process.poll() is not None:
+ return False
+ try:
+ display_process.stdin.write(jpeg)
+ display_process.stdin.flush()
+ return True
+ except (BrokenPipeError, OSError):
+ return False
+
+
+def list_devices():
+ command = [
+ "ffmpeg",
+ "-hide_banner",
+ "-f",
+ "avfoundation",
+ "-list_devices",
+ "true",
+ "-i",
+ "",
+ ]
+ subprocess.run(command, check=False)
+
+
+def read_jpeg_frames(stream):
+ buffer = bytearray()
+ while True:
+ chunk = stream.read(65536)
+ if not chunk:
+ break
+ buffer.extend(chunk)
+
+ while True:
+ start = buffer.find(b"\xff\xd8")
+ if start < 0:
+ if len(buffer) > 1024 * 1024:
+ del buffer[:-2]
+ break
+
+ end = buffer.find(b"\xff\xd9", start + 2)
+ if end < 0:
+ if start > 0:
+ del buffer[:start]
+ break
+
+ frame = bytes(buffer[start : end + 2])
+ del buffer[: end + 2]
+ yield frame
+
+
+def run_monitor(args):
+ model_path = Path(args.model)
+ class_name = CLASS_NAMES.get(args.target_class, f"class_{args.target_class}")
+ visualize_dir = Path(args.visualize_dir) if args.visualize_dir else None
+ stream_state = StreamState() if args.serve else None
+ stream_server = None
+ display_process = None
+ if stream_state:
+ stream_server = start_stream_server(args.host, args.port, stream_state)
+ url = f"http://{args.host}:{args.port}/"
+ emit({"type": "stream_server_ready", "url": url})
+ if args.open_visualizer:
+ webbrowser.open(url)
+ if visualize_dir:
+ write_visualizer_html(visualize_dir / "index.html")
+ emit({"type": "visualizer_ready", "path": str(visualize_dir / "index.html")})
+ if args.open_visualizer and not args.serve:
+ webbrowser.open((visualize_dir / "index.html").resolve().as_uri())
+
+ if args.display:
+ display_process = start_ffplay(args)
+ emit({"type": "display_started", "title": args.display_title})
+
+ session = ort.InferenceSession(str(model_path))
+ input_meta = session.get_inputs()[0]
+ emit(
+ {
+ "type": "monitor_started",
+ "timestamp": utc_now(),
+ "model": str(model_path),
+ "providers": session.get_providers(),
+ "input": args.input,
+ "fps": args.fps,
+ "target_class": args.target_class,
+ "target_name": class_name,
+ "confidence": args.confidence,
+ }
+ )
+
+ process = subprocess.Popen(
+ ffmpeg_command(args),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ bufsize=0,
+ )
+
+ if process.stdout is None:
+ raise RuntimeError("ffmpeg stdout pipe is not available")
+
+ frame_index = 0
+ try:
+ for frame_bytes in read_jpeg_frames(process.stdout):
+ frame_index += 1
+ image = Image.open(BytesIO(frame_bytes)).convert("RGB")
+ detections = detect(
+ session,
+ input_meta,
+ image,
+ args.target_class,
+ args.confidence,
+ )
+
+ if len(detections) > 0:
+ best = detections[np.argmax(detections[:, 4])]
+ event = detection_event(best, frame_index, image.size, class_name)
+ emit(event)
+ elif args.emit_empty:
+ event = no_detection_event(frame_index, image.size, args.target_class, class_name)
+ emit(event)
+ else:
+ event = no_detection_event(frame_index, image.size, args.target_class, class_name)
+
+ if args.debug_dir and args.debug_every > 0 and frame_index % args.debug_every == 0:
+ save_debug_image(
+ image,
+ detections,
+ Path(args.debug_dir) / f"frame_{frame_index:06d}.jpg",
+ class_name,
+ )
+
+ if (
+ visualize_dir
+ and args.visualize_every > 0
+ and frame_index % args.visualize_every == 0
+ ):
+ update_visualizer(image, detections, event, visualize_dir, class_name)
+
+ annotated = None
+ if stream_state:
+ annotated = draw_detections(image, detections, class_name)
+ stream_state.update(image_to_jpeg_bytes(annotated), event)
+
+ if display_process:
+ if annotated is None:
+ annotated = draw_detections(image, detections, class_name)
+ annotated = draw_frame_info(annotated, event)
+ if not write_display_frame(display_process, image_to_jpeg_bytes(annotated)):
+ emit({"type": "display_closed", "timestamp": utc_now()})
+ break
+
+ if args.max_frames > 0 and frame_index >= args.max_frames:
+ break
+ finally:
+ process.terminate()
+ try:
+ process.wait(timeout=2)
+ except subprocess.TimeoutExpired:
+ process.kill()
+
+ stderr = b""
+ if process.stderr is not None:
+ try:
+ stderr = process.stderr.read()
+ except Exception:
+ stderr = b""
+ if frame_index == 0 and stderr:
+ log_error(stderr.decode(errors="replace").strip())
+
+ if display_process:
+ if display_process.stdin:
+ try:
+ display_process.stdin.close()
+ except Exception:
+ pass
+ display_process.terminate()
+ try:
+ display_process.wait(timeout=2)
+ except subprocess.TimeoutExpired:
+ display_process.kill()
+
+ emit(
+ {
+ "type": "monitor_stopped",
+ "timestamp": utc_now(),
+ "frames": frame_index,
+ }
+ )
+
+ if stream_server:
+ stream_server.shutdown()
+
+
+def main():
+ def stop_handler(signum, frame):
+ raise KeyboardInterrupt
+
+ signal.signal(signal.SIGTERM, stop_handler)
+ signal.signal(signal.SIGINT, stop_handler)
+
+ args = parse_args()
+ if args.list_devices:
+ list_devices()
+ return
+
+ if args.debug_every > 0 and not args.debug_dir:
+ args.debug_dir = str(DEBUG_DIR)
+ if args.open_visualizer and not args.visualize_dir and not args.serve:
+ args.visualize_dir = str(VISUALIZE_DIR)
+
+ try:
+ run_monitor(args)
+ except KeyboardInterrupt:
+ emit({"type": "monitor_interrupted", "timestamp": utc_now()})
+
+
+if __name__ == "__main__":
+ main()
diff --git a/wechat_vision/screen_srk_click_send.py b/wechat_vision/screen_srk_click_send.py
new file mode 100644
index 0000000..9fb5146
--- /dev/null
+++ b/wechat_vision/screen_srk_click_send.py
@@ -0,0 +1,180 @@
+import argparse
+import time
+from pathlib import Path
+
+import numpy as np
+import onnxruntime as ort
+import pyautogui
+import pyperclip
+from PIL import ImageDraw
+
+
+BASE_DIR = Path(__file__).resolve().parent
+MODEL_PATH = BASE_DIR / "best.onnx"
+OUTPUT_DIR = BASE_DIR / "ouptsw"
+CLASS_MAP = {"srk": 0}
+IOU_THRESHOLD = 0.5
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(
+ description="Screenshot screen, detect srk target, click center, type text, and send."
+ )
+ parser.add_argument("--class-name", default="srk", help="target class name")
+ parser.add_argument("--confidence", type=float, default=0.1, help="minimum score")
+ parser.add_argument("--text", default="你好", help="text to paste after clicking")
+ parser.add_argument("--dry-run", action="store_true", help="detect only; do not click/type")
+ parser.add_argument(
+ "--debug-image",
+ default=str(OUTPUT_DIR / "screen_srk_debug.png"),
+ help="path to save screenshot with detection annotation",
+ )
+ parser.add_argument(
+ "--delay",
+ type=float,
+ default=1.0,
+ help="seconds to wait before taking screenshot",
+ )
+ return parser.parse_args()
+
+
+def input_shape(input_meta):
+ return [dim if isinstance(dim, int) else 1 for dim in input_meta.shape]
+
+
+def preprocess(image, shape):
+ _, _, height, width = shape
+ resized = image.convert("RGB").resize((width, height))
+ array = np.asarray(resized, dtype=np.float32) / 255.0
+ return np.transpose(array, (2, 0, 1))[None]
+
+
+def iou(box, boxes):
+ x1 = np.maximum(box[0], boxes[:, 0])
+ y1 = np.maximum(box[1], boxes[:, 1])
+ x2 = np.minimum(box[2], boxes[:, 2])
+ y2 = np.minimum(box[3], boxes[:, 3])
+ intersection = np.maximum(0, x2 - x1) * np.maximum(0, y2 - y1)
+ box_area = np.maximum(0, box[2] - box[0]) * np.maximum(0, box[3] - box[1])
+ boxes_area = np.maximum(0, boxes[:, 2] - boxes[:, 0]) * np.maximum(
+ 0,
+ boxes[:, 3] - boxes[:, 1],
+ )
+ return intersection / np.maximum(box_area + boxes_area - intersection, 1e-6)
+
+
+def nms(detections, confidence):
+ detections = detections[detections[:, 4] >= confidence]
+ if len(detections) == 0:
+ return detections
+
+ detections = detections[np.argsort(detections[:, 4])[::-1]]
+ kept = []
+ while len(detections) > 0:
+ best = detections[0]
+ kept.append(best)
+ if len(detections) == 1:
+ break
+ detections = detections[1:][iou(best[:4], detections[1:, :4]) < IOU_THRESHOLD]
+ return np.array(kept)
+
+
+def scale_detections(detections, original_size, shape):
+ _, _, input_height, input_width = shape
+ original_width, original_height = original_size
+ scaled = detections.copy()
+ scaled[:, [0, 2]] *= original_width / input_width
+ scaled[:, [1, 3]] *= original_height / input_height
+ scaled[:, [0, 2]] = np.clip(scaled[:, [0, 2]], 0, original_width)
+ scaled[:, [1, 3]] = np.clip(scaled[:, [1, 3]], 0, original_height)
+ return scaled
+
+
+def detect_srk(session, input_meta, screenshot, class_id, confidence):
+ shape = input_shape(input_meta)
+ tensor = preprocess(screenshot, shape)
+ detections = session.run(None, {input_meta.name: tensor})[0][0]
+ detections = detections[detections[:, 5].astype(int) == class_id]
+ detections = nms(detections, confidence)
+ return scale_detections(detections, screenshot.size, shape)
+
+
+def save_debug_image(screenshot, detections, path):
+ debug = screenshot.convert("RGB")
+ draw = ImageDraw.Draw(debug)
+ for detection in detections:
+ x1, y1, x2, y2, score, _ = detection
+ cx = (x1 + x2) / 2
+ cy = (y1 + y2) / 2
+ draw.rectangle((x1, y1, x2, y2), outline="red", width=3)
+ draw.ellipse((cx - 5, cy - 5, cx + 5, cy + 5), fill="yellow", outline="red")
+ draw.text((x1, max(0, y1 - 14)), f"srk ({cx:.1f}, {cy:.1f}) {score:.2f}", fill="yellow")
+ path = Path(path)
+ path.parent.mkdir(exist_ok=True)
+ debug.save(path)
+
+
+def screen_click_position(x, y, screenshot_size):
+ screen_width, screen_height = pyautogui.size()
+ screenshot_width, screenshot_height = screenshot_size
+ return (
+ x * screen_width / screenshot_width,
+ y * screen_height / screenshot_height,
+ )
+
+
+def click_type_send(x, y, text):
+ pyautogui.click(x, y)
+ time.sleep(0.2)
+ pyperclip.copy(text)
+ pyautogui.hotkey("command", "v")
+ time.sleep(0.1)
+ pyautogui.press("enter")
+
+
+def main():
+ args = parse_args()
+ if args.class_name not in CLASS_MAP:
+ raise ValueError(f"Unknown class name: {args.class_name}. CLASS_MAP={CLASS_MAP}")
+
+ pyautogui.FAILSAFE = True
+ time.sleep(args.delay)
+
+ session = ort.InferenceSession(str(MODEL_PATH))
+ input_meta = session.get_inputs()[0]
+ screenshot = pyautogui.screenshot()
+ detections = detect_srk(
+ session,
+ input_meta,
+ screenshot,
+ CLASS_MAP[args.class_name],
+ args.confidence,
+ )
+
+ save_debug_image(screenshot, detections, args.debug_image)
+ if len(detections) == 0:
+ print(f"No {args.class_name} target found. debug_image={args.debug_image}")
+ return
+
+ target = detections[np.argmax(detections[:, 4])]
+ x1, y1, x2, y2, score, class_id = target
+ cx = (x1 + x2) / 2
+ cy = (y1 + y2) / 2
+ click_x, click_y = screen_click_position(cx, cy, screenshot.size)
+ print(
+ f"target={args.class_name}, class_id={int(class_id)}, score={score:.3f}, "
+ f"image_center=({cx:.1f}, {cy:.1f}), click_center=({click_x:.1f}, {click_y:.1f}), "
+ f"screenshot_size={screenshot.size}, screen_size={pyautogui.size()}, "
+ f"debug_image={args.debug_image}"
+ )
+
+ if args.dry_run:
+ print("dry-run enabled; skip click/type/send")
+ return
+
+ click_type_send(click_x, click_y, args.text)
+ print("clicked target, pasted text, and pressed enter")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/wechat_vision/start_realtime_stream.sh b/wechat_vision/start_realtime_stream.sh
new file mode 100755
index 0000000..98d5aa8
--- /dev/null
+++ b/wechat_vision/start_realtime_stream.sh
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")"
+mkdir -p ouptsw
+
+PID_FILE="ouptsw/realtime_stream.pid"
+LOG_FILE="ouptsw/realtime_stream.log"
+
+# Clean up stale FFmpeg capture children from a previously killed monitor.
+pkill -f "avfoundation -framerate 2.0 -i 3:none" 2>/dev/null || true
+pkill -f "ffplay .*WeChat Vision ONNX Preview" 2>/dev/null || true
+
+if [[ -f "$PID_FILE" ]]; then
+ OLD_PID="$(cat "$PID_FILE")"
+ if [[ -n "$OLD_PID" ]] && kill -0 "$OLD_PID" 2>/dev/null; then
+ printf 'Realtime stream is already running: pid=%s\n' "$OLD_PID"
+ printf 'Open http://127.0.0.1:8765/\n'
+ exit 0
+ fi
+fi
+
+nohup ./venv/bin/python ffmpeg_realtime_detect.py \
+ --input 3:none \
+ --fps 2 \
+ --confidence 0.05 \
+ --target-class 0 \
+ --display \
+ > "$LOG_FILE" 2>&1 &
+
+PID="$!"
+printf '%s' "$PID" > "$PID_FILE"
+
+printf 'Realtime stream started: pid=%s\n' "$PID"
+printf 'An ffplay preview window should open automatically.\n'
+printf 'Log: %s\n' "$LOG_FILE"
diff --git a/wechat_vision/start_wechat_algorithm_live.sh b/wechat_vision/start_wechat_algorithm_live.sh
new file mode 100755
index 0000000..344ecc1
--- /dev/null
+++ b/wechat_vision/start_wechat_algorithm_live.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")"
+mkdir -p ouptsw
+
+PID_FILE="ouptsw/wechat_algorithm_live.pid"
+LOG_FILE="ouptsw/wechat_algorithm_live.log"
+
+if [[ -f "$PID_FILE" ]]; then
+ OLD_PID="$(cat "$PID_FILE")"
+ if [[ -n "$OLD_PID" ]] && kill -0 "$OLD_PID" 2>/dev/null; then
+ printf 'WeChat algorithm live monitor is already running: pid=%s\n' "$OLD_PID"
+ printf 'Open http://127.0.0.1:8765/\n'
+ exit 0
+ fi
+fi
+
+nohup ./venv/bin/python wechat_algorithm_live.py \
+ --fps 30 \
+ --host 127.0.0.1 \
+ --port 8765 \
+ --click-on-badge \
+ --llm-on-click \
+ --llm-on-chat-change \
+ --reply-on-chat-change \
+ --send-reply \
+ --typing-chunk-size 2 \
+ --typing-delay 0.08 \
+ --open-browser \
+ > "$LOG_FILE" 2>&1 &
+
+PID="$!"
+printf '%s' "$PID" > "$PID_FILE"
+
+printf 'WeChat algorithm live monitor started: pid=%s\n' "$PID"
+printf 'Open http://127.0.0.1:8765/\n'
+printf 'Log: %s\n' "$LOG_FILE"
diff --git a/wechat_vision/start_wechat_window_live.sh b/wechat_vision/start_wechat_window_live.sh
new file mode 100755
index 0000000..8429ad2
--- /dev/null
+++ b/wechat_vision/start_wechat_window_live.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")"
+mkdir -p ouptsw
+
+PID_FILE="ouptsw/wechat_window_live.pid"
+LOG_FILE="ouptsw/wechat_window_live.log"
+
+if [[ -f "$PID_FILE" ]]; then
+ OLD_PID="$(cat "$PID_FILE")"
+ if [[ -n "$OLD_PID" ]] && kill -0 "$OLD_PID" 2>/dev/null; then
+ printf 'WeChat window live stream is already running: pid=%s\n' "$OLD_PID"
+ printf 'Open http://127.0.0.1:8765/\n'
+ exit 0
+ fi
+fi
+
+nohup ./venv/bin/python wechat_window_live.py \
+ --fps 60 \
+ --infer-interval 3 \
+ --confidence 0.05 \
+ --target-class 0 \
+ --host 127.0.0.1 \
+ --port 8765 \
+ --open-browser \
+ > "$LOG_FILE" 2>&1 &
+
+PID="$!"
+printf '%s' "$PID" > "$PID_FILE"
+
+printf 'WeChat window live stream started: pid=%s\n' "$PID"
+printf 'Open http://127.0.0.1:8765/\n'
+printf 'Stream http://127.0.0.1:8765/stream.mjpg\n'
+printf 'Log: %s\n' "$LOG_FILE"
diff --git a/wechat_vision/stop_realtime_stream.sh b/wechat_vision/stop_realtime_stream.sh
new file mode 100755
index 0000000..3e49442
--- /dev/null
+++ b/wechat_vision/stop_realtime_stream.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")"
+
+PID_FILE="ouptsw/realtime_stream.pid"
+if [[ ! -f "$PID_FILE" ]]; then
+ printf 'Realtime stream is not running: missing %s\n' "$PID_FILE"
+ exit 0
+fi
+
+PID="$(cat "$PID_FILE")"
+if [[ -z "$PID" ]] || ! kill -0 "$PID" 2>/dev/null; then
+ rm -f "$PID_FILE"
+ printf 'Realtime stream is not running. Removed stale pid file.\n'
+ exit 0
+fi
+
+kill "$PID"
+sleep 1
+pkill -f "avfoundation -framerate 2.0 -i 3:none" 2>/dev/null || true
+pkill -f "ffplay .*WeChat Vision ONNX Preview" 2>/dev/null || true
+rm -f "$PID_FILE"
+printf 'Realtime stream stopped: pid=%s\n' "$PID"
diff --git a/wechat_vision/stop_wechat_algorithm_live.sh b/wechat_vision/stop_wechat_algorithm_live.sh
new file mode 100755
index 0000000..fd0df68
--- /dev/null
+++ b/wechat_vision/stop_wechat_algorithm_live.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")"
+
+PID_FILE="ouptsw/wechat_algorithm_live.pid"
+if [[ ! -f "$PID_FILE" ]]; then
+ printf 'WeChat algorithm live monitor is not running: missing %s\n' "$PID_FILE"
+ exit 0
+fi
+
+PID="$(cat "$PID_FILE")"
+if [[ -z "$PID" ]] || ! kill -0 "$PID" 2>/dev/null; then
+ rm -f "$PID_FILE"
+ printf 'WeChat algorithm live monitor is not running. Removed stale pid file.\n'
+ exit 0
+fi
+
+kill "$PID"
+sleep 1
+rm -f "$PID_FILE"
+printf 'WeChat algorithm live monitor stopped: pid=%s\n' "$PID"
diff --git a/wechat_vision/stop_wechat_window_live.sh b/wechat_vision/stop_wechat_window_live.sh
new file mode 100755
index 0000000..e72ccb6
--- /dev/null
+++ b/wechat_vision/stop_wechat_window_live.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")"
+
+PID_FILE="ouptsw/wechat_window_live.pid"
+if [[ ! -f "$PID_FILE" ]]; then
+ printf 'WeChat window live stream is not running: missing %s\n' "$PID_FILE"
+ exit 0
+fi
+
+PID="$(cat "$PID_FILE")"
+if [[ -z "$PID" ]] || ! kill -0 "$PID" 2>/dev/null; then
+ rm -f "$PID_FILE"
+ printf 'WeChat window live stream is not running. Removed stale pid file.\n'
+ exit 0
+fi
+
+kill "$PID"
+sleep 1
+rm -f "$PID_FILE"
+printf 'WeChat window live stream stopped: pid=%s\n' "$PID"
diff --git a/wechat_vision/wechat_algorithm_live.py b/wechat_vision/wechat_algorithm_live.py
new file mode 100644
index 0000000..4da63db
--- /dev/null
+++ b/wechat_vision/wechat_algorithm_live.py
@@ -0,0 +1,773 @@
+import argparse
+import base64
+import json
+import signal
+import threading
+import time
+import webbrowser
+from datetime import datetime, timezone
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from io import BytesIO
+from pathlib import Path
+from urllib import request as urlrequest
+
+import numpy as np
+from PIL import Image, ImageDraw
+import pyautogui
+import pyperclip
+import tomllib
+
+from wechat_window_live import capture_window_image, find_wechat_window, image_to_jpeg_bytes
+
+
+DEFAULT_REGIONS = Path.home() / "Library/Application Support/com.tauri.dev/data/regions/wechat.json"
+DEFAULT_AGENT_CONFIG = Path(__file__).resolve().parent.parent / "agent" / "config.toml"
+
+
+class StreamState:
+ def __init__(self):
+ self.condition = threading.Condition()
+ self.jpeg = None
+ self.event = None
+ self.sequence = 0
+
+ def update(self, jpeg, event):
+ with self.condition:
+ self.jpeg = jpeg
+ self.event = event
+ self.sequence += 1
+ self.condition.notify_all()
+
+ def snapshot(self):
+ with self.condition:
+ return self.jpeg, self.event, self.sequence
+
+
+class SharedFrame:
+ def __init__(self):
+ self.lock = threading.Lock()
+ self.image = None
+ self.window = None
+ self.boxes = {}
+ self.frame_index = 0
+ self.latest_detection = {"events": [], "badge_events": [], "change_events": [], "timestamp": utc_now()}
+ self.latest_llm = None
+ self.stop = threading.Event()
+
+ def update_frame(self, image, window, boxes, frame_index):
+ with self.lock:
+ self.image = image
+ self.window = window
+ self.boxes = boxes
+ self.frame_index = frame_index
+
+ def snapshot(self):
+ with self.lock:
+ return self.image, self.window, dict(self.boxes), self.frame_index, dict(self.latest_detection), self.latest_llm
+
+ def update_detection(self, detection):
+ with self.lock:
+ self.latest_detection = detection
+
+ def update_llm(self, reading):
+ with self.lock:
+ self.latest_llm = reading
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(description="Pure algorithm WeChat new-message monitor.")
+ parser.add_argument("--regions", default=str(DEFAULT_REGIONS), help="wechat.json annotation path")
+ parser.add_argument("--fps", type=float, default=30.0, help="preview FPS target")
+ parser.add_argument("--host", default="127.0.0.1", help="HTTP host")
+ parser.add_argument("--port", type=int, default=8765, help="HTTP port")
+ parser.add_argument("--max-frames", type=int, default=0, help="stop after N frames; 0 runs forever")
+ parser.add_argument("--open-browser", action="store_true", help="open browser preview")
+ parser.add_argument("--chat-diff-threshold", type=int, default=34, help="chat content pixel diff threshold")
+ parser.add_argument("--dry-run", action="store_true", default=True, help="draw only; never click")
+ parser.add_argument("--click-on-badge", action="store_true", help="click the first detected unread contact row")
+ parser.add_argument("--llm-on-click", action="store_true", help="after clicking a contact, ask LLM to read current chat content")
+ parser.add_argument("--llm-on-chat-change", action="store_true", help="ask LLM to read current chat when chat_content diff indicates a new message")
+ parser.add_argument("--reply-on-chat-change", action="store_true", help="send reply_text for current-chat diff events")
+ parser.add_argument("--send-reply", action="store_true", help="paste LLM reply_text into input_box and press Enter")
+ parser.add_argument("--typing-chunk-size", type=int, default=2, help="characters pasted per simulated typing chunk")
+ parser.add_argument("--typing-delay", type=float, default=0.08, help="seconds between simulated typing chunks")
+ parser.add_argument("--agent-config", default=str(DEFAULT_AGENT_CONFIG), help="agent/config.toml path for LLM settings")
+ parser.add_argument("--click-cooldown", type=float, default=30.0, help="seconds before clicking the same contact row again")
+ parser.add_argument("--chat-change-cooldown", type=float, default=30.0, help="seconds before handling current chat diff again")
+ parser.add_argument("--after-click-wait", type=float, default=1.0, help="seconds to wait before capturing chat after click")
+ return parser.parse_args()
+
+
+def utc_now():
+ return datetime.now(timezone.utc).isoformat()
+
+
+def emit(event):
+ print(json.dumps(json_safe(event), ensure_ascii=False, separators=(",", ":")), flush=True)
+
+
+def emit_llm_reading(reading):
+ payload = {
+ "type": "llm_chat_reading_print",
+ "timestamp": utc_now(),
+ "contact_name": reading.get("contact_name", "unknown") if isinstance(reading, dict) else "unknown",
+ "latest_user_message": reading.get("latest_user_message", "") if isinstance(reading, dict) else "",
+ "visible_messages": reading.get("visible_messages", []) if isinstance(reading, dict) else [],
+ "summary": reading.get("summary", "") if isinstance(reading, dict) else "",
+ "reply_text": reading.get("reply_text", "") if isinstance(reading, dict) else "",
+ "send_status": reading.get("send_status", "not_sent") if isinstance(reading, dict) else "not_sent",
+ "send_error": reading.get("send_error", "") if isinstance(reading, dict) else "",
+ }
+ emit(payload)
+
+
+def json_safe(value):
+ if isinstance(value, dict):
+ return {key: json_safe(item) for key, item in value.items()}
+ if isinstance(value, list):
+ return [json_safe(item) for item in value]
+ if isinstance(value, tuple):
+ return [json_safe(item) for item in value]
+ if isinstance(value, np.integer):
+ return int(value)
+ if isinstance(value, np.floating):
+ return float(value)
+ return value
+
+
+def load_regions(path):
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
+ regions = {}
+ for region in data.get("regions", []):
+ regions.setdefault(region.get("type"), region)
+ if region.get("type") == "custom" and "未读消息" in region.get("description", ""):
+ regions["message_button"] = region
+ return data, regions
+
+
+def load_agent_config(path):
+ with Path(path).open("rb") as file:
+ return tomllib.load(file)
+
+
+def image_data_url(image):
+ buffer = BytesIO()
+ image.save(buffer, format="JPEG", quality=88)
+ encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
+ return "data:image/jpeg;base64," + encoded
+
+
+def request_chat_reading(config, chat_image):
+ volcengine = config.get("volcengine", {})
+ base_url = (volcengine.get("base_url") or "").rstrip("/")
+ api_key = volcengine.get("api_key") or ""
+ model = volcengine.get("model") or ""
+ if not base_url or not api_key or not model:
+ raise RuntimeError("missing volcengine base_url/api_key/model in agent config")
+
+ prompt = """你是微信聊天截图读取和回复草稿助手。
+请读取图片中当前聊天区域的可见消息,不要编造看不见的内容。
+请基于可见上下文生成一条自然、简短、适合直接发送的中文回复草稿。
+这里只生成草稿,不要假设已经发送,也不要执行任何操作。
+返回合法 JSON,格式如下:
+{
+ "contact_name": "unknown",
+ "latest_user_message": "",
+ "visible_messages": [
+ {"sender":"unknown", "role":"user|me|system|unknown", "content":""}
+ ],
+ "summary": "",
+ "reply_text": ""
+}
+"""
+ payload = {
+ "model": model,
+ "temperature": 0.2,
+ "messages": [
+ {
+ "role": "user",
+ "content": [
+ {"type": "text", "text": prompt},
+ {"type": "image_url", "image_url": {"url": image_data_url(chat_image)}},
+ ],
+ }
+ ],
+ }
+ data = json.dumps(payload).encode("utf-8")
+ req = urlrequest.Request(
+ base_url + "/chat/completions",
+ data=data,
+ headers={"Authorization": "Bearer " + api_key, "Content-Type": "application/json"},
+ method="POST",
+ )
+ with urlrequest.urlopen(req, timeout=90) as response:
+ body = json.loads(response.read().decode("utf-8"))
+ choices = body.get("choices") or []
+ if not choices:
+ raise RuntimeError("LLM returned no choices")
+ content = choices[0].get("message", {}).get("content", "")
+ return extract_json_object(content)
+
+
+def extract_json_object(text):
+ stripped = text.strip()
+ if stripped.startswith("```json"):
+ stripped = stripped[len("```json"):].strip()
+ if stripped.startswith("```"):
+ stripped = stripped[3:].strip()
+ if stripped.endswith("```"):
+ stripped = stripped[:-3].strip()
+ start = stripped.find("{")
+ end = stripped.rfind("}")
+ if start >= 0 and end > start:
+ stripped = stripped[start:end + 1]
+ try:
+ return json.loads(stripped)
+ except json.JSONDecodeError:
+ return {"raw_text": text[:1200]}
+
+
+def scaled_box(region, annotation, image_size, window):
+ # Annotation bbox_image is stored in physical pixels at annotation-time
+ # scaleFactor. Convert back to logical window coords, then map to the
+ # current capture's physical pixels. This stays accurate when the WeChat
+ # window is moved or resized horizontally.
+ annotation_scale = region.get("scaleFactor") or annotation.get("scaleFactor") or 1
+ current_scale_x = image_size[0] / max(window["width"], 1)
+ current_scale_y = image_size[1] / max(window["height"], 1)
+ x1, y1, x2, y2 = region["bbox_image"]
+ return [
+ int((x1 / annotation_scale) * current_scale_x),
+ int((y1 / annotation_scale) * current_scale_y),
+ int((x2 / annotation_scale) * current_scale_x),
+ int((y2 / annotation_scale) * current_scale_y),
+ ]
+
+
+def clamp_box(box, image_size):
+ width, height = image_size
+ x1, y1, x2, y2 = box
+ x1, x2 = sorted((x1, x2))
+ y1, y2 = sorted((y1, y2))
+ return [max(0, x1), max(0, y1), min(width, x2), min(height, y2)]
+
+
+def valid_box(box):
+ return box and len(box) == 4 and box[2] > box[0] and box[3] > box[1]
+
+
+def components(mask, min_area=12):
+ h, w = mask.shape
+ visited = np.zeros_like(mask, dtype=bool)
+ found = []
+ ys, xs = np.where(mask)
+ for start_x, start_y in zip(xs, ys):
+ if visited[start_y, start_x]:
+ continue
+ stack = [(start_x, start_y)]
+ visited[start_y, start_x] = True
+ area = 0
+ min_x = max_x = start_x
+ min_y = max_y = start_y
+ while stack:
+ x, y = stack.pop()
+ area += 1
+ min_x, max_x = min(min_x, x), max(max_x, x)
+ min_y, max_y = min(min_y, y), max(max_y, y)
+ for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
+ if 0 <= nx < w and 0 <= ny < h and mask[ny, nx] and not visited[ny, nx]:
+ visited[ny, nx] = True
+ stack.append((nx, ny))
+ if area >= min_area:
+ found.append({"bbox": [min_x, min_y, max_x + 1, max_y + 1], "area": area})
+ return found
+
+
+def detect_red_badges(image, contact_box):
+ x1, y1, x2, y2 = contact_box
+ crop = np.asarray(image.crop((x1, y1, x2, y2)).convert("RGB"))
+ r, g, b = crop[:, :, 0], crop[:, :, 1], crop[:, :, 2]
+ mask = (r > 175) & (g < 105) & (b < 105) & ((r.astype(int) - g.astype(int)) > 65) & ((r.astype(int) - b.astype(int)) > 65)
+ digit_mask = (r > 215) & (g > 215) & (b > 215) & (np.maximum.reduce([r, g, b]) - np.minimum.reduce([r, g, b]) < 42)
+ results = []
+ for comp in components(mask, min_area=18):
+ bx1, by1, bx2, by2 = comp["bbox"]
+ bw, bh = bx2 - bx1, by2 - by1
+ if not (6 <= bw <= 52 and 6 <= bh <= 52):
+ continue
+ if comp["area"] / max(bw * bh, 1) < 0.22:
+ continue
+ pad_x = max(2, bw // 5)
+ pad_y = max(2, bh // 5)
+ inner_x1 = min(bx2, bx1 + pad_x)
+ inner_y1 = min(by2, by1 + pad_y)
+ inner_x2 = max(inner_x1, bx2 - pad_x)
+ inner_y2 = max(inner_y1, by2 - pad_y)
+ digit_pixels = int(digit_mask[inner_y1:inner_y2, inner_x1:inner_x2].sum())
+ # Require white digit strokes inside the red badge. This intentionally
+ # ignores plain red dots and unrelated red UI fragments.
+ if digit_pixels < 3:
+ continue
+ gx1, gy1, gx2, gy2 = x1 + bx1, y1 + by1, x1 + bx2, y1 + by2
+ center_y = (gy1 + gy2) // 2
+ # WeChat conversation rows are roughly 64-76 logical px tall. The
+ # captured image is usually Retina scale 2, so 132 image px is a good
+ # first approximation and less noisy than deriving height from badge.
+ row_h = 132
+ row = [x1, max(y1, center_y - row_h // 2), x2, min(y2, center_y + row_h // 2)]
+ click = [(row[0] + row[2]) // 2, (row[1] + row[2] * 0 + row[3]) // 2]
+ results.append({"type": "new_message_badge", "badge_box": [gx1, gy1, gx2, gy2], "contact_row": row, "click_image": click, "score": round(float(comp["area"] / max(bw * bh, 1)), 3), "digit_pixels": digit_pixels})
+ return results
+
+
+def detect_chat_change(image, previous_gray, chat_box, threshold):
+ x1, y1, x2, y2 = chat_box
+ gray = np.asarray(image.crop((x1, y1, x2, y2)).convert("L"), dtype=np.int16)
+ if previous_gray is None or previous_gray.shape != gray.shape:
+ return gray, []
+ diff = np.abs(gray - previous_gray)
+ mask = diff > threshold
+ crop_h, crop_w = gray.shape
+ changed_ratio = float(mask.sum()) / max(crop_w * crop_h, 1)
+ if changed_ratio > 0.28:
+ # Window moves, scrolls, or large reflows are not new-message events.
+ return gray, []
+ results = []
+ for comp in components(mask, min_area=120):
+ bx1, by1, bx2, by2 = comp["bbox"]
+ bw, bh = bx2 - bx1, by2 - by1
+ if bw < 24 or bh < 10:
+ continue
+ if by1 < crop_h * 0.38:
+ continue
+ if comp["area"] > crop_w * crop_h * 0.18:
+ continue
+ # Focus on localized lower-area changes where new messages appear.
+ gx1, gy1, gx2, gy2 = x1 + bx1, y1 + by1, x1 + bx2, y1 + by2
+ results.append({"type": "current_chat_new_message", "change_box": [gx1, gy1, gx2, gy2], "area": comp["area"], "changed_ratio": round(changed_ratio, 4)})
+ return gray, results[:5]
+
+
+def image_to_screen_box(box, window, image_size):
+ scale_x = image_size[0] / max(window["width"], 1)
+ scale_y = image_size[1] / max(window["height"], 1)
+ x1, y1, x2, y2 = box
+ return [round(window["x"] + x1 / scale_x, 2), round(window["y"] + y1 / scale_y, 2), round(window["x"] + x2 / scale_x, 2), round(window["y"] + y2 / scale_y, 2)]
+
+
+def point_to_screen(point, window, image_size):
+ scale_x = image_size[0] / max(window["width"], 1)
+ scale_y = image_size[1] / max(window["height"], 1)
+ return [round(window["x"] + point[0] / scale_x, 2), round(window["y"] + point[1] / scale_y, 2)]
+
+
+def box_center_screen(box, window, image_size):
+ cx = (box[0] + box[2]) / 2
+ cy = (box[1] + box[3]) / 2
+ return point_to_screen([cx, cy], window, image_size)
+
+
+def text_chunks(text, chunk_size):
+ runes = list(text)
+ size = max(1, chunk_size)
+ for index in range(0, len(runes), size):
+ yield "".join(runes[index:index + size])
+
+
+def send_reply_text(reply_text, input_box, window, image_size, chunk_size=2, typing_delay=0.08):
+ text = (reply_text or "").strip()
+ if not text:
+ return {"send_status": "skipped", "send_error": "empty reply_text"}
+ if not valid_box(input_box):
+ return {"send_status": "failed", "send_error": "invalid input_box"}
+ point = box_center_screen(input_box, window, image_size)
+ pyautogui.click(point[0], point[1])
+ time.sleep(0.15)
+ chunk_count = 0
+ for chunk in text_chunks(text, chunk_size):
+ pyperclip.copy(chunk)
+ pyautogui.hotkey("command", "v")
+ chunk_count += 1
+ time.sleep(max(0.01, typing_delay))
+ pyautogui.press("enter")
+ return {"send_status": "sent", "send_error": "", "input_click_screen": point, "typing_mode": "chunked_paste", "typing_chunk_size": max(1, chunk_size), "typing_chunks": chunk_count}
+
+
+def decorate_events(events, window, image_size):
+ decorated = []
+ for event in events:
+ item = dict(event)
+ for key in ("badge_box", "contact_row", "change_box"):
+ if key in item:
+ item[key + "_screen"] = image_to_screen_box(item[key], window, image_size)
+ if "click_image" in item:
+ item["click_screen"] = point_to_screen(item["click_image"], window, image_size)
+ item["dry_run"] = True
+ decorated.append(item)
+ return decorated
+
+
+def draw_events(image, events, boxes):
+ draw = ImageDraw.Draw(image)
+ for name, box in boxes.items():
+ if not valid_box(box):
+ continue
+ color = {"contact_list": "#666666", "chat_content": "#5555ff", "input_box": "#22c55e", "message_button": "#888888"}.get(name, "#666666")
+ draw.rectangle(box, outline=color, width=2)
+ draw.text((box[0] + 4, box[1] + 4), name, fill=color)
+ for event in events:
+ if event["type"] == "new_message_badge":
+ if valid_box(event.get("badge_box")):
+ draw.rectangle(event["badge_box"], outline="red", width=4)
+ if valid_box(event.get("contact_row")):
+ draw.rectangle(event["contact_row"], outline="yellow", width=4)
+ draw.text((event["contact_row"][0] + 8, event["contact_row"][1] + 8), "new message", fill="yellow")
+ elif event["type"] in {"chat_content_changed", "current_chat_new_message"}:
+ if valid_box(event.get("change_box")):
+ draw.rectangle(event["change_box"], outline="#00aaff", width=3)
+ draw.text((event["change_box"][0] + 8, event["change_box"][1] + 8), "chat diff", fill="#00aaff")
+ return image
+
+
+def draw_overlay(image, event):
+ draw = ImageDraw.Draw(image)
+ text = f"frame={event.get('frame_index')} badges={event.get('badge_count')} changes={event.get('change_count')} click={event.get('click_enabled')} llm={event.get('llm_enabled')}"
+ draw.rectangle((12, 12, 980, 56), fill=(0, 0, 0))
+ draw.text((22, 24), text, fill=(80, 255, 120))
+ return image
+
+
+def html_page():
+ return """
+
+
+
+
+ WeChat Monitor
+
+
+
+
+

+
+
+
+
+"""
+
+
+def make_handler(state):
+ class Handler(BaseHTTPRequestHandler):
+ def log_message(self, format, *args):
+ return
+
+ def do_GET(self):
+ if self.path == "/" or self.path.startswith("/index.html"):
+ data = html_page().encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+ return
+ if self.path.startswith("/latest.json"):
+ _, event, sequence = state.snapshot()
+ data = json.dumps(json_safe({"sequence": sequence, "updated_at": utc_now(), "event": event or {"type": "waiting"}}), ensure_ascii=False).encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Cache-Control", "no-store")
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+ return
+ if self.path.startswith("/stream.mjpg"):
+ self.send_response(200)
+ self.send_header("Cache-Control", "no-cache, private")
+ self.send_header("Pragma", "no-cache")
+ self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
+ self.end_headers()
+ last_sequence = -1
+ while True:
+ with state.condition:
+ state.condition.wait_for(lambda: state.sequence != last_sequence, timeout=5)
+ jpeg, last_sequence = state.jpeg, state.sequence
+ if not jpeg:
+ continue
+ try:
+ self.wfile.write(b"--frame\r\nContent-Type: image/jpeg\r\n")
+ self.wfile.write(f"Content-Length: {len(jpeg)}\r\n\r\n".encode("ascii"))
+ self.wfile.write(jpeg)
+ self.wfile.write(b"\r\n")
+ except (BrokenPipeError, ConnectionResetError):
+ break
+ return
+ self.send_error(404)
+ return Handler
+
+
+def start_server(host, port, state):
+ server = ThreadingHTTPServer((host, port), make_handler(state))
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+ return server
+
+
+def boxes_for_frame(annotation, regions, image, window):
+ boxes = {name: clamp_box(scaled_box(regions[name], annotation, image.size, window), image.size) for name in ("contact_list", "chat_content")}
+ if "input_box" in regions:
+ boxes["input_box"] = clamp_box(scaled_box(regions["input_box"], annotation, image.size, window), image.size)
+ if "message_button" in regions:
+ boxes["message_button"] = clamp_box(scaled_box(regions["message_button"], annotation, image.size, window), image.size)
+ return boxes
+
+
+def preview_loop(args, annotation, regions, stream_state, shared):
+ interval = 1 / max(args.fps, 1)
+ frame_index = 0
+ while not shared.stop.is_set() and (args.max_frames <= 0 or frame_index < args.max_frames):
+ start = time.time()
+ window = find_wechat_window()
+ if not window:
+ event = {"type": "wechat_window_not_found", "timestamp": utc_now()}
+ stream_state.update(image_to_jpeg_bytes(Image.new("RGB", (960, 540), "black")), event)
+ time.sleep(interval)
+ continue
+
+ image = capture_window_image(window["id"])
+ if image is None:
+ time.sleep(interval)
+ continue
+
+ frame_index += 1
+ boxes = boxes_for_frame(annotation, regions, image, window)
+ shared.update_frame(image, window, boxes, frame_index)
+ _, _, _, _, detection, latest_llm = shared.snapshot()
+ badge_events = detection.get("badge_events", [])
+ change_events = detection.get("change_events", [])
+ event = {
+ "type": "algorithm_monitor",
+ "timestamp": utc_now(),
+ "frame_index": frame_index,
+ "badge_count": len(badge_events),
+ "change_count": len(change_events),
+ "events": detection.get("events", []),
+ "window": window,
+ "dry_run": not args.click_on_badge,
+ "click_enabled": args.click_on_badge,
+ "llm_enabled": args.llm_on_click,
+ "preview_only": True,
+ }
+ if latest_llm:
+ event["latest_llm"] = latest_llm
+ annotated = draw_events(image.copy(), badge_events + change_events, boxes)
+ annotated = draw_overlay(annotated, event)
+ stream_state.update(image_to_jpeg_bytes(annotated), event)
+ time.sleep(max(0, interval - (time.time() - start)))
+ shared.stop.set()
+
+
+def llm_worker(args, annotation, regions, agent_config, shared, job_event):
+ last_click_at = {}
+ last_chat_change_at = 0.0
+ while not shared.stop.is_set():
+ if not job_event.wait(timeout=0.5):
+ continue
+ job_event.clear()
+ image, window, boxes, frame_index, detection, _ = shared.snapshot()
+ if image is None or window is None:
+ continue
+ target = next((item for item in detection.get("events", []) if item.get("type") == "new_message_badge"), None)
+ chat_change = next((item for item in detection.get("events", []) if item.get("type") == "current_chat_new_message"), None)
+ trigger = "unread_badge" if target else ("current_chat_change" if chat_change else "")
+ if not trigger:
+ continue
+
+ if trigger == "unread_badge":
+ click_key = str(round(target["click_screen"][1] / 10) * 10)
+ if time.time() - last_click_at.get(click_key, 0) < args.click_cooldown:
+ continue
+ if args.click_on_badge:
+ pyautogui.click(target["click_screen"][0], target["click_screen"][1])
+ last_click_at[click_key] = time.time()
+ else:
+ continue
+ if not args.llm_on_click:
+ continue
+ elif trigger == "current_chat_change":
+ if not args.llm_on_chat_change:
+ continue
+ if time.time() - last_chat_change_at < args.chat_change_cooldown:
+ continue
+ last_chat_change_at = time.time()
+
+ time.sleep(args.after_click_wait)
+ refreshed_window = find_wechat_window() or window
+ refreshed_image = capture_window_image(refreshed_window["id"])
+ if refreshed_image is None:
+ continue
+ refreshed_chat_box = clamp_box(scaled_box(regions["chat_content"], annotation, refreshed_image.size, refreshed_window), refreshed_image.size)
+ if not valid_box(refreshed_chat_box):
+ continue
+ x1, y1, x2, y2 = refreshed_chat_box
+ chat_crop = refreshed_image.crop((x1, y1, x2, y2))
+ try:
+ reading = request_chat_reading(agent_config, chat_crop)
+ reading["send_status"] = "not_sent"
+ reading["trigger"] = trigger
+ should_send = args.send_reply and (trigger == "unread_badge" or args.reply_on_chat_change)
+ if should_send:
+ input_region = regions.get("input_box")
+ if input_region:
+ input_box = clamp_box(scaled_box(input_region, annotation, refreshed_image.size, refreshed_window), refreshed_image.size)
+ send_result = send_reply_text(
+ reading.get("reply_text", ""),
+ input_box,
+ refreshed_window,
+ refreshed_image.size,
+ args.typing_chunk_size,
+ args.typing_delay,
+ )
+ reading.update(send_result)
+ else:
+ reading["send_status"] = "failed"
+ reading["send_error"] = "missing input_box region"
+ shared.update_llm(reading)
+ emit_llm_reading(reading)
+ except Exception as error:
+ emit({"type": "llm_error", "timestamp": utc_now(), "error": str(error)})
+
+
+def detection_loop(args, shared, job_event):
+ previous_chat_gray = None
+ interval = 1.0
+ while not shared.stop.is_set():
+ start = time.time()
+ image, window, boxes, frame_index, _, _ = shared.snapshot()
+ if image is not None and window is not None and "contact_list" in boxes and "chat_content" in boxes:
+ badge_events = detect_red_badges(image, boxes["contact_list"])
+ previous_chat_gray, change_events = detect_chat_change(image, previous_chat_gray, boxes["chat_content"], args.chat_diff_threshold)
+ events = decorate_events(badge_events + change_events, window, image.size)
+ detection = {
+ "timestamp": utc_now(),
+ "frame_index": frame_index,
+ "events": events,
+ "badge_events": badge_events,
+ "change_events": change_events,
+ }
+ shared.update_detection(detection)
+ event = {
+ "type": "algorithm_detection",
+ "timestamp": utc_now(),
+ "frame_index": frame_index,
+ "badge_count": len(badge_events),
+ "change_count": len(change_events),
+ "events": events,
+ "window": window,
+ "detect_interval_sec": interval,
+ }
+ if badge_events or change_events:
+ emit(event)
+ if badge_events and args.click_on_badge:
+ job_event.set()
+ elif change_events and args.llm_on_chat_change:
+ job_event.set()
+ time.sleep(max(0, interval - (time.time() - start)))
+
+
+def run(args):
+ annotation, regions = load_regions(args.regions)
+ agent_config = load_agent_config(args.agent_config) if args.llm_on_click else None
+ required = ["contact_list", "chat_content"]
+ missing = [name for name in required if name not in regions]
+ if missing:
+ raise RuntimeError(f"missing required regions: {missing}")
+ state = StreamState()
+ server = start_server(args.host, args.port, state)
+ url = f"http://{args.host}:{args.port}/"
+ emit({"type": "server_started", "url": url, "optimized": True})
+ if args.open_browser:
+ webbrowser.open(url)
+
+ shared = SharedFrame()
+ job_event = threading.Event()
+ threads = [
+ threading.Thread(target=preview_loop, args=(args, annotation, regions, state, shared), daemon=True),
+ threading.Thread(target=detection_loop, args=(args, shared, job_event), daemon=True),
+ ]
+ if args.click_on_badge and args.llm_on_click:
+ threads.append(threading.Thread(target=llm_worker, args=(args, annotation, regions, agent_config, shared, job_event), daemon=True))
+ for thread in threads:
+ thread.start()
+ try:
+ while not shared.stop.is_set():
+ time.sleep(0.5)
+ finally:
+ shared.stop.set()
+ server.shutdown()
+
+
+def main():
+ def stop_handler(signum, frame):
+ raise KeyboardInterrupt
+ signal.signal(signal.SIGTERM, stop_handler)
+ signal.signal(signal.SIGINT, stop_handler)
+ args = parse_args()
+ try:
+ run(args)
+ except KeyboardInterrupt:
+ emit({"type": "stopped", "timestamp": utc_now()})
+
+
+if __name__ == "__main__":
+ main()
diff --git a/wechat_vision/wechat_window_live.py b/wechat_vision/wechat_window_live.py
new file mode 100644
index 0000000..70fc7a2
--- /dev/null
+++ b/wechat_vision/wechat_window_live.py
@@ -0,0 +1,408 @@
+import argparse
+import json
+import signal
+import subprocess
+import threading
+import time
+import webbrowser
+from datetime import datetime, timezone
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from io import BytesIO
+from pathlib import Path
+
+import numpy as np
+import onnxruntime as ort
+from PIL import Image, ImageDraw
+
+import Quartz
+
+from ffmpeg_realtime_detect import (
+ CLASS_NAMES,
+ MODEL_PATH,
+ detect,
+ detection_event,
+ draw_detections,
+ image_to_jpeg_bytes,
+ no_detection_event,
+)
+
+
+class StreamState:
+ def __init__(self):
+ self.condition = threading.Condition()
+ self.jpeg = None
+ self.event = None
+ self.sequence = 0
+
+ def update(self, jpeg, event):
+ with self.condition:
+ self.jpeg = jpeg
+ self.event = event
+ self.sequence += 1
+ self.condition.notify_all()
+
+ def snapshot(self):
+ with self.condition:
+ return self.jpeg, self.event, self.sequence
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(
+ description="Capture only the WeChat window, run ONNX, and expose a live stream."
+ )
+ parser.add_argument("--model", default=str(MODEL_PATH), help="ONNX model path")
+ parser.add_argument("--fps", type=float, default=2.0, help="capture FPS")
+ parser.add_argument(
+ "--infer-interval",
+ type=float,
+ default=3.0,
+ help="seconds between ONNX inference runs; preview frames reuse the latest detections",
+ )
+ parser.add_argument(
+ "--log-every",
+ type=int,
+ default=60,
+ help="log every N preview frames in addition to inference frames; 0 disables periodic logs",
+ )
+ parser.add_argument("--confidence", type=float, default=0.05, help="minimum score")
+ parser.add_argument("--target-class", type=int, default=0, help="target class id")
+ parser.add_argument("--host", default="127.0.0.1", help="HTTP host")
+ parser.add_argument("--port", type=int, default=8765, help="HTTP port")
+ parser.add_argument("--max-frames", type=int, default=0, help="stop after N frames; 0 runs forever")
+ parser.add_argument("--open-browser", action="store_true", help="open the live preview in browser")
+ parser.add_argument("--open-ffplay", action="store_true", help="open ffplay for the MJPEG stream")
+ return parser.parse_args()
+
+
+def utc_now():
+ return datetime.now(timezone.utc).isoformat()
+
+
+def emit(event):
+ print(json.dumps(event, ensure_ascii=False, separators=(",", ":")), flush=True)
+
+
+def find_wechat_window():
+ options = Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements
+ windows = Quartz.CGWindowListCopyWindowInfo(options, Quartz.kCGNullWindowID) or []
+ for window in windows:
+ owner = window.get(Quartz.kCGWindowOwnerName, "") or ""
+ title = window.get(Quartz.kCGWindowName, "") or ""
+ layer = window.get(Quartz.kCGWindowLayer, -1)
+ if layer != 0:
+ continue
+ if owner == "微信" or owner == "WeChat" or "wechat" in owner.lower() or title == "微信":
+ bounds = window.get(Quartz.kCGWindowBounds, {}) or {}
+ return {
+ "id": int(window.get(Quartz.kCGWindowNumber)),
+ "owner": owner,
+ "title": title,
+ "x": float(bounds.get("X", 0)),
+ "y": float(bounds.get("Y", 0)),
+ "width": float(bounds.get("Width", 0)),
+ "height": float(bounds.get("Height", 0)),
+ }
+ return None
+
+
+def capture_window_image(window_id):
+ image_ref = Quartz.CGWindowListCreateImage(
+ Quartz.CGRectNull,
+ Quartz.kCGWindowListOptionIncludingWindow,
+ window_id,
+ Quartz.kCGWindowImageBoundsIgnoreFraming,
+ )
+ if image_ref is None:
+ return None
+
+ width = Quartz.CGImageGetWidth(image_ref)
+ height = Quartz.CGImageGetHeight(image_ref)
+ bytes_per_row = Quartz.CGImageGetBytesPerRow(image_ref)
+ provider = Quartz.CGImageGetDataProvider(image_ref)
+ data = Quartz.CGDataProviderCopyData(provider)
+ array = np.frombuffer(data, dtype=np.uint8)
+ array = array.reshape((height, bytes_per_row))[:, : width * 4]
+ bgra = array.reshape((height, width, 4))
+ rgba = bgra[:, :, [2, 1, 0, 3]]
+ return Image.fromarray(rgba, "RGBA").convert("RGB")
+
+
+def screen_event(event, window):
+ event = dict(event)
+ event["window"] = window
+ frame_width, frame_height = event.get("frame_size") or [window["width"], window["height"]]
+ scale_x = frame_width / max(window["width"], 1)
+ scale_y = frame_height / max(window["height"], 1)
+ if event.get("bbox_screen"):
+ x1, y1, x2, y2 = event["bbox_screen"]
+ event["bbox_image"] = [x1, y1, x2, y2]
+ wx1, wy1, wx2, wy2 = x1 / scale_x, y1 / scale_y, x2 / scale_x, y2 / scale_y
+ event["bbox_window"] = [round(wx1, 2), round(wy1, 2), round(wx2, 2), round(wy2, 2)]
+ event["bbox_screen"] = [
+ round(window["x"] + wx1, 2),
+ round(window["y"] + wy1, 2),
+ round(window["x"] + wx2, 2),
+ round(window["y"] + wy2, 2),
+ ]
+ if event.get("center_screen"):
+ cx, cy = event["center_screen"]
+ event["center_image"] = [cx, cy]
+ wcx, wcy = cx / scale_x, cy / scale_y
+ event["center_window"] = [round(wcx, 2), round(wcy, 2)]
+ event["center_screen"] = [round(window["x"] + wcx, 2), round(window["y"] + wcy, 2)]
+ return event
+
+
+def event_from_detections(detections, frame_index, frame_size, class_name, target_class):
+ if len(detections) > 0:
+ best = detections[np.argmax(detections[:, 4])]
+ return detection_event(best, frame_index, frame_size, class_name)
+ return no_detection_event(frame_index, frame_size, target_class, class_name)
+
+
+def draw_overlay(image, event):
+ draw = ImageDraw.Draw(image)
+ text = f"frame={event.get('frame_index', '-')} {event.get('timestamp', '')}"
+ draw.rectangle((12, 12, 720, 56), fill=(0, 0, 0))
+ draw.text((22, 24), text, fill=(80, 255, 120))
+ return image
+
+
+def html_page():
+ return """
+
+
+
+
+ WeChat Window Live
+
+
+
+
+

+
+
+
+
+
+"""
+
+
+def make_handler(state):
+ class Handler(BaseHTTPRequestHandler):
+ def log_message(self, format, *args):
+ return
+
+ def do_HEAD(self):
+ if self.path == "/" or self.path.startswith("/index.html"):
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ return
+ if self.path.startswith("/latest.json"):
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Cache-Control", "no-store")
+ self.end_headers()
+ return
+ if self.path.startswith("/stream.mjpg"):
+ self.send_response(200)
+ self.send_header("Cache-Control", "no-cache, private")
+ self.send_header("Pragma", "no-cache")
+ self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
+ self.end_headers()
+ return
+ self.send_error(404)
+
+ def do_GET(self):
+ if self.path == "/" or self.path.startswith("/index.html"):
+ data = html_page().encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Cache-Control", "no-store")
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+ return
+ if self.path.startswith("/latest.json"):
+ _, event, sequence = state.snapshot()
+ payload = {"sequence": sequence, "updated_at": utc_now(), "event": event or {"type": "waiting"}}
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Cache-Control", "no-store")
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+ return
+ if self.path.startswith("/stream.mjpg"):
+ self.send_response(200)
+ self.send_header("Cache-Control", "no-cache, private")
+ self.send_header("Pragma", "no-cache")
+ self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
+ self.end_headers()
+ last_sequence = -1
+ while True:
+ with state.condition:
+ state.condition.wait_for(lambda: state.sequence != last_sequence, timeout=5)
+ jpeg, _, last_sequence = state.jpeg, state.event, state.sequence
+ if not jpeg:
+ continue
+ try:
+ self.wfile.write(b"--frame\r\n")
+ self.wfile.write(b"Content-Type: image/jpeg\r\n")
+ self.wfile.write(f"Content-Length: {len(jpeg)}\r\n\r\n".encode("ascii"))
+ self.wfile.write(jpeg)
+ self.wfile.write(b"\r\n")
+ except (BrokenPipeError, ConnectionResetError):
+ break
+ return
+ self.send_error(404)
+
+ return Handler
+
+
+def start_server(host, port, state):
+ server = ThreadingHTTPServer((host, port), make_handler(state))
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
+ thread.start()
+ return server
+
+
+def open_ffplay(url):
+ return subprocess.Popen(
+ ["ffplay", "-hide_banner", "-loglevel", "error", "-fflags", "nobuffer", "-flags", "low_delay", "-framedrop", url],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+
+
+def run(args):
+ state = StreamState()
+ server = start_server(args.host, args.port, state)
+ url = f"http://{args.host}:{args.port}/"
+ stream_url = f"http://{args.host}:{args.port}/stream.mjpg"
+ emit({"type": "server_started", "url": url, "stream_url": stream_url})
+ if args.open_browser:
+ webbrowser.open(url)
+ ffplay_process = open_ffplay(stream_url) if args.open_ffplay else None
+
+ session = ort.InferenceSession(str(Path(args.model)))
+ input_meta = session.get_inputs()[0]
+ class_name = CLASS_NAMES.get(args.target_class, f"class_{args.target_class}")
+ interval = 1 / max(args.fps, 0.1)
+ frame_index = 0
+ last_infer_at = 0.0
+ last_infer_size = None
+ last_detections = np.empty((0, 6))
+ try:
+ while args.max_frames <= 0 or frame_index < args.max_frames:
+ start = time.time()
+ window = find_wechat_window()
+ if not window:
+ event = {"type": "wechat_window_not_found", "timestamp": utc_now()}
+ state.update(image_to_jpeg_bytes(Image.new("RGB", (960, 540), "black")), event)
+ emit(event)
+ time.sleep(interval)
+ continue
+
+ image = capture_window_image(window["id"])
+ if image is None:
+ event = {"type": "capture_failed", "timestamp": utc_now(), "window": window}
+ emit(event)
+ time.sleep(interval)
+ continue
+
+ frame_index += 1
+ now = time.time()
+ should_infer = (
+ now - last_infer_at >= args.infer_interval
+ or last_infer_size != image.size
+ or frame_index == 1
+ )
+ if should_infer:
+ last_detections = detect(session, input_meta, image, args.target_class, args.confidence)
+ last_infer_at = now
+ last_infer_size = image.size
+
+ event = event_from_detections(
+ last_detections,
+ frame_index,
+ image.size,
+ class_name,
+ args.target_class,
+ )
+ event["inferred"] = should_infer
+ event["inference_age_ms"] = round((now - last_infer_at) * 1000, 1)
+ event = screen_event(event, window)
+ annotated = draw_detections(image, last_detections, class_name)
+ annotated = draw_overlay(annotated, event)
+ state.update(image_to_jpeg_bytes(annotated), event)
+ if should_infer or (args.log_every > 0 and frame_index % args.log_every == 0):
+ emit(event)
+
+ elapsed = time.time() - start
+ time.sleep(max(0, interval - elapsed))
+ finally:
+ server.shutdown()
+ if ffplay_process:
+ ffplay_process.terminate()
+
+
+def main():
+ def stop_handler(signum, frame):
+ raise KeyboardInterrupt
+
+ signal.signal(signal.SIGTERM, stop_handler)
+ signal.signal(signal.SIGINT, stop_handler)
+ args = parse_args()
+ try:
+ run(args)
+ except KeyboardInterrupt:
+ emit({"type": "stopped", "timestamp": utc_now()})
+
+
+if __name__ == "__main__":
+ main()