1332 lines
58 KiB
Python
1332 lines
58 KiB
Python
"""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 _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引擎资源"""
|
||
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,
|
||
'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()
|
||
|
||
# ⭐ 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()
|
||
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()
|
||
|
||
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()
|