1475 lines
58 KiB
Python
1475 lines
58 KiB
Python
"""
|
||
企业微信 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()
|