Files
2026-07-16 12:11:05 +08:00

1475 lines
58 KiB
Python
Raw Permalink 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.
"""
企业微信 RPA 控制台
依赖:pip install mss Pillow numpy pyautogui pyperclip pywin32
运行:python wechat_gui.py
"""
import tkinter as tk
from tkinter import scrolledtext, ttk
import threading
import queue
import time
import sys
import os
import json
sys.path.insert(0, os.path.dirname(__file__))
from wechat_bot import WeChatBot, AUTO_REPLY_TEXT, POLL_INTERVAL, MOUSE_IDLE_SECONDS, MOUSE_IDLE_ENABLED
# ─────────────────── 设计系统:Linear × Cursor 产品化暗色 ───────────────────
# 参考:Linear / Cursor / ChatGPT — 弱边框、强字阶、单一强调色、零军工装饰
BG = "#0B0B0F"
SURFACE = "#121216"
SURFACE2 = "#18181D"
ELEVATED = "#1C1C22"
INK = "#EDEDF0"
MUTED = "#8B8B96"
FAINT = "#5C5C66"
LINE = "#26262D"
LINE_SOFT = "#1F1F26"
ACCENT = "#6E7BF2" # Linear indigo · 唯一主色
ACCENT_HOV = "#838DF5"
ACCENT_DIM = "#22264A"
ACCENT_SOFT= "#16182A"
GREEN = "#3ECF8E"
GREEN_DIM = "#1A3A2C"
AMBER = "#E8A838"
ROSE = "#E5484D"
ROSE_SOFT = "#2A1518"
# 兼容旧引用名
TEAL = ACCENT
TEAL_HOV = ACCENT_HOV
TEAL_SOFT = ACCENT_SOFT
CYAN = ACCENT
CYAN_DIM = ACCENT_DIM
MAGENTA = ACCENT
TERMINAL = GREEN
PHOSPHOR = INK
GLASS = ELEVATED
VOID = BG
LINE_HI = LINE
VIOLET = ACCENT
FONT = "Microsoft YaHei UI"
FONT_MONO = "Consolas"
WIN_TITLE = "甄养堂 · 企微客服助手"
FS_TITLE = 17
FS_H1 = 14
FS_BODY = 12
FS_LABEL = 11
FS_META = 11
FS_BTN = 12
FS_STAT = 22
FS_LOG = 12
TAB_NAMES = ("运行", "智能", "业务")
# ──────────────────────────────────────────────────
class LogQueue:
"""将 print 重定向到队列"""
def __init__(self, q):
self.q = q
def write(self, msg):
if msg.strip():
self.q.put(("log", msg.rstrip()))
def flush(self):
pass
class BotThread(threading.Thread):
def __init__(self, q, reply_text, poll_sec,
mouse_idle_enabled=True, mouse_idle_sec=20):
super().__init__(daemon=True)
self.q = q
self.reply_text = reply_text
self.poll_sec = poll_sec
self.mouse_idle_enabled = mouse_idle_enabled
self.mouse_idle_sec = mouse_idle_sec
self._stop = threading.Event()
def run(self):
import wechat_bot as wb
import win32gui
wb.AUTO_REPLY_TEXT = self.reply_text
bot = WeChatBot()
bot.mouse_idle_enabled = self.mouse_idle_enabled
bot.mouse_idle_seconds = self.mouse_idle_sec
bot._stop_check = self._stop
gui_hwnd = win32gui.FindWindow(None, WIN_TITLE)
bot.gui_hwnd = gui_hwnd
if not bot.connect():
self.q.put(("status", "error"))
return
bot.set_topmost(True)
print("[+] 企业微信已设为置顶窗口,监听期间保持最前显示。")
if bot.mouse_idle_enabled:
print(f"[+] 人机共存已开启:鼠标静止 {bot.mouse_idle_seconds:.0f}s 后才自动操作。")
self.q.put(("status", "running"))
self.q.put(("info", {
"hwnd": f"0x{bot.hwnd:08X}",
"size": f"{bot.R - bot.L}×{bot.B - bot.T}",
"input": f"({bot.input_x}, {bot.input_y})",
}))
while not self._stop.is_set():
bot._poll_once()
self.q.put(("stats", {
"replied": len(bot.replied),
"false_pos": len(bot.false_pos_rows),
}))
self._stop.wait(self.poll_sec)
bot.set_topmost(False)
print("[+] 企业微信置顶已取消。")
self.q.put(("status", "stopped"))
def stop(self):
self._stop.set()
# ──────────────────────────────────────────────────
class Toggle(tk.Frame):
"""胶囊开关。"""
def __init__(self, parent, variable: tk.BooleanVar, text="", command=None, **kw):
super().__init__(parent, bg=kw.get("bg", SURFACE))
self.var = variable
self.command = command
self._bg = kw.get("bg", SURFACE)
self.canvas = tk.Canvas(self, width=42, height=24,
bg=self._bg, highlightthickness=0, cursor="hand2")
self.canvas.pack(side="left")
self.canvas.bind("<Button-1>", self._toggle)
self.label = tk.Label(self, text=text, bg=self._bg, fg=INK,
font=(FONT, FS_BODY), cursor="hand2", justify="left")
self.label.pack(side="left", padx=(10, 0))
self.label.bind("<Button-1>", self._toggle)
self.var.trace_add("write", lambda *_: self._draw())
self._draw()
def _toggle(self, _=None):
self.var.set(not self.var.get())
if self.command:
self.command()
def _draw(self):
c = self.canvas
c.delete("all")
on = self.var.get()
track = ACCENT if on else "#2E2E36"
c.create_oval(0, 0, 24, 24, fill=track, outline="")
c.create_oval(18, 0, 42, 24, fill=track, outline="")
c.create_rectangle(12, 0, 30, 24, fill=track, outline="")
kx = 20 if on else 2
c.create_oval(kx, 3, kx + 18, 21, fill="#FFFFFF", outline="")
class SoftButton(tk.Label):
"""扁平按钮(不用 Canvas,避免毛边叠字)。"""
def __init__(self, parent, text, command=None, *,
width=None, height=None, primary=False, danger=False, **kw):
self._command = command
self._primary = primary
self._danger = danger
self._enabled = True
self._hover = False
super().__init__(
parent, text=text,
font=(FONT, FS_BTN, "bold" if primary else "normal"),
padx=16, pady=8, cursor="hand2",
)
self._apply()
self.bind("<Enter>", self._on_enter)
self.bind("<Leave>", self._on_leave)
self.bind("<Button-1>", self._on_click)
def _colors(self):
if not self._enabled:
return SURFACE2, FAINT
if self._danger:
return (ROSE if self._hover else ROSE_SOFT,
"#FFFFFF" if self._hover else ROSE)
if self._primary:
return (ACCENT_HOV if self._hover else ACCENT), "#FFFFFF"
return (ELEVATED if self._hover else SURFACE2,
INK if self._hover else MUTED)
def _apply(self):
bg, fg = self._colors()
self.configure(bg=bg, fg=fg)
def _on_enter(self, _=None):
self._hover = True
self._apply()
def _on_leave(self, _=None):
self._hover = False
self._apply()
def _on_click(self, _=None):
if self._enabled and self._command:
self._command()
def set_enabled(self, enabled: bool):
self._enabled = enabled
self.configure(cursor="hand2" if enabled else "arrow")
self._apply()
def set_text(self, text: str):
self.configure(text=text)
NeonButton = SoftButton
class SoftCard(tk.Frame):
"""干净卡片:只放标题,说明放在内容区,禁止 wraplength 叠字。"""
def __init__(self, parent, title=None, subtitle=None, pad=20, **kw):
super().__init__(parent, bg=kw.get("bg", BG))
shell = tk.Frame(self, bg=SURFACE, highlightthickness=1,
highlightbackground=LINE)
shell.pack(fill="both", expand=True)
if title:
head = tk.Frame(shell, bg=SURFACE)
head.pack(fill="x", padx=pad, pady=(16, 0))
tk.Label(head, text=title, bg=SURFACE, fg=INK,
font=(FONT, FS_H1)).pack(anchor="w")
self.body = tk.Frame(shell, bg=SURFACE)
self.body.pack(fill="both", expand=True, padx=pad, pady=(12, 18))
HullPanel = SoftCard
class SoftTabs(tk.Frame):
"""分段 Tab。"""
def __init__(self, parent, names, on_change, **kw):
super().__init__(parent, bg=kw.get("bg", BG))
self._on_change = on_change
self._btns = {}
self._bars = {}
self._ready = False
bar = tk.Frame(self, bg=BG)
bar.pack(fill="x")
for i, name in enumerate(names):
cell = tk.Frame(bar, bg=BG, cursor="hand2")
cell.pack(side="left", padx=(0 if i == 0 else 2, 0))
lbl = tk.Label(cell, text=name, bg=BG, fg=MUTED,
font=(FONT, FS_BODY), padx=12, pady=8, cursor="hand2")
lbl.pack()
underline = tk.Frame(cell, bg=BG, height=2)
underline.pack(fill="x")
for w in (cell, lbl, underline):
w.bind("<Button-1>", lambda e, n=name: self.select(n))
self._btns[name] = lbl
self._bars[name] = underline
tk.Frame(self, bg=LINE, height=1).pack(fill="x")
self._ready = True
self.select(names[0])
def select(self, name):
for n, lbl in self._btns.items():
on = n == name
lbl.configure(fg=INK if on else MUTED)
self._bars[n].configure(bg=ACCENT if on else BG)
if self._ready:
self._on_change(name)
TabStrip = SoftTabs
class App(tk.Tk):
def __init__(self):
super().__init__()
self.title(WIN_TITLE)
self.configure(bg=BG)
self.geometry("1280x860")
self.minsize(1080, 720)
self._q = queue.Queue()
self._thread = None
self._start_time = None
self._running = False
self._fx_phase = 0
self._status_key = "stopped"
self._build()
self._tick()
self._fx_tick()
def _build(self):
self._build_header()
self._build_metrics_bar()
body = tk.Frame(self, bg=BG)
body.pack(fill="both", expand=True, padx=24, pady=(8, 20))
left = tk.Frame(body, bg=BG, width=400)
left.pack(side="left", fill="y", padx=(0, 16))
left.pack_propagate(False)
self._tab_pages = {}
page_host = tk.Frame(left, bg=BG)
self._tab_strip = SoftTabs(left, TAB_NAMES, self._show_tab, bg=BG)
self._tab_strip.pack(fill="x", pady=(0, 12))
page_host.pack(fill="both", expand=True)
for name in TAB_NAMES:
self._tab_pages[name] = tk.Frame(page_host, bg=BG)
self._build_system_tab(self._tab_pages["运行"])
self._build_ai_tab(self._tab_pages["智能"])
self._build_ops_tab(self._tab_pages["业务"])
self._show_tab("运行")
right = tk.Frame(body, bg=BG)
right.pack(side="left", fill="both", expand=True)
self._build_log(right)
self._refresh_registration_count()
self._refresh_sessions_count()
def _show_tab(self, name):
for n, page in self._tab_pages.items():
if n == name:
page.pack(fill="both", expand=True)
else:
page.pack_forget()
def _fx_tick(self):
self._fx_phase = (self._fx_phase + 1) % 100000
try:
if hasattr(self, "_sdot") and self._status_key == "running":
t = (self._fx_phase % 50) / 50.0
col = GREEN if t < 0.55 else GREEN_DIM
self._sdot.itemconfig(self._sdot_id, fill=col)
except Exception:
pass
self.after(80, self._fx_tick)
def _field_label(self, parent, text):
tk.Label(parent, text=text, bg=SURFACE, fg=MUTED,
font=(FONT, FS_META)).pack(anchor="w")
def _padded_entry(self, parent, textvariable, width=None):
box = tk.Frame(parent, bg=SURFACE2,
highlightthickness=1, highlightbackground=LINE)
kw = dict(
textvariable=textvariable, bg=SURFACE2, fg=INK,
insertbackground=ACCENT, relief="flat", font=(FONT, FS_BODY),
bd=0, highlightthickness=0,
)
if width is not None:
kw["width"] = width
ent = tk.Entry(box, **kw)
ent.pack(fill="x", padx=12, pady=9)
def _focus_in(_=None):
box.configure(highlightbackground=ACCENT)
def _focus_out(_=None):
box.configure(highlightbackground=LINE)
ent.bind("<FocusIn>", _focus_in)
ent.bind("<FocusOut>", _focus_out)
return box, ent
def _build_header(self):
hdr = tk.Frame(self, bg=SURFACE)
hdr.pack(fill="x")
tk.Frame(self, bg=LINE, height=1).pack(fill="x")
inner = tk.Frame(hdr, bg=SURFACE)
inner.pack(fill="x", padx=24, pady=14)
brand = tk.Frame(inner, bg=SURFACE)
brand.pack(side="left")
mark = tk.Label(brand, text="甄", bg=ACCENT, fg="#FFFFFF",
font=(FONT, 12, "bold"), width=3, pady=6)
mark.pack(side="left")
titles = tk.Frame(brand, bg=SURFACE)
titles.pack(side="left", padx=(12, 0))
tk.Label(titles, text="企微客服助手", bg=SURFACE, fg=INK,
font=(FONT, FS_TITLE)).pack(anchor="w")
tk.Label(titles, text="甄养堂互联网医院", bg=SURFACE, fg=MUTED,
font=(FONT, FS_META)).pack(anchor="w")
status = tk.Frame(inner, bg=SURFACE2, highlightthickness=1,
highlightbackground=LINE)
status.pack(side="left", padx=(24, 0), ipadx=10, ipady=5)
sr = tk.Frame(status, bg=SURFACE2)
sr.pack()
self._sdot = tk.Canvas(sr, width=8, height=8, bg=SURFACE2, highlightthickness=0)
self._sdot.pack(side="left", pady=3)
self._sdot_id = self._sdot.create_oval(0, 0, 8, 8, fill=FAINT, outline="")
self._s_lbl = tk.Label(sr, text="未连接", bg=SURFACE2, fg=MUTED,
font=(FONT, FS_META))
self._s_lbl.pack(side="left", padx=(8, 0))
meta = tk.Frame(inner, bg=SURFACE)
meta.pack(side="left", padx=(18, 0))
for i, (label, attr) in enumerate([
("窗口", "_hwnd_lbl"), ("尺寸", "_size_lbl"), ("输入", "_ibox_lbl"),
]):
cell = tk.Frame(meta, bg=SURFACE)
cell.pack(side="left", padx=(0 if i == 0 else 14, 0))
tk.Label(cell, text=label, bg=SURFACE, fg=FAINT,
font=(FONT, 10)).pack(anchor="w")
lbl = tk.Label(cell, text="—", bg=SURFACE, fg=MUTED,
font=(FONT_MONO, 10))
lbl.pack(anchor="w")
setattr(self, attr, lbl)
btns = tk.Frame(inner, bg=SURFACE)
btns.pack(side="right")
self._stop_btn = SoftButton(
btns, "停止", command=self._on_stop, danger=True)
self._stop_btn.pack(side="right", padx=(8, 0))
self._stop_btn.set_enabled(False)
self._start_btn = SoftButton(
btns, "开始监听", command=self._on_start, primary=True)
self._start_btn.pack(side="right")
def _build_metrics_bar(self):
bar = tk.Frame(self, bg=BG)
bar.pack(fill="x", padx=24, pady=(14, 0))
for i, (label, attr) in enumerate([
("已回复", "_lbl_replied"),
("假阳性", "_lbl_fp"),
("待挂号", "_reg_count_lbl"),
("会话档案", "_sess_count_lbl"),
]):
cell = tk.Frame(bar, bg=SURFACE, highlightthickness=1,
highlightbackground=LINE)
cell.pack(side="left", fill="x", expand=True,
padx=(0 if i == 0 else 8, 0))
pad = tk.Frame(cell, bg=SURFACE)
pad.pack(fill="x", padx=14, pady=10)
tk.Label(pad, text=label, bg=SURFACE, fg=MUTED,
font=(FONT, FS_META)).pack(anchor="w")
v = tk.Label(pad, text="0", bg=SURFACE, fg=INK, font=(FONT, 20))
v.pack(anchor="w")
setattr(self, attr, v)
self._timer_lbl = tk.Label(self, text="运行时长 —", bg=BG, fg=FAINT,
font=(FONT, FS_META))
self._timer_lbl.pack(anchor="w", padx=24, pady=(8, 0))
def _card(self, parent, title=None, pad=16):
panel = SoftCard(parent, title=title, pad=pad, bg=BG)
panel.pack(fill="both", expand=True, pady=(0, 10))
return panel, panel.body
def _build_system_tab(self, p):
panel = SoftCard(p, title="运行参数", bg=BG)
panel.pack(fill="both", expand=True)
inner = panel.body
self._field_label(inner, "固定回复(AI 关闭时使用)")
self._reply_var = tk.StringVar(value=AUTO_REPLY_TEXT)
box, _ = self._padded_entry(inner, self._reply_var)
box.pack(fill="x", pady=(6, 14))
row = tk.Frame(inner, bg=SURFACE)
row.pack(fill="x", pady=(0, 14))
left = tk.Frame(row, bg=SURFACE)
left.pack(side="left", fill="x", expand=True, padx=(0, 10))
self._field_label(left, "轮询间隔(秒)")
self._poll_var = tk.StringVar(value=str(POLL_INTERVAL))
box, _ = self._padded_entry(left, self._poll_var, width=8)
box.pack(fill="x", pady=(6, 0))
right = tk.Frame(row, bg=SURFACE)
right.pack(side="left", fill="x", expand=True)
self._field_label(right, "静止等待(秒)")
self._idle_sec_var = tk.StringVar(value=str(MOUSE_IDLE_SECONDS))
box, _ = self._padded_entry(right, self._idle_sec_var, width=8)
box.pack(fill="x", pady=(6, 0))
tk.Frame(inner, bg=LINE, height=1).pack(fill="x", pady=(4, 12))
self._mouse_idle_var = tk.BooleanVar(value=MOUSE_IDLE_ENABLED)
Toggle(inner, self._mouse_idle_var, "鼠标静止后再自动操作").pack(anchor="w", fill="x")
tk.Label(inner, text="有人动鼠标时暂停,静止满设定秒数后继续",
bg=SURFACE, fg=FAINT, font=(FONT, 10)).pack(anchor="w", pady=(6, 0))
def _build_ai_tab(self, p):
panel = SoftCard(p, title="智能回复", bg=BG)
panel.pack(fill="both", expand=True)
inner = panel.body
self._ai_var = tk.BooleanVar(value=False)
self._ctx_var = tk.BooleanVar(value=True)
self._insult_var = tk.BooleanVar(value=False)
self._mcp_var = tk.BooleanVar(value=False)
try:
from ai_config import (
AI_ENABLED, AI_CONTEXT_ENABLED, AI_COUNTER_INSULT_ENABLED,
AI_MCP_ENABLED,
)
self._ai_var.set(AI_ENABLED)
self._ctx_var.set(AI_CONTEXT_ENABLED)
self._insult_var.set(AI_COUNTER_INSULT_ENABLED)
self._mcp_var.set(AI_MCP_ENABLED)
except ImportError:
pass
for text, var in (
("启用 AI 回复", self._ai_var),
("携带会话上下文", self._ctx_var),
("对骂模式(被骂时怼回)", self._insult_var),
("MCP 工具增强回复", self._mcp_var),
):
Toggle(inner, var, text, command=self._refresh_ai_status).pack(
anchor="w", pady=(0, 10), fill="x")
tk.Frame(inner, bg=LINE, height=1).pack(fill="x", pady=(4, 12))
btn_row = tk.Frame(inner, bg=SURFACE)
btn_row.pack(anchor="w", fill="x")
SoftButton(btn_row, "高级配置", command=self._open_ai_config).pack(side="left")
SoftButton(btn_row, "MCP 服务器", command=self._open_mcp_config).pack(
side="left", padx=(8, 0))
self._ai_status = tk.Label(inner, text="", bg=SURFACE, fg=MUTED,
font=(FONT, FS_META), anchor="w", justify="left")
self._ai_status.pack(anchor="w", fill="x", pady=(12, 0))
self._refresh_ai_status()
def _build_ops_tab(self, p):
reg = SoftCard(p, title="挂号问诊登记", bg=BG)
reg.pack(fill="x", pady=(0, 10))
row = tk.Frame(reg.body, bg=SURFACE)
row.pack(fill="x")
left = tk.Frame(row, bg=SURFACE)
left.pack(side="left", fill="x", expand=True)
self._reg_mirror = tk.Label(left, text="0", bg=SURFACE, fg=INK, font=(FONT, 26))
self._reg_mirror.pack(anchor="w")
tk.Label(left, text="待处理线索", bg=SURFACE, fg=MUTED,
font=(FONT, FS_META)).pack(anchor="w")
SoftButton(row, "打开登记", command=self._open_registration_leads,
primary=True).pack(side="right")
sess = SoftCard(p, title="会话档案", bg=BG)
sess.pack(fill="both", expand=True)
row2 = tk.Frame(sess.body, bg=SURFACE)
row2.pack(fill="x")
left2 = tk.Frame(row2, bg=SURFACE)
left2.pack(side="left", fill="x", expand=True)
self._sess_mirror = tk.Label(left2, text="0", bg=SURFACE, fg=INK, font=(FONT, 26))
self._sess_mirror.pack(anchor="w")
tk.Label(left2, text="上下文档案数", bg=SURFACE, fg=MUTED,
font=(FONT, FS_META)).pack(anchor="w")
SoftButton(row2, "管理档案", command=self._open_sessions).pack(side="right")
def _build_log(self, p):
card = SoftCard(p, title="运行日志", bg=BG)
card.pack(fill="both", expand=True)
inner = card.body
tool = tk.Frame(inner, bg=SURFACE)
tool.pack(fill="x", pady=(0, 10))
tk.Label(tool, text="实时输出机器人状态与回复过程",
bg=SURFACE, fg=FAINT, font=(FONT, 10)).pack(side="left")
SoftButton(tool, "清空", command=self._clear_log).pack(side="right")
log_shell = tk.Frame(inner, bg="#0E0E12",
highlightthickness=1, highlightbackground=LINE)
log_shell.pack(fill="both", expand=True)
self._log = scrolledtext.ScrolledText(
log_shell, bg="#0E0E12", fg="#C8C8D0",
font=(FONT_MONO, FS_LOG), relief="flat", bd=0,
insertbackground=ACCENT, state="disabled",
wrap="word", padx=14, pady=12)
self._log.pack(fill="both", expand=True)
self._log.tag_config("ok", foreground=GREEN)
self._log.tag_config("warn", foreground=AMBER)
self._log.tag_config("err", foreground=ROSE)
self._log.tag_config("notify", foreground=ACCENT_HOV)
self._log.tag_config("dim", foreground=FAINT)
def _refresh_sessions_count(self):
try:
from conversation_store import ConversationStore
path = os.path.join(os.path.dirname(__file__), "conversations.json")
n = ConversationStore(path).count()
if hasattr(self, "_sess_count_lbl"):
self._sess_count_lbl.config(text=str(n), fg=INK if n else MUTED)
if hasattr(self, "_sess_mirror"):
self._sess_mirror.config(text=str(n), fg=INK if n else MUTED)
except Exception:
if hasattr(self, "_sess_count_lbl"):
self._sess_count_lbl.config(text="—", fg=MUTED)
if hasattr(self, "_sess_mirror"):
self._sess_mirror.config(text="—", fg=MUTED)
def _refresh_registration_count(self):
try:
from registration_store import RegistrationStore
n = RegistrationStore().pending_count()
if hasattr(self, "_reg_count_lbl"):
self._reg_count_lbl.config(text=str(n), fg=INK if n else MUTED)
if hasattr(self, "_reg_mirror"):
self._reg_mirror.config(text=str(n), fg=INK if n else MUTED)
except Exception:
if hasattr(self, "_reg_count_lbl"):
self._reg_count_lbl.config(text="—", fg=MUTED)
if hasattr(self, "_reg_mirror"):
self._reg_mirror.config(text="—", fg=MUTED)
def _style_tree(self, win):
style = ttk.Style(win)
try:
style.theme_use("clam")
except Exception:
pass
style.configure(
"Mgr.Treeview",
background=SURFACE2,
fieldbackground=SURFACE2,
foreground=INK,
rowheight=40,
font=(FONT, 12),
borderwidth=0,
relief="flat",
)
style.configure(
"Mgr.Treeview.Heading",
background=SURFACE,
foreground=MUTED,
font=(FONT, 11),
relief="flat",
padding=(12, 10),
)
style.map(
"Mgr.Treeview",
background=[("selected", ACCENT_DIM)],
foreground=[("selected", INK)],
)
return style
def _mgr_action_btn(self, parent, text, command, *, primary=False, danger=False, muted=False):
return SoftButton(
parent, text, command=command,
primary=primary, danger=danger,
)
def _open_mgr_shell(self, title, subtitle, geometry="1180x760", minsize=(1000, 640)):
win = tk.Toplevel(self)
win.title(title)
win.configure(bg=BG)
win.geometry(geometry)
win.minsize(*minsize)
win.transient(self)
self._style_tree(win)
shell = tk.Frame(win, bg=BG)
shell.pack(fill="both", expand=True, padx=20, pady=20)
card = tk.Frame(shell, bg=SURFACE, highlightthickness=1, highlightbackground=LINE)
card.pack(fill="both", expand=True)
footer = tk.Frame(card, bg=SURFACE)
footer.pack(side="bottom", fill="x", padx=24, pady=(12, 20))
tk.Frame(footer, bg=LINE, height=1).pack(fill="x", pady=(0, 14))
foot_inner = tk.Frame(footer, bg=SURFACE)
foot_inner.pack(fill="x")
hdr = tk.Frame(card, bg=SURFACE)
hdr.pack(side="top", fill="x", padx=28, pady=(24, 8))
tk.Label(hdr, text=title, bg=SURFACE, fg=INK,
font=(FONT, 18)).pack(anchor="w")
tk.Label(
hdr, text=subtitle, bg=SURFACE, fg=MUTED,
font=(FONT, 12), justify="left", wraplength=980,
).pack(anchor="w", pady=(6, 0))
body = tk.Frame(card, bg=SURFACE)
body.pack(side="top", fill="both", expand=True, padx=24, pady=(8, 0))
return win, body, foot_inner
def _conversations_store(self):
from conversation_store import ConversationStore
return ConversationStore(os.path.join(os.path.dirname(__file__), "conversations.json"))
def _open_sessions(self):
import time as _time
from tkinter import messagebox
win, body, foot = self._open_mgr_shell(
"会话档案",
"支持单选 / Ctrl·Shift 多选删除。删除后该客户下次再聊会重新建档。",
)
status_lbl = tk.Label(foot, text="", bg=SURFACE, fg=MUTED, font=(FONT, 11))
status_lbl.pack(side="left")
btn_row = tk.Frame(foot, bg=SURFACE)
btn_row.pack(side="right")
# 左右分栏:列表 + 预览
panes = tk.PanedWindow(body, orient="horizontal", bg=SURFACE, sashwidth=6,
sashrelief="flat", bd=0, opaqueresize=True)
panes.pack(fill="both", expand=True)
left = tk.Frame(panes, bg=SURFACE)
right = tk.Frame(panes, bg=SURFACE)
panes.add(left, minsize=520, stretch="always")
panes.add(right, minsize=360, stretch="always")
list_card = tk.Frame(left, bg=SURFACE, highlightthickness=1, highlightbackground=LINE)
list_card.pack(fill="both", expand=True, padx=(0, 8))
tk.Label(list_card, text="全部会话", bg=SURFACE, fg=MUTED,
font=(FONT, 11, "bold")).pack(anchor="w", padx=16, pady=(14, 6))
tree_wrap = tk.Frame(list_card, bg=SURFACE)
tree_wrap.pack(fill="both", expand=True, padx=8, pady=(0, 8))
cols = ("time", "msgs", "preview")
tree = ttk.Treeview(
tree_wrap, columns=cols, show="headings",
selectmode="extended", style="Mgr.Treeview",
)
tree.heading("time", text="最近更新")
tree.heading("msgs", text="消息")
tree.heading("preview", text="内容预览")
tree.column("time", width=120, minwidth=110, stretch=False, anchor="w")
tree.column("msgs", width=64, minwidth=56, stretch=False, anchor="center")
tree.column("preview", width=420, minwidth=220, stretch=True, anchor="w")
vsb = ttk.Scrollbar(tree_wrap, orient="vertical", command=tree.yview)
tree.configure(yscrollcommand=vsb.set)
tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
tree_wrap.rowconfigure(0, weight=1)
tree_wrap.columnconfigure(0, weight=1)
prev_card = tk.Frame(right, bg=SURFACE, highlightthickness=1, highlightbackground=LINE)
prev_card.pack(fill="both", expand=True, padx=(8, 0))
prev_hdr = tk.Frame(prev_card, bg=SURFACE)
prev_hdr.pack(fill="x", padx=16, pady=(14, 6))
tk.Label(prev_hdr, text="对话预览", bg=SURFACE, fg=MUTED,
font=(FONT, 11, "bold")).pack(side="left")
sid_lbl = tk.Label(prev_hdr, text="", bg=SURFACE, fg=MUTED, font=(FONT_MONO, 10))
sid_lbl.pack(side="right")
detail = scrolledtext.ScrolledText(
prev_card, bg="#0E0E12", fg=INK, font=(FONT, 12),
relief="flat", bd=0, wrap="word", padx=16, pady=14,
spacing1=4, spacing3=8, insertbackground=ACCENT,
)
detail.pack(fill="both", expand=True, padx=8, pady=(0, 8))
detail.tag_config("user", foreground=ACCENT_HOV, font=(FONT, 12))
detail.tag_config("agent", foreground=MUTED, font=(FONT, 12))
detail.tag_config("body", foreground=INK, font=(FONT, 12), lmargin1=12, lmargin2=12)
detail.tag_config("empty", foreground=MUTED, font=(FONT, 12))
detail.configure(state="disabled")
store = self._conversations_store()
def _fmt_ts(ts):
try:
return _time.strftime("%m-%d %H:%M", _time.localtime(float(ts)))
except Exception:
return "—"
def _set_status(text):
status_lbl.config(text=text)
def _clear_detail():
detail.configure(state="normal")
detail.delete("1.0", "end")
detail.configure(state="disabled")
sid_lbl.config(text="")
def _reload():
nonlocal store
store = self._conversations_store()
tree.delete(*tree.get_children())
for item in store.list_sessions():
tree.insert(
"", "end", iid=item["session_id"],
values=(
_fmt_ts(item.get("updated")),
item.get("message_count", 0),
(item.get("preview") or "—")[:80],
),
)
_clear_detail()
_set_status(f"共 {store.count()} 条 · Ctrl / Shift 多选")
self._refresh_sessions_count()
def _on_select(_=None):
sel = tree.selection()
detail.configure(state="normal")
detail.delete("1.0", "end")
if len(sel) == 1:
sid = sel[0]
sid_lbl.config(text=sid)
hist = store.history(sid)[-20:]
if not hist:
detail.insert("end", "这条会话还没有文字记录。", "empty")
else:
for m in hist:
is_user = m.get("role") == "user"
tag = "user" if is_user else "agent"
label = "客户" if is_user else "客服"
c = (m.get("content") or "").strip() or "—"
detail.insert("end", f"{label}\n", tag)
detail.insert("end", f"{c}\n\n", "body")
_set_status(f"已选 1 条")
elif len(sel) > 1:
sid_lbl.config(text="")
detail.insert("end", f"已选中 {len(sel)} 条会话。\n可批量删除,或点其中一条查看详情。", "empty")
_set_status(f"已选 {len(sel)} 条")
else:
sid_lbl.config(text="")
_set_status(f"共 {store.count()} 条 · Ctrl / Shift 多选")
detail.configure(state="disabled")
def _select_all():
kids = tree.get_children()
if kids:
tree.selection_set(kids)
_on_select()
def _delete_one():
sel = list(tree.selection())
if len(sel) != 1:
messagebox.showinfo("提示", "请先选中 1 条要删除的会话", parent=win)
return
sid = sel[0]
if not messagebox.askyesno("确认删除", "确定删除这条会话档案?", parent=win):
return
if self._conversations_store().delete(sid):
self._append("✓ 已删除 1 条会话档案", "ok")
_reload()
def _delete_selected():
sel = list(tree.selection())
if not sel:
messagebox.showinfo("提示", "请先选中要删除的会话(可多选)", parent=win)
return
if not messagebox.askyesno(
"批量删除",
f"确定删除选中的 {len(sel)} 条会话档案吗?不可恢复。",
parent=win,
):
return
st = self._conversations_store()
ok = sum(1 for sid in sel if st.delete(sid))
self._append(f"✓ 已批量删除 {ok} 条会话档案", "ok")
_reload()
def _clear_all():
st = self._conversations_store()
n = st.count()
if n <= 0:
messagebox.showinfo("提示", "当前没有会话档案", parent=win)
return
if not messagebox.askyesno(
"清空全部", f"确定清空全部 {n} 条会话档案吗?不可恢复。", parent=win
):
return
deleted = st.clear_all()
self._append(f"✓ 已清空全部会话档案({deleted}", "ok")
_reload()
tree.bind("<<TreeviewSelect>>", _on_select)
tree.bind("<Delete>", lambda e: _delete_selected())
self._mgr_action_btn(btn_row, "刷新", _reload).pack(side="left")
self._mgr_action_btn(btn_row, "全选", _select_all).pack(side="left", padx=(10, 0))
self._mgr_action_btn(btn_row, "删除", _delete_one, danger=True).pack(side="left", padx=(10, 0))
self._mgr_action_btn(btn_row, "批量删除", _delete_selected, danger=True).pack(side="left", padx=(10, 0))
self._mgr_action_btn(btn_row, "清空全部", _clear_all, muted=True).pack(side="left", padx=(10, 0))
self._mgr_action_btn(btn_row, "关闭", win.destroy, muted=True).pack(side="left", padx=(10, 0))
_reload()
def _refresh_ai_status(self):
try:
import ai_config
if self._ai_var.get():
bits = [ai_config.AI_MODEL]
if self._ctx_var.get():
bits.append("上下文")
if self._insult_var.get():
bits.append("对骂")
if self._mcp_var.get():
n = len(getattr(ai_config, "AI_MCP_SERVERS", []) or [])
bits.append(f"MCP×{n}")
try:
from registration_store import RegistrationStore
n = RegistrationStore().pending_count()
if n:
bits.append(f"待挂号{n}")
except Exception:
pass
self._ai_status.config(text=" · ".join(bits), fg=MUTED)
else:
self._ai_status.config(text="已关闭 · 使用固定回复", fg=FAINT)
self._refresh_registration_count()
except ImportError:
self._ai_status.config(text="ai_config.py 未找到", fg=ROSE)
# ─────────── 挂号登记 ─────────────────────────────
def _open_registration_leads(self):
from registration_store import RegistrationStore
import time as _time
from tkinter import messagebox
win, body, foot = self._open_mgr_shell(
"挂号问诊登记",
"客户明确要求挂号时自动登记。支持单删 / 批量删除;约上后点「已联系」归档。",
)
status_lbl = tk.Label(foot, text="", bg=SURFACE, fg=MUTED, font=(FONT, 11))
status_lbl.pack(side="left")
btn_row = tk.Frame(foot, bg=SURFACE)
btn_row.pack(side="right")
panes = tk.PanedWindow(body, orient="horizontal", bg=SURFACE, sashwidth=6,
sashrelief="flat", bd=0, opaqueresize=True)
panes.pack(fill="both", expand=True)
left = tk.Frame(panes, bg=SURFACE)
right = tk.Frame(panes, bg=SURFACE)
panes.add(left, minsize=540, stretch="always")
panes.add(right, minsize=340, stretch="always")
list_card = tk.Frame(left, bg=SURFACE, highlightthickness=1, highlightbackground=LINE)
list_card.pack(fill="both", expand=True, padx=(0, 8))
tk.Label(list_card, text="全部登记", bg=SURFACE, fg=MUTED,
font=(FONT, 11, "bold")).pack(anchor="w", padx=16, pady=(14, 6))
tree_wrap = tk.Frame(list_card, bg=SURFACE)
tree_wrap.pack(fill="both", expand=True, padx=8, pady=(0, 8))
store = RegistrationStore()
status_map = {
"pending_symptom": "待问清病症",
"booked": "已预约待联系",
"done": "已联系完成",
}
cols = ("time", "contact", "symptom", "status")
tree = ttk.Treeview(
tree_wrap, columns=cols, show="headings",
selectmode="extended", style="Mgr.Treeview",
)
tree.heading("time", text="时间")
tree.heading("contact", text="微信客户")
tree.heading("symptom", text="病症 / 诉求")
tree.heading("status", text="状态")
tree.column("time", width=120, minwidth=110, stretch=False, anchor="w")
tree.column("contact", width=140, minwidth=120, stretch=False, anchor="w")
tree.column("symptom", width=300, minwidth=180, stretch=True, anchor="w")
tree.column("status", width=130, minwidth=110, stretch=False, anchor="w")
vsb = ttk.Scrollbar(tree_wrap, orient="vertical", command=tree.yview)
tree.configure(yscrollcommand=vsb.set)
tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
tree_wrap.rowconfigure(0, weight=1)
tree_wrap.columnconfigure(0, weight=1)
prev_card = tk.Frame(right, bg=SURFACE, highlightthickness=1, highlightbackground=LINE)
prev_card.pack(fill="both", expand=True, padx=(8, 0))
tk.Label(prev_card, text="登记详情", bg=SURFACE, fg=MUTED,
font=(FONT, 11, "bold")).pack(anchor="w", padx=16, pady=(14, 6))
detail = scrolledtext.ScrolledText(
prev_card, bg="#0E0E12", fg=INK, font=(FONT, 12),
relief="flat", bd=0, wrap="word", padx=16, pady=14,
spacing1=4, spacing3=8, insertbackground=ACCENT,
)
detail.pack(fill="both", expand=True, padx=8, pady=(0, 8))
detail.tag_config("label", foreground=MUTED, font=(FONT, 11))
detail.tag_config("value", foreground=INK, font=(FONT, 12), lmargin1=4, lmargin2=4)
detail.tag_config("empty", foreground=MUTED, font=(FONT, 12))
detail.configure(state="disabled")
def _fmt_ts(ts):
try:
return _time.strftime("%m-%d %H:%M", _time.localtime(float(ts)))
except Exception:
return "—"
def _set_status(text):
status_lbl.config(text=text)
def _reload():
tree.delete(*tree.get_children())
leads = store.list_leads(include_done=True)
for item in leads:
tree.insert(
"", "end", iid=item["id"],
values=(
_fmt_ts(item.get("updated") or item.get("created")),
item.get("contact") or "未知客户",
(item.get("symptom") or "(待问清)")[:60],
status_map.get(item.get("status"), item.get("status")),
),
)
detail.configure(state="normal")
detail.delete("1.0", "end")
detail.configure(state="disabled")
_set_status(f"共 {len(leads)} 条 · 待处理 {store.pending_count()}")
self._refresh_registration_count()
self._refresh_ai_status()
def _on_select(_=None):
sel = tree.selection()
detail.configure(state="normal")
detail.delete("1.0", "end")
if len(sel) == 1:
lead = next((x for x in store.list_leads() if x.get("id") == sel[0]), None)
if lead:
pairs = [
("微信客户", lead.get("contact") or "未知"),
("病症 / 诉求", lead.get("symptom") or "(待问清)"),
("状态", status_map.get(lead.get("status"), lead.get("status"))),
("最近客户消息", (lead.get("last_user") or "—")[:400]),
("最近客服回复", (lead.get("last_reply") or "—")[:400]),
]
for label, value in pairs:
detail.insert("end", f"{label}\n", "label")
detail.insert("end", f"{value}\n\n", "value")
_set_status("已选 1 条")
elif len(sel) > 1:
detail.insert("end", f"已选中 {len(sel)} 条登记。\n可批量删除,或批量标记已联系。", "empty")
_set_status(f"已选 {len(sel)} 条")
else:
_set_status(f"共 {len(store.list_leads())} 条 · Ctrl / Shift 多选")
detail.configure(state="disabled")
def _select_all():
kids = tree.get_children()
if kids:
tree.selection_set(kids)
_on_select()
def _mark_done():
sel = list(tree.selection())
if not sel:
messagebox.showinfo("提示", "请先选中要归档的记录", parent=win)
return
n = store.set_status_many(sel, "done")
self._append(f"✓ 已标记 {n} 条挂号登记为已联系", "ok")
_reload()
def _delete_one():
sel = list(tree.selection())
if len(sel) != 1:
messagebox.showinfo("提示", "请先选中 1 条要删除的记录", parent=win)
return
if not messagebox.askyesno("确认删除", "确定删除这条挂号登记?", parent=win):
return
store.delete(sel[0])
self._append("✓ 已删除 1 条挂号登记", "ok")
_reload()
def _delete_selected():
sel = list(tree.selection())
if not sel:
messagebox.showinfo("提示", "请先选中要删除的记录(可多选)", parent=win)
return
if not messagebox.askyesno(
"批量删除",
f"确定删除选中的 {len(sel)} 条挂号登记吗?不可恢复。",
parent=win,
):
return
n = store.delete_many(sel)
self._append(f"✓ 已批量删除 {n} 条挂号登记", "ok")
_reload()
tree.bind("<<TreeviewSelect>>", _on_select)
tree.bind("<Delete>", lambda e: _delete_selected())
self._mgr_action_btn(btn_row, "刷新", _reload).pack(side="left")
self._mgr_action_btn(btn_row, "全选", _select_all).pack(side="left", padx=(10, 0))
self._mgr_action_btn(btn_row, "已联系", _mark_done, primary=True).pack(side="left", padx=(10, 0))
self._mgr_action_btn(btn_row, "删除", _delete_one, danger=True).pack(side="left", padx=(10, 0))
self._mgr_action_btn(btn_row, "批量删除", _delete_selected, danger=True).pack(side="left", padx=(10, 0))
self._mgr_action_btn(btn_row, "关闭", win.destroy, muted=True).pack(side="left", padx=(10, 0))
_reload()
# ─────────── MCP 服务器配置 ───────────────────────
def _open_mcp_config(self):
try:
import ai_config
except ImportError:
self._append("ai_config.py 未找到", "err")
return
win = tk.Toplevel(self)
win.title("MCP 服务器")
win.configure(bg=BG)
win.geometry("720x560")
win.minsize(640, 480)
win.grab_set()
card = tk.Frame(win, bg=SURFACE,
highlightthickness=1, highlightbackground=LINE)
card.pack(fill="both", expand=True, padx=16, pady=16)
tk.Label(card, text="MCP 服务器", bg=SURFACE, fg=INK,
font=(FONT, FS_H1)).pack(anchor="w", padx=20, pady=(18, 2))
tk.Label(
card,
text="自动回复时可调用这些工具。保存后立即生效。",
bg=SURFACE, fg=MUTED, font=(FONT, FS_META), wraplength=640, justify="left",
).pack(anchor="w", padx=20)
example = (
'[\n'
' {\n'
' "name": "filesystem",\n'
' "enabled": true,\n'
' "transport": "stdio",\n'
' "command": "npx",\n'
' "args": ["-y", "@modelcontextprotocol/server-filesystem", "D:/docs"],\n'
' "env": {},\n'
' "cwd": null\n'
' }\n'
']'
)
servers = getattr(ai_config, "AI_MCP_SERVERS", None) or []
try:
initial = json.dumps(servers, ensure_ascii=False, indent=2)
except Exception:
initial = "[]"
if initial.strip() in ("[]", "null"):
initial = example
editor = scrolledtext.ScrolledText(
card, bg="#0E0E12", fg=INK, insertbackground=ACCENT,
font=(FONT_MONO, FS_META), relief="flat", bd=0,
wrap="none", padx=12, pady=12, height=18,
highlightthickness=1, highlightbackground=LINE)
editor.pack(fill="both", expand=True, padx=20, pady=(12, 8))
editor.insert("1.0", initial)
rounds_row = tk.Frame(card, bg=SURFACE)
rounds_row.pack(fill="x", padx=20, pady=(0, 8))
tk.Label(rounds_row, text="单次回复最多工具轮数", bg=SURFACE, fg=MUTED,
font=(FONT, FS_BODY)).pack(side="left")
rounds_var = tk.StringVar(value=str(getattr(ai_config, "AI_MCP_MAX_ROUNDS", 5)))
tk.Entry(rounds_row, textvariable=rounds_var, width=6,
bg=SURFACE2, fg=INK, insertbackground=TEAL,
relief="flat", font=(FONT_MONO, FS_BODY),
highlightthickness=1, highlightbackground=LINE
).pack(side="left", padx=(10, 0), ipady=4)
status = tk.Label(card, text="", bg=SURFACE, fg=MUTED,
font=(FONT, FS_META), anchor="w", justify="left")
status.pack(fill="x", padx=20)
def _parse_servers():
raw = editor.get("1.0", "end").strip()
data = json.loads(raw)
if not isinstance(data, list):
raise ValueError("根节点必须是 JSON 数组")
return data
def _test():
try:
servers = _parse_servers()
except Exception as e:
status.config(text=f"JSON 无效: {e}", fg=ROSE)
return
status.config(text="正在探测 MCP 服务器…", fg=AMBER)
win.update_idletasks()
def work():
try:
from mcp_bridge import run_coro, probe_servers
result = run_coro(probe_servers(servers))
tools = result.get("tools") or []
names = ", ".join(result.get("servers") or []) or "无"
msg = (f"连通 {len(result.get('servers') or [])} 台 · "
f"工具 {result.get('tool_count', 0)} 个 · 服务器: {names}")
if tools:
sample = "、".join(t["name"] for t in tools[:6])
msg += f"\n示例: {sample}"
self.after(0, lambda: status.config(text=msg, fg=TEAL))
except Exception as e:
self.after(0, lambda: status.config(text=f"探测失败: {e}", fg=ROSE))
threading.Thread(target=work, daemon=True).start()
def _save():
try:
servers = _parse_servers()
rounds = int(rounds_var.get().strip())
if rounds < 1:
rounds = 1
except Exception as e:
status.config(text=f"保存失败: {e}", fg=ROSE)
return
ai_config.AI_MCP_SERVERS = servers
ai_config.AI_MCP_MAX_ROUNDS = rounds
ai_config.AI_MCP_ENABLED = self._mcp_var.get()
try:
ai_config.save_settings()
self._append("✓ MCP 服务器配置已保存", "ok")
self._refresh_ai_status()
except Exception as e:
status.config(text=f"写入失败: {e}", fg=ROSE)
return
win.destroy()
btns = tk.Frame(card, bg=SURFACE)
btns.pack(pady=(4, 18))
NeonButton(btns, "测试连通", command=_test,
width=110, height=40, bg=SURFACE).pack(side="left", padx=6)
NeonButton(btns, "保存", command=_save,
width=100, height=40, primary=True, bg=SURFACE).pack(side="left", padx=6)
NeonButton(btns, "取消", command=win.destroy,
width=100, height=40, bg=SURFACE).pack(side="left", padx=6)
# ─────────── AI 高级配置对话框 ───────────────────
def _open_ai_config(self):
try:
import ai_config
except ImportError:
self._append("ai_config.py 未找到", "err")
return
win = tk.Toplevel(self)
win.title("AI 高级配置")
win.configure(bg=BG)
win.resizable(False, False)
win.grab_set()
card = tk.Frame(win, bg=SURFACE,
highlightthickness=1, highlightbackground=LINE)
card.pack(padx=16, pady=16)
tk.Label(card, text="AI 高级配置", bg=SURFACE, fg=INK,
font=(FONT, FS_H1)).pack(anchor="w", padx=20, pady=(18, 2))
tk.Label(card, text="保存后立即生效,并写入 ai_settings.json",
bg=SURFACE, fg=MUTED, font=(FONT, FS_META)).pack(anchor="w", padx=20)
body = tk.Frame(card, bg=SURFACE)
body.pack(padx=20, pady=14)
fields = [
("API 地址", "AI_API_BASE", str),
("API Key", "AI_API_KEY", str),
("模型名称", "AI_MODEL", str),
("客服昵称", "AI_AGENT_NAME", str),
("医院名称", "AI_HOSPITAL_NAME", str),
("上下文记忆轮数", "AI_CONTEXT_MAX_ROUNDS", int),
("最大回复 tokens", "AI_MAX_TOKENS", int),
("温度 (0~1.5)", "AI_TEMPERATURE", float),
("请求超时(秒)", "AI_TIMEOUT", int),
]
entry_vars = {}
for i, (label, key, _typ) in enumerate(fields):
tk.Label(body, text=label, bg=SURFACE, fg=MUTED,
font=(FONT, FS_BODY)).grid(row=i, column=0, sticky="w", pady=5)
v = tk.StringVar(value=str(getattr(ai_config, key)))
box = tk.Frame(body, bg=SURFACE2,
highlightthickness=1, highlightbackground=LINE)
box.grid(row=i, column=1, sticky="we", padx=(14, 0), pady=5)
tk.Entry(box, textvariable=v, width=42,
bg=SURFACE2, fg=INK, insertbackground=TEAL,
relief="flat", font=(FONT_MONO, FS_META),
bd=0, highlightthickness=0
).pack(fill="x", padx=10, pady=8)
entry_vars[key] = v
vision_var = tk.BooleanVar(value=ai_config.AI_USE_VISION)
Toggle(body, vision_var, "视觉模式(截图发给多模态模型)"
).grid(row=len(fields), column=0, columnspan=2, sticky="w", pady=(10, 0))
def _save():
for label, key, typ in fields:
raw = entry_vars[key].get().strip()
try:
val = typ(raw)
except ValueError:
self._append(f"配置「{label}」格式不正确: {raw}", "err")
return
setattr(ai_config, key, val)
ai_config.AI_USE_VISION = vision_var.get()
ai_config.AI_ENABLED = self._ai_var.get()
ai_config.AI_CONTEXT_ENABLED = self._ctx_var.get()
ai_config.AI_COUNTER_INSULT_ENABLED = self._insult_var.get()
ai_config.AI_MCP_ENABLED = self._mcp_var.get()
ai_config.AI_SYSTEM_PROMPT = ai_config.build_system_prompt()
try:
ai_config.save_settings()
self._append("✓ AI 配置已保存并生效", "ok")
self._refresh_ai_status()
except Exception as e:
self._append(f"配置保存失败: {e}", "err")
return
win.destroy()
btns = tk.Frame(card, bg=SURFACE)
btns.pack(pady=(4, 18))
NeonButton(btns, "保存", command=_save,
width=100, height=40, primary=True, bg=SURFACE).pack(side="left", padx=6)
NeonButton(btns, "取消", command=win.destroy,
width=100, height=40, bg=SURFACE).pack(side="left", padx=6)
# ─────────── 逻辑 ───────────────────────────────
def _on_start(self):
reply = self._reply_var.get().strip() or "你好"
try:
poll = float(self._poll_var.get())
except ValueError:
poll = 2.0
try:
idle_sec = float(self._idle_sec_var.get())
if idle_sec < 0:
idle_sec = 0
except ValueError:
idle_sec = float(MOUSE_IDLE_SECONDS)
mouse_idle_on = self._mouse_idle_var.get()
ai_on = self._ai_var.get()
ctx_on = self._ctx_var.get()
insult_on = self._insult_var.get()
mcp_on = self._mcp_var.get()
try:
import ai_config
ai_config.AI_ENABLED = ai_on
ai_config.AI_CONTEXT_ENABLED = ctx_on
ai_config.AI_COUNTER_INSULT_ENABLED = insult_on
ai_config.AI_MCP_ENABLED = mcp_on
try:
ai_config.save_settings()
except Exception:
pass
self._refresh_ai_status()
except ImportError:
self._ai_status.config(text="ai_config.py 未找到", fg=ROSE)
sys.stdout = LogQueue(self._q)
self._thread = BotThread(
self._q, reply, poll,
mouse_idle_enabled=mouse_idle_on,
mouse_idle_sec=idle_sec,
)
self._start_time = time.time()
self._running = True
self._thread.start()
self._start_btn.set_enabled(False)
self._stop_btn.set_enabled(True)
self._set_status("connecting", "连接中…")
self._append("▶ 机器人启动中…", "notify")
def _on_stop(self):
self._running = False
if self._thread:
self._thread.stop()
sys.stdout = sys.__stdout__
self._start_btn.set_enabled(True)
self._stop_btn.set_enabled(False)
self._set_status("stopped", "已停止")
self._append("■ 监听已停止", "warn")
def _set_status(self, state, text):
self._status_key = state
c = {"connecting": AMBER, "running": GREEN,
"error": ROSE, "stopped": FAINT}.get(state, FAINT)
self._sdot.itemconfig(self._sdot_id, fill=c)
self._s_lbl.config(text=text, fg=MUTED if state == "stopped" else c)
def _append(self, msg, tag=""):
ts = time.strftime("%H:%M:%S")
self._log.config(state="normal")
self._log.insert("end", f"[{ts}] ", "dim")
self._log.insert("end", msg + "\n", tag)
self._log.see("end")
self._log.config(state="disabled")
def _clear_log(self):
self._log.config(state="normal")
self._log.delete("1.0", "end")
self._log.config(state="disabled")
# ─────────── 队列消费 ────────────────────────────
def _tick(self):
try:
while True:
kind, data = self._q.get_nowait()
if kind == "log":
if any(k in data for k in ("✓", "回复完成")):
self._append(data, "ok")
elif any(k in data for k in ("⚠", "假阳性")):
self._append(data, "warn")
elif any(k in data for k in ("错误", "[-]")):
self._append(data, "err")
elif any(k in data for k in ("🔔", "未读红点", "🖱", "人手", "[MCP]", "[挂号]")):
self._append(data, "notify")
if "[挂号]" in data:
self._refresh_registration_count()
else:
self._append(data)
elif kind == "status":
if data == "running":
self._set_status("running", "监听中")
elif data == "error":
self._set_status("error", "连接失败")
self._on_stop()
elif data == "stopped":
self._set_status("stopped", "已停止")
elif kind == "info":
self._hwnd_lbl.config(text=data["hwnd"])
self._size_lbl.config(text=data["size"])
self._ibox_lbl.config(text=data["input"])
elif kind == "stats":
self._lbl_replied.config(text=str(data["replied"]))
self._lbl_fp.config(text=str(data["false_pos"]))
except queue.Empty:
pass
if self._start_time and self._running:
e = int(time.time() - self._start_time)
self._timer_lbl.config(
text=f"运行时长 {e//3600:02d}:{(e%3600)//60:02d}:{e%60:02d}")
self.after(200, self._tick)
def on_close(self):
self._running = False
if self._thread:
self._thread.stop()
sys.stdout = sys.__stdout__
self.destroy()
if __name__ == "__main__":
app = App()
app.protocol("WM_DELETE_WINDOW", app.on_close)
app.mainloop()