init: 消息问答助手 — 微信/QQ监控 + 本地知识库搜索 + GUI

This commit is contained in:
youpaishidifu
2026-07-24 19:36:14 +08:00
parent 552c2cb0c5
commit bdfc24a4c7
8 changed files with 666 additions and 1 deletions
+294
View File
@@ -0,0 +1,294 @@
"""GUI — customtkinter 界面,开关 + 知识库路径 + 日志 """
import tkinter as tk
from tkinter import filedialog, messagebox
from typing import Optional
import logging
import queue
import threading
import os
import customtkinter as ctk
from config import load_config, save_config
from monitor import MessageMonitor
from knowledge_base import KnowledgeBase
logger = logging.getLogger("msgbot.gui")
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._log_queue: queue.Queue[str] = queue.Queue()
self._build_ui()
# 定时从队列取日志刷新
self.after(200, self._flush_log)
# 窗口关闭
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")
# ── 日志区域 ──
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
self.monitor.start()
self._status_label.configure(text="▶ 运行中", text_color="#2ecc71")
self._log("✅ 消息监控已启动")
else:
self.monitor.stop()
self._status_label.configure(text="⏸ 已暂停")
self._log("⏸ 消息监控已暂停")
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["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 _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 _on_new_message(self, platform: str, msg: str):
"""收到新消息 -> 搜索知识库 -> 日志输出"""
if not self.kb.is_ready:
self._log_queue.put(f"[{platform.upper()}] {msg}")
self._log_queue.put(" ⚠ 知识库未加载,无法搜索答案")
return
# 在后台线程搜索
threading.Thread(target=self._search_and_log,
args=(platform, msg), daemon=True).start()
def _search_and_log(self, platform: str, msg: str):
"""搜索知识库并输出结果"""
results = self.kb.search(msg, top_k=3)
lines = [f"[{platform.upper()}] {msg}"]
if results:
lines.append(f" 🔍 找到 {len(results)} 条相关结果:")
for doc, score in results:
title = doc["title"]
# 截取匹配段落
snippet = doc["content"][:200].replace("\n", " ")
if len(snippet) > 200:
snippet = snippet[:197] + "..."
lines.append(f" 📄 [{title}] (匹配度 {score:.2f})")
lines.append(f" {snippet}")
else:
lines.append(" ❌ 知识库中未找到相关答案")
for line in lines:
self._log_queue.put(line)
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 _clear_log(self):
self._log_text.configure(state="normal")
self._log_text.delete("1.0", "end")
self._log_text.configure(state="disabled")
# ── 窗口关闭 ──
def _on_close(self):
self.monitor.stop()
self.destroy()