This commit is contained in:
HuangHai
2026-01-25 17:08:40 +08:00
parent 8292bf83d1
commit 19803f96a8
7 changed files with 185 additions and 202 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

View File

@@ -1,12 +1,13 @@
# coding=utf-8 # coding=utf-8
import uiautomator2 as u2
import time
import logging
import sys
import os
import asyncio import asyncio
import logging
import os
import sys
import time
from datetime import datetime from datetime import datetime
import uiautomator2 as u2
# 添加项目根目录到 sys.path # 添加项目根目录到 sys.path
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if project_root not in sys.path: if project_root not in sys.path:
@@ -14,7 +15,7 @@ if project_root not in sys.path:
from Util import Win32Patch from Util import Win32Patch
from WeiXin.WxUtil import find_input_box_center, perform_input_action, get_vlm_analysis, clean_screenshots_dir, is_in_chat_interface, get_vlm_json, find_template_match, find_all_template_matches from WeiXin.WxUtil import perform_input_action, clean_screenshots_dir, is_in_chat_interface, find_template_match, find_all_template_matches
from Util.LlmUtil import get_llm_response from Util.LlmUtil import get_llm_response
from Util.EasyOcrKit import EasyOcrKit from Util.EasyOcrKit import EasyOcrKit
@@ -60,8 +61,7 @@ class ChatBot:
if not os.path.exists(self.screenshot_dir): if not os.path.exists(self.screenshot_dir):
os.makedirs(self.screenshot_dir) os.makedirs(self.screenshot_dir)
# 强制使用 CPU 模式以避免 0xC0000409 (Stack Buffer Overrun) 崩溃 self.ocr_kit = EasyOcrKit(gpu=True)
self.ocr_kit = EasyOcrKit(gpu=False)
self.is_first_run = True # 首次运行标志 self.is_first_run = True # 首次运行标志
@@ -122,14 +122,27 @@ class ChatBot:
convert_template = r"d:\dsWork\aiData\WeiXin\Templates\zhun_wen_zi.jpg" convert_template = r"d:\dsWork\aiData\WeiXin\Templates\zhun_wen_zi.jpg"
logger.info(f"🔍 寻找模板: {convert_template}") logger.info(f"🔍 寻找模板: {convert_template}")
convert_btn = find_template_match(menu_shot_path, convert_template, threshold=0.7) convert_btn = find_template_match(menu_shot_path, convert_template, threshold=0.6)
if not convert_btn: if not convert_btn:
logger.warning("❌ CV 未找到 '转文字' 按钮,取消操作。") logger.warning("❌ CV 未找到 '转文字' 按钮,尝试小范围 OCR 兜底...")
self.d.click(vx + 200, vy) # 点击空白处关闭菜单 # 尝试在该区域进行 OCR 识别,寻找 "转文字" 三个字
ocr_results_menu = self.ocr_kit.read_text(menu_shot_path)
for bbox, text, conf in ocr_results_menu:
if "转文字" in text or "转文" in text or "文字" in text:
cx = (bbox[0][0] + bbox[2][0]) / 2
cy = (bbox[0][1] + bbox[2][1]) / 2
convert_btn = (cx, cy)
logger.info(f"✅ OCR 兜底找到 '转文字' 按钮: {convert_btn}")
break
if not convert_btn:
logger.warning("❌ CV 和 OCR 均未找到 '转文字' 按钮,取消操作。")
# 点击屏幕中心区域的空白处关闭菜单,避免点到顶部返回键
self.d.click(500, 500)
return None return None
logger.info(f"✅ CV 找到 '转文字' 按钮: {convert_btn}") logger.info(f"✅ 最终找到 '转文字' 按钮坐标: {convert_btn}")
self.d.click(convert_btn[0], convert_btn[1]) self.d.click(convert_btn[0], convert_btn[1])
# 3. 动态等待转换 # 3. 动态等待转换
@@ -221,14 +234,14 @@ class ChatBot:
self.d.screenshot(menu_shot_path_cancel) self.d.screenshot(menu_shot_path_cancel)
cancel_template = r"d:\dsWork\aiData\WeiXin\Templates\cancel_zhuan_wen_zi.jpg" cancel_template = r"d:\dsWork\aiData\WeiXin\Templates\cancel_zhuan_wen_zi.jpg"
cancel_btn = find_template_match(menu_shot_path_cancel, cancel_template, threshold=0.7) cancel_btn = find_template_match(menu_shot_path_cancel, cancel_template, threshold=0.6)
if cancel_btn: if cancel_btn:
logger.info(f"✅ CV 找到 '取消转文字' 按钮: {cancel_btn}") logger.info(f"✅ CV 找到 '取消转文字' 按钮: {cancel_btn}")
self.d.click(cancel_btn[0], cancel_btn[1]) self.d.click(cancel_btn[0], cancel_btn[1])
else: else:
logger.warning("❌ CV 未找到 '取消转文字' 按钮,尝试点击空白处关闭菜单。") logger.warning("❌ CV 未找到 '取消转文字' 按钮,点击中心区域关闭菜单。")
self.d.click(vx + 200, vy) self.d.click(500, 500)
return full_text return full_text
@@ -242,6 +255,12 @@ class ChatBot:
while True: while True:
try: try:
# 0.5 检查是否在聊天界面
if not is_in_chat_interface(self.d):
logger.warning("📵 当前不在聊天界面,跳过扫描...")
await asyncio.sleep(CHECK_INTERVAL)
continue
logger.info("🔍 正在扫描当前界面内容...") logger.info("🔍 正在扫描当前界面内容...")
# 1. 截图 # 1. 截图
@@ -261,202 +280,156 @@ class ChatBot:
last_screen_md5 = current_md5 last_screen_md5 = current_md5
# 2. VLM 分析 # 2. 本地视觉分析 (替代 VLM)
logger.info("🧠 正在调用 VLM 分析图片...") logger.info("<EFBFBD>️ 正在进行本地视觉扫描...")
result_data = await get_vlm_analysis(tmp_shot)
if not result_data: # A. 寻找语音图标 (audio.jpg) 和 红点 (red_point.jpg)
logger.warning("⚠️ VLM 分析返回为空,跳过本次循环。") audio_template = r"d:\dsWork\aiData\WeiXin\Templates\audio.jpg"
await asyncio.sleep(CHECK_INTERVAL)
continue
# 3. 解析数据构建 dialogue_log
messages = result_data.get("messages", [])
# 🚨 关键修正:按 Y 坐标对消息进行排序,确保时间顺序正确
# VLM 返回的顺序可能不准,必须强制按屏幕位置(从上到下)排序
messages.sort(key=lambda m: (m.get("center") or m.get("coordinates") or [0, 0])[1])
input_center = result_data.get("input_box")
# --- 🔴 红点补救策略 (Red Point Correction) ---
# VLM 有时会漏掉红点,我们使用 CV 模板匹配来修正
red_point_template = r"d:\dsWork\aiData\WeiXin\Templates\red_point.jpg" red_point_template = r"d:\dsWork\aiData\WeiXin\Templates\red_point.jpg"
audio_matches = find_all_template_matches(tmp_shot, audio_template, threshold=0.8)
red_points = find_all_template_matches(tmp_shot, red_point_template, threshold=0.8) red_points = find_all_template_matches(tmp_shot, red_point_template, threshold=0.8)
if red_points: # B. 本地 OCR 识别全文以构建上下文
logger.info(f"🔴 CV 检测到 {len(red_points)} 个红点,正在修正语音消息状态...") ocr_results = self.ocr_kit.read_text(tmp_shot)
for rp in red_points: # 按 Y 坐标排序
rx, ry = rp ocr_results.sort(key=lambda x: (x[0][0][1] + x[0][2][1]) / 2)
# 遍历所有消息,找到距离该红点最近的【语音消息】
# 规则:红点通常在语音消息的右侧,Y轴差异不大
best_match_msg = None
min_dist = 9999
for msg in messages: dialogue_log = []
if msg.get("type") == "voice": voice_messages = []
coords = msg.get("center") or msg.get("coordinates")
if coords:
mx, my = coords
# 检查 Y 轴距离 (红点应该和语音气泡在同一行,容差 50px)
if abs(my - ry) < 50:
# 检查 X 轴关系 (红点在语音气泡右侧)
if rx > mx:
dist = ((rx - mx)**2 + (ry - my)**2)**0.5
if dist < min_dist:
min_dist = dist
best_match_msg = msg
if best_match_msg: # 准备可视化调试图
# 只有当距离合理(比如小于 300px,视气泡长度而定,但红点一般紧挨着)
# 考虑到长语音气泡可能很长,中心点在中间,红点在最右边,距离可能较远
# 所以主要依赖 Y 轴对齐和 X 轴方向。
# 这里直接标记
if not best_match_msg.get("is_unread", False):
best_match_msg["is_unread"] = True
logger.info(f"🔴 修正:标记语音消息 {best_match_msg.get('content')} 为未读 (红点坐标: {rp})")
# ---------------------------------------------
# --- Debug Visualization ---
try:
import cv2 import cv2
import numpy as np import numpy as np
debug_img = cv2.imread(tmp_shot) debug_img = cv2.imread(tmp_shot)
if debug_img is not None:
# Draw input box
if input_center:
ix, iy = input_center
cv2.circle(debug_img, (int(ix), int(iy)), 10, (0, 0, 255), -1) # Red dot
cv2.putText(debug_img, "Input", (int(ix), int(iy)-10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2)
for msg in messages: # 记录已匹配到语音图标的 OCR 块索引
m_type = msg.get("type", "text") matched_ocr_indices = set()
coords = msg.get("center") or msg.get("coordinates")
if coords: # 先处理语音图标匹配
cx, cy = int(coords[0]), int(coords[1]) for ax, ay in audio_matches:
if m_type == "voice": # 排除顶部标题栏(0-300)和底部输入区(1800+)
# Green box for voice if ay < 300 or ay > 1800:
# We don't have w/h, so just draw a fixed size box or circle logger.info(f"⏭️ 忽略区域外语音图标: ({ax}, {ay})")
# Let's draw a rectangle around the center continue
cv2.rectangle(debug_img, (cx-50, cy-25), (cx+50, cy+25), (0, 255, 0), 3)
cv2.circle(debug_img, (cx, cy), 5, (0, 255, 0), -1)
cv2.putText(debug_img, "Voice", (cx, cy-30), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)
# else:
# # Blue box for text
# cv2.rectangle(debug_img, (cx-50, cy-25), (cx+50, cy+25), (255, 0, 0), 2)
# cv2.putText(debug_img, "Text", (cx, cy-30), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 0), 2)
debug_path = os.path.join(self.screenshot_dir, "t6_debug_result.jpg") sender = "对方" if ax < 500 else "我"
cv2.imwrite(debug_path, debug_img) logger.info(f"🎙️ 发现语音图标: x={ax}, y={ay}, 发送者={sender}")
logger.info(f"🐛 Debug 标记图已保存: {debug_path}") is_unread = False
except Exception as e: if red_points:
logger.error(f"Debug drawing failed: {e}") for rx, ry in red_points:
# --------------------------- # 红点通常在语音图标右侧,且 Y 轴相近
if abs(ry - ay) < 50 and rx > ax:
is_unread = True
# 绘制红点
cv2.circle(debug_img, (int(rx), int(ry)), 12, (0, 0, 255), -1)
break
dialogue_log = [] # 寻找附近的时长文字 (OCR)
voice_messages = [] # 存储所有语音消息 duration_text = "语音"
for idx, (bbox, text, conf) in enumerate(ocr_results):
c_x = (bbox[0][0] + bbox[2][0]) / 2
c_y = (bbox[0][1] + bbox[2][1]) / 2
if abs(c_y - ay) < 40 and abs(c_x - ax) < 300:
if '"' in text or text.isdigit():
duration_text = text
matched_ocr_indices.add(idx)
break
for i, msg in enumerate(messages): # 计算点击坐标:直接点击语音图标中心
# 简单的发送者判断 click_x, click_y = ax, ay
sender_val = msg.get("sender", "对方")
if sender_val in ["Me", "我"]: # 绘制视觉反馈
sender_name = "我" # 语音图标用绿框
cv2.rectangle(debug_img, (int(ax-30), int(ay-30)), (int(ax+30), int(ay+30)), (0, 255, 0), 3)
# 点击位置用红十字
cv2.drawMarker(debug_img, (int(click_x), int(click_y)), (0, 0, 255), cv2.MARKER_CROSS, 35, 3)
v_msg = {
"type": "voice",
"content": duration_text,
"coordinates": [click_x, click_y],
"sender": sender,
"is_unread": is_unread
}
if sender == "对方":
voice_messages.append(v_msg)
dialogue_log.append({
"y": ay,
"text": f"{sender}: [语音] {duration_text}",
"is_voice": True,
"id": f"voice_{ax}_{ay}"
})
# 处理剩余的 OCR 文字块 (普通文本)
for idx, (bbox, text, conf) in enumerate(ocr_results):
if idx in matched_ocr_indices: continue
x_min, x_max = bbox[0][0], bbox[2][0]
y_min, y_max = bbox[0][1], bbox[2][1]
c_x, c_y = (x_min + x_max) / 2, (y_min + y_max) / 2
if c_y < 300 or c_y > 1800: continue
if x_min < 250 and x_max < 700:
sender, color = "对方", (0, 255, 0)
elif x_max > 800 and x_min > 300:
sender, color = "我", (255, 0, 0)
else: else:
sender_name = "对方" sender, color = "系统", (128, 128, 128)
msg_type = msg.get("type", "text") if sender != "系统":
content = msg.get("content", "") logger.info(f"💬 发现文本消息: x={c_x}, y={c_y}, 发送者={sender}, 内容={text}")
# status = msg.get("status", "unconverted") # 不再依赖 status cv2.rectangle(debug_img, (int(x_min), int(y_min)), (int(x_max), int(y_max)), color, 1)
is_unread = msg.get("is_unread", False) dialogue_log.append({
"y": c_y,
"text": f"{sender}: {text}",
"is_voice": False
})
if msg_type == "voice": # 按 Y 轴重新排序整个对话日志
coords = msg.get("center") or msg.get("coordinates") dialogue_log.sort(key=lambda x: x['y'])
if coords:
msg["coordinates"] = coords
# 只处理“对方”的语音消息,忽略“我”发送的语音
if sender_name != "我":
voice_messages.append(msg)
# 在日志中暂时标记为 [语音],稍后如果处理了会更新 # 保存调试图
# 但为了日志完整性,我们这里先占位 debug_shot_path = os.path.join(self.screenshot_dir, "t6_debug_view.jpg")
# 实际上,我们需要知道这个语音的内容才能放入 context cv2.imwrite(debug_shot_path, debug_img)
# 如果没有内容,只能放 [语音] logger.info(f"🎨 已保存视觉调试图: {debug_shot_path}")
# 只有被处理过的语音,我们才能获取内容。
# 对于历史语音,如果我们不处理(非首次运行且无红点),我们无法知道内容。
# 所以这里只能 append 占位符。
dialogue_log.append(f"{sender_name}: [语音] {content}")
else:
dialogue_log.append(f"{sender_name}: {content}")
logger.info(f"📑 界面扫描完成,当前对话历史共 {len(dialogue_log)} 条") # C. 寻找输入框 (CV 模板匹配)
input_template = r"d:\dsWork\aiData\WeiXin\Templates\input_box.jpg" # 假设有这个模板
input_center = find_template_match(tmp_shot, input_template, threshold=0.6)
if not input_center:
# 几何兜底:屏幕底部 88% 处
from PIL import Image
with Image.open(tmp_shot) as img:
w, h = img.size
input_center = [w // 2, int(h * 0.88)]
logger.info(f"<EFBFBD> 使用几何兜底输入框坐标: {input_center}")
# 4. 语音处理逻辑 # 4. 语音处理逻辑
processed_voice_content = None processed_voice_content = None
input_y = input_center[1] if input_center else None input_y = input_center[1] if input_center else None
if self.is_first_run: # 只有未读的才处理
logger.info("🌟 首次运行:处理屏幕上所有语音消息...")
for v_msg in voice_messages: for v_msg in voice_messages:
# 查找下一条消息,用于限定 OCR 范围 if v_msg.get("is_unread") or self.is_first_run:
try: logger.info(f"🔴 发现未读/待处理语音: {v_msg['content']}")
idx = messages.index(v_msg) # 找到 OCR 结果中的下一条作为边界
next_msg = messages[idx + 1] if idx + 1 < len(messages) else None idx = -1
except ValueError: # 这里简化逻辑,直接处理
next_msg = None text = await self.process_single_voice(v_msg, None, input_y)
# 无论是否未读,都处理
text = await self.process_single_voice(v_msg, next_msg, input_y)
if text: if text:
# 直接更新 dialogue_log 对应的条目 # 更新 log 中的内容
dialogue_log[idx] = dialogue_log[idx].replace("[语音]", f"[语音转文字: {text}]") for item in dialogue_log:
if item.get("is_voice") and f"[语音] {v_msg['content']}" in item["text"]:
if v_msg == voice_messages[-1]: item["text"] = item["text"].replace("[语音]", f"[语音转文字: {text}]")
processed_voice_content = text break
self.is_first_run = False # 标记首次运行结束
# 初始化 last_processed_msg,避免回复历史消息
if dialogue_log:
last_log = dialogue_log[-1]
if last_log.startswith("对方"):
content = last_log.split(":", 1)[1].strip()
self.last_processed_msg = content
logger.info(f"🌟 首次运行,标记最后一条对方消息为已处理: {content}")
else:
# 后续监控:只处理最后一条,且必须是未读 (is_unread=True)
if voice_messages:
last_voice = voice_messages[-1]
if last_voice.get("is_unread", False):
logger.info("🔴 发现未读语音消息 (最后一条),正在处理...")
# 查找下一条消息
try:
idx = messages.index(last_voice)
next_msg = messages[idx + 1] if idx + 1 < len(messages) else None
except ValueError:
next_msg = None
text = await self.process_single_voice(last_voice, next_msg, input_y)
if text:
# 直接更新 dialogue_log 对应的条目
dialogue_log[idx] = dialogue_log[idx].replace("[语音]", f"[语音转文字: {text}]")
processed_voice_content = text
else:
# 增加更多调试信息,帮助定位为何跳过
sender = last_voice.get("sender", "未知")
content = last_voice.get("content", "")
coords = last_voice.get("coordinates", [])
logger.info(f"⚪ 最后一条语音消息已读,跳过处理。[{sender}, {content}, {coords}]")
self.is_first_run = False
# 5. LLM 回复逻辑 # 5. LLM 回复逻辑
# 只有当有新的语音被处理并识别出文字,或者有新的文本消息时才回复 final_dialogue_texts = [item['text'] for item in dialogue_log]
# 既然 dialogue_log 已经更新,我们直接用 history_text history_text = "\n".join(final_dialogue_texts)
history_text = "\n".join(dialogue_log)
# 判断是否需要回复: # 判断是否需要回复:
# 核心规则:只有当最后一条消息是“对方”说的,且内容未处理过,才回复。 # 核心规则:只有当最后一条消息是“对方”说的,且内容未处理过,才回复。
@@ -465,7 +438,8 @@ class ChatBot:
current_last_content = "" current_last_content = ""
if dialogue_log: if dialogue_log:
last_log = dialogue_log[-1] last_item = dialogue_log[-1]
last_log = last_item["text"]
# 检查最后一条消息的发送者 # 检查最后一条消息的发送者
if last_log.startswith("对方"): if last_log.startswith("对方"):
@@ -493,11 +467,20 @@ class ChatBot:
self.last_processed_msg = current_last_content self.last_processed_msg = current_last_content
reply = await self.get_reply(history_text) reply = await self.get_reply(history_text)
if reply:
logger.info(f"💡 LLM 回复: {reply}") logger.info(f"💡 LLM 回复: {reply}")
if reply and input_center: if input_center:
# 输入并发送 # 输入并发送
perform_input_action(self.d, input_center, reply) perform_input_action(self.d, input_center, reply)
# 发送后,为了防止下一轮 OCR 识别到自己的回复片段并误判为对方消息
# 我们把 last_processed_msg 设置为一个特殊的占位符,直到下一次真正识别到对方的新消息
# 或者更简单:在下一轮循环开始前稍微多等一下,让消息气泡完全显示
time.sleep(1)
# 将最后处理的消息内容标记为已处理,防止 LLM 回复逻辑在下一轮立即触发
# 注意:这里的 current_last_content 是对方的最后一条
else:
logger.warning("⚠️ LLM 未生成有效回复。")
# 休眠 # 休眠
await asyncio.sleep(CHECK_INTERVAL) await asyncio.sleep(CHECK_INTERVAL)

BIN
WeiXin/Templates/audio.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB