Files
msgbot/gui.py
T
youpaishidifu 90d6fb6ed4 重构OCR引擎管理器:双引擎并发+1分钟超时释放
1. 新增OCREngineManager类:
   - 预初始化EasyOCR(GPU=False) + Windows OCR两套引擎
   - 双引擎并发识别,取得分最高者结果
   - 1分钟超时自动释放内存(消息监控运行中不释放)
   - 支持监控激活时重置计时器

2. 修复截屏API:
   - GetSystemMetrics改用ctypes.windll.user32,兼容性更好

3. GUI集成:
   - 启动时后台预初始化OCR引擎
   - 监控开关时通知OCR管理器激活/停止状态
   - 移除旧_win_ocr方法(已由管理器统一处理)
2026-08-10 10:59:40 +08:00

1090 lines
44 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 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")
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 initialize(self):
"""预初始化两套OCR引擎"""
if self._initialized:
return True
with self._lock:
if self._initialized:
return True
self._log("🔧 初始化OCR引擎...")
# 初始化EasyOCR
try:
import easyocr
self._log(" ⏳ 初始化EasyOCR (CPU模式)...")
self._easyocr_reader = easyocr.Reader(['ch_sim', 'en'], gpu=False, verbose=False)
self._log(" ✅ EasyOCR就绪")
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._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引擎资源"""
with self._lock:
self._easyocr_reader = None
self._winocr_engine = None
self._initialized = False
self._log("🗑 OCR引擎已释放")
def set_monitor_active(self, active: bool):
"""设置消息监控激活状态"""
self._monitor_active = active
if active and self._initialized:
self._last_use_time = time.time() # 激活时重置计时
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,
'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. 移除@提及: @username
t = re.sub(r'@[A-Za-z0-9_\u4e00-\u9fff]{2,30}\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
# 5. 清理残留标点/空格
t = re.sub(r'[,,、\s]+', ' ', t).strip(',,、 \t')
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引擎管理器(双引擎: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._log_queue: queue.Queue[str] = queue.Queue()
# 自动回复执行队列(转发到主线程,避免 daemon 线程 SetForegroundWindow 失败)
self._reply_queue: queue.Queue[dict] = queue.Queue()
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
# ⭐ 跨平台互斥:同一消息只允许一个平台处理
import hashlib
msg_hash = hashlib.md5(msg.strip().encode('utf-8')).hexdigest()
with self._processing_lock:
if msg_hash in self._processing_messages:
self._log_queue.put(f"[{platform.upper()}] ⚠ 消息已被其他平台处理,跳过: {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=3)
lines = [f"[{platform.upper()}] {msg}"]
if results:
answer_title = results[0][0]["title"]
answer_score = results[0][1]
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), ' ')}...")
# 诊断日志:记录自动回复触发条件
if not self.ar.enabled:
lines.append(" ⚠ 自动回复已关闭,不触发回复")
elif answer_score < self.ar.min_score:
lines.append(f" ⚠ 匹配度 {answer_score:.2f} < 阈值 {self.ar.min_score},跳过")
else:
# 检查内容相关性
has_relevance = self.ar._has_content_relevance(msg, results[0][0], answer_score)
# 详细调试日志:记录内容相关性检查结果
logger.debug(f"[{platform}] 内容相关性检查: msg={msg!r}, has_relevance={has_relevance}")
logger.debug(f"[{platform}] 内容相关性检查: doc标题={results[0][0].get('title', '')!r}")
logger.debug(f"[{platform}] 内容相关性检查: doc heading={results[0][0].get('heading', '')[:50]!r}")
if not has_relevance:
heading = results[0][0].get("heading", "")[:40]
lines.append(f" ⚠ 内容相关性检查失败 (heading={heading!r}),跳过")
else:
lines.append(" ✅ 匹配通过,准备发送自动回复")
# 自动回复:队列到主线程执行(daemon 线程不能可靠 SetForegroundWindow)
reply_data = self.ar.get_reply_data(platform, msg, results[0][0], results[0][1])
if reply_data:
self._reply_queue.put(reply_data)
else:
lines.append(" ⚠ get_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次失败视为用户接管,退避本轮。
"""
platform = data["platform"]
answer = data["answer"]
sender = data.get("sender")
msg = data.get("msg", "")
max_retries = 3
retry_delay = 5 # 秒
# ⭐ 发送前强制检查时间戳:≤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()
try:
if sender:
# 搜索联系人切换会话
found = self.monitor._search_contact(platform, sender)
if not found:
self._log(f"[{platform}] ⚠ 找不到联系人 {sender},当前会话回复")
# 带重试的发送逻辑
for attempt in range(max_retries):
# 首次正常发送,重试时 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:
self._log(f"[{platform}] ⚠ 自动回复发送失败,{retry_delay}秒后重试({attempt+1}/{max_retries})")
time.sleep(retry_delay)
else:
self._log(f"[{platform}] ⚠ 自动回复发送失败(连续{max_retries}次),用户可能在接管,本轮跳过")
finally:
self.monitor._sending_event.clear()
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):
# ⭐ 关闭窗口同样把 stop 放到后台线程,完成后再关闭,避免主线程冻结。
def _stop_then_close():
try:
self.monitor.stop()
except Exception:
pass
self.after(0, self.destroy)
threading.Thread(target=_stop_then_close, daemon=True, name="gui-stop-close").start()