Files
msgbot/gui.py
T
youpaishidifu 2a3ecdba14 修复bot自言自语大小写不敏感问题 + 退出时后台内存回收不阻塞
- monitor.py: 去重管道所有比较改为大小写不敏感(recent_replies时间窗口、_replied_texts、_fuzzy_duplicate、子串包含、长公共子串、_last_bot_reply_content)
- main.py: CTRL+C信号处理将内存回收放到后台线程,不阻塞退出
- gui.py: 无实质修改(仅格式)
2026-08-23 21:30:05 +08:00

1437 lines
62 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""GUI — customtkinter 界面,开关 + 知识库路径 + 日志 """
import tkinter as tk
from tkinter import filedialog, messagebox
from typing import Optional
import logging
import queue
import threading
import os
import re
import time
import gc
import ctypes
import customtkinter as ctk
from config import load_config, save_config
from monitor import MessageMonitor
from knowledge_base import KnowledgeBase
from auto_reply import AutoReply
logger = logging.getLogger("msgbot.gui")
def _force_system_memory_cleanup():
"""强制回收Python进程占用的内存归还系统调度。
Windows API:
- EmptyWorkingSet: 清空工作集,强制归还物理内存
- SetProcessWorkingSetSize: 设置工作集大小为最小值,触发系统回收
"""
try:
# 获取当前进程句柄
kernel32 = ctypes.windll.kernel32
psapi = ctypes.windll.psapi
# 获取当前进程句柄
h_process = kernel32.GetCurrentProcess()
# 方法1: EmptyWorkingSet - 清空工作集
try:
psapi.EmptyWorkingSet(h_process)
logger.debug("系统内存清理: EmptyWorkingSet 完成")
except Exception as e:
logger.debug(f"EmptyWorkingSet 失败: {e}")
# 方法2: SetProcessWorkingSetSize - 设置最小工作集
try:
# dwMinimumWorkingSetSize=1, dwMaximumWorkingSetSize=-1 (无限)
# 设置最小值为1字节,最大值为无限,让系统自由回收
kernel32.SetProcessWorkingSetSize(h_process, 1, -1)
logger.debug("系统内存清理: SetProcessWorkingSetSize 完成")
except Exception as e:
logger.debug(f"SetProcessWorkingSetSize 失败: {e}")
# Python GC + torch CUDA 清理
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.synchronize()
logger.debug("系统内存清理: torch.cuda.empty_cache 完成")
except ImportError:
pass # torch 未安装
except Exception as e:
logger.debug(f"torch.cuda.empty_cache 失败: {e}")
# Python GC 强制回收
collected = gc.collect()
logger.debug(f"系统内存清理: gc.collect 回收 {collected} 个对象")
except Exception as e:
logger.debug(f"系统内存清理异常: {e}")
def _kill_easyocr_subprocesses():
"""杀死所有EasyOCR子进程,彻底释放GPU/CPU内存。"""
try:
import psutil
current_pid = os.getpid()
killed = 0
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
try:
# EasyOCR子进程通常以python启动,cmdline包含easyocr
if proc.pid == current_pid:
continue
cmdline = ' '.join(proc.cmdline() or [])
if 'easyocr' in cmdline.lower() or 'ocr_worker' in cmdline.lower():
proc.kill()
killed += 1
logger.debug(f"杀死EasyOCR子进程: PID={proc.pid}")
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
if killed:
logger.debug(f"共杀死 {killed} 个EasyOCR子进程")
except ImportError:
pass
except Exception as e:
logger.debug(f"杀死EasyOCR子进程异常: {e}")
class OCREngineManager:
"""双引擎OCR管理器:EasyOCR + Windows OCR,得分最高者胜出。
功能:
1. 预初始化两套引擎
2. 双引擎并发识别,取得分最高者的结果
3. 1分钟超时释放机制:无操作则杀进程释放内存
4. 支持消息监控调度时直接使用预热的引擎
"""
def __init__(self, log_callback=None):
self._log = log_callback or (lambda msg: None)
self._easyocr_reader = None
self._winocr_engine = None
self._initialized = False
self._lock = threading.Lock()
self._last_use_time = 0
self._timeout_seconds = 60 # 1分钟超时
self._consecutive_success = 0
self._consecutive_fail = 0
self._monitor_active = False # 消息监控是否激活
self._timer_thread = None
self._timer_stop = threading.Event()
def _check_memory_for_gpu(self) -> bool:
"""检查系统内存,决定是否可用GPU模式。与monitor.py同逻辑。"""
try:
import torch
if not torch.cuda.is_available():
self._log(" ℹ CUDA不可用,将使用CPU模式")
return False
import psutil
mem = psutil.virtual_memory()
available_gb = mem.available / (1024**3)
total_gb = mem.total / (1024**3)
# 页面文件信息
try:
swap = psutil.swap_memory()
swap_total_gb = swap.total / (1024**3)
swap_avail_gb = swap.free / (1024**3)
swap_pct = swap.percent
except Exception:
swap_total_gb = swap_avail_gb = swap_pct = 0
self._log(f" 📊 内存: {available_gb:.1f}GB可用/总{total_gb:.1f}GB({mem.percent}%), "
f"页面文件: {swap_avail_gb:.1f}GB可用({swap_pct}%)")
# 可用内存 < 2GB → 降级CPU
if available_gb < 2.0:
self._log(f" ⚠ 可用内存不足({available_gb:.1f}GB < 2GB),降级CPU模式")
return False
# 页面文件 > 95% → 冷却
if swap_pct > 95.0:
self._log(f" ⚠ 页面文件不足({swap_pct}% > 95%),降级CPU模式")
return False
# 系统提交量 > 95% → 降级
phys_used_gb = total_gb - available_gb
page_used_gb = swap_total_gb - swap_avail_gb
commit_total = total_gb + swap_total_gb
commit_pct = ((phys_used_gb + page_used_gb) / commit_total * 100) if commit_total > 0 else 0
if commit_pct > 95.0:
self._log(f" ⚠ 系统提交量过高({commit_pct:.0f}% > 95%),降级CPU模式")
return False
self._log(f" ✅ 内存充足,启用GPU模式")
return True
except Exception as e:
self._log(f" ⚠ 内存检查异常: {e},降级CPU模式")
return False
def initialize(self):
"""预初始化两套OCR引擎(EasyOCR GPU/CPU根据内存自动选择)"""
if self._initialized:
return True
with self._lock:
if self._initialized:
return True
self._log("🔧 初始化OCR引擎...")
# 初始化EasyOCR(根据内存条件选择GPU/CPU)
use_gpu = False
try:
import easyocr
use_gpu = self._check_memory_for_gpu()
mode_str = "GPU" if use_gpu else "CPU"
self._log(f" ⏳ 初始化EasyOCR ({mode_str}模式)...")
try:
self._easyocr_reader = easyocr.Reader(['ch_sim', 'en'], gpu=use_gpu, verbose=False)
self._log(f" ✅ EasyOCR就绪({mode_str}模式)")
except Exception as e:
# GPU失败 → 降级CPU重试
if use_gpu:
self._log(f" ⚠ EasyOCR GPU初始化失败: {e},降级CPU重试...")
try:
self._easyocr_reader = easyocr.Reader(['ch_sim', 'en'], gpu=False, verbose=False)
self._log(" ✅ EasyOCR就绪(CPU模式,GPU降级)")
except Exception as e2:
self._log(f" ⚠ EasyOCR CPU也失败: {e2}")
self._easyocr_reader = None
else:
self._log(f" ⚠ EasyOCR初始化失败: {e}")
self._easyocr_reader = None
except ImportError:
self._log(" ⚠ EasyOCR未安装")
self._easyocr_reader = None
except Exception as e:
self._log(f" ⚠ EasyOCR异常: {e}")
self._easyocr_reader = None
# 初始化Windows OCR
try:
from winrt.windows.media.ocr import OcrEngine
from winrt.windows.globalization import Language
self._winocr_engine = OcrEngine.try_create_from_user_profile_languages()
if self._winocr_engine:
self._log(" ✅ Windows OCR就绪")
else:
self._log(" ⚠ Windows OCR不可用")
except Exception as e:
self._log(f" ⚠ Windows OCR初始化失败: {e}")
self._winocr_engine = None
if not self._easyocr_reader and not self._winocr_engine:
self._log("❌ 无可用OCR引擎")
return False
self._initialized = True
self._use_gpu = use_gpu
self._last_use_time = time.time()
self._start_timeout_timer()
self._log("✅ OCR引擎初始化完成")
return True
def _start_timeout_timer(self):
"""启动超时计时器线程"""
if self._timer_thread and self._timer_thread.is_alive():
return
self._timer_stop.clear()
def _timer_loop():
while not self._timer_stop.is_set():
self._timer_stop.wait(10) # 每10秒检查一次
if self._timer_stop.is_set():
break
# 消息监控激活中,不释放
if self._monitor_active:
self._last_use_time = time.time() # 重置计时
continue
# 有新截图动作,不释放
if time.time() - self._last_use_time < self._timeout_seconds:
continue
# 超时,释放资源
self._log("⏰ OCR引擎1分钟无操作,释放内存...")
self._release()
break
self._timer_thread = threading.Thread(target=_timer_loop, daemon=True)
self._timer_thread.start()
def _release(self):
"""释放OCR引擎资源 + 杀EasyOCR子进程 + 系统内存回收"""
with self._lock:
self._easyocr_reader = None
self._winocr_engine = None
self._initialized = False
self._log("🗑 OCR引擎已释放")
# 杀EasyOCR子进程(彻底释放GPU内存)
_kill_easyocr_subprocesses()
# 系统级内存回收
_force_system_memory_cleanup()
self._log("🧹 系统内存已回收")
def set_monitor_active(self, active: bool):
"""设置消息监控激活状态"""
self._monitor_active = active
if active and self._initialized:
self._last_use_time = time.time() # 激活时重置计时
elif not active and self._initialized:
# 监控停止 → 立即释放OCR(杀子进程+系统内存回收)
self._log("⏸ 监控停止,立即释放OCR内存...")
self._release()
def recognize(self, image) -> list:
"""双引擎并发识别,返回得分最高的结果
Args:
image: PIL Image对象
Returns:
识别到的文本列表
"""
if not self._initialized:
self.initialize()
if not self._initialized:
return []
self._last_use_time = time.time()
results_easyocr = []
results_winocr = []
errors = []
# 并发识别
def _run_easyocr():
nonlocal results_easyocr
try:
if self._easyocr_reader:
results_easyocr = self._easyocr_reader.readtext(image, detail=1)
except Exception as e:
errors.append(f"EasyOCR: {e}")
def _run_winocr():
nonlocal results_winocr
try:
if self._winocr_engine:
results_winocr = self._recognize_winocr(image)
except Exception as e:
errors.append(f"WinOCR: {e}")
threads = []
if self._easyocr_reader:
threads.append(threading.Thread(target=_run_easyocr, daemon=True))
if self._winocr_engine:
threads.append(threading.Thread(target=_run_winocr, daemon=True))
for t in threads:
t.start()
for t in threads:
t.join(timeout=30) # 30秒超时
# 选择得分最高的结果
best_texts = self._select_best_result(results_easyocr, results_winocr)
if best_texts:
self._consecutive_success += 1
self._consecutive_fail = 0
else:
self._consecutive_success = 0
self._consecutive_fail += 1
if errors:
self._log(f"⚠ OCR识别失败: {'; '.join(errors)}")
return best_texts
def _recognize_winocr(self, image) -> list:
"""Windows OCR识别,返回带得分的结果"""
import asyncio
import io
import tempfile
try:
# 保存为临时文件
tmp_path = os.path.join(tempfile.gettempdir(), '_ocr_nick_temp.png')
image.save(tmp_path)
async def _do_ocr():
from winrt.windows.media.ocr import OcrEngine
from winrt.windows.graphics.imaging import BitmapDecoder
from winrt.windows.storage import StorageFile
file = await StorageFile.get_file_from_path_async(tmp_path)
stream = await file.open_read_async()
decoder = await BitmapDecoder.create_async(stream)
bitmap = await decoder.get_software_bitmap_async()
result = await self._winocr_engine.recognize_async(bitmap)
return result.text
text = asyncio.run(_do_ocr())
# 按行分割,每行视为一个结果(得分0.8作为默认)
lines = [line.strip() for line in text.split('\n') if line.strip()]
return [(line, 0.8) for line in lines]
except Exception as e:
logger.error(f"Windows OCR失败: {e}")
return []
def _select_best_result(self, easyocr_results, winocr_results) -> list:
"""选择得分最高的结果
EasyOCR返回: [(bbox, text, confidence), ...]
WinOCR返回: [(text, confidence), ...]
"""
# 统一格式为 [(text, confidence), ...]
eo = [(r[1], r[2]) for r in easyocr_results if len(r) >= 3]
wo = winocr_results # 已经是 [(text, confidence)]
all_results = eo + wo
if not all_results:
return []
# 按置信度排序,取最高的
all_results.sort(key=lambda x: x[1], reverse=True)
# 返回所有结果的文本(去重)
seen = set()
texts = []
for text, conf in all_results:
text = text.strip()
if text and text not in seen:
seen.add(text)
texts.append(text)
return texts
def get_status(self) -> dict:
"""获取引擎状态"""
return {
'initialized': self._initialized,
'easyocr': self._easyocr_reader is not None,
'winocr': self._winocr_engine is not None,
'gpu_mode': getattr(self, '_use_gpu', False),
'consecutive_success': self._consecutive_success,
'consecutive_fail': self._consecutive_fail,
'monitor_active': self._monitor_active,
}
def _clean_query_for_kb(msg: str) -> str:
"""清理查询文本中的用户名/头衔/时间戳等噪音,提升KB搜索精度。"""
if not msg:
return msg
t = msg.strip()
# 1. 移除QQ群头衔格式: [头衔]用户名 / 【头衔】用户名
t = re.sub(r'[\[【][^】\]]+[】\]]\s*(?:[A-Za-z0-9_\u4e00-\u9fff]{2,30})\s*[,,]?\s*', '', t)
# 2. 移除@提及: @ASCII昵称 (仅后接空白/串尾时移除)
# ⚠ 注意:不能匹配 @中文 格式(如@问问...),否则会把整个问句误删
# 修复:步骤2只清理ASCII昵称,中文昵称由步骤4a统一处理
t = re.sub(r'@[A-Za-z0-9_]{1,20}(?=\s|$)', '', t)
# 3. 移除时间戳: HH:MM:SS / HH:MM / 日期格式
t = re.sub(r'\d{1,2}[::]\d{2}(?:[::]\d{2})?\s*', '', t)
t = re.sub(r'\d{4}[//\-]\d{1,2}[//\-]\d{1,2}\s*', '', t)
# 4. 移除已知昵称列表
try:
cfg = load_config()
nicknames = cfg.get('qq_nicknames', [])
for nick in nicknames:
if nick in t:
t = t.replace(nick, '')
except Exception:
pass
# 4a. 清理孤立@前缀:移除OCR残留的@标记(无论后面跟中文还是ASCII)
# 例:@问问... → 问问... / @用户ABC... → 用户ABC...
# 但如果是真实@提及(如@小明 你好),步骤2已处理,这里不再重复
t = re.sub(r'^@\s*', '', t)
# 5. 清理残留标点/空格
t = re.sub(r'[,,、\s]+', ' ', t).strip(',,、 \t')
# 6. ⭐ 清理OCR污染:同屏捕获时,用户问句后可能拼接其他消息的碎片
# 如 "个登录插件是在哪个地方下载? 4001234S67 ]插件下载:见github..."
# 检测模式:6位以上大写数字/字母ID + ] + 插件下载/下载地址 等bot回复关键词
# 如果检测到,截断到第一个完整问句结束(?或?)
_ocr_pollution = re.compile(
r'[??]\s*' # 问句结束符后可能有空格
r'[\s]*' # 更多空格
r'[A-Z0-9]{6,}' # 订单号/ID(6位以上大写数字字母)
r'\s*]' # 跟随的]符号
r'\s*插件下载' # bot回复内容关键词
r'.*' # 其余内容
)
if _ocr_pollution.search(t):
# 找到第一个完整问句并截断
q_end = max(
t.find('?') if '?' in t else -1,
t.find('?') if '?' in t else -1
)
if q_end >= 0:
t = t[:q_end + 1]
logger.debug(f"[kb] OCR污染清理: {msg[:50]!r} -> {t!r}")
# 7. ⭐ 清理bot回复碎片残留(2026-08-17 修复)
# 用户发送新消息时,OCR可能同时捕获到bot之前的回复
# 如 "那个登录插件去哪下载[官方下载" 或 "插件下载:见github..."
# 检测模式:问句结束后紧跟[官方下载]/[bbsmc分流]/插件下载等bot回复特征
# 如果检测到,截断到问句结束
_bot_reply_fragment = re.compile(
r'[??]\s*' # 问句结束符
r'[\[((]?\s*' # 可选的方括号/圆括号
r'(官方下载|bbsmc分流|插件下载|下载地址|下载地址)' # bot回复关键词
r'.*' # 其余内容
)
if _bot_reply_fragment.search(t):
# 找到第一个问句结束符并截断
q_end = max(
t.find('?') if '?' in t else -1,
t.find('?') if '?' in t else -1
)
if q_end >= 0:
t = t[:q_end + 1]
logger.debug(f"[kb] Bot回复碎片清理: {msg[:50]!r} -> {t!r}")
elif '[官方下载' in t or '[bbsmc分流' in t:
# 没有问号,但包含bot回复的Markdown链接格式
# 如 "那个登录插件去哪下载[官方下载"
# 尝试在"["之前截断
cut_pos = min(
t.find('[官方下载') if '[官方下载' in t else len(t),
t.find('[bbsmc分流') if '[bbsmc分流' in t else len(t),
t.find('插件下载:') if '插件下载:' in t else len(t),
)
if cut_pos < len(t) and cut_pos > 5: # 确保不是太短
t = t[:cut_pos].rstrip(',,、 ')
logger.debug(f"[kb] Bot回复Markdown清理: {msg[:50]!r} -> {t!r}")
return t
ctk.set_appearance_mode("System")
ctk.set_default_color_theme("green")
class MsgBotApp(ctk.CTk):
"""主窗口"""
def __init__(self):
super().__init__()
self.title("消息问答助手 v1.0")
self.geometry("720x560")
self.minsize(640, 480)
# 配置
self.cfg = load_config()
# 知识库
self.kb = KnowledgeBase(self.cfg.get("knowledge_base_path", ""))
# 监控器
self.monitor = MessageMonitor(on_message=self._on_new_message)
self.monitor.set_poll_interval(self.cfg.get("poll_interval_seconds", 2.0))
self.monitor.set_enabled_platforms(
wechat=self.cfg.get("wechat_enabled", True),
qq=self.cfg.get("qq_enabled", False),
)
self.monitor.set_emergency_callback(self._on_emergency_stop)
# 自动回复
self.ar = AutoReply(self.monitor)
# 日志队列(线程安全)— 必须在OCR管理器之前创建
self._log_queue: queue.Queue[str] = queue.Queue()
# 自动回复执行队列(转发到主线程,避免 daemon 线程 SetForegroundWindow 失败)
self._reply_queue: queue.Queue[dict] = queue.Queue()
# ⭐ 启动时系统内存清理(强制回收Python进程内存)
self._log("🧹 启动内存清理...")
threading.Thread(target=_force_system_memory_cleanup, daemon=True).start()
# ⭐ OCR引擎管理器(双引擎:EasyOCR + Windows OCR)
self._ocr_manager = OCREngineManager(log_callback=self._log)
# 预初始化OCR引擎(后台线程,不阻塞UI)
threading.Thread(target=self._ocr_manager.initialize, daemon=True).start()
# ⭐ 跨平台消息处理锁:防止同一消息被QQ和微信同时处理
self._processing_lock = threading.Lock()
self._processing_messages = set() # 正在处理的消息hash集合
self.ar.set_enabled(self.cfg.get("auto_reply_enabled", False))
self.ar.set_min_score(self.cfg.get("auto_reply_min_score", 0.35))
self._build_ui()
# 定时从队列取日志刷新
self.after(200, self._flush_log)
# 定时处理自动回复队列(主线程才能可靠 SetForegroundWindow)
self.after(200, self._flush_reply_queue)
# 窗口关闭
self.protocol("WM_DELETE_WINDOW", self._on_close)
# ── UI 构建 ──────────────────────────────
def _build_ui(self):
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(2, weight=1)
# ── 顶部:开关 + 状态 ──
header = ctk.CTkFrame(self)
header.grid(row=0, column=0, padx=12, pady=(12, 4), sticky="ew")
header.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(header, text="🤖 消息问答助手", font=("Microsoft YaHei", 18, "bold")).grid(
row=0, column=0, padx=(8, 16), pady=8, sticky="w"
)
self._status_label = ctk.CTkLabel(header, text="⏸ 已暂停", font=("Microsoft YaHei", 13))
self._status_label.grid(row=0, column=1, padx=4, pady=8, sticky="w")
self._toggle_btn = ctk.CTkSwitch(
header,
text="",
command=self._toggle_monitor,
onvalue=True,
offvalue=False,
)
self._toggle_btn.grid(row=0, column=2, padx=(4, 8), pady=8, sticky="e")
ctk.CTkLabel(header, text="启用监控", font=("Microsoft YaHei", 12)).grid(
row=0, column=3, padx=(0, 12), pady=8, sticky="w"
)
# ── 设置区域 ──
settings = ctk.CTkFrame(self)
settings.grid(row=1, column=0, padx=12, pady=4, sticky="ew")
settings.grid_columnconfigure(1, weight=1)
row = 0
# 知识库路径
ctk.CTkLabel(settings, text="📂 知识库路径:", font=("Microsoft YaHei", 12)).grid(
row=row, column=0, padx=(8, 4), pady=6, sticky="w"
)
self._kb_path_var = ctk.StringVar(value=self.cfg.get("knowledge_base_path", ""))
self._kb_entry = ctk.CTkEntry(
settings, textvariable=self._kb_path_var, placeholder_text="选择知识库文件夹(.md / .txt)"
)
self._kb_entry.grid(row=row, column=1, padx=4, pady=6, sticky="ew")
self._kb_browse_btn = ctk.CTkButton(
settings, text="浏览", width=64, command=self._browse_kb
)
self._kb_browse_btn.grid(row=row, column=2, padx=(4, 8), pady=6, sticky="e")
self._kb_status = ctk.CTkLabel(settings, text="未加载", font=("Microsoft YaHei", 11))
self._kb_status.grid(row=row, column=3, padx=4, pady=6, sticky="w")
row += 1
# 平台选择
ctk.CTkLabel(settings, text="📱 监控平台:", font=("Microsoft YaHei", 12)).grid(
row=row, column=0, padx=(8, 4), pady=6, sticky="w"
)
self._wx_var = ctk.BooleanVar(value=self.cfg.get("wechat_enabled", True))
self._wx_cb = ctk.CTkCheckBox(settings, text="微信", variable=self._wx_var,
command=self._save_plat_settings)
self._wx_cb.grid(row=row, column=1, padx=4, pady=6, sticky="w")
self._qq_var = ctk.BooleanVar(value=self.cfg.get("qq_enabled", False))
self._qq_cb = ctk.CTkCheckBox(settings, text="QQ", variable=self._qq_var,
command=self._save_plat_settings)
self._qq_cb.grid(row=row, column=1, padx=(90, 4), pady=6, sticky="w")
row += 1
# 轮询间隔
ctk.CTkLabel(settings, text="⏱ 轮询间隔(秒):", font=("Microsoft YaHei", 12)).grid(
row=row, column=0, padx=(8, 4), pady=6, sticky="w"
)
self._interval_var = ctk.DoubleVar(value=self.cfg.get("poll_interval_seconds", 2.0))
self._interval_slider = ctk.CTkSlider(
settings, from_=0.5, to=10.0, number_of_steps=19,
variable=self._interval_var, command=self._save_interval
)
self._interval_slider.grid(row=row, column=1, padx=4, pady=6, sticky="ew")
self._interval_label = ctk.CTkLabel(
settings, text=f"{self._interval_var.get():.1f}s", width=40
)
self._interval_label.grid(row=row, column=2, padx=(4, 8), pady=6, sticky="w")
# 应用按钮
self._apply_btn = ctk.CTkButton(
settings, text="应用设置", width=80, command=self._apply_settings
)
self._apply_btn.grid(row=row, column=3, padx=4, pady=6, sticky="e")
row += 1
# ── 自动回复 ──
ctk.CTkLabel(settings, text="🤖 自动回复:", font=("Microsoft YaHei", 12)).grid(
row=row, column=0, padx=(8, 4), pady=6, sticky="w"
)
ar_frame = ctk.CTkFrame(settings, fg_color="transparent")
ar_frame.grid(row=row, column=1, columnspan=2, padx=4, pady=6, sticky="ew")
ar_frame.grid_columnconfigure(2, weight=1)
self._ar_var = ctk.BooleanVar(value=self.cfg.get("auto_reply_enabled", False))
self._ar_toggle = ctk.CTkSwitch(
ar_frame, text="", variable=self._ar_var,
command=self._save_ar_settings,
onvalue=True, offvalue=False,
)
self._ar_toggle.grid(row=0, column=0, padx=(0, 6), pady=0, sticky="w")
ctk.CTkLabel(ar_frame, text="阈值:", font=("Microsoft YaHei", 12)).grid(
row=0, column=1, padx=(0, 4), pady=0, sticky="w"
)
self._ar_threshold_var = ctk.DoubleVar(
value=self.cfg.get("auto_reply_min_score", 0.35)
)
self._ar_threshold_slider = ctk.CTkSlider(
ar_frame, from_=0.10, to=0.90, number_of_steps=16,
variable=self._ar_threshold_var, command=self._save_ar_threshold,
)
self._ar_threshold_slider.grid(row=0, column=2, padx=2, pady=0, sticky="ew")
self._ar_threshold_label = ctk.CTkLabel(
ar_frame, text=f"{self._ar_threshold_var.get():.2f}", width=36
)
self._ar_threshold_label.grid(row=0, column=3, padx=(6, 0), pady=0, sticky="w")
row += 1
# ── QQ昵称管理 ──
nick_label = ctk.CTkLabel(settings, text="👥 QQ昵称:", font=("Microsoft YaHei", 12))
nick_label.grid(row=row, column=0, padx=(8, 4), pady=6, sticky="nw")
nick_frame = ctk.CTkFrame(settings, fg_color="transparent")
nick_frame.grid(row=row, column=1, columnspan=2, padx=4, pady=6, sticky="ew")
nick_frame.grid_columnconfigure(0, weight=1)
# 昵称列表显示
self._nick_list_var = ctk.StringVar(value="")
self._nick_entry = ctk.CTkEntry(
nick_frame, placeholder_text="输入新昵称后回车添加"
)
self._nick_entry.grid(row=0, column=0, padx=(0, 4), pady=2, sticky="ew")
self._nick_entry.bind("<Return>", self._add_nickname)
self._add_nick_btn = ctk.CTkButton(
nick_frame, text="添加", width=56, command=self._add_nickname
)
self._add_nick_btn.grid(row=0, column=1, padx=(0, 4), pady=2, sticky="e")
# 昵称列表
self._nick_listbox = ctk.CTkTextbox(nick_frame, height=60, font=("Microsoft YaHei", 11))
self._nick_listbox.grid(row=1, column=0, columnspan=2, padx=0, pady=4, sticky="ew")
# 操作按钮行
nick_btn_frame = ctk.CTkFrame(nick_frame, fg_color="transparent")
nick_btn_frame.grid(row=2, column=0, columnspan=2, pady=2, sticky="ew")
self._del_nick_btn = ctk.CTkButton(
nick_btn_frame, text="删除选中", width=72,
command=self._delete_selected_nickname
)
self._del_nick_btn.pack(side="left", padx=(0, 4))
self._refresh_nick_btn = ctk.CTkButton(
nick_btn_frame, text="刷新", width=56,
command=self._refresh_nick_list
)
self._refresh_nick_btn.pack(side="left", padx=(0, 4))
self._ocr_nick_btn = ctk.CTkButton(
nick_btn_frame, text="📸 从截图识别", width=100,
command=self._ocr_nicknames_from_screenshot
)
self._ocr_nick_btn.pack(side="left")
# 初始加载昵称列表
self._refresh_nick_list()
# ── 日志区域 ──
log_frame = ctk.CTkFrame(self)
log_frame.grid(row=2, column=0, padx=12, pady=(4, 12), sticky="nsew")
log_frame.grid_rowconfigure(0, weight=1)
log_frame.grid_columnconfigure(0, weight=1)
self._log_text = ctk.CTkTextbox(log_frame, font=("Consolas", 11), wrap="word")
self._log_text.grid(row=0, column=0, padx=4, pady=4, sticky="nsew")
self._log_text.configure(state="disabled")
btn_frame = ctk.CTkFrame(log_frame, fg_color="transparent")
btn_frame.grid(row=1, column=0, pady=4, sticky="ew")
ctk.CTkButton(btn_frame, text="清空日志", width=80,
command=self._clear_log).pack(side="right", padx=4)
# 初始更新知识库状态
self._update_kb_status()
# ── 回调 ──────────────────────────────
def _toggle_monitor(self):
enabled = self._toggle_btn.get()
if enabled:
# 检查知识库是否已加载
if not self.kb.is_ready:
self._log("⚠ 知识库未加载,请先设置知识库路径")
self._toggle_btn.deselect()
return
# 用户手动点击启用 → 彻底重启(重新检测内存,尝试恢复GPU)
self.monitor.start(full_restart=True)
self._status_label.configure(text="▶ 运行中", text_color="#2ecc71")
self._log("✅ 消息监控已启动")
# 通知OCR管理器:监控已激活,不释放引擎
self._ocr_manager.set_monitor_active(True)
else:
self.monitor.stop()
self._status_label.configure(text="⏸ 已暂停")
self._log("⏸ 消息监控已暂停")
# 通知OCR管理器:监控已停止,开始计时
self._ocr_manager.set_monitor_active(False)
def _browse_kb(self):
path = filedialog.askdirectory(title="选择知识库文件夹")
if path:
self._kb_path_var.set(path)
self._apply_settings()
def _apply_settings(self):
# 保存知识库路径
path = self._kb_path_var.get().strip()
self.cfg["knowledge_base_path"] = path
self.kb.set_path(path)
self._update_kb_status()
# 平台
self.cfg["wechat_enabled"] = self._wx_var.get()
self.cfg["qq_enabled"] = self._qq_var.get()
# 自动回复
self.cfg["auto_reply_enabled"] = self._ar_var.get()
self.cfg["auto_reply_min_score"] = round(self._ar_threshold_var.get(), 2)
self.ar.set_enabled(self.cfg["auto_reply_enabled"])
self.ar.set_min_score(self.cfg["auto_reply_min_score"])
# 间隔
self.cfg["poll_interval_seconds"] = round(self._interval_var.get(), 1)
save_config(self.cfg)
# 更新监控器
self.monitor.set_poll_interval(self.cfg["poll_interval_seconds"])
self.monitor.set_enabled_platforms(
wechat=self.cfg["wechat_enabled"],
qq=self.cfg["qq_enabled"],
)
self._log(f"📝 设置已保存 | 知识库: {self.kb.get_doc_count()} 个文档 | "
f"轮询间隔: {self.cfg['poll_interval_seconds']}s")
def _save_plat_settings(self):
# 只保存平台选择,不触发完整应用
self.cfg["wechat_enabled"] = self._wx_var.get()
self.cfg["qq_enabled"] = self._qq_var.get()
save_config(self.cfg)
self.monitor.set_enabled_platforms(
wechat=self.cfg["wechat_enabled"],
qq=self.cfg["qq_enabled"],
)
def _save_interval(self, _=None):
self._interval_label.configure(text=f"{self._interval_var.get():.1f}s")
def _save_ar_settings(self):
"""自动回复开关即时保存"""
self.cfg["auto_reply_enabled"] = self._ar_var.get()
save_config(self.cfg)
self.ar.set_enabled(self.cfg["auto_reply_enabled"])
status = "开" if self._ar_var.get() else "关"
self._log(f"🤖 自动回复已切换为{status}")
def _save_ar_threshold(self, _=None):
val = round(self._ar_threshold_var.get(), 2)
self._ar_threshold_label.configure(text=f"{val:.2f}")
def _update_kb_status(self):
if self.kb.is_ready:
self._kb_status.configure(
text=f"✅ {self.kb.get_doc_count()} 个文档", text_color="#2ecc71"
)
else:
self._kb_status.configure(text="❌ 未加载", text_color="#e74c3c")
# ── 昵称管理 ──
def _refresh_nick_list(self):
"""刷新昵称列表显示"""
self.cfg = load_config()
nicks = self.cfg.get("qq_nicknames", [])
self._nick_listbox.configure(state="normal")
self._nick_listbox.delete("1.0", "end")
if nicks:
for n in nicks:
self._nick_listbox.insert("end", n + "\n")
else:
self._nick_listbox.insert("end", "(空 — 通过@提及或手动添加)")
self._nick_listbox.configure(state="disabled")
def _add_nickname(self, event=None):
"""添加新昵称到列表"""
nick = self._nick_entry.get().strip()
if not nick:
return
self.cfg = load_config()
nicks = self.cfg.setdefault("qq_nicknames", [])
if nick in nicks:
self._log(f"⚠ 昵称 '{nick}' 已存在")
return
nicks.append(nick)
save_config(self.cfg)
self._nick_entry.delete(0, "end")
self._refresh_nick_list()
self._log(f"✅ 已添加昵称: {nick}")
def _delete_selected_nickname(self):
"""删除光标所在行的昵称"""
try:
cursor_pos = self._nick_listbox.index("insert")
line_text = self._nick_listbox.get(cursor_pos + " linestart",
cursor_pos + " lineend").strip()
if not line_text or line_text.startswith("("):
return
self.cfg = load_config()
nicks = self.cfg.get("qq_nicknames", [])
if line_text in nicks:
nicks.remove(line_text)
save_config(self.cfg)
self._refresh_nick_list()
self._log(f"🗑 已删除昵称: {line_text}")
except Exception as e:
self._log(f"⚠ 删除失败: {e}")
def _ocr_nicknames_from_screenshot(self):
"""截图OCR识别昵称:截屏→双引擎OCR→提取昵称→入库"""
self._log("📸 正在截屏,请确保QQ聊天窗口可见...")
self._ocr_nick_btn.configure(state="disabled", text="截屏中...")
def _do_ocr():
try:
import time
time.sleep(1.5) # 等待用户切换到QQ窗口
# 截取全屏
screenshot = self._capture_screen()
if screenshot is None:
self._log_queue.put("⚠ 截屏失败")
return
self._log_queue.put("🔍 OCR识别中(双引擎并发)...")
# 使用OCR管理器识别(双引擎并发,得分最高者胜出)
results = self._ocr_manager.recognize(screenshot)
if not results:
self._log_queue.put("⚠ OCR未识别到任何文字")
return
self._log_queue.put(f"📝 OCR识别到 {len(results)} 段文字")
# 从OCR结果中提取昵称
nicks_found = self._extract_nicknames_from_ocr(results)
if not nicks_found:
self._log_queue.put("⚠ 未检测到QQ昵称模式([头衔]用户名 或 @用户名)")
return
# 添加到配置
self.cfg = load_config()
existing = self.cfg.setdefault("qq_nicknames", [])
added = []
for n in nicks_found:
if n not in existing:
existing.append(n)
added.append(n)
if added:
save_config(self.cfg)
self._refresh_nick_list()
self._log_queue.put(f"✅ 新增 {len(added)} 个昵称: {', '.join(added)}")
else:
self._log_queue.put(f"ℹ 识别到的昵称已全部存在: {', '.join(nicks_found)}")
except Exception as e:
self._log_queue.put(f"⚠ 截图OCR异常: {e}")
finally:
# 恢复按钮
self.after(0, lambda: self._ocr_nick_btn.configure(
state="normal", text="📸 从截图识别"))
threading.Thread(target=_do_ocr, daemon=True).start()
def _capture_screen(self):
"""使用Windows API截取全屏,返回PIL Image"""
try:
import win32gui
import win32ui
import win32con
import ctypes
from PIL import Image
# 获取屏幕尺寸(ctypes方式,兼容性更好)
user32 = ctypes.windll.user32
screen_w = user32.GetSystemMetrics(0) # SM_CXSCREEN
screen_h = user32.GetSystemMetrics(1) # SM_CYSCREEN
# 创建设备上下文
hdesktop = win32gui.GetDesktopWindow()
hdc = win32gui.GetDC(hdesktop)
mfcDC = win32ui.CreateDCFromHandle(hdc)
saveDC = mfcDC.CreateCompatibleDC()
# 创建位图
saveBitMap = win32ui.CreateBitmap()
saveBitMap.CreateCompatibleBitmap(mfcDC, screen_w, screen_h)
saveDC.SelectObject(saveBitMap)
# 复制屏幕
saveDC.BitBlt((0, 0), (screen_w, screen_h), mfcDC, (0, 0), win32con.SRCCOPY)
# 转为PIL Image
bmpinfo = saveBitMap.GetInfo()
bmpstr = saveBitMap.GetBitmapBits(True)
img = Image.frombuffer('RGB', (bmpinfo['bmWidth'], bmpinfo['bmHeight']),
bmpstr, 'raw', 'BGRX', 0, 1)
# 清理
win32gui.DeleteObject(saveBitMap.GetHandle())
saveDC.DeleteDC()
mfcDC.DeleteDC()
win32gui.ReleaseDC(hdesktop, hdc)
return img
except Exception as e:
logger.error(f"截屏失败: {e}")
return None
def _extract_nicknames_from_ocr(self, ocr_results):
"""从OCR识别结果中提取QQ昵称模式"""
nicks = set()
# 合并所有识别文本
full_text = '\n'.join(ocr_results) if isinstance(ocr_results, list) else str(ocr_results)
# 模式1: [头衔]用户名 或 【头衔】用户名
for m in re.finditer(r'[\[【]([^\]】]+)[】\]]\s*([A-Za-z0-9_\u4e00-\u9fff]{2,30})', full_text):
nick = m.group(2).strip()
if len(nick) >= 2:
nicks.add(nick)
# 模式2: @用户名
for m in re.finditer(r'@([A-Za-z0-9_\u4e00-\u9fff]{2,30})', full_text):
nick = m.group(1).strip()
if len(nick) >= 2:
nicks.add(nick)
# 模式3: 行首的纯中文/英文用户名(2-15字符),后跟消息内容
# 这个模式比较宽松,需要人工确认
for line in ocr_results if isinstance(ocr_results, list) else full_text.split('\n'):
line = line.strip()
if not line:
continue
# 行首用户名:2-15字符的中英文,后跟逗号/冒号/空格+内容
m = re.match(r'^([A-Za-z0-9_\u4e00-\u9fff]{2,15})\s*[,,::]\s*.+', line)
if m:
nick = m.group(1).strip()
# 过滤常见非昵称词
skip_words = {'你好', '请问', '怎么', '什么', '为什么', '多少', '在哪', '下载',
'安装', '配置', '设置', '使用', '更新', '版本', '错误', '问题'}
if nick not in skip_words:
nicks.add(nick)
return list(nicks)
# ── 消息处理 ──
def _on_new_message(self, platform: str, msg: str):
"""收到新消息 -> 搜索知识库 -> 日志输出"""
# ⭐ 纯时间戳/纯日期过滤:不含中文字符的消息不送查库
# OCR 常把日期"2026/8 / 6"作为独立 part 传入,匹配度虚高但答非所问
if not re.search(r'[\u4e00-\u9fff]', msg):
self._log_queue.put(f"[{platform.upper()}] ⚠ 消息无中文字符,跳过查库: {msg[:40]}")
return
if not self.kb.is_ready:
self._log_queue.put(f"[{platform.upper()}] {msg}")
self._log_queue.put(" ⚠ 知识库未加载,无法搜索答案")
return
# ⭐ 计算消息hash,用于锁内去重(防竞态条件)
import hashlib
msg_hash = hashlib.md5(msg.strip().encode('utf-8')).hexdigest()
# ⭐ 在锁内完成所有检查,防止竞态条件:
# 原来DB查重在无锁情况下运行,两个线程可同时通过DB查重,
# 然后都加入_processing_messages,导致同一条消息被处理两次。
with self._processing_lock:
# 检查是否正在处理(同一消息的第二次调用)
if msg_hash in self._processing_messages:
self._log_queue.put(f"[{platform.upper()}] ⚠ 消息正在处理中(锁去重),跳过: {msg[:40]}")
return
# DB查重:检查用户消息是否已被回复过(防重复回复)
if self.monitor._msg_history:
if self.monitor._msg_history.is_user_message_replied(platform, msg, time.time()):
self._log_queue.put(f"[{platform.upper()}] ⚠ 消息已被回复过(DB查重),跳过: {msg[:40]}")
return
# 加入正在处理集合
self._processing_messages.add(msg_hash)
# 在后台线程搜索
threading.Thread(target=self._search_and_log,
args=(platform, msg, msg_hash), daemon=True).start()
def _search_and_log(self, platform: str, msg: str, msg_hash: str):
"""搜索知识库并输出结果,匹配成功时队列自动回复到主线程"""
try:
# 详细调试日志:记录发送给KB搜索的完整消息
logger.debug(f"[{platform}] KB搜索输入: msg={msg!r} (长度={len(msg)})")
# 清理查询文本:移除用户名/头衔/时间戳等噪音,提升搜索精度
cleaned_msg = _clean_query_for_kb(msg)
if cleaned_msg != msg:
logger.debug(f"[{platform}] KB清理后: cleaned={cleaned_msg!r}")
results = self.kb.search(cleaned_msg, top_k=10)
lines = [f"[{platform.upper()}] {msg}"]
if results:
lines.append(f" 🔍 找到 {len(results)} 条相关结果:")
for i, (doc, score) in enumerate(results):
title = doc["title"]
heading = doc.get("heading", "")
body = doc.get("body", "")
content = doc.get("content", "")
# 详细调试日志:记录每个搜索结果的详细信息
logger.debug(f"[{platform}] KB结果[{i}]: 标题={title!r}, 匹配度={score:.2f}")
logger.debug(f"[{platform}] KB结果[{i}] heading={heading[:80]!r}")
logger.debug(f"[{platform}] KB结果[{i}] body={body[:120]!r}")
logger.debug(f"[{platform}] KB结果[{i}] content={content[:150]!r}")
# 显示更详细的信息
lines.append(f" 📄 [{title}] (匹配度 {score:.2f})")
lines.append(f" 标题: {heading[:60]}")
lines.append(f" 正文片段: {body[:100].replace(chr(10), ' ')}...")
if i == 0:
lines.append(f" 完整内容片段: {content[:200].replace(chr(10), ' ')}...")
# 从 top_k 结果中选取【第一个通过内容相关性校验】的文档发送,
# 避免永远只取 results[0] 导致"牛头不对马嘴"的错误文档被发送。
# 一条消息只回复一段(选中最相关且语义匹配的那一条),不触发 N 段回复。
# ⭐ 2026-08-17 修复:收集所有相关候选,按质量分排序选最优
# 原来只选第一个通过的,导致 protect"温馨提示"(score高但内容浅)压过 sdf1_login"2.1下载"(score略低但内容深)
chosen = None
if self.ar.enabled:
# 提取query的核心词(复用auto_reply的逻辑)
q_tokens = AutoReply._meaningful_tokens(cleaned_msg)
# 非核心词(复制自auto_reply.py)
non_core_kw = {
'多少', '哪些', '哪个', '什么', '怎么', '如何', '吗', '呢', '吧',
'备用', '深险', '端的', '那', '这', '些', '个', '种', '类',
'最近', '近期', '刚才', '现在', '当前', '刚刚', '以前', '之后', '当天',
'第一', '最新', '全部', '所有', '一起', '各种', '各种各样',
'哪些', '哪个', '什么', '怎么', '如何', '多少',
'相关联', '相关', '有关', '以及', '以及',
'问问', '问一嘴', '打听一下', '能够', '可以到',
'有个', '问题', '常用', '他们', '各位', '大家', '小伙伴',
'知道', '想知道',
'下载', '下到', '哪下', '在哪下', '怎么下',
'信息', '内容', '详情', '情况', '介绍',
'一句', '问一句', '再问',
'有人',
'一嘴',
'那家', '那价',
}
core_kw = q_tokens - non_core_kw
# OCR碎片过滤
core_kw = {kw for kw in core_kw if not (kw.isascii() and len(kw) <= 2)}
# 收集所有通过相关性检查的候选
relevant_candidates = []
for idx, (doc, sc) in enumerate(results):
if sc < self.ar.min_score:
continue
rel = self.ar._has_content_relevance(cleaned_msg, doc, sc)
logger.debug(
f"[{platform}] 相关性候选[{idx}]: title={doc.get('title', '')!r} "
f"score={sc:.2f} relevance={rel}"
)
if rel:
# 计算质量分:body越长、有表格/链接列表,质量越高
body = doc.get('body', '') or doc.get('content', '')
body_len = len(body.strip())
# 下载类查询优先选择详细表格,而非简略提示
is_download_query = any(w in cleaned_msg for w in ('下载', '下到', '哪下', '怎么下'))
is_table_body = '|' in body or '- [' in body
# ⭐ heading 可能把正文一起吞进来(如'## 温馨提示...<br>如果您:<br> - [ √ ] 插件下载...')
# 质量分只看 heading 第一段(<br>/换行/| 之前),避免'温馨提示'借正文'下载'蹭加分
_heading_head = re.split(r'<br>|\n|\||- ', (doc.get('heading') or ''))[0].lower()
if is_download_query:
if '下载' in _heading_head:
# ⭐ heading 明确是"下载"section(如'### 2.1 下载'、'## 4. 下载地址')
# → 这是真正的下载答案,强加分
quality_score = sc + 20.0
elif ('见' in body and body_len < 80) or '依托' in _heading_head:
# ⭐ 指针式提示(section内无实际内容,只写"见xxx"或"本系统依托[xx]")
# 优先于表格分支判定——body即使带'- ['链接列表也降权,
# 避免'温馨提示'这类简略提示压过真正下载表格
quality_score = sc - 10.0
elif is_table_body:
quality_score = sc + 10.0 # 下载查询中,表格/链接列表body额外+10分
else:
quality_score = sc + body_len / 100.0
else:
# 计算heading核心词命中奖励
heading_text = (doc.get('heading') or '').lower()
heading_hits = sum(1 for kw in core_kw if kw in heading_text)
heading_bonus = heading_hits * 10.0 # 每个核心词命中heading加10分
# 表格body加分(非下载查询也适用)
table_bonus = 5.0 if is_table_body else 0.0
quality_score = sc + body_len / 100.0 + heading_bonus + table_bonus
relevant_candidates.append((quality_score, sc, doc))
if relevant_candidates:
# 按质量分降序排列,选最高的
relevant_candidates.sort(key=lambda x: x[0], reverse=True)
_, orig_score, chosen_doc = relevant_candidates[0]
chosen = (chosen_doc, orig_score)
logger.debug(
f"[{platform}] 选优: score={orig_score:.2f} "
f"heading={(chosen_doc.get('heading') or '')[:30]!r}"
)
if not self.ar.enabled:
lines.append(" ⚠ 自动回复已关闭,不触发回复")
elif chosen is None:
lines.append(" ⚠ 所有结果均未通过内容相关性/阈值检查,跳过回复")
else:
lines.append(" ✅ 匹配通过,准备发送自动回复")
# 自动回复:队列到主线程执行(daemon 线程不能可靠 SetForegroundWindow)
if chosen is not None:
reply_data = self.ar.get_reply_data(
platform, msg, chosen[0], chosen[1], cleaned_msg=cleaned_msg
)
if reply_data:
self._reply_queue.put(reply_data)
else:
lines.append(" ⚠ get_reply_data 返回 None(重复命中或内部检查失败)")
else:
reply_data = None
else:
lines.append(" ❌ 知识库中未找到相关答案")
for line in lines:
self._log_queue.put(line)
finally:
# ⭐ 处理完成后从集合中移除消息hash
with self._processing_lock:
self._processing_messages.discard(msg_hash)
def _log(self, text: str):
"""直接写日志(主线程)"""
self._log_queue.put(text)
def _flush_log(self):
"""从队列消费日志到文本框"""
try:
while True:
line = self._log_queue.get_nowait()
self._log_text.configure(state="normal")
self._log_text.insert("end", line + "\n")
self._log_text.see("end")
self._log_text.configure(state="disabled")
except queue.Empty:
pass
self.after(200, self._flush_log)
def _flush_reply_queue(self):
"""从自动回复队列消费——主线程执行,SetForegroundWindow 可靠。"""
try:
while True:
data = self._reply_queue.get_nowait()
try:
self._execute_reply(data)
except Exception as e:
logger.error(f"自动回复执行异常: {e}", exc_info=True)
self.monitor._sending_event.clear()
except queue.Empty:
pass
self.after(200, self._flush_reply_queue)
def _execute_reply(self, data: dict):
"""主线程执行自动回复发送。加上发送锁防止监控轮询干扰。
自动重试:失败后延缓5秒重试,连续3次失败视为用户接管,退避本轮。
⭐ 2026-08-20 修复:记录目标重试时间,防止Tkinter after()延迟导致计时不准。
"""
import time as _time
platform = data["platform"]
answer = data["answer"]
sender = data.get("sender")
msg = data.get("msg", "")
max_retries = 3
retry_delay = 5 # 秒
attempt = data.get("_attempt", 0)
# ⭐ 验证重试时机:若目标时间未到,重新调度(防止after()延迟)
if attempt > 0:
_retry_at = data.get("_retry_at", 0)
_now = _time.time()
if _now < _retry_at:
_remaining = int((_retry_at - _now) * 1000)
logger.debug(f"[{platform}] 重试时机未到({_remaining}ms),重新调度")
data["_retry_at"] = _retry_at
self.after(_remaining, lambda d=data: self._execute_reply(d))
return
# ⭐ 发送前强制检查时间戳:≤300秒则发送,≥300秒则丢弃
msg_age = self._check_message_freshness(msg)
if msg_age is not None and msg_age > 300:
self._log(f"[{platform}] ⚠ 消息时间戳过期({msg_age:.0f}秒>300秒),丢弃: {msg[:40]}")
return
# 设发送锁,阻止监控轮询切走焦点
self.monitor._sending_event.set()
# ⭐ 设置回复进行中标志:防止打断后下次轮询重复触发
self.monitor._reply_in_progress[platform] = True
try:
if sender:
# 搜索联系人切换会话
found = self.monitor._search_contact(platform, sender)
if not found:
self._log(f"[{platform}] ⚠ 找不到联系人 {sender},当前会话回复")
# 首次正常发送,重试时 force_retry 跳过60秒冷却
ok = self.monitor._paste_and_send(platform, answer, force_retry=(attempt > 0))
if ok:
self._log(f"[{platform}] 🤖 已自动回复: {answer[:60]}...")
return # 成功直接退出
else:
if attempt < max_retries - 1:
# ⭐ 记录目标重试时间,确保重试间隔准确
_retry_at = _time.time() + retry_delay
data["_attempt"] = attempt + 1
data["_retry_at"] = _retry_at
self._log(f"[{platform}] ⚠ 自动回复发送失败,{retry_delay}秒后重试({attempt+1}/{max_retries})")
# ⭐ 2026-08-17 修复GUI冻结:原实现主线程 time.sleep(5) ×3 = 界面冻结15秒+
# 窗口不前置时 _paste_and_send 必失败,用户跟踪日志时 GUI 无响应。
# 改为 after() 异步重试:主线程立即返回,5秒后再执行,界面保持流畅。
self.after(int(retry_delay * 1000), lambda d=data: self._execute_reply(d))
else:
self._log(f"[{platform}] ⚠ 自动回复发送失败(连续{max_retries}次),用户可能在接管,本轮跳过")
finally:
self.monitor._sending_event.clear()
# ⭐ 回复完成或失败后清除进行中标志
self.monitor._reply_in_progress.pop(platform, None)
def _check_message_freshness(self, msg: str) -> float or None:
"""检查消息时间戳是否新鲜(≤300秒)。返回消息年龄(秒),无法解析返回None。"""
import re
from datetime import datetime, timedelta
# 尝试从消息中提取时间戳
# 常见格式:HH:MM, YYYY-MM-DD HH:MM, MM-DD HH:MM
patterns = [
(r'(\d{1,2}):(\d{2})', 'time_only'), # HH:MM
(r'(\d{4})-(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2})', 'full'), # YYYY-MM-DD HH:MM
(r'(\d{1,2})-(\d{1,2})\s+(\d{1,2}):(\d{2})', 'date_time'), # MM-DD HH:MM
]
for pattern, fmt in patterns:
match = re.search(pattern, msg)
if match:
now = datetime.now()
try:
if fmt == 'time_only':
# 只有时间,假设是今天
hour, minute = int(match.group(1)), int(match.group(2))
msg_time = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
# 如果时间在未来,可能是昨天的消息
if msg_time > now:
msg_time -= timedelta(days=1)
elif fmt == 'full':
year, month, day = int(match.group(1)), int(match.group(2)), int(match.group(3))
hour, minute = int(match.group(4)), int(match.group(5))
msg_time = datetime(year, month, day, hour, minute)
elif fmt == 'date_time':
month, day = int(match.group(1)), int(match.group(2))
hour, minute = int(match.group(3)), int(match.group(4))
msg_time = now.replace(month=month, day=day, hour=hour, minute=minute, second=0, microsecond=0)
# 如果时间在未来,可能是去年的消息
if msg_time > now:
msg_time = msg_time.replace(year=now.year - 1)
age = (now - msg_time).total_seconds()
if age >= 0:
return age
except (ValueError, OverflowError):
continue
# 无法解析时间戳,返回None(允许发送)
return None
def _clear_log(self):
self._log_text.configure(state="normal")
self._log_text.delete("1.0", "end")
self._log_text.configure(state="disabled")
# ── 急停回调 ──
def _on_emergency_stop(self):
"""双击 ESC 急停的回调(从 daemon 线程调用,日志队列安全)。"""
self._log_queue.put("⚠️⚠️⚠️ 双击ESC急停!消息监控已强制终止")
# 安全更新 toggle 状态(主线程 after 调度)
self.after(0, self._update_stop_ui)
def _stop_monitor_async(self):
"""后台线程执行 monitor.stop(),避免阻塞 GUI 主线程触发 Windows 冻结弹窗。
stop() 内含多个线程 join + _kill_easyocr_proc(terminate+wait 最多 6s),
同步在 GUI 主线程执行会冻结 6~8s,触发系统"软件无响应"弹窗。
"""
try:
self.monitor.stop()
except Exception as e:
logger.warning(f"[gui] 后台 stop 异常: {e}")
def _update_stop_ui(self):
"""主线程更新急停后的 UI(stop 放到后台线程,主线程不冻结)。"""
self._toggle_btn.deselect()
self._status_label.configure(text="⏸ 急停")
self._log_queue.put("🔄 可点击「启用监控」重新启动")
# ⭐ UI 立即更新;stop() 在后台线程执行,主线程保持响应,不再触发冻结弹窗。
threading.Thread(target=self._stop_monitor_async, daemon=True, name="gui-stop").start()
# ── 窗口关闭 ──
def _on_close(self):
# ⭐ 关闭窗口:释放OCR + 系统内存清理 → stop → destroy
def _stop_then_close():
try:
# 释放OCR引擎(杀子进程+系统内存回收)
self._ocr_manager._release()
except Exception:
pass
try:
self.monitor.stop()
except Exception:
pass
# 最终系统内存清理
_force_system_memory_cleanup()
self.after(0, self.destroy)
threading.Thread(target=_stop_then_close, daemon=True, name="gui-stop-close").start()