Files
2026-07-23 11:30:11 +08:00

3832 lines
138 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.
# -*- coding: utf-8 -*-
"""企业微信 RPA 桌面控制台。"""
import json
import os
import queue
import subprocess
import sys
import threading
import time
import traceback
import ctypes
import webbrowser
from ctypes import wintypes
def _enable_per_monitor_dpi_awareness():
"""在创建任何 Tk 窗口前启用 Windows 每显示器 DPI 感知。"""
if sys.platform != "win32":
return
user32 = ctypes.windll.user32
context = ctypes.c_void_p(-4) # DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2
try:
setter = user32.SetProcessDpiAwarenessContext
setter.argtypes = (ctypes.c_void_p,)
setter.restype = wintypes.BOOL
if setter(context):
return
except Exception:
pass
try:
setter = user32.SetThreadDpiAwarenessContext
setter.argtypes = (ctypes.c_void_p,)
setter.restype = ctypes.c_void_p
if setter(context):
return
except Exception:
pass
try:
ctypes.windll.shcore.SetProcessDpiAwareness(2)
except Exception:
try:
user32.SetProcessDPIAware()
except Exception:
pass
_enable_per_monitor_dpi_awareness()
import tkinter as tk
import tkinter.font as tkfont
from tkinter import messagebox, scrolledtext, ttk
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
APP_SETTINGS_FILE = os.path.join(SCRIPT_DIR, "app_settings.json")
CUSTOMER_SERVICE_URL = "http://kf.zhenyangtang.com.cn/"
EDGE_PROFILE_DIR = os.path.join(
os.environ.get("LOCALAPPDATA", SCRIPT_DIR),
"ZhenYangTangRPA",
"CustomerServiceProfile",
)
EDGE_LIGHT_THEME_DIR = os.path.join(SCRIPT_DIR, "edge_light_theme")
WIN_TITLE = "甄养堂 · 企微客服助手"
AUTO_REPLY_TEXT = "你好"
POLL_INTERVAL = 2.0
MOUSE_IDLE_ENABLED = True
MOUSE_IDLE_SECONDS = 20.0
# 暖白与医疗绿组成的浅色主题,保持长时间使用时的清晰度与舒适度。
BG = "#F2F6F3"
PANEL = "#FFFFFF"
PANEL_ALT = "#F5F8F6"
ELEVATED = "#E8F0EC"
CONTENT_BG = "#F8FAF9"
TEXT = "#18251F"
TEXT_MUTED = "#56685F"
TEXT_FAINT = "#819088"
BORDER = "#D5E0DA"
BORDER_SOFT = "#E5ECE8"
ACCENT = "#139A75"
ACCENT_HOVER = "#0D7E60"
ACCENT_SOFT = "#DDF3EA"
ON_ACCENT = "#FFFFFF"
SUCCESS = "#168765"
SUCCESS_SOFT = "#E2F3EC"
WARNING = "#A66B16"
DANGER = "#D64D5B"
DANGER_SOFT = "#FBE8EA"
LOG_BG = "#FBFCFB"
LOG_TEXT = "#33463D"
TOGGLE_OFF = "#C8D5CE"
CAPSULE_SHADOW = "#A9B8B0"
CAPSULE_HIGHLIGHT = "#EEF3F0"
CAPSULE_TRANSPARENT = "#02050B"
CAPSULE_WIDTH = 372
CAPSULE_HEIGHT = 68
WECOM_WAITING_MESSAGE = (
"企业微信主界面未显示、未在前台或已最小化,正在尝试切到前台;"
"本轮失败时下一轮将自动重试。"
)
BASE_WINDOW_WIDTH = 1360
BASE_WINDOW_HEIGHT = 900
BASE_MIN_WIDTH = 1180
BASE_MIN_HEIGHT = 780
BASE_SIDEBAR_WIDTH = 238
class _WindowPlacement(ctypes.Structure):
_fields_ = (
("length", wintypes.UINT),
("flags", wintypes.UINT),
("show_cmd", wintypes.UINT),
("min_position", wintypes.POINT),
("max_position", wintypes.POINT),
("normal_position", wintypes.RECT),
)
class _MonitorInfo(ctypes.Structure):
_fields_ = (
("size", wintypes.DWORD),
("monitor", wintypes.RECT),
("work", wintypes.RECT),
("flags", wintypes.DWORD),
)
FONT = "Microsoft YaHei UI"
FONT_MONO = "Cascadia Mono"
# Typography follows the Stitch reference: 20/16/14/12/10 px visual tiers.
# Tk sizes are points, so the existing per-monitor scaling keeps them physically
# consistent when the capsule moves between displays with different DPI values.
TYPE_HEADLINE = "WecomHeadline"
TYPE_TITLE = "WecomTitle"
TYPE_STATUS = "WecomStatus"
TYPE_BODY = "WecomBody"
TYPE_BODY_BOLD = "WecomBodyBold"
TYPE_SMALL = "WecomSmall"
TYPE_SMALL_BOLD = "WecomSmallBold"
TYPE_CAPTION = "WecomCaption"
TYPE_ICON = "WecomIcon"
TYPE_MONO_CONTENT = "WecomMonoContent"
TYPE_MONO_BODY = "WecomMonoBody"
TYPE_MONO_BODY_BOLD = "WecomMonoBodyBold"
TYPE_MONO_LABEL = "WecomMonoLabel"
TYPE_MONO_LABEL_BOLD = "WecomMonoLabelBold"
TYPE_BRAND = "WecomBrand"
TYPE_METRIC = "WecomMetric"
TYPE_METRIC_LARGE = "WecomMetricLarge"
UI_FONT_SPECS = {
TYPE_HEADLINE: (FONT, 15, "bold"),
TYPE_TITLE: (FONT, 12, "bold"),
TYPE_STATUS: (FONT, 18, "bold"),
TYPE_BODY: (FONT, 11),
TYPE_BODY_BOLD: (FONT, 11, "bold"),
TYPE_SMALL: (FONT, 10),
TYPE_SMALL_BOLD: (FONT, 10, "bold"),
TYPE_CAPTION: (FONT, 9),
TYPE_ICON: (FONT, 12, "bold"),
TYPE_MONO_CONTENT: (FONT_MONO, 11),
TYPE_MONO_BODY: (FONT_MONO, 10),
TYPE_MONO_BODY_BOLD: (FONT_MONO, 10, "bold"),
TYPE_MONO_LABEL: (FONT_MONO, 9),
TYPE_MONO_LABEL_BOLD: (FONT_MONO, 9, "bold"),
TYPE_BRAND: (FONT_MONO, 13, "bold"),
TYPE_METRIC: (FONT_MONO, 22, "bold"),
TYPE_METRIC_LARGE: (FONT_MONO, 32, "bold"),
}
# The capsule itself has a fixed pixel size, so its type must also stay in
# pixels instead of following the console's point-based per-monitor scaling.
TYPE_CAPSULE_STATUS = (FONT, -13, "bold")
TYPE_CAPSULE_HINT = (FONT, -10)
TYPE_CAPSULE_MONO = (FONT_MONO, -11, "bold")
TYPE_CAPSULE_ICON = (FONT, -16, "bold")
TYPE_TOOLTIP = (FONT, -11)
PAGES = (
("AI 客服", "01"),
("自动回复", "02"),
("通用设置", "03"),
("业务数据", "04"),
("AI 人格", "05"),
("运行日志", "06"),
)
class LogQueue:
"""把后台线程的标准输出转发到界面。"""
def __init__(self, target_queue):
self.target_queue = target_queue
def write(self, message):
text = str(message).rstrip()
if text:
self.target_queue.put(("log", text))
def flush(self):
pass
class BotThread(threading.Thread):
"""在后台运行企业微信轮询,避免阻塞 Tk 主线程。"""
def __init__(self, target_queue, reply_text, poll_seconds,
mouse_idle_enabled=True, mouse_idle_seconds=20.0):
super().__init__(daemon=True)
self.target_queue = target_queue
self.reply_text = reply_text
self.poll_seconds = poll_seconds
self.mouse_idle_enabled = mouse_idle_enabled
self.mouse_idle_seconds = mouse_idle_seconds
self.stop_event = threading.Event()
def run(self):
bot = None
failed = False
try:
import wechat_bot as bot_module
bot_module.AUTO_REPLY_TEXT = self.reply_text
bot = bot_module.WeChatBot()
bot.mouse_idle_enabled = self.mouse_idle_enabled
bot.mouse_idle_seconds = self.mouse_idle_seconds
bot._stop_check = self.stop_event
bot.safe_window_mode = True
bot.auto_activate_window = True
if not bot.connect(activate=False, wait_if_missing=True):
failed = True
self.target_queue.put(("status", "error"))
return
print("[+] 窗口激活模式已开启:企业微信未显示时会自动还原到前台")
print("[i] 不会设置系统级置顶;仅检测到未读红点后才执行操作")
if bot.mouse_idle_enabled:
print(
f"[+] 人机共存已开启:鼠标静止 "
f"{bot.mouse_idle_seconds:.0f} 秒后才自动操作"
)
last_ready = bool(bot._window_ready)
self.target_queue.put(("status", "running" if last_ready else "waiting"))
self.target_queue.put(("info", {
"hwnd": f"0x{bot.hwnd:08X}",
"size": f"{bot.R - bot.L} x {bot.B - bot.T}",
"input": f"({bot.input_x}, {bot.input_y})",
}))
while not self.stop_event.is_set():
bot._poll_once()
ready = bool(bot._window_ready)
if ready != last_ready:
last_ready = ready
self.target_queue.put(("status", "running" if ready else "waiting"))
if ready:
self.target_queue.put(("info", {
"hwnd": f"0x{bot.hwnd:08X}",
"size": f"{bot.R - bot.L} x {bot.B - bot.T}",
"input": f"({bot.input_x}, {bot.input_y})",
}))
self.target_queue.put(("stats", {
"replied": len(bot.replied),
"false_pos": len(bot.false_pos_rows),
}))
self.stop_event.wait(self.poll_seconds)
except Exception as exc:
failed = True
self.target_queue.put((
"log",
f"[-] 后台线程异常:{exc}\n{traceback.format_exc()}",
))
self.target_queue.put(("status", "error"))
finally:
if not failed:
self.target_queue.put(("status", "stopped"))
def stop(self):
self.stop_event.set()
class ActionButton(tk.Button):
"""统一的按钮样式,包含悬停、按下和禁用状态。"""
def __init__(self, parent, text, command=None, *, kind="secondary", **kwargs):
self.kind = kind
self._enabled = True
colors = self._palette(False)
super().__init__(
parent,
text=text,
command=command,
bg=colors[0],
fg=colors[1],
activebackground=colors[2],
activeforeground=colors[3],
disabledforeground=TEXT_FAINT,
font=TYPE_BODY_BOLD if kind == "primary" else TYPE_BODY,
relief="flat",
bd=0,
highlightthickness=0,
cursor="hand2",
padx=16,
pady=9,
**kwargs,
)
self.bind("<Enter>", self._on_enter)
self.bind("<Leave>", self._on_leave)
def _palette(self, hovered):
if self.kind == "primary":
return (
ACCENT_HOVER if hovered else ACCENT,
ON_ACCENT,
ACCENT_HOVER,
ON_ACCENT,
)
if self.kind == "danger":
return (
DANGER if hovered else DANGER_SOFT,
ON_ACCENT if hovered else DANGER,
DANGER,
ON_ACCENT,
)
return (
ELEVATED if hovered else PANEL_ALT,
TEXT if hovered else TEXT_MUTED,
ELEVATED,
TEXT,
)
def _on_enter(self, _event=None):
if self._enabled:
bg, fg, active_bg, active_fg = self._palette(True)
self.configure(
bg=bg,
fg=fg,
activebackground=active_bg,
activeforeground=active_fg,
)
def _on_leave(self, _event=None):
if self._enabled:
bg, fg, active_bg, active_fg = self._palette(False)
self.configure(
bg=bg,
fg=fg,
activebackground=active_bg,
activeforeground=active_fg,
)
def set_enabled(self, enabled):
self._enabled = bool(enabled)
self.configure(
state="normal" if enabled else "disabled",
cursor="hand2" if enabled else "arrow",
)
if enabled:
self._on_leave()
else:
self.configure(bg=PANEL_ALT, fg=TEXT_FAINT)
class Toggle(tk.Frame):
"""不依赖第三方组件的布尔开关。"""
def __init__(self, parent, variable, text, command=None, *, bg=PANEL):
super().__init__(parent, bg=bg)
self.variable = variable
self.command = command
self.background = bg
self.canvas = tk.Canvas(
self,
width=42,
height=24,
bg=bg,
highlightthickness=0,
cursor="hand2",
)
self.canvas.pack(side="left")
self.label = tk.Label(
self,
text=text,
bg=bg,
fg=TEXT,
font=TYPE_BODY,
cursor="hand2",
)
self.label.pack(side="left", padx=(10, 0))
self.canvas.bind("<Button-1>", self._toggle)
self.label.bind("<Button-1>", self._toggle)
self.variable.trace_add("write", self._redraw)
self._redraw()
def _toggle(self, _event=None):
self.variable.set(not self.variable.get())
if self.command:
self.command()
def _redraw(self, *_args):
self.canvas.delete("all")
enabled = bool(self.variable.get())
track = ACCENT if enabled else TOGGLE_OFF
self.canvas.create_oval(0, 0, 24, 24, fill=track, outline="")
self.canvas.create_oval(18, 0, 42, 24, fill=track, outline="")
self.canvas.create_rectangle(12, 0, 30, 24, fill=track, outline="")
knob_x = 20 if enabled else 2
self.canvas.create_oval(
knob_x,
3,
knob_x + 18,
21,
fill="#FFFFFF",
outline="",
)
class Card(tk.Frame):
"""带细边框的内容区块。"""
def __init__(self, parent, title=None, subtitle=None, *, bg=CONTENT_BG, pad=18):
super().__init__(parent, bg=bg)
self.shell = tk.Frame(
self,
bg=PANEL,
highlightthickness=1,
highlightbackground=BORDER,
)
self.shell.pack(fill="both", expand=True)
if title:
header = tk.Frame(self.shell, bg=PANEL)
header.pack(fill="x", padx=pad, pady=(16, 0))
tk.Label(
header,
text=title,
bg=PANEL,
fg=TEXT,
font=TYPE_TITLE,
).pack(anchor="w")
if subtitle:
tk.Label(
header,
text=subtitle,
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_SMALL,
justify="left",
).pack(anchor="w", pady=(4, 0))
self.body = tk.Frame(self.shell, bg=PANEL)
self.body.pack(fill="both", expand=True, padx=pad, pady=(12, 18))
class App(tk.Tk):
def __init__(self):
super().__init__()
self._configure_default_fonts()
self.title(WIN_TITLE)
initial_dpi = round(float(self.tk.call("tk", "scaling")) * 72)
self._layout_scale = max(1.0, initial_dpi / 96.0)
initial_monitor = self._monitor_info_for(self)
work = (initial_monitor or {}).get("work")
work_width = (work[2] - work[0]) if work else self.winfo_screenwidth()
work_height = (work[3] - work[1]) if work else self.winfo_screenheight()
max_width = max(BASE_MIN_WIDTH, work_width - 96)
max_height = max(BASE_MIN_HEIGHT, work_height - 96)
window_width = min(round(BASE_WINDOW_WIDTH * self._layout_scale), max_width)
window_height = min(round(BASE_WINDOW_HEIGHT * self._layout_scale), max_height)
min_width = min(round(BASE_MIN_WIDTH * self._layout_scale), max_width)
min_height = min(round(BASE_MIN_HEIGHT * self._layout_scale), max_height)
work_left = work[0] if work else 0
work_top = work[1] if work else 0
window_x = work_left + max(12, (work_width - window_width) // 2)
window_y = work_top + 12
self.geometry(
f"{window_width}x{window_height}{window_x:+d}{window_y:+d}",
)
self.minsize(min_width, min_height)
self.configure(bg=BG)
self._queue = queue.Queue()
self._thread = None
self._running = False
self._start_time = None
self._status_key = "stopped"
self._original_stdout = sys.stdout
self._stdout_proxy = None
self._page_frames = {}
self._nav_items = {}
self._capsule_mode = False
self._capsule_position = None
self._capsule_drag_offset = (0, 0)
self._capsule_dpi = None
self._console_geometry = (
f"{window_width}x{window_height}{window_x:+d}{window_y:+d}"
)
self._console_was_zoomed = False
self._console_native_state = None
self._console_dpi = initial_dpi
self._console_font_scale = 1.0
self._runtime_settings = self._load_runtime_settings()
self._last_runtime_settings = dict(self._runtime_settings)
self._runtime_save_job = None
self._portal_process = None
self._portal_hwnd = 0
self._portal_poll_attempts = 0
self._portal_windows_before = set()
self._portal_embedded = False
self._portal_launching = False
self._portal_cleanup_done = False
self._portal_resize_job = None
self._portal_frame_insets = None
self._build()
self._build_capsule()
self.update_idletasks()
self._apply_tk_scaling(initial_dpi)
self._apply_dark_titlebar(self)
self._refresh_counts()
self._refresh_ai_status()
self._append("控制台已就绪。请先确认企业微信已登录,再开始监听。", "notify")
self.after(150, self._process_queue)
self.after(300, self._start_backend_sync)
if os.environ.get("WECOM_RPA_DISABLE_PORTAL") != "1":
self.after(350, self._open_customer_service_portal)
def _start_backend_sync(self):
"""经典界面也会沿用 Qt 界面保存的后台登录并自动同步。"""
try:
import backend_client
if not backend_client.is_configured():
return
except Exception as exc:
self._queue.put(("log", f"后台连接配置读取失败:{exc}"))
return
def worker():
try:
result = backend_client.sync_config()
except Exception as exc:
self._queue.put(("log", f"后台配置同步失败:{exc}"))
else:
if result.get("synced"):
self._queue.put(("backend_config", result))
threading.Thread(target=worker, daemon=True).start()
@staticmethod
def _runtime_defaults():
return {
"auto_reply_text": AUTO_REPLY_TEXT,
"poll_interval": POLL_INTERVAL,
"mouse_idle_enabled": MOUSE_IDLE_ENABLED,
"mouse_idle_seconds": MOUSE_IDLE_SECONDS,
}
def _load_runtime_settings(self):
settings = self._runtime_defaults()
try:
with open(APP_SETTINGS_FILE, encoding="utf-8") as file:
saved = json.load(file)
except (OSError, ValueError, TypeError):
return settings
if not isinstance(saved, dict):
return settings
try:
reply = str(saved.get("auto_reply_text", settings["auto_reply_text"])).strip()
poll = float(saved.get("poll_interval", settings["poll_interval"]))
idle = float(saved.get("mouse_idle_seconds", settings["mouse_idle_seconds"]))
if poll <= 0 or idle < 0:
raise ValueError
settings.update({
"auto_reply_text": reply or AUTO_REPLY_TEXT,
"poll_interval": poll,
"mouse_idle_enabled": bool(
saved.get("mouse_idle_enabled", settings["mouse_idle_enabled"])
),
"mouse_idle_seconds": idle,
})
except (TypeError, ValueError):
return self._runtime_defaults()
return settings
def _schedule_runtime_settings_save(self, *_args):
if not hasattr(self, "_runtime_save_status"):
return
if self._runtime_save_job is not None:
self.after_cancel(self._runtime_save_job)
self._runtime_save_status.configure(text="正在保存…", fg=WARNING)
self._runtime_save_job = self.after(700, self._save_runtime_settings)
def _save_runtime_settings(self, silent=False):
pending_job = self._runtime_save_job
self._runtime_save_job = None
if pending_job is not None:
try:
self.after_cancel(pending_job)
except tk.TclError:
pass
if not all(hasattr(self, name) for name in (
"_reply_var",
"_poll_var",
"_idle_seconds_var",
"_mouse_idle_var",
)):
return False
try:
poll = float(self._poll_var.get().strip())
idle = float(self._idle_seconds_var.get().strip())
if poll <= 0 or idle < 0:
raise ValueError
except ValueError:
if not silent and hasattr(self, "_runtime_save_status"):
self._runtime_save_status.configure(
text="数字格式无效,尚未保存",
fg=DANGER,
)
return False
settings = {
"auto_reply_text": self._reply_var.get().strip() or AUTO_REPLY_TEXT,
"poll_interval": poll,
"mouse_idle_enabled": bool(self._mouse_idle_var.get()),
"mouse_idle_seconds": idle,
}
if settings != self._last_runtime_settings:
tmp_path = APP_SETTINGS_FILE + ".tmp"
try:
with open(tmp_path, "w", encoding="utf-8") as file:
json.dump(settings, file, ensure_ascii=False, indent=2)
os.replace(tmp_path, APP_SETTINGS_FILE)
except OSError as exc:
if not silent and hasattr(self, "_runtime_save_status"):
self._runtime_save_status.configure(
text=f"保存失败:{exc}",
fg=DANGER,
)
return False
self._last_runtime_settings = dict(settings)
if not silent and hasattr(self, "_runtime_save_status"):
self._runtime_save_status.configure(
text=f"已自动保存 {time.strftime('%H:%M:%S')}",
fg=SUCCESS,
)
return True
def _configure_default_fonts(self):
self._ui_fonts = {}
for name, spec in UI_FONT_SPECS.items():
self._ui_fonts[name] = tkfont.Font(
root=self,
name=name,
family=spec[0],
size=spec[1],
weight=spec[2] if len(spec) > 2 else "normal",
)
defaults = {
"TkDefaultFont": UI_FONT_SPECS[TYPE_BODY],
"TkTextFont": UI_FONT_SPECS[TYPE_BODY],
"TkHeadingFont": UI_FONT_SPECS[TYPE_BODY_BOLD],
"TkMenuFont": UI_FONT_SPECS[TYPE_SMALL],
"TkCaptionFont": UI_FONT_SPECS[TYPE_SMALL],
"TkSmallCaptionFont": UI_FONT_SPECS[TYPE_CAPTION],
"TkIconFont": UI_FONT_SPECS[TYPE_BODY],
"TkFixedFont": UI_FONT_SPECS[TYPE_MONO_CONTENT],
"TkTooltipFont": UI_FONT_SPECS[TYPE_CAPTION],
}
for name, spec in defaults.items():
try:
font = tkfont.nametofont(name, root=self)
except tk.TclError:
continue
font.configure(
family=spec[0],
size=spec[1],
weight=spec[2] if len(spec) > 2 else "normal",
)
capsule_specs = {
"status": TYPE_CAPSULE_STATUS,
"hint": TYPE_CAPSULE_HINT,
"mono": TYPE_CAPSULE_MONO,
"icon": TYPE_CAPSULE_ICON,
"tooltip": TYPE_TOOLTIP,
}
self._capsule_font_specs = capsule_specs
self._capsule_fonts = {
name: tkfont.Font(
root=self,
family=spec[0],
size=spec[1],
weight=spec[2] if len(spec) > 2 else "normal",
)
for name, spec in capsule_specs.items()
}
@staticmethod
def _colorref(color):
color = color.lstrip("#")
red = int(color[0:2], 16)
green = int(color[2:4], 16)
blue = int(color[4:6], 16)
return red | (green << 8) | (blue << 16)
def _apply_dark_titlebar(self, window):
if sys.platform != "win32":
return False
try:
window.update_idletasks()
hwnd = wintypes.HWND(self._native_handle_for(window))
set_attribute = ctypes.windll.dwmapi.DwmSetWindowAttribute
set_attribute.argtypes = (
wintypes.HWND,
wintypes.DWORD,
ctypes.c_void_p,
wintypes.DWORD,
)
set_attribute.restype = ctypes.c_long
enabled = ctypes.c_int(0)
result = set_attribute(
hwnd,
20,
ctypes.byref(enabled),
ctypes.sizeof(enabled),
)
if result != 0:
set_attribute(
hwnd,
19,
ctypes.byref(enabled),
ctypes.sizeof(enabled),
)
colors = (
(34, BORDER),
(35, BG),
(36, TEXT),
)
applied = result == 0
for attribute, color in colors:
value = wintypes.DWORD(self._colorref(color))
if set_attribute(
hwnd,
attribute,
ctypes.byref(value),
ctypes.sizeof(value),
) == 0:
applied = True
return applied
except Exception:
return False
@staticmethod
def _edge_executable():
candidates = (
os.path.join(
os.environ.get("PROGRAMFILES(X86)", r"C:\Program Files (x86)"),
"Microsoft",
"Edge",
"Application",
"msedge.exe",
),
os.path.join(
os.environ.get("PROGRAMFILES", r"C:\Program Files"),
"Microsoft",
"Edge",
"Application",
"msedge.exe",
),
)
return next((path for path in candidates if os.path.exists(path)), "")
@staticmethod
def _enumerate_edge_windows():
if sys.platform != "win32":
return []
user32 = ctypes.windll.user32
enum_windows = user32.EnumWindows
get_class = user32.GetClassNameW
get_text = user32.GetWindowTextW
get_text_length = user32.GetWindowTextLengthW
get_pid = user32.GetWindowThreadProcessId
is_visible = user32.IsWindowVisible
callback_type = ctypes.WINFUNCTYPE(
wintypes.BOOL,
wintypes.HWND,
wintypes.LPARAM,
)
windows = []
def callback(hwnd, _lparam):
if not is_visible(hwnd):
return True
class_buffer = ctypes.create_unicode_buffer(128)
get_class(hwnd, class_buffer, len(class_buffer))
if not class_buffer.value.startswith("Chrome_WidgetWin_"):
return True
process_id = wintypes.DWORD()
get_pid(hwnd, ctypes.byref(process_id))
length = get_text_length(hwnd)
title_buffer = ctypes.create_unicode_buffer(max(1, length + 1))
get_text(hwnd, title_buffer, len(title_buffer))
windows.append({
"hwnd": int(hwnd),
"pid": int(process_id.value),
"title": title_buffer.value,
})
return True
enum_windows(callback_type(callback), 0)
return windows
def _focus_customer_service_portal(self):
if (
not self._portal_hwnd
or not self._portal_embedded
or sys.platform != "win32"
):
return False
try:
user32 = ctypes.windll.user32
if not user32.IsWindow(wintypes.HWND(self._portal_hwnd)):
self._portal_hwnd = 0
self._portal_embedded = False
return False
self._set_customer_service_portal_visible(True)
self._resize_customer_service_portal()
render_hwnd = self._customer_service_render_hwnd() or self._portal_hwnd
target_hwnd = wintypes.HWND(render_hwnd)
portal_hwnd = wintypes.HWND(self._portal_hwnd)
current_thread = ctypes.windll.kernel32.GetCurrentThreadId()
foreground_hwnd = user32.GetForegroundWindow()
foreground_thread = (
user32.GetWindowThreadProcessId(foreground_hwnd, None)
if foreground_hwnd
else 0
)
foreground_attached = bool(
foreground_thread
and foreground_thread != current_thread
and user32.AttachThreadInput(
current_thread,
foreground_thread,
True,
)
)
try:
user32.BringWindowToTop(portal_hwnd)
user32.SetForegroundWindow(portal_hwnd)
user32.SetActiveWindow(portal_hwnd)
finally:
if foreground_attached:
user32.AttachThreadInput(
current_thread,
foreground_thread,
False,
)
target_thread = user32.GetWindowThreadProcessId(target_hwnd, None)
target_attached = bool(
target_thread
and target_thread != current_thread
and user32.AttachThreadInput(current_thread, target_thread, True)
)
try:
user32.SetFocus(target_hwnd)
finally:
if target_attached:
user32.AttachThreadInput(current_thread, target_thread, False)
return True
except Exception:
self._portal_hwnd = 0
self._portal_embedded = False
return False
def _customer_service_render_hwnd(self):
if not self._portal_hwnd or sys.platform != "win32":
return 0
user32 = ctypes.windll.user32
callback_type = ctypes.WINFUNCTYPE(
wintypes.BOOL,
wintypes.HWND,
wintypes.LPARAM,
)
candidates = []
def callback(hwnd, _lparam):
class_buffer = ctypes.create_unicode_buffer(128)
user32.GetClassNameW(hwnd, class_buffer, len(class_buffer))
if class_buffer.value == "Chrome_RenderWidgetHostHWND":
candidates.append((int(hwnd), bool(user32.IsWindowVisible(hwnd))))
return True
user32.EnumChildWindows(
wintypes.HWND(self._portal_hwnd),
callback_type(callback),
0,
)
visible = next((hwnd for hwnd, is_visible in reversed(candidates) if is_visible), 0)
return visible or (candidates[-1][0] if candidates else 0)
def _embed_customer_service_portal(self):
if (
not self._portal_hwnd
or not hasattr(self, "_portal_host")
or sys.platform != "win32"
):
return False
try:
self._portal_host.update_idletasks()
user32 = ctypes.windll.user32
hwnd = wintypes.HWND(self._portal_hwnd)
set_parent = user32.SetParent
set_parent.argtypes = (wintypes.HWND, wintypes.HWND)
set_parent.restype = wintypes.HWND
get_style = user32.GetWindowLongW
get_style.argtypes = (wintypes.HWND, ctypes.c_int)
get_style.restype = ctypes.c_long
set_style = user32.SetWindowLongW
set_style.argtypes = (wintypes.HWND, ctypes.c_int, ctypes.c_long)
set_style.restype = ctypes.c_long
# Keep Edge as a genuine top-level input window. SetParent across
# processes breaks keyboard/IME delivery after renderer changes.
set_parent(hwnd, wintypes.HWND(0))
style = int(get_style(hwnd, -16))
style &= ~(
0x40000000 # WS_CHILD
| 0x00C00000 # WS_CAPTION
| 0x00040000 # WS_THICKFRAME
| 0x00080000 # WS_SYSMENU
| 0x00020000 # WS_MINIMIZEBOX
| 0x00010000 # WS_MAXIMIZEBOX
)
style |= 0x80000000 | 0x10000000 # WS_POPUP | WS_VISIBLE
set_style(hwnd, -16, style)
ex_style = int(get_style(hwnd, -20))
ex_style &= ~0x00040000 # WS_EX_APPWINDOW
ex_style |= 0x00000080 # WS_EX_TOOLWINDOW
set_style(hwnd, -20, ex_style)
app_hwnd = wintypes.HWND(int(self.winfo_id()))
get_ancestor = user32.GetAncestor
get_ancestor.argtypes = (wintypes.HWND, wintypes.UINT)
get_ancestor.restype = wintypes.HWND
root_hwnd = get_ancestor(app_hwnd, 2) or app_hwnd
root_value = getattr(root_hwnd, "value", root_hwnd)
set_owner = getattr(user32, "SetWindowLongPtrW", user32.SetWindowLongW)
set_owner.argtypes = (wintypes.HWND, ctypes.c_int, ctypes.c_ssize_t)
set_owner.restype = ctypes.c_ssize_t
set_owner(hwnd, -8, root_value) # GWLP_HWNDPARENT / owner
self._portal_embedded = True
self._portal_launching = False
self._portal_loading_label.place_forget()
self._portal_status_label.configure(
text="已接入客服系统 · 登录状态会自动保留",
fg=SUCCESS,
)
self._resize_customer_service_portal()
self._set_customer_service_portal_visible(
getattr(self, "_current_page", "AI 客服") == "AI 客服"
)
self.after(120, self._focus_customer_service_portal)
self.after(350, self._resize_customer_service_portal)
self.after(1200, self._resize_customer_service_portal)
return True
except Exception as exc:
self._portal_embedded = False
self._portal_launching = False
self._portal_status_label.configure(
text=f"内嵌失败:{exc}",
fg=DANGER,
)
return False
def _schedule_customer_service_portal_resize(self, _event=None):
if self._portal_resize_job is not None:
return
self._portal_resize_job = self.after_idle(
self._flush_customer_service_portal_resize
)
def _flush_customer_service_portal_resize(self):
self._portal_resize_job = None
self._resize_customer_service_portal()
def _resize_customer_service_portal(self, _event=None):
if not self._portal_hwnd or not self._portal_embedded:
return
try:
user32 = ctypes.windll.user32
gdi32 = ctypes.windll.gdi32
host_hwnd = wintypes.HWND(int(self._portal_host.winfo_id()))
client = wintypes.RECT()
origin = wintypes.POINT(0, 0)
user32.GetClientRect(host_hwnd, ctypes.byref(client))
user32.ClientToScreen(host_hwnd, ctypes.byref(origin))
width = max(1, client.right - client.left)
height = max(1, client.bottom - client.top)
set_window_pos = user32.SetWindowPos
set_window_pos.argtypes = (
wintypes.HWND,
wintypes.HWND,
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
wintypes.UINT,
)
set_window_pos.restype = wintypes.BOOL
set_window_region = user32.SetWindowRgn
set_window_region.argtypes = (
wintypes.HWND,
wintypes.HRGN,
wintypes.BOOL,
)
set_window_region.restype = ctypes.c_int
create_region = gdi32.CreateRectRgn
create_region.argtypes = (
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
)
create_region.restype = wintypes.HRGN
delete_object = gdi32.DeleteObject
delete_object.argtypes = (wintypes.HGDIOBJ,)
delete_object.restype = wintypes.BOOL
edge_rect = wintypes.RECT()
render_rect = wintypes.RECT()
render_hwnd = self._customer_service_render_hwnd()
if render_hwnd:
user32.GetWindowRect(
wintypes.HWND(self._portal_hwnd),
ctypes.byref(edge_rect),
)
user32.GetWindowRect(
wintypes.HWND(render_hwnd),
ctypes.byref(render_rect),
)
measured = (
render_rect.left - edge_rect.left,
render_rect.top - edge_rect.top,
edge_rect.right - render_rect.right,
edge_rect.bottom - render_rect.bottom,
)
dpi = 96
try:
dpi = int(user32.GetDpiForWindow(host_hwnd)) or 96
except Exception:
pass
maximum = max(160, round(200 * dpi / 96.0))
if all(0 <= inset <= maximum for inset in measured) and measured[1] > 0:
self._portal_frame_insets = measured
insets = self._portal_frame_insets
visible = (
getattr(self, "_current_page", "AI 客服") == "AI 客服"
and not self._capsule_mode
and self.state() != "withdrawn"
)
flags = 0x0010 | 0x0020 # SWP_NOACTIVATE | SWP_FRAMECHANGED
if visible:
flags |= 0x0040 # SWP_SHOWWINDOW
else:
flags |= 0x0004 # SWP_NOZORDER
portal_hwnd = wintypes.HWND(self._portal_hwnd)
if not insets:
if hasattr(self, "_portal_embed_header"):
self._portal_embed_header.place_forget()
set_window_region(portal_hwnd, wintypes.HRGN(0), True)
set_window_pos(
portal_hwnd,
wintypes.HWND(0),
origin.x,
origin.y,
width,
height,
flags,
)
return
_left, top, _right, _bottom = insets
positioned = set_window_pos(
portal_hwnd,
wintypes.HWND(0),
origin.x,
origin.y,
width,
height,
flags,
)
region = create_region(
0,
top,
width,
height,
)
if positioned and region and set_window_region(portal_hwnd, region, True):
if hasattr(self, "_portal_embed_header"):
self._portal_embed_header.place(
x=0,
y=0,
relwidth=1,
height=top,
)
self._portal_embed_header.lift()
return
if region:
delete_object(region)
if hasattr(self, "_portal_embed_header"):
self._portal_embed_header.place_forget()
set_window_region(portal_hwnd, wintypes.HRGN(0), True)
set_window_pos(
portal_hwnd,
wintypes.HWND(0),
origin.x,
origin.y,
width,
height,
flags,
)
except Exception:
pass
def _set_customer_service_portal_visible(self, visible):
if not self._portal_hwnd or not self._portal_embedded:
return
try:
if visible:
self._resize_customer_service_portal()
ctypes.windll.user32.ShowWindow(
wintypes.HWND(self._portal_hwnd),
4, # SW_SHOWNOACTIVATE
)
self.after(80, self._resize_customer_service_portal)
else:
ctypes.windll.user32.ShowWindow(
wintypes.HWND(self._portal_hwnd),
0,
)
except Exception:
pass
def _reload_customer_service_portal(self):
if not self._focus_customer_service_portal():
self._open_customer_service_portal()
return
try:
user32 = ctypes.windll.user32
user32.PostMessageW(wintypes.HWND(self._portal_hwnd), 0x0100, 0x74, 0)
user32.PostMessageW(wintypes.HWND(self._portal_hwnd), 0x0101, 0x74, 0)
self._portal_status_label.configure(text="正在刷新…", fg=WARNING)
self.after(
1200,
lambda: self._portal_status_label.configure(
text="已接入客服系统 · 登录状态会自动保留",
fg=SUCCESS,
),
)
except Exception:
pass
def _poll_customer_service_portal(self):
process_id = (
self._portal_process.pid
if self._portal_process is not None
else 0
)
windows = self._enumerate_edge_windows()
candidate = next(
(item for item in windows if process_id and item["pid"] == process_id),
None,
)
if candidate is None:
candidate = next(
(
item
for item in windows
if item["hwnd"] not in self._portal_windows_before
and "AI Chat" in item["title"]
),
None,
)
if candidate is None:
candidate = next(
(item for item in windows if "AI Chat" in item["title"]),
None,
)
if candidate is not None:
self._portal_hwnd = candidate["hwnd"]
if self._embed_customer_service_portal():
self._append("AI 客服页面已接入主界面", "ok")
return
self._portal_poll_attempts += 1
if self._portal_poll_attempts < 50 and self.winfo_exists():
self.after(100, self._poll_customer_service_portal)
else:
self._portal_launching = False
self._portal_status_label.configure(
text="客服系统加载超时,可点击刷新重试",
fg=DANGER,
)
@staticmethod
def _stop_customer_service_edge_processes(delay_ms=0):
if sys.platform != "win32":
return
env = os.environ.copy()
env["WECOM_RPA_EDGE_PROFILE"] = EDGE_PROFILE_DIR
env["WECOM_RPA_EDGE_STOP_DELAY"] = str(max(0, int(delay_ms)))
script = (
"$delay=[int]$env:WECOM_RPA_EDGE_STOP_DELAY;"
"if($delay -gt 0){Start-Sleep -Milliseconds $delay};"
"$profile=$env:WECOM_RPA_EDGE_PROFILE;"
"$items=Get-CimInstance Win32_Process -Filter \"Name = 'msedge.exe'\" "
"| Where-Object {$_.CommandLine -and $_.CommandLine.Contains($profile)};"
"$items | ForEach-Object {"
"Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue}"
)
try:
subprocess.run(
("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script),
env=env,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=6,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
pass
def _open_customer_service_portal(self):
if (
hasattr(self, "_page_frames")
and "AI 客服" in self._page_frames
and getattr(self, "_current_page", "AI 客服") != "AI 客服"
):
self._show_page("AI 客服")
if self._focus_customer_service_portal():
return
if self._portal_launching:
return
edge = self._edge_executable()
if not edge:
webbrowser.open_new(CUSTOMER_SERVICE_URL)
self._portal_status_label.configure(
text="未找到 Edge,已改用外部浏览器",
fg=WARNING,
)
return
try:
self._portal_launching = True
self._portal_status_label.configure(
text="正在启动内嵌客服系统…",
fg=WARNING,
)
os.makedirs(EDGE_PROFILE_DIR, exist_ok=True)
if not self._portal_cleanup_done:
self._portal_status_label.configure(
text="正在清理上次遗留的客服窗口…",
fg=WARNING,
)
self._stop_customer_service_edge_processes()
self._portal_cleanup_done = True
self._portal_windows_before = {
item["hwnd"] for item in self._enumerate_edge_windows()
}
command = (
edge,
f"--app={CUSTOMER_SERVICE_URL}",
f"--disable-extensions-except={EDGE_LIGHT_THEME_DIR}",
f"--load-extension={EDGE_LIGHT_THEME_DIR}",
f"--user-data-dir={EDGE_PROFILE_DIR}",
"--no-first-run",
"--disable-default-apps",
"--disable-background-mode",
"--window-position=-32000,-32000",
"--window-size=1200,800",
)
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
self._portal_process = subprocess.Popen(
command,
creationflags=creation_flags,
)
self._portal_poll_attempts = 0
self.after(100, self._poll_customer_service_portal)
except OSError as exc:
self._portal_launching = False
webbrowser.open_new(CUSTOMER_SERVICE_URL)
self._portal_status_label.configure(
text=f"内嵌启动失败,已改用外部浏览器:{exc}",
fg=DANGER,
)
def _close_customer_service_portal(self):
had_portal = bool(
self._portal_hwnd
or self._portal_process is not None
or self._portal_cleanup_done
)
if self._portal_hwnd and sys.platform == "win32":
try:
ctypes.windll.user32.PostMessageW(
wintypes.HWND(self._portal_hwnd),
0x0010,
0,
0,
)
except Exception:
pass
elif (
self._portal_process is not None
and self._portal_process.poll() is None
):
try:
self._portal_process.terminate()
except OSError:
pass
if had_portal:
self._stop_customer_service_edge_processes(delay_ms=250)
self._portal_hwnd = 0
self._portal_process = None
self._portal_embedded = False
self._portal_launching = False
self._portal_frame_insets = None
def _build(self):
shell = tk.Frame(self, bg=BG)
shell.pack(fill="both", expand=True)
self._build_sidebar(shell)
workspace = tk.Frame(shell, bg=BG)
workspace.pack(side="left", fill="both", expand=True)
self._workspace = workspace
self._build_header(workspace)
stage = tk.Frame(
workspace,
bg=CONTENT_BG,
highlightthickness=1,
highlightbackground=BORDER_SOFT,
)
stage.pack(fill="both", expand=True, padx=14, pady=(0, 14))
self._stage = stage
for name, _code in PAGES:
self._page_frames[name] = tk.Frame(stage, bg=CONTENT_BG)
self._build_customer_service_page(self._page_frames["AI 客服"])
self._build_dashboard_page(self._page_frames["自动回复"])
self._build_general_page(self._page_frames["通用设置"])
self._build_business_page(self._page_frames["业务数据"])
self._build_persona_page(self._page_frames["AI 人格"])
self._build_log_page(self._page_frames["运行日志"])
self._show_page("AI 客服")
@staticmethod
def _rounded_rectangle(canvas, x1, y1, x2, y2, radius, **kwargs):
points = (
x1 + radius, y1,
x2 - radius, y1,
x2, y1,
x2, y1 + radius,
x2, y2 - radius,
x2, y2,
x2 - radius, y2,
x1 + radius, y2,
x1, y2,
x1, y2 - radius,
x1, y1 + radius,
x1, y1,
)
return canvas.create_polygon(points, smooth=True, splinesteps=24, **kwargs)
def _build_capsule(self):
self._capsule = tk.Toplevel(self)
self._capsule.withdraw()
self._capsule.overrideredirect(True)
self._capsule.configure(bg=CAPSULE_TRANSPARENT)
self._capsule.attributes("-topmost", True)
try:
self._capsule.wm_attributes("-transparentcolor", CAPSULE_TRANSPARENT)
except tk.TclError:
pass
try:
self._capsule.wm_attributes("-toolwindow", True)
except tk.TclError:
pass
canvas = tk.Canvas(
self._capsule,
width=CAPSULE_WIDTH,
height=CAPSULE_HEIGHT,
bg=CAPSULE_TRANSPARENT,
highlightthickness=0,
bd=0,
)
canvas.pack(fill="both", expand=True)
self._capsule_canvas = canvas
self._capsule_shadow_id = self._rounded_rectangle(
canvas,
5,
7,
CAPSULE_WIDTH - 3,
CAPSULE_HEIGHT - 1,
28,
fill=CAPSULE_SHADOW,
outline="",
)
self._capsule_body_id = self._rounded_rectangle(
canvas,
2,
2,
CAPSULE_WIDTH - 6,
CAPSULE_HEIGHT - 6,
28,
fill=PANEL,
outline=BORDER,
width=1,
)
self._capsule_line_id = canvas.create_line(
28,
3,
CAPSULE_WIDTH - 34,
3,
fill=CAPSULE_HIGHLIGHT,
width=1,
)
content = tk.Frame(canvas, bg=PANEL, height=48)
self._capsule_content = content
self._capsule_content_window_id = canvas.create_window(
18,
10,
anchor="nw",
width=336,
height=48,
window=content,
)
handle = tk.Label(
content,
text=":::",
bg=PANEL,
fg=TEXT_FAINT,
font=self._capsule_fonts["mono"],
cursor="fleur",
)
self._capsule_handle = handle
handle.pack(side="left", padx=(0, 9))
dot = tk.Canvas(
content,
width=12,
height=12,
bg=PANEL,
highlightthickness=0,
)
dot.pack(side="left", padx=(0, 8))
self._capsule_dot = dot
self._capsule_dot_id = dot.create_oval(1, 1, 11, 11, fill=WARNING, outline="")
status_box = tk.Frame(content, bg=PANEL, width=108)
self._capsule_status_box = status_box
status_box.pack(side="left", fill="y")
status_box.pack_propagate(False)
self._capsule_status = tk.Label(
status_box,
text="连接中",
bg=PANEL,
fg=WARNING,
font=self._capsule_fonts["status"],
anchor="w",
)
self._capsule_status.pack(anchor="w")
self._capsule_hint = tk.Label(
status_box,
text="安全监听",
bg=PANEL,
fg=TEXT_FAINT,
font=self._capsule_fonts["hint"],
anchor="w",
)
self._capsule_hint.pack(anchor="w", pady=(1, 0))
self._capsule_timer = tk.Label(
content,
text="00:00:00",
bg=PANEL,
fg=TEXT_MUTED,
font=self._capsule_fonts["mono"],
width=9,
anchor="center",
)
self._capsule_timer.pack(side="left", padx=(6, 7))
expand_button = self._capsule_button(
content,
"□",
"展开控制台",
self._expand_console,
)
self._capsule_expand_button = expand_button
expand_button.pack(side="left", padx=(0, 5))
stop_button = self._capsule_button(
content,
"×",
"停止监听",
self._on_stop,
danger=True,
)
self._capsule_stop_button = stop_button
stop_button.pack(side="left")
drag_widgets = (
canvas,
content,
handle,
dot,
status_box,
self._capsule_status,
self._capsule_hint,
self._capsule_timer,
)
for widget in drag_widgets:
widget.bind("<ButtonPress-1>", self._capsule_drag_start)
widget.bind("<B1-Motion>", self._capsule_drag_motion)
widget.bind("<Double-Button-1>", lambda _event: self._expand_console())
self._apply_capsule_scaling(self._console_dpi)
def _apply_capsule_scaling(self, dpi):
dpi = max(96, int(dpi or 96))
if self._capsule_dpi == dpi:
return
scale = dpi / 96.0
px = lambda value: max(1, round(value * scale))
for name, font in self._capsule_fonts.items():
spec = self._capsule_font_specs[name]
font.configure(
family=spec[0],
size=-px(abs(spec[1])),
weight=spec[2] if len(spec) > 2 else "normal",
)
width = px(CAPSULE_WIDTH)
height = px(CAPSULE_HEIGHT)
canvas = self._capsule_canvas
canvas.configure(width=width, height=height)
for item_id in (
self._capsule_shadow_id,
self._capsule_body_id,
self._capsule_line_id,
):
canvas.delete(item_id)
self._capsule_shadow_id = self._rounded_rectangle(
canvas,
px(5),
px(7),
width - px(3),
height - px(1),
px(28),
fill=CAPSULE_SHADOW,
outline="",
)
self._capsule_body_id = self._rounded_rectangle(
canvas,
px(2),
px(2),
width - px(6),
height - px(6),
px(28),
fill=PANEL,
outline=BORDER,
width=px(1),
)
self._capsule_line_id = canvas.create_line(
px(28),
px(3),
width - px(34),
px(3),
fill=CAPSULE_HIGHLIGHT,
width=px(1),
)
canvas.coords(self._capsule_content_window_id, px(18), px(10))
canvas.itemconfigure(
self._capsule_content_window_id,
width=px(336),
height=px(48),
)
canvas.tag_raise(self._capsule_body_id, self._capsule_shadow_id)
canvas.tag_raise(self._capsule_line_id, self._capsule_body_id)
canvas.tag_raise(self._capsule_content_window_id, self._capsule_line_id)
self._capsule_content.configure(height=px(48))
self._capsule_handle.pack_configure(padx=(0, px(9)))
dot_color = self._capsule_dot.itemcget(self._capsule_dot_id, "fill")
self._capsule_dot.configure(width=px(12), height=px(12))
self._capsule_dot.delete(self._capsule_dot_id)
self._capsule_dot_id = self._capsule_dot.create_oval(
px(1),
px(1),
px(11),
px(11),
fill=dot_color,
outline="",
)
self._capsule_dot.pack_configure(padx=(0, px(8)))
self._capsule_status_box.configure(width=px(108))
self._capsule_hint.pack_configure(pady=(px(1), 0))
self._capsule_timer.pack_configure(padx=(px(6), px(7)))
self._capsule_expand_button.configure(padx=px(3), pady=px(4))
self._capsule_expand_button.pack_configure(padx=(0, px(5)))
self._capsule_stop_button.configure(padx=px(3), pady=px(4))
self._capsule_dpi = dpi
self._capsule.update_idletasks()
def _sync_capsule_dpi(self):
monitor = self._monitor_info_for(self._capsule)
dpi = int((monitor or {}).get("dpi") or 96)
if dpi != self._capsule_dpi:
self._apply_capsule_scaling(dpi)
return monitor
def _capsule_button(self, parent, text, tooltip, command, danger=False):
normal_bg = DANGER_SOFT if danger else PANEL_ALT
normal_fg = DANGER if danger else TEXT_MUTED
hover_bg = DANGER if danger else ELEVATED
hover_fg = ON_ACCENT if danger else ACCENT_HOVER
button = tk.Label(
parent,
text=text,
bg=normal_bg,
fg=normal_fg,
font=self._capsule_fonts["icon"],
width=2,
height=1,
cursor="hand2",
padx=3,
pady=4,
)
button.bind("<Enter>", lambda _event: button.configure(bg=hover_bg, fg=hover_fg))
button.bind("<Leave>", lambda _event: button.configure(bg=normal_bg, fg=normal_fg))
button.bind("<ButtonPress-1>", lambda _event: button.configure(relief="sunken"))
def release(_event=None):
button.configure(relief="flat")
command()
button.bind("<ButtonRelease-1>", release)
self._create_tooltip(button, tooltip)
return button
def _create_tooltip(self, widget, text):
state = {"window": None, "job": None, "hover": False}
def show(_event=None):
state["job"] = None
if not state["hover"] or state["window"] is not None:
return
tip = tk.Toplevel(widget)
tip.overrideredirect(True)
tip.attributes("-topmost", True)
x = widget.winfo_rootx() + widget.winfo_width() // 2
y = widget.winfo_rooty() + widget.winfo_height() + 8
tip.geometry(f"{x:+d}{y:+d}")
tk.Label(
tip,
text=text,
bg=ELEVATED,
fg=TEXT,
font=self._capsule_fonts["tooltip"],
padx=8,
pady=5,
relief="solid",
bd=1,
).pack()
state["window"] = tip
def hide(_event=None):
state["hover"] = False
if state["job"] is not None:
widget.after_cancel(state["job"])
state["job"] = None
if state["window"] is not None:
state["window"].destroy()
state["window"] = None
def enter(event=None):
state["hover"] = True
widget.configure(
bg=DANGER if text == "停止监听" else ELEVATED,
fg=ON_ACCENT if text == "停止监听" else ACCENT_HOVER,
)
state["job"] = widget.after(450, lambda: show(event))
widget.bind("<Enter>", enter)
widget.bind("<Leave>", lambda event: (hide(event), widget.configure(
bg=DANGER_SOFT if text == "停止监听" else PANEL_ALT,
fg=DANGER if text == "停止监听" else TEXT_MUTED,
)))
def _capsule_drag_start(self, event):
self._capsule_drag_offset = (
event.x_root - self._capsule.winfo_x(),
event.y_root - self._capsule.winfo_y(),
)
def _capsule_drag_motion(self, event):
offset_x, offset_y = self._capsule_drag_offset
x = event.x_root - offset_x
y = event.y_root - offset_y
self._capsule_position = (x, y)
self._move_native_window(self._capsule, x, y)
self._sync_capsule_dpi()
self._move_native_window(self._capsule, x, y)
def _native_handle_for(self, widget):
hwnd = int(widget.winfo_id())
try:
get_parent = ctypes.windll.user32.GetParent
get_parent.argtypes = (wintypes.HWND,)
get_parent.restype = wintypes.HWND
parent = int(get_parent(wintypes.HWND(hwnd)) or 0)
return parent or hwnd
except Exception:
return hwnd
def _native_window_handle(self):
return self._native_handle_for(self)
def _native_rect_for(self, widget):
try:
user32 = ctypes.windll.user32
get_rect = user32.GetWindowRect
get_rect.argtypes = (wintypes.HWND, ctypes.POINTER(wintypes.RECT))
get_rect.restype = wintypes.BOOL
rect = wintypes.RECT()
hwnd = self._native_handle_for(widget)
if get_rect(wintypes.HWND(hwnd), ctypes.byref(rect)):
return (int(rect.left), int(rect.top), int(rect.right), int(rect.bottom))
except Exception:
pass
return None
def _move_native_window(self, widget, x, y):
try:
widget.update_idletasks()
rect = self._native_rect_for(widget)
width = max(1, rect[2] - rect[0]) if rect else widget.winfo_reqwidth()
height = max(1, rect[3] - rect[1]) if rect else widget.winfo_reqheight()
user32 = ctypes.windll.user32
set_pos = user32.SetWindowPos
set_pos.argtypes = (
wintypes.HWND,
wintypes.HWND,
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
wintypes.UINT,
)
set_pos.restype = wintypes.BOOL
set_pos(
wintypes.HWND(self._native_handle_for(widget)),
wintypes.HWND(0),
int(x),
int(y),
int(width),
int(height),
0x0004 | 0x0010,
)
return
except Exception:
pass
widget.geometry(f"+{max(0, int(x))}+{max(0, int(y))}")
def _monitor_info_for(self, widget):
try:
user32 = ctypes.windll.user32
monitor_from_window = user32.MonitorFromWindow
monitor_from_window.argtypes = (wintypes.HWND, wintypes.DWORD)
monitor_from_window.restype = wintypes.HANDLE
get_monitor_info = user32.GetMonitorInfoW
get_monitor_info.argtypes = (wintypes.HANDLE, ctypes.POINTER(_MonitorInfo))
get_monitor_info.restype = wintypes.BOOL
hwnd = self._native_handle_for(widget)
monitor_handle = monitor_from_window(wintypes.HWND(hwnd), 2)
info = _MonitorInfo()
info.size = ctypes.sizeof(_MonitorInfo)
if not monitor_handle or not get_monitor_info(monitor_handle, ctypes.byref(info)):
return None
dpi = 96
try:
get_dpi = user32.GetDpiForWindow
get_dpi.argtypes = (wintypes.HWND,)
get_dpi.restype = wintypes.UINT
dpi = int(get_dpi(wintypes.HWND(hwnd)) or 96)
except Exception:
pass
return {
"handle": int(monitor_handle),
"dpi": max(96, dpi),
"work": (
int(info.work.left),
int(info.work.top),
int(info.work.right),
int(info.work.bottom),
),
}
except Exception:
return None
def _font_scale_for_window(self, dpi):
rect = self._native_rect_for(self)
if not rect:
return 1.0
width = max(1, rect[2] - rect[0])
height = max(1, rect[3] - rect[1])
logical_width = width * 96.0 / max(96, dpi)
logical_height = height * 96.0 / max(96, dpi)
available_scale = min(logical_width / 1260.0, logical_height / 820.0)
return min(1.2, max(1.0, available_scale))
def _scaled_geometry(self, geometry):
width, height = geometry.lower().split("x", 1)
return (
f"{round(int(width) * self._layout_scale)}x"
f"{round(int(height) * self._layout_scale)}"
)
def _apply_tk_scaling(self, dpi, font_scale=None):
try:
dpi = max(96, int(dpi or 96))
font_scale = (
self._font_scale_for_window(dpi)
if font_scale is None
else min(1.2, max(1.0, float(font_scale)))
)
self.tk.call("tk", "scaling", max(1.0, float(dpi) / 72.0))
self._layout_scale = max(1.0, dpi / 96.0)
self.minsize(
round(BASE_MIN_WIDTH * self._layout_scale),
round(BASE_MIN_HEIGHT * self._layout_scale),
)
if hasattr(self, "_sidebar"):
self._sidebar.configure(
width=round(BASE_SIDEBAR_WIDTH * self._layout_scale),
)
for name, font in self._ui_fonts.items():
spec = UI_FONT_SPECS[name]
font.configure(
family=spec[0],
size=max(1, round(spec[1] * font_scale)),
weight=spec[2] if len(spec) > 2 else "normal",
)
self._console_dpi = dpi
self._console_font_scale = font_scale
except tk.TclError:
pass
def _sync_console_dpi(self):
if self._capsule_mode or self.state() in ("withdrawn", "iconic"):
return
monitor = self._monitor_info_for(self)
dpi = int((monitor or {}).get("dpi") or 96)
font_scale = self._font_scale_for_window(dpi)
if dpi != self._console_dpi or abs(font_scale - self._console_font_scale) >= 0.04:
self._apply_tk_scaling(dpi, font_scale)
@staticmethod
def _scaled_rect_for_monitor(native, target, use_normal=False):
source_rect = native.get("normal_rect") if use_normal else native.get("rect")
source_rect = source_rect or native["rect"]
source_work = native.get("monitor_work") or source_rect
target_work = target.get("work") or source_work
source_dpi = max(96, int(native.get("dpi") or 96))
target_dpi = max(96, int(target.get("dpi") or 96))
if tuple(source_work) == tuple(target_work) and source_dpi == target_dpi:
return tuple(source_rect)
left, top, right, bottom = source_rect
source_width = max(1, right - left)
source_height = max(1, bottom - top)
target_work_width = max(1, target_work[2] - target_work[0])
target_work_height = max(1, target_work[3] - target_work[1])
scale = target_dpi / source_dpi
width = min(target_work_width, max(1, round(source_width * scale)))
height = min(target_work_height, max(1, round(source_height * scale)))
source_free_x = max(1, (source_work[2] - source_work[0]) - source_width)
source_free_y = max(1, (source_work[3] - source_work[1]) - source_height)
x_ratio = min(1.0, max(0.0, (left - source_work[0]) / source_free_x))
y_ratio = min(1.0, max(0.0, (top - source_work[1]) / source_free_y))
target_free_x = max(0, target_work_width - width)
target_free_y = max(0, target_work_height - height)
x = target_work[0] + round(target_free_x * x_ratio)
y = target_work[1] + round(target_free_y * y_ratio)
return (x, y, x + width, y + height)
def _capture_console_window_state(self):
self.update_idletasks()
self._console_geometry = self.geometry()
self._console_was_zoomed = self.state() == "zoomed"
self._console_native_state = None
try:
user32 = ctypes.windll.user32
hwnd = self._native_window_handle()
get_rect = user32.GetWindowRect
get_rect.argtypes = (wintypes.HWND, ctypes.POINTER(wintypes.RECT))
get_rect.restype = wintypes.BOOL
get_placement = user32.GetWindowPlacement
get_placement.argtypes = (wintypes.HWND, ctypes.POINTER(_WindowPlacement))
get_placement.restype = wintypes.BOOL
rect = wintypes.RECT()
placement = _WindowPlacement()
placement.length = ctypes.sizeof(_WindowPlacement)
if not get_rect(wintypes.HWND(hwnd), ctypes.byref(rect)):
return
show_cmd = 3 if self._console_was_zoomed else 1
normal_rect = (int(rect.left), int(rect.top), int(rect.right), int(rect.bottom))
if get_placement(wintypes.HWND(hwnd), ctypes.byref(placement)):
show_cmd = int(placement.show_cmd)
normal_rect = (
int(placement.normal_position.left),
int(placement.normal_position.top),
int(placement.normal_position.right),
int(placement.normal_position.bottom),
)
monitor = self._monitor_info_for(self)
self._console_native_state = {
"rect": (int(rect.left), int(rect.top), int(rect.right), int(rect.bottom)),
"normal_rect": normal_rect,
"show_cmd": show_cmd,
"dpi": (monitor or {}).get("dpi", 96),
"monitor_work": (monitor or {}).get("work"),
}
except Exception:
self._console_native_state = None
def _restore_console_window_state(self, target_monitor=None):
native = self._console_native_state
if native:
try:
user32 = ctypes.windll.user32
hwnd = self._native_window_handle()
show_window = user32.ShowWindow
show_window.argtypes = (wintypes.HWND, ctypes.c_int)
show_window.restype = wintypes.BOOL
set_pos = user32.SetWindowPos
set_pos.argtypes = (
wintypes.HWND,
wintypes.HWND,
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
wintypes.UINT,
)
set_pos.restype = wintypes.BOOL
target = target_monitor or {
"dpi": native.get("dpi", 96),
"work": native.get("monitor_work"),
}
self._apply_tk_scaling(target.get("dpi", 96))
use_normal = native["show_cmd"] == 3
left, top, right, bottom = self._scaled_rect_for_monitor(
native,
target,
use_normal=use_normal,
)
width = max(1, right - left)
height = max(1, bottom - top)
if native["show_cmd"] == 3:
show_window(wintypes.HWND(hwnd), 9)
set_pos(
wintypes.HWND(hwnd),
wintypes.HWND(0),
left,
top,
width,
height,
0x0004 | 0x0010,
)
if native["show_cmd"] == 3:
show_window(wintypes.HWND(hwnd), 3)
return
except Exception:
pass
if self._console_was_zoomed:
try:
self.state("zoomed")
return
except tk.TclError:
pass
self.geometry(self._console_geometry)
def _enter_capsule_mode(self):
if self._capsule_mode:
return
if self.state() != "withdrawn":
self._capture_console_window_state()
source_dpi = int((self._console_native_state or {}).get("dpi") or 96)
self._apply_capsule_scaling(source_dpi)
if self._capsule_position is None:
work = (self._console_native_state or {}).get("monitor_work")
scale = source_dpi / 96.0
capsule_width = round(CAPSULE_WIDTH * scale)
margin = round(28 * scale)
if work:
x = work[2] - capsule_width - margin
y = work[1] + margin
else:
x = self.winfo_screenwidth() - capsule_width - margin
y = margin
self._capsule_position = (x, y)
x, y = self._capsule_position
self._set_customer_service_portal_visible(False)
self.withdraw()
self._capsule.deiconify()
self._capsule.update_idletasks()
self._move_native_window(self._capsule, x, y)
self._sync_capsule_dpi()
self._move_native_window(self._capsule, x, y)
self._capsule.lift()
self._capsule.attributes("-topmost", True)
self._capsule_mode = True
def _expand_console(self):
target_monitor = None
if hasattr(self, "_capsule"):
target_monitor = self._monitor_info_for(self._capsule)
self._capsule.withdraw()
self._capsule_mode = False
self.deiconify()
self._restore_console_window_state(target_monitor)
self.after_idle(lambda target=target_monitor: self._restore_console_window_state(target))
self.lift()
self._set_customer_service_portal_visible(
getattr(self, "_current_page", "AI 客服") == "AI 客服"
)
if getattr(self, "_current_page", "AI 客服") == "AI 客服":
for delay in (80, 350, 700):
self.after(delay, self._focus_customer_service_portal)
if self._running and self._collapse_button.winfo_manager() == "":
self._collapse_button.pack(side="right", padx=(0, 8))
def _update_capsule_status(self, state, text):
if not hasattr(self, "_capsule_status"):
return
colors = {
"connecting": WARNING,
"waiting": WARNING,
"running": SUCCESS,
"stopping": WARNING,
"error": DANGER,
"stopped": TEXT_FAINT,
}
hints = {
"connecting": "正在挂载窗口",
"waiting": "正在切到企业微信前台",
"running": "安全监听中",
"stopping": "正在停止",
"error": "打开控制台查看",
"stopped": "监听已停止",
}
color = colors.get(state, TEXT_FAINT)
self._capsule_dot.itemconfigure(self._capsule_dot_id, fill=color)
self._capsule_status.configure(text=text, fg=color)
self._capsule_hint.configure(text=hints.get(state, "安全监听"))
def _build_sidebar(self, parent):
sidebar = tk.Frame(
parent,
bg=PANEL,
width=round(BASE_SIDEBAR_WIDTH * self._layout_scale),
highlightthickness=1,
highlightbackground=BORDER,
)
self._sidebar = sidebar
sidebar.pack(side="left", fill="y")
sidebar.pack_propagate(False)
brand = tk.Frame(sidebar, bg=PANEL)
brand.pack(fill="x", padx=18, pady=(20, 14))
tk.Frame(brand, width=4, height=38, bg=ACCENT).pack(side="left")
brand_text = tk.Frame(brand, bg=PANEL)
brand_text.pack(side="left", padx=(10, 0))
tk.Label(
brand_text,
text="ZHEN AI DESK",
bg=PANEL,
fg=ACCENT,
font=TYPE_BRAND,
).pack(anchor="w")
tk.Label(
brand_text,
text="AI 客服 · 企业微信自动回复",
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_SMALL,
).pack(anchor="w", pady=(2, 0))
status_card = tk.Frame(
sidebar,
bg=PANEL_ALT,
highlightthickness=1,
highlightbackground=BORDER,
)
status_card.pack(fill="x", padx=14, pady=(0, 20))
self._status_dot = tk.Canvas(
status_card,
width=12,
height=12,
bg=PANEL_ALT,
highlightthickness=0,
)
self._status_dot.pack(side="left", padx=(12, 9), pady=12)
self._status_dot_id = self._status_dot.create_oval(
1,
1,
11,
11,
fill=TEXT_FAINT,
outline="",
)
status_text = tk.Frame(status_card, bg=PANEL_ALT)
status_text.pack(side="left", fill="x", expand=True, pady=9)
self._status_label = tk.Label(
status_text,
text="已停止",
bg=PANEL_ALT,
fg=TEXT_MUTED,
font=TYPE_BODY_BOLD,
)
self._status_label.pack(anchor="w")
self._status_hint = tk.Label(
status_text,
text="等待连接企业微信",
bg=PANEL_ALT,
fg=TEXT_FAINT,
font=TYPE_CAPTION,
)
self._status_hint.pack(anchor="w", pady=(2, 0))
tk.Label(
sidebar,
text="CONTROL CENTER",
bg=PANEL,
fg=TEXT_FAINT,
font=TYPE_MONO_LABEL,
).pack(anchor="w", padx=18, pady=(0, 8))
nav = tk.Frame(sidebar, bg=PANEL)
nav.pack(fill="x", padx=10)
for name, code in PAGES:
row = tk.Frame(nav, bg=PANEL, cursor="hand2")
row.pack(fill="x", pady=2)
bar = tk.Frame(row, bg=PANEL, width=3)
bar.pack(side="left", fill="y")
code_label = tk.Label(
row,
text=code,
bg=PANEL,
fg=TEXT_FAINT,
font=TYPE_MONO_BODY,
width=3,
cursor="hand2",
)
code_label.pack(side="left", padx=(10, 3), pady=11)
text_label = tk.Label(
row,
text=name,
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_BODY,
anchor="w",
cursor="hand2",
)
text_label.pack(side="left", fill="x", expand=True, pady=11)
for widget in (row, bar, code_label, text_label):
widget.bind("<Button-1>", lambda _event, page=name: self._show_page(page))
self._nav_items[name] = (row, bar, code_label, text_label)
footer = tk.Frame(sidebar, bg=PANEL)
footer.pack(side="bottom", fill="x", padx=14, pady=14)
self._stop_button = ActionButton(
footer,
"停止监听",
self._on_stop,
kind="danger",
)
self._stop_button.pack(fill="x")
self._stop_button.set_enabled(False)
tk.Label(
footer,
text="RPA DESKTOP / LOCAL",
bg=PANEL,
fg=TEXT_FAINT,
font=TYPE_MONO_LABEL,
).pack(anchor="w", pady=(14, 0))
def _build_header(self, parent):
header = tk.Frame(
parent,
bg=PANEL,
highlightthickness=1,
highlightbackground=BORDER,
)
header.pack(fill="x", padx=14, pady=(14, 12))
self._header = header
inner = tk.Frame(header, bg=PANEL)
inner.pack(fill="x", padx=20, pady=14)
left = tk.Frame(inner, bg=PANEL)
left.pack(side="left")
self._page_code = tk.Label(
left,
text="01",
bg=ACCENT_SOFT,
fg=ACCENT,
font=TYPE_MONO_BODY_BOLD,
padx=9,
pady=5,
)
self._page_code.pack(side="left")
self._page_title = tk.Label(
left,
text="自动回复",
bg=PANEL,
fg=TEXT,
font=TYPE_HEADLINE,
)
self._page_title.pack(side="left", padx=(12, 0))
actions = tk.Frame(inner, bg=PANEL)
actions.pack(side="right")
ActionButton(actions, "AI 配置", self._open_ai_config).pack(side="right")
ActionButton(actions, "MCP", self._open_mcp_config).pack(side="right", padx=(0, 8))
self._start_button = ActionButton(
actions,
"开始监听",
self._on_start,
kind="primary",
)
self._start_button.pack(side="right", padx=(0, 8))
self._collapse_button = ActionButton(
actions,
"收起胶囊",
self._enter_capsule_mode,
)
def _show_page(self, name):
self._current_page = name
is_customer_service = name == "AI 客服"
if is_customer_service:
self._header.pack_forget()
self._stage.pack_configure(padx=0, pady=0)
self._stage.configure(highlightthickness=0)
else:
if not self._header.winfo_manager():
self._header.pack(
fill="x",
padx=14,
pady=(14, 12),
before=self._stage,
)
self._stage.pack_configure(padx=14, pady=(0, 14))
self._stage.configure(highlightthickness=1)
for page_name, frame in self._page_frames.items():
if page_name == name:
frame.pack(fill="both", expand=True)
else:
frame.pack_forget()
for page_name, widgets in self._nav_items.items():
row, bar, code_label, text_label = widgets
active = page_name == name
background = PANEL_ALT if active else PANEL
row.configure(bg=background)
bar.configure(bg=ACCENT if active else PANEL)
code_label.configure(
bg=background,
fg=ACCENT if active else TEXT_FAINT,
)
text_label.configure(
bg=background,
fg=TEXT if active else TEXT_MUTED,
font=TYPE_BODY_BOLD if active else TYPE_BODY,
)
code = dict(PAGES).get(name, "--")
self._page_code.configure(text=code)
self._page_title.configure(text=name)
self._set_customer_service_portal_visible(is_customer_service)
if is_customer_service:
if self._portal_embedded:
self.after(80, self._focus_customer_service_portal)
else:
self.after_idle(self._open_customer_service_portal)
def _metric_card(self, parent, column, label, attr_name, note):
card = tk.Frame(
parent,
bg=PANEL,
highlightthickness=1,
highlightbackground=BORDER,
)
card.grid(row=0, column=column, sticky="nsew", padx=(0 if column == 0 else 10, 0))
tk.Label(
card,
text=label,
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_SMALL,
).pack(anchor="w", padx=15, pady=(13, 0))
value = tk.Label(
card,
text="0",
bg=PANEL,
fg=TEXT,
font=TYPE_METRIC,
)
value.pack(anchor="w", padx=15, pady=(4, 0))
tk.Label(
card,
text=note,
bg=PANEL,
fg=TEXT_FAINT,
font=TYPE_CAPTION,
).pack(anchor="w", padx=15, pady=(1, 12))
setattr(self, attr_name, value)
def _build_customer_service_page(self, page):
self._portal_status_label = tk.Label(
page,
text="正在加载客服系统…",
bg=CONTENT_BG,
fg=TEXT_MUTED,
font=TYPE_SMALL,
)
self._portal_host = tk.Frame(page, bg=CONTENT_BG)
self._portal_host.pack(fill="both", expand=True)
self._portal_embed_header = tk.Frame(
self._portal_host,
bg=PANEL,
highlightthickness=0,
)
tk.Frame(
self._portal_embed_header,
bg=BORDER_SOFT,
height=1,
).pack(side="bottom", fill="x")
tk.Label(
self._portal_embed_header,
text="甄养堂 AI 客服",
bg=PANEL,
fg=TEXT,
font=TYPE_SMALL_BOLD,
).pack(side="left", padx=18)
tk.Label(
self._portal_embed_header,
text="● 网页已连接",
bg=PANEL,
fg=SUCCESS,
font=TYPE_CAPTION,
).pack(side="right", padx=18)
self._portal_loading_label = tk.Label(
self._portal_host,
text="正在接入 AI 客服页面…",
bg=CONTENT_BG,
fg=TEXT_MUTED,
font=TYPE_BODY,
)
self._portal_loading_label.place(relx=0.5, rely=0.5, anchor="center")
self._portal_host.bind(
"<Configure>",
self._schedule_customer_service_portal_resize,
)
self.bind(
"<Configure>",
self._schedule_customer_service_portal_resize,
add="+",
)
def _build_dashboard_page(self, page):
wrap = tk.Frame(page, bg=CONTENT_BG)
wrap.pack(fill="both", expand=True, padx=20, pady=20)
metrics = tk.Frame(wrap, bg=CONTENT_BG)
metrics.pack(fill="x", pady=(0, 14))
for column in range(4):
metrics.grid_columnconfigure(column, weight=1)
self._metric_card(metrics, 0, "本次已回复", "_replied_label", "自动回复数量")
self._metric_card(metrics, 1, "识别误判", "_false_label", "已跳过的会话")
self._metric_card(metrics, 2, "待处理登记", "_registration_count_label", "挂号与回访线索")
self._metric_card(metrics, 3, "会话档案", "_session_count_label", "已保存上下文")
control = Card(
wrap,
"企业微信监听",
"连接已登录的企业微信窗口,自动识别新消息并按当前规则处理。",
)
control.pack(fill="x", pady=(0, 14))
control_row = tk.Frame(control.body, bg=PANEL)
control_row.pack(fill="x")
left = tk.Frame(control_row, bg=PANEL)
left.pack(side="left", fill="x", expand=True)
self._dashboard_state = tk.Label(
left,
text="待命",
bg=PANEL,
fg=TEXT,
font=TYPE_STATUS,
)
self._dashboard_state.pack(anchor="w")
self._dashboard_notice = tk.Label(
left,
text=WECOM_WAITING_MESSAGE,
bg=PANEL,
fg=WARNING,
font=TYPE_BODY_BOLD,
justify="left",
anchor="w",
)
self._timer_label = tk.Label(
left,
text="运行时长 --:--:--",
bg=PANEL,
fg=TEXT_FAINT,
font=TYPE_MONO_BODY,
)
self._timer_label.pack(anchor="w", pady=(4, 0))
ActionButton(
control_row,
"开始监听",
self._on_start,
kind="primary",
).pack(side="right")
flow = Card(
wrap,
"实际工作流",
"设计稿中的自动化规则已映射到本软件现有的可执行能力。",
)
flow.pack(fill="both", expand=True)
rows = (
("A1", "消息监听", "未读识别 → 会话提取 → 自动回复", "通用设置", lambda: self._show_page("通用设置")),
("A2", "AI 决策", "上下文记忆 → 医疗客服人格 → MCP 工具", "AI 人格", lambda: self._show_page("AI 人格")),
("A3", "业务沉淀", "挂号登记 → 会话档案 → 运行记录", "查看数据", lambda: self._show_page("业务数据")),
)
for index, (code, title, description, action_text, action) in enumerate(rows):
row = tk.Frame(
flow.body,
bg=PANEL_ALT,
highlightthickness=1,
highlightbackground=BORDER_SOFT,
)
row.pack(fill="x", pady=(0 if index == 0 else 9, 0))
tk.Label(
row,
text=code,
bg=ACCENT_SOFT,
fg=ACCENT,
font=TYPE_MONO_BODY_BOLD,
padx=10,
pady=13,
).pack(side="left", fill="y")
text_box = tk.Frame(row, bg=PANEL_ALT)
text_box.pack(side="left", fill="x", expand=True, padx=14, pady=9)
tk.Label(
text_box,
text=title,
bg=PANEL_ALT,
fg=TEXT,
font=TYPE_BODY_BOLD,
).pack(anchor="w")
tk.Label(
text_box,
text=description,
bg=PANEL_ALT,
fg=TEXT_MUTED,
font=TYPE_SMALL,
).pack(anchor="w", pady=(2, 0))
ActionButton(row, action_text, action).pack(side="right", padx=10)
def _show_workbench_tab(self, tab):
self._show_page("AI 客服" if tab == "ai" else "自动回复")
def _entry(self, parent, variable, *, show=None):
shell = tk.Frame(
parent,
bg=PANEL_ALT,
highlightthickness=1,
highlightbackground=BORDER,
)
entry = tk.Entry(
shell,
textvariable=variable,
show=show,
bg=PANEL_ALT,
fg=TEXT,
insertbackground=ACCENT,
selectbackground=ACCENT_SOFT,
selectforeground=TEXT,
relief="flat",
bd=0,
highlightthickness=0,
font=TYPE_BODY,
)
entry.pack(fill="x", padx=11, pady=9)
entry.bind("<FocusIn>", lambda _event: shell.configure(highlightbackground=ACCENT))
entry.bind("<FocusOut>", lambda _event: shell.configure(highlightbackground=BORDER))
return shell, entry
def _field(self, parent, label, variable, *, column=0, show=None):
box = tk.Frame(parent, bg=PANEL)
box.grid(row=0, column=column, sticky="ew", padx=(0 if column == 0 else 10, 0))
tk.Label(
box,
text=label,
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_SMALL,
).pack(anchor="w")
entry_shell, entry = self._entry(box, variable, show=show)
entry_shell.pack(fill="x", pady=(6, 0))
return entry
def _build_general_page(self, page):
wrap = tk.Frame(page, bg=CONTENT_BG)
wrap.pack(fill="both", expand=True, padx=20, pady=20)
settings = Card(
wrap,
"运行参数",
"这些参数直接参与企业微信监听,不是仅用于展示。",
)
settings.pack(fill="x", pady=(0, 14))
self._reply_var = tk.StringVar(
value=self._runtime_settings["auto_reply_text"],
)
tk.Label(
settings.body,
text="固定回复(关闭 AI 时使用)",
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_SMALL,
).pack(anchor="w")
entry_shell, _entry = self._entry(settings.body, self._reply_var)
entry_shell.pack(fill="x", pady=(6, 14))
number_row = tk.Frame(settings.body, bg=PANEL)
number_row.pack(fill="x")
number_row.grid_columnconfigure(0, weight=1)
number_row.grid_columnconfigure(1, weight=1)
self._poll_var = tk.StringVar(
value=str(self._runtime_settings["poll_interval"]),
)
self._idle_seconds_var = tk.StringVar(
value=str(self._runtime_settings["mouse_idle_seconds"]),
)
self._field(number_row, "轮询间隔(秒)", self._poll_var, column=0)
self._field(number_row, "鼠标静止等待(秒)", self._idle_seconds_var, column=1)
self._runtime_save_status = tk.Label(
settings.body,
text="已加载保存配置" if os.path.exists(APP_SETTINGS_FILE) else "修改后自动保存",
bg=PANEL,
fg=SUCCESS if os.path.exists(APP_SETTINGS_FILE) else TEXT_FAINT,
font=TYPE_SMALL,
anchor="e",
)
self._runtime_save_status.pack(fill="x", pady=(10, 0))
safety = Card(
wrap,
"人机共存",
"默认使用安全窗口模式:不置顶、不抢前台、没有未读消息时不执行任何鼠标动作。",
)
safety.pack(fill="x", pady=(0, 14))
self._mouse_idle_var = tk.BooleanVar(
value=self._runtime_settings["mouse_idle_enabled"],
)
Toggle(
safety.body,
self._mouse_idle_var,
"启用鼠标空闲检测",
).pack(anchor="w")
for variable in (
self._reply_var,
self._poll_var,
self._idle_seconds_var,
self._mouse_idle_var,
):
variable.trace_add("write", self._schedule_runtime_settings_save)
tk.Label(
safety.body,
text="启动后请手动切回企业微信。切换到其他窗口时,机器人会自动暂停。",
bg=PANEL,
fg=TEXT_FAINT,
font=TYPE_SMALL,
).pack(anchor="w", pady=(10, 0))
note = Card(wrap, "启动前检查")
note.pack(fill="both", expand=True)
checks = (
"企业微信 PC 端已登录,主窗口没有最小化到托盘。",
"如启用 AI,API 地址、密钥与模型配置已保存。",
"监听期间不要改变企业微信窗口缩放比例或侧栏宽度。",
)
for index, check in enumerate(checks, start=1):
row = tk.Frame(note.body, bg=PANEL)
row.pack(fill="x", pady=(0, 9))
tk.Label(
row,
text=f"{index:02d}",
bg=ACCENT_SOFT,
fg=ACCENT,
font=TYPE_MONO_BODY,
padx=7,
pady=3,
).pack(side="left")
tk.Label(
row,
text=check,
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_BODY,
).pack(side="left", padx=(10, 0))
def _data_card(self, parent, column, title, attr_name, unit, action_text, action):
card = tk.Frame(
parent,
bg=PANEL,
highlightthickness=1,
highlightbackground=BORDER,
)
card.grid(row=0, column=column, sticky="nsew", padx=(0 if column == 0 else 12, 0))
tk.Label(
card,
text=title,
bg=PANEL,
fg=TEXT,
font=TYPE_TITLE,
).pack(anchor="w", padx=18, pady=(18, 0))
count = tk.Label(
card,
text="0",
bg=PANEL,
fg=TEXT,
font=TYPE_METRIC_LARGE,
)
count.pack(anchor="w", padx=18, pady=(12, 0))
tk.Label(
card,
text=unit,
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_SMALL,
).pack(anchor="w", padx=18, pady=(0, 16))
ActionButton(card, action_text, action, kind="primary" if column == 0 else "secondary").pack(
anchor="w",
padx=18,
pady=(0, 18),
)
setattr(self, attr_name, count)
def _build_business_page(self, page):
wrap = tk.Frame(page, bg=CONTENT_BG)
wrap.pack(fill="both", expand=True, padx=20, pady=20)
data_row = tk.Frame(wrap, bg=CONTENT_BG)
data_row.pack(fill="x", pady=(0, 14))
data_row.grid_columnconfigure(0, weight=1)
data_row.grid_columnconfigure(1, weight=1)
self._data_card(
data_row,
0,
"挂号与回访登记",
"_registration_business_label",
"条待处理线索",
"打开登记",
self._open_registration_leads,
)
self._data_card(
data_row,
1,
"客户会话档案",
"_session_business_label",
"份上下文档案",
"管理档案",
self._open_sessions,
)
platform = Card(
wrap,
"服务连接",
"设计稿中的多平台区已按当前软件真实能力映射。",
)
platform.pack(fill="both", expand=True)
services = (
("企业微信桌面端", "窗口监听与自动回复", "_wecom_service_state", self._on_start),
("AI 回复服务", "OpenAI 兼容接口", "_ai_service_state", self._open_ai_config),
("MCP 工具服务", "按需调用外部工具", "_mcp_service_state", self._open_mcp_config),
)
for index, (title, description, attr_name, action) in enumerate(services):
row = tk.Frame(
platform.body,
bg=PANEL_ALT,
highlightthickness=1,
highlightbackground=BORDER_SOFT,
)
row.pack(fill="x", pady=(0 if index == 0 else 9, 0))
dot = tk.Label(
row,
text="■",
bg=PANEL_ALT,
fg=SUCCESS if index == 0 else TEXT_FAINT,
font=TYPE_SMALL,
)
dot.pack(side="left", padx=(14, 10))
text_box = tk.Frame(row, bg=PANEL_ALT)
text_box.pack(side="left", fill="x", expand=True, pady=10)
tk.Label(
text_box,
text=title,
bg=PANEL_ALT,
fg=TEXT,
font=TYPE_BODY_BOLD,
).pack(anchor="w")
tk.Label(
text_box,
text=description,
bg=PANEL_ALT,
fg=TEXT_MUTED,
font=TYPE_SMALL,
).pack(anchor="w", pady=(2, 0))
state = tk.Label(
row,
text="待连接" if index == 0 else "未启用",
bg=PANEL_ALT,
fg=TEXT_FAINT,
font=TYPE_SMALL,
)
state.pack(side="right", padx=(10, 8))
ActionButton(row, "配置" if index else "启动", action).pack(side="right", pady=8)
setattr(self, attr_name, state)
def _build_persona_page(self, page):
wrap = tk.Frame(page, bg=CONTENT_BG)
wrap.pack(fill="both", expand=True, padx=20, pady=20)
self._ai_var = tk.BooleanVar(value=False)
self._context_var = tk.BooleanVar(value=True)
self._counter_insult_var = tk.BooleanVar(value=False)
self._mcp_var = tk.BooleanVar(value=False)
try:
import ai_config
self._ai_var.set(bool(ai_config.AI_ENABLED))
self._context_var.set(bool(ai_config.AI_CONTEXT_ENABLED))
self._counter_insult_var.set(bool(ai_config.AI_COUNTER_INSULT_ENABLED))
self._mcp_var.set(bool(ai_config.AI_MCP_ENABLED))
except Exception:
pass
profile = Card(
wrap,
"AI 人格核心",
"当前软件使用医疗客服提示词,身份与机构信息由高级配置生成。",
)
profile.pack(fill="x", pady=(0, 14))
summary = tk.Frame(
profile.body,
bg=ACCENT_SOFT,
highlightthickness=1,
highlightbackground=ACCENT,
)
summary.pack(fill="x")
tk.Label(
summary,
text="MEDICAL SERVICE",
bg=ACCENT_SOFT,
fg=ACCENT,
font=TYPE_MONO_BODY_BOLD,
).pack(anchor="w", padx=14, pady=(12, 2))
self._persona_summary = tk.Label(
summary,
text="正在读取 AI 配置...",
bg=ACCENT_SOFT,
fg=TEXT,
font=TYPE_TITLE,
)
self._persona_summary.pack(anchor="w", padx=14)
self._persona_model = tk.Label(
summary,
text="--",
bg=ACCENT_SOFT,
fg=TEXT_MUTED,
font=TYPE_MONO_BODY,
)
self._persona_model.pack(anchor="w", padx=14, pady=(3, 12))
capability = Card(wrap, "能力开关", "修改后立即写入 ai_settings.json。")
capability.pack(fill="x", pady=(0, 14))
toggles = (
("启用 AI 自动回复", self._ai_var),
("携带同一客户的会话上下文", self._context_var),
("启用强硬回应模式", self._counter_insult_var),
("允许 AI 调用 MCP 工具", self._mcp_var),
)
for index, (text, variable) in enumerate(toggles):
row = tk.Frame(capability.body, bg=PANEL)
row.pack(fill="x", pady=(0 if index == 0 else 11, 0))
Toggle(
row,
variable,
text,
command=self._save_ai_switches,
).pack(anchor="w")
actions = Card(wrap, "配置入口")
actions.pack(fill="both", expand=True)
action_row = tk.Frame(actions.body, bg=PANEL)
action_row.pack(fill="x")
ActionButton(
action_row,
"编辑 AI 高级配置",
self._open_ai_config,
kind="primary",
).pack(side="left")
ActionButton(
action_row,
"管理 MCP 服务器",
self._open_mcp_config,
).pack(side="left", padx=(9, 0))
self._ai_status = tk.Label(
actions.body,
text="",
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_SMALL,
justify="left",
anchor="w",
)
self._ai_status.pack(fill="x", pady=(14, 0))
def _build_log_page(self, page):
wrap = tk.Frame(page, bg=CONTENT_BG)
wrap.pack(fill="both", expand=True, padx=20, pady=20)
log_card = Card(
wrap,
"运行日志",
"显示窗口连接、消息识别、AI 调用和业务登记过程。",
)
log_card.pack(fill="both", expand=True)
meta = tk.Frame(log_card.body, bg=PANEL)
meta.pack(fill="x", pady=(0, 10))
for column, (label, attr_name) in enumerate((
("窗口句柄", "_hwnd_label"),
("窗口尺寸", "_window_size_label"),
("输入位置", "_input_position_label"),
)):
meta.grid_columnconfigure(column, weight=1)
box = tk.Frame(
meta,
bg=PANEL_ALT,
highlightthickness=1,
highlightbackground=BORDER_SOFT,
)
box.grid(row=0, column=column, sticky="ew", padx=(0 if column == 0 else 8, 0))
tk.Label(
box,
text=label,
bg=PANEL_ALT,
fg=TEXT_FAINT,
font=TYPE_CAPTION,
).pack(anchor="w", padx=11, pady=(8, 1))
value = tk.Label(
box,
text="--",
bg=PANEL_ALT,
fg=TEXT_MUTED,
font=TYPE_MONO_BODY,
)
value.pack(anchor="w", padx=11, pady=(0, 8))
setattr(self, attr_name, value)
log_shell = tk.Frame(
log_card.body,
bg=LOG_BG,
highlightthickness=1,
highlightbackground=BORDER,
)
log_shell.pack(fill="both", expand=True)
self._log = scrolledtext.ScrolledText(
log_shell,
bg=LOG_BG,
fg=LOG_TEXT,
insertbackground=ACCENT,
selectbackground=ACCENT_SOFT,
selectforeground=TEXT,
relief="flat",
bd=0,
wrap="word",
state="disabled",
font=TYPE_MONO_CONTENT,
padx=13,
pady=11,
)
self._log.pack(fill="both", expand=True)
self._log.tag_config("ok", foreground=SUCCESS)
self._log.tag_config("warn", foreground=WARNING)
self._log.tag_config("err", foreground=DANGER)
self._log.tag_config("notify", foreground=ACCENT_HOVER)
self._log.tag_config("dim", foreground=TEXT_FAINT)
footer = tk.Frame(log_card.body, bg=PANEL)
footer.pack(fill="x", pady=(10, 0))
ActionButton(footer, "清空日志", self._clear_log).pack(side="right")
ActionButton(footer, "会话档案", self._open_sessions).pack(side="right", padx=(0, 8))
ActionButton(footer, "挂号登记", self._open_registration_leads).pack(side="right", padx=(0, 8))
def _style_tree(self, window):
style = ttk.Style(window)
try:
style.theme_use("clam")
except tk.TclError:
pass
style.configure(
"Console.Treeview",
background=PANEL_ALT,
fieldbackground=PANEL_ALT,
foreground=TEXT,
rowheight=38,
borderwidth=0,
relief="flat",
font=TYPE_BODY,
)
style.configure(
"Console.Treeview.Heading",
background=PANEL,
foreground=TEXT_MUTED,
relief="flat",
borderwidth=0,
font=TYPE_SMALL_BOLD,
padding=(10, 9),
)
style.map(
"Console.Treeview",
background=[("selected", ACCENT_SOFT)],
foreground=[("selected", TEXT)],
)
def _manager_window(self, title, subtitle, geometry="1120x720"):
window = tk.Toplevel(self)
window.title(title)
window.geometry(self._scaled_geometry(geometry))
window.minsize(
round(900 * self._layout_scale),
round(600 * self._layout_scale),
)
window.configure(bg=BG)
window.transient(self)
self._style_tree(window)
shell = tk.Frame(window, bg=BG)
shell.pack(fill="both", expand=True, padx=18, pady=18)
card = tk.Frame(
shell,
bg=PANEL,
highlightthickness=1,
highlightbackground=BORDER,
)
card.pack(fill="both", expand=True)
header = tk.Frame(card, bg=PANEL)
header.pack(fill="x", padx=20, pady=(18, 10))
tk.Label(
header,
text=title,
bg=PANEL,
fg=TEXT,
font=TYPE_HEADLINE,
).pack(anchor="w")
tk.Label(
header,
text=subtitle,
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_SMALL,
).pack(anchor="w", pady=(4, 0))
body = tk.Frame(card, bg=PANEL)
body.pack(fill="both", expand=True, padx=20, pady=(0, 10))
footer = tk.Frame(card, bg=PANEL)
footer.pack(fill="x", padx=20, pady=(0, 18))
self._apply_dark_titlebar(window)
return window, body, footer
@staticmethod
def _format_time(timestamp):
if not timestamp:
return "--"
try:
return time.strftime("%Y-%m-%d %H:%M", time.localtime(timestamp))
except Exception:
return "--"
def _conversation_store(self):
from conversation_store import ConversationStore
return ConversationStore(os.path.join(SCRIPT_DIR, "conversations.json"))
def _open_sessions(self):
try:
store = self._conversation_store()
except Exception as exc:
messagebox.showerror("无法打开会话档案", str(exc), parent=self)
return
window, body, footer = self._manager_window(
"会话档案",
"查看 AI 上下文记录;删除后不可恢复。",
)
body.grid_columnconfigure(0, weight=3)
body.grid_columnconfigure(1, weight=2)
body.grid_rowconfigure(0, weight=1)
tree = ttk.Treeview(
body,
columns=("updated", "messages", "preview"),
show="headings",
selectmode="extended",
style="Console.Treeview",
)
tree.heading("updated", text="最近更新")
tree.heading("messages", text="消息数")
tree.heading("preview", text="内容预览")
tree.column("updated", width=140, anchor="w")
tree.column("messages", width=70, anchor="center")
tree.column("preview", width=430, anchor="w")
tree.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
scroll = ttk.Scrollbar(body, orient="vertical", command=tree.yview)
scroll.grid(row=0, column=0, sticky="nse", padx=(0, 10))
tree.configure(yscrollcommand=scroll.set)
detail = scrolledtext.ScrolledText(
body,
bg=LOG_BG,
fg=TEXT,
relief="flat",
bd=0,
state="disabled",
wrap="word",
font=TYPE_BODY,
padx=12,
pady=12,
)
detail.grid(row=0, column=1, sticky="nsew")
detail.tag_config("user", foreground=ACCENT_HOVER)
detail.tag_config("assistant", foreground=SUCCESS)
detail.tag_config("meta", foreground=TEXT_FAINT)
status = tk.Label(
footer,
text="",
bg=PANEL,
fg=TEXT_FAINT,
font=TYPE_SMALL,
)
status.pack(side="right")
def reload_data():
tree.delete(*tree.get_children())
sessions = store.list_sessions(limit=500)
for item in sessions:
tree.insert(
"",
"end",
iid=item["session_id"],
values=(
self._format_time(item.get("updated")),
item.get("message_count", 0),
item.get("preview") or "--",
),
)
status.configure(text=f"共 {len(sessions)} 份档案")
self._refresh_counts()
def show_detail(_event=None):
selection = tree.selection()
detail.configure(state="normal")
detail.delete("1.0", "end")
if len(selection) == 1:
session_id = selection[0]
detail.insert("end", f"会话编号\n{session_id}\n\n", "meta")
for message in store.history(session_id):
role = message.get("role") or "unknown"
label = "客户" if role == "user" else "客服"
tag = "user" if role == "user" else "assistant"
detail.insert("end", f"{label}\n", tag)
detail.insert("end", f"{message.get('content') or ''}\n\n")
elif len(selection) > 1:
detail.insert("end", f"已选择 {len(selection)} 份档案。", "meta")
else:
detail.insert("end", "选择一份档案查看完整上下文。", "meta")
detail.configure(state="disabled")
def delete_selected():
selection = list(tree.selection())
if not selection:
messagebox.showinfo("提示", "请先选择要删除的档案。", parent=window)
return
if not messagebox.askyesno(
"确认删除",
f"确定删除选中的 {len(selection)} 份会话档案吗?",
parent=window,
):
return
deleted = sum(1 for session_id in selection if store.delete(session_id))
self._append(f"已删除 {deleted} 份会话档案", "ok")
reload_data()
show_detail()
def clear_all():
if not messagebox.askyesno(
"清空全部档案",
"确定清空全部会话档案吗?此操作不可恢复。",
parent=window,
):
return
count = store.clear_all()
self._append(f"已清空 {count} 份会话档案", "warn")
reload_data()
show_detail()
tree.bind("<<TreeviewSelect>>", show_detail)
tree.bind("<Delete>", lambda _event: delete_selected())
ActionButton(footer, "刷新", reload_data).pack(side="left")
ActionButton(footer, "删除所选", delete_selected, kind="danger").pack(side="left", padx=(8, 0))
ActionButton(footer, "清空全部", clear_all, kind="danger").pack(side="left", padx=(8, 0))
reload_data()
show_detail()
def _open_registration_leads(self):
try:
from registration_store import RegistrationStore
store = RegistrationStore()
except Exception as exc:
messagebox.showerror("无法打开挂号登记", str(exc), parent=self)
return
window, body, footer = self._manager_window(
"挂号与回访登记",
"集中处理 AI 识别出的挂号意向、症状和后续联系状态。",
)
body.grid_columnconfigure(0, weight=3)
body.grid_columnconfigure(1, weight=2)
body.grid_rowconfigure(0, weight=1)
tree = ttk.Treeview(
body,
columns=("updated", "contact", "symptom", "status"),
show="headings",
selectmode="extended",
style="Console.Treeview",
)
tree.heading("updated", text="最近更新")
tree.heading("contact", text="微信客户")
tree.heading("symptom", text="症状 / 诉求")
tree.heading("status", text="状态")
tree.column("updated", width=140, anchor="w")
tree.column("contact", width=120, anchor="w")
tree.column("symptom", width=330, anchor="w")
tree.column("status", width=90, anchor="center")
tree.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
scrollbar = ttk.Scrollbar(body, orient="vertical", command=tree.yview)
scrollbar.grid(row=0, column=0, sticky="nse", padx=(0, 10))
tree.configure(yscrollcommand=scrollbar.set)
detail = scrolledtext.ScrolledText(
body,
bg=LOG_BG,
fg=TEXT,
relief="flat",
bd=0,
state="disabled",
wrap="word",
font=TYPE_BODY,
padx=12,
pady=12,
)
detail.grid(row=0, column=1, sticky="nsew")
detail.tag_config("label", foreground=ACCENT_HOVER, font=TYPE_SMALL_BOLD)
detail.tag_config("meta", foreground=TEXT_FAINT)
status_map = {
"pending_symptom": "待补症状",
"booked": "待联系",
"done": "已联系",
}
leads_by_id = {}
footer_status = tk.Label(
footer,
text="",
bg=PANEL,
fg=TEXT_FAINT,
font=TYPE_SMALL,
)
footer_status.pack(side="right")
def reload_data():
nonlocal leads_by_id
tree.delete(*tree.get_children())
leads = store.list_leads(include_done=True)
leads_by_id = {item.get("id"): item for item in leads}
for item in leads:
lead_id = item.get("id")
tree.insert(
"",
"end",
iid=lead_id,
values=(
self._format_time(item.get("updated")),
item.get("contact") or "未知客户",
(item.get("symptom") or "待补充").replace("\n", " ")[:80],
status_map.get(item.get("status"), item.get("status") or "--"),
),
)
footer_status.configure(text=f"共 {len(leads)} 条登记")
self._refresh_counts()
def show_detail(_event=None):
selection = tree.selection()
detail.configure(state="normal")
detail.delete("1.0", "end")
if len(selection) == 1:
item = leads_by_id.get(selection[0], {})
fields = (
("客户", item.get("contact") or "未知客户"),
("症状 / 诉求", item.get("symptom") or "待补充"),
("状态", status_map.get(item.get("status"), item.get("status") or "--")),
("最近客户消息", item.get("last_user") or "--"),
("最近客服回复", item.get("last_reply") or "--"),
)
for label, value in fields:
detail.insert("end", f"{label}\n", "label")
detail.insert("end", f"{value}\n\n")
elif len(selection) > 1:
detail.insert("end", f"已选择 {len(selection)} 条登记。", "meta")
else:
detail.insert("end", "选择一条登记查看详情。", "meta")
detail.configure(state="disabled")
def mark_done():
selection = list(tree.selection())
if not selection:
messagebox.showinfo("提示", "请先选择登记记录。", parent=window)
return
count = store.set_status_many(selection, "done")
self._append(f"已将 {count} 条登记标记为已联系", "ok")
reload_data()
show_detail()
def delete_selected():
selection = list(tree.selection())
if not selection:
messagebox.showinfo("提示", "请先选择要删除的登记。", parent=window)
return
if not messagebox.askyesno(
"确认删除",
f"确定删除选中的 {len(selection)} 条登记吗?",
parent=window,
):
return
count = store.delete_many(selection)
self._append(f"已删除 {count} 条挂号登记", "warn")
reload_data()
show_detail()
tree.bind("<<TreeviewSelect>>", show_detail)
tree.bind("<Delete>", lambda _event: delete_selected())
ActionButton(footer, "刷新", reload_data).pack(side="left")
ActionButton(footer, "标记已联系", mark_done, kind="primary").pack(side="left", padx=(8, 0))
ActionButton(footer, "删除所选", delete_selected, kind="danger").pack(side="left", padx=(8, 0))
reload_data()
show_detail()
def _open_ai_config(self):
try:
import ai_config
except Exception as exc:
messagebox.showerror("无法读取 AI 配置", str(exc), parent=self)
return
window = tk.Toplevel(self)
window.title("AI 高级配置")
window.geometry(self._scaled_geometry("720x690"))
window.resizable(False, False)
window.configure(bg=BG)
window.transient(self)
window.grab_set()
self._apply_dark_titlebar(window)
card = Card(window, "AI 高级配置", "保存后立即生效,并写入 ai_settings.json。", bg=BG)
card.pack(fill="both", expand=True, padx=16, pady=16)
body = card.body
fields = (
("API 地址", "AI_API_BASE", str, False),
("API Key", "AI_API_KEY", str, True),
("模型名称", "AI_MODEL", str, False),
("客服名称", "AI_AGENT_NAME", str, False),
("机构名称", "AI_HOSPITAL_NAME", str, False),
("上下文记忆轮数", "AI_CONTEXT_MAX_ROUNDS", int, False),
("最大回复 tokens", "AI_MAX_TOKENS", int, False),
("温度", "AI_TEMPERATURE", float, False),
("请求超时(秒)", "AI_TIMEOUT", int, False),
)
variables = {}
for row_index, (label, key, _type, secret) in enumerate(fields):
tk.Label(
body,
text=label,
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_SMALL,
).grid(row=row_index, column=0, sticky="w", pady=5)
variable = tk.StringVar(value=str(getattr(ai_config, key, "")))
shell, _entry = self._entry(body, variable, show="*" if secret else None)
shell.grid(row=row_index, column=1, sticky="ew", padx=(16, 0), pady=5)
variables[key] = variable
body.grid_columnconfigure(1, weight=1)
vision_var = tk.BooleanVar(value=bool(getattr(ai_config, "AI_USE_VISION", False)))
Toggle(
body,
vision_var,
"启用视觉模式(需要多模态模型)",
).grid(row=len(fields), column=0, columnspan=2, sticky="w", pady=(10, 0))
status = tk.Label(
body,
text="",
bg=PANEL,
fg=DANGER,
font=TYPE_SMALL,
anchor="w",
)
status.grid(row=len(fields) + 1, column=0, columnspan=2, sticky="ew", pady=(10, 0))
buttons = tk.Frame(body, bg=PANEL)
buttons.grid(row=len(fields) + 2, column=0, columnspan=2, sticky="e", pady=(14, 0))
def save_config():
for label, key, value_type, _secret in fields:
raw = variables[key].get().strip()
try:
value = value_type(raw)
except ValueError:
status.configure(text=f"{label} 的格式不正确:{raw}")
return
setattr(ai_config, key, value)
ai_config.AI_USE_VISION = vision_var.get()
ai_config.AI_ENABLED = self._ai_var.get()
ai_config.AI_CONTEXT_ENABLED = self._context_var.get()
ai_config.AI_COUNTER_INSULT_ENABLED = self._counter_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()
except Exception as exc:
status.configure(text=f"保存失败:{exc}")
return
self._append("AI 高级配置已保存并生效", "ok")
self._refresh_ai_status()
window.destroy()
ActionButton(buttons, "取消", window.destroy).pack(side="right")
ActionButton(buttons, "保存配置", save_config, kind="primary").pack(side="right", padx=(0, 8))
def _open_mcp_config(self):
try:
import ai_config
except Exception as exc:
messagebox.showerror("无法读取 MCP 配置", str(exc), parent=self)
return
window = tk.Toplevel(self)
window.title("MCP 服务器")
window.geometry(self._scaled_geometry("760x620"))
window.minsize(
round(680 * self._layout_scale),
round(540 * self._layout_scale),
)
window.configure(bg=BG)
window.transient(self)
window.grab_set()
self._apply_dark_titlebar(window)
card = Card(
window,
"MCP 服务器",
"填写服务器 JSON 数组;测试不会修改当前配置。",
bg=BG,
)
card.pack(fill="both", expand=True, padx=16, pady=16)
editor = scrolledtext.ScrolledText(
card.body,
bg=LOG_BG,
fg=TEXT,
insertbackground=ACCENT,
relief="flat",
bd=0,
wrap="none",
font=TYPE_MONO_CONTENT,
padx=12,
pady=12,
height=20,
)
editor.pack(fill="both", expand=True)
editor.insert(
"1.0",
json.dumps(
getattr(ai_config, "AI_MCP_SERVERS", []) or [],
ensure_ascii=False,
indent=2,
),
)
options = tk.Frame(card.body, bg=PANEL)
options.pack(fill="x", pady=(10, 0))
tk.Label(
options,
text="单次回复最多工具轮数",
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_SMALL,
).pack(side="left")
rounds_var = tk.StringVar(value=str(getattr(ai_config, "AI_MCP_MAX_ROUNDS", 5)))
rounds_shell, _rounds_entry = self._entry(options, rounds_var)
rounds_shell.pack(side="left", padx=(10, 0))
status = tk.Label(
card.body,
text="",
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_SMALL,
anchor="w",
justify="left",
)
status.pack(fill="x", pady=(9, 0))
buttons = tk.Frame(card.body, bg=PANEL)
buttons.pack(fill="x", pady=(12, 0))
def parse_servers():
data = json.loads(editor.get("1.0", "end").strip() or "[]")
if not isinstance(data, list):
raise ValueError("JSON 根节点必须是数组")
return data
def test_servers():
try:
servers = parse_servers()
except Exception as exc:
status.configure(text=f"JSON 无效:{exc}", fg=DANGER)
return
status.configure(text="正在测试服务器连接...", fg=WARNING)
def worker():
try:
from mcp_bridge import probe_servers, run_coro
result = run_coro(probe_servers(servers))
names = ", ".join(result.get("servers") or []) or "无"
message = (
f"连接成功:{len(result.get('servers') or [])} 个服务器,"
f"{result.get('tool_count', 0)} 个工具;{names}"
)
self.after(0, lambda text=message: status.configure(text=text, fg=SUCCESS))
except Exception as exc:
message = f"测试失败:{exc}"
self.after(0, lambda text=message: status.configure(text=text, fg=DANGER))
threading.Thread(target=worker, daemon=True).start()
def save_config():
try:
servers = parse_servers()
rounds = max(1, int(rounds_var.get().strip()))
except Exception as exc:
status.configure(text=f"保存失败:{exc}", fg=DANGER)
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()
except Exception as exc:
status.configure(text=f"写入失败:{exc}", fg=DANGER)
return
self._append("MCP 配置已保存", "ok")
self._refresh_ai_status()
window.destroy()
ActionButton(buttons, "取消", window.destroy).pack(side="right")
ActionButton(buttons, "保存", save_config, kind="primary").pack(side="right", padx=(0, 8))
ActionButton(buttons, "测试连接", test_servers).pack(side="right", padx=(0, 8))
def _save_ai_switches(self):
try:
import ai_config
ai_config.AI_ENABLED = self._ai_var.get()
ai_config.AI_CONTEXT_ENABLED = self._context_var.get()
ai_config.AI_COUNTER_INSULT_ENABLED = self._counter_insult_var.get()
ai_config.AI_MCP_ENABLED = self._mcp_var.get()
ai_config.AI_SYSTEM_PROMPT = ai_config.build_system_prompt()
ai_config.save_settings()
self._refresh_ai_status()
self._append("AI 能力开关已保存", "ok")
except Exception as exc:
self._append(f"AI 开关保存失败:{exc}", "err")
def _refresh_ai_status(self):
try:
import ai_config
enabled = bool(self._ai_var.get()) if hasattr(self, "_ai_var") else bool(ai_config.AI_ENABLED)
context_enabled = (
bool(self._context_var.get())
if hasattr(self, "_context_var")
else bool(ai_config.AI_CONTEXT_ENABLED)
)
mcp_enabled = bool(self._mcp_var.get()) if hasattr(self, "_mcp_var") else bool(ai_config.AI_MCP_ENABLED)
model = getattr(ai_config, "AI_MODEL", "未配置") or "未配置"
agent = getattr(ai_config, "AI_AGENT_NAME", "客服") or "客服"
hospital = getattr(ai_config, "AI_HOSPITAL_NAME", "未配置机构") or "未配置机构"
text = (
f"AI {'已启用' if enabled else '未启用'} · 模型 {model} · "
f"上下文 {'开启' if context_enabled else '关闭'} · "
f"MCP {'开启' if mcp_enabled else '关闭'}"
)
if hasattr(self, "_ai_status"):
self._ai_status.configure(text=text, fg=SUCCESS if enabled else TEXT_MUTED)
if hasattr(self, "_persona_summary"):
self._persona_summary.configure(text=f"{agent} / {hospital}")
if hasattr(self, "_persona_model"):
self._persona_model.configure(text=f"MODEL {model}")
if hasattr(self, "_ai_service_state"):
self._ai_service_state.configure(
text="已启用" if enabled else "未启用",
fg=SUCCESS if enabled else TEXT_FAINT,
)
if hasattr(self, "_mcp_service_state"):
self._mcp_service_state.configure(
text="已启用" if mcp_enabled else "未启用",
fg=SUCCESS if mcp_enabled else TEXT_FAINT,
)
except Exception as exc:
if hasattr(self, "_ai_status"):
self._ai_status.configure(text=f"AI 配置读取失败:{exc}", fg=DANGER)
def _refresh_counts(self):
try:
count = self._conversation_store().count()
except Exception:
count = None
for attr_name in ("_session_count_label", "_session_business_label"):
label = getattr(self, attr_name, None)
if label is not None:
label.configure(
text="--" if count is None else str(count),
fg=TEXT_MUTED if not count else TEXT,
)
try:
from registration_store import RegistrationStore
count = RegistrationStore().pending_count()
except Exception:
count = None
for attr_name in ("_registration_count_label", "_registration_business_label"):
label = getattr(self, attr_name, None)
if label is not None:
label.configure(
text="--" if count is None else str(count),
fg=TEXT_MUTED if not count else TEXT,
)
def _on_start(self):
if self._thread is not None and self._thread.is_alive():
self._append("监听线程已经在运行", "warn")
return
reply_text = self._reply_var.get().strip() or AUTO_REPLY_TEXT
try:
poll_seconds = float(self._poll_var.get().strip())
if poll_seconds <= 0:
raise ValueError
except ValueError:
messagebox.showerror("参数错误", "轮询间隔必须是大于 0 的数字。", parent=self)
self._show_page("通用设置")
return
try:
idle_seconds = float(self._idle_seconds_var.get().strip())
if idle_seconds < 0:
raise ValueError
except ValueError:
messagebox.showerror("参数错误", "鼠标静止等待必须是大于或等于 0 的数字。", parent=self)
self._show_page("通用设置")
return
self._save_ai_switches()
self._stdout_proxy = LogQueue(self._queue)
sys.stdout = self._stdout_proxy
self._thread = BotThread(
self._queue,
reply_text,
poll_seconds,
mouse_idle_enabled=self._mouse_idle_var.get(),
mouse_idle_seconds=idle_seconds,
)
self._running = True
self._start_time = time.time()
self._start_button.set_enabled(False)
self._stop_button.set_enabled(True)
self._set_status("connecting", "连接中")
self._append("正在连接企业微信窗口...", "notify")
self._thread.start()
def _on_stop(self):
if self._thread is None or not self._thread.is_alive():
self._finish_thread("stopped")
return
self._running = False
self._thread.stop()
self._stop_button.set_enabled(False)
self._set_status("stopping", "正在停止")
self._append("正在停止监听...", "warn")
def _finish_thread(self, final_state):
self._running = False
if self._capsule_mode:
self._expand_console()
self._collapse_button.pack_forget()
self._start_button.set_enabled(True)
self._stop_button.set_enabled(False)
if sys.stdout is self._stdout_proxy:
sys.stdout = self._original_stdout
self._stdout_proxy = None
if final_state == "error":
self._set_status("error", "连接失败")
self._dashboard_state.configure(text="连接失败", fg=DANGER)
else:
self._set_status("stopped", "已停止")
self._dashboard_state.configure(text="待命", fg=TEXT)
self._timer_label.configure(text="运行时长 --:--:--")
if hasattr(self, "_wecom_service_state"):
self._wecom_service_state.configure(text="待连接", fg=TEXT_FAINT)
def _set_status(self, state, text):
self._status_key = state
colors = {
"connecting": WARNING,
"waiting": WARNING,
"running": SUCCESS,
"stopping": WARNING,
"error": DANGER,
"stopped": TEXT_FAINT,
}
color = colors.get(state, TEXT_FAINT)
self._status_dot.itemconfigure(self._status_dot_id, fill=color)
self._status_label.configure(text=text, fg=color if state != "stopped" else TEXT_MUTED)
hints = {
"connecting": "正在查找企业微信窗口",
"waiting": WECOM_WAITING_MESSAGE,
"running": "安全模式:切到企业微信后监听",
"stopping": "等待后台任务退出",
"error": "请查看运行日志",
"stopped": "等待连接企业微信",
}
self._status_hint.configure(text=hints.get(state, text))
self._update_capsule_status(state, text)
if state == "running":
self._dashboard_notice.pack_forget()
self._dashboard_state.configure(text="监听中", fg=SUCCESS)
if hasattr(self, "_wecom_service_state"):
self._wecom_service_state.configure(text="运行中", fg=SUCCESS)
elif state == "waiting":
self._dashboard_state.configure(text="等待企业微信", fg=WARNING)
if self._dashboard_notice.winfo_manager() == "":
self._dashboard_notice.pack(
anchor="w",
pady=(6, 0),
before=self._timer_label,
)
if hasattr(self, "_wecom_service_state"):
self._wecom_service_state.configure(text="等待窗口", fg=WARNING)
else:
self._dashboard_notice.pack_forget()
def _append(self, message, tag=""):
if not hasattr(self, "_log"):
return
timestamp = time.strftime("%H:%M:%S")
self._log.configure(state="normal")
self._log.insert("end", f"[{timestamp}] ", "dim")
self._log.insert("end", str(message) + "\n", tag)
self._log.see("end")
self._log.configure(state="disabled")
def _clear_log(self):
self._log.configure(state="normal")
self._log.delete("1.0", "end")
self._log.configure(state="disabled")
def _process_queue(self):
self._sync_console_dpi()
try:
while True:
kind, data = self._queue.get_nowait()
if kind == "log":
lowered = data.lower()
if any(token in data for token in ("[+]", "回复完成", "已保存")):
tag = "ok"
elif any(token in data for token in ("[!]", "误判", "停止")):
tag = "warn"
elif any(token in lowered for token in ("错误", "[-]", "失败", "exception", "traceback")):
tag = "err"
elif any(token in data for token in ("[MCP]", "[挂号]", "启动", "连接")):
tag = "notify"
else:
tag = ""
self._append(data, tag)
if "[挂号]" in data:
self._refresh_counts()
elif kind == "status":
if data == "running":
self._set_status("running", "监听中")
self._enter_capsule_mode()
elif data == "waiting":
self._set_status("waiting", "等待企业微信")
elif data == "error":
self._finish_thread("error")
elif data == "stopped":
self._finish_thread("stopped")
elif kind == "info":
self._hwnd_label.configure(text=data.get("hwnd", "--"))
self._window_size_label.configure(text=data.get("size", "--"))
self._input_position_label.configure(text=data.get("input", "--"))
elif kind == "stats":
self._replied_label.configure(text=str(data.get("replied", 0)))
self._false_label.configure(text=str(data.get("false_pos", 0)))
elif kind == "backend_config":
try:
import ai_config
self._ai_var.set(bool(ai_config.AI_ENABLED))
self._context_var.set(bool(ai_config.AI_CONTEXT_ENABLED))
self._counter_insult_var.set(bool(ai_config.AI_COUNTER_INSULT_ENABLED))
self._mcp_var.set(bool(ai_config.AI_MCP_ENABLED))
self._refresh_ai_status()
except Exception:
pass
self._append(str(data.get("message") or "后台配置同步完成"), "ok")
except queue.Empty:
pass
if self._start_time and self._running:
elapsed = int(time.time() - self._start_time)
runtime = (
f"{elapsed // 3600:02d}:"
f"{(elapsed % 3600) // 60:02d}:{elapsed % 60:02d}"
)
self._timer_label.configure(text=f"运行时长 {runtime}")
self._capsule_timer.configure(text=runtime)
self.after(150, self._process_queue)
def on_close(self):
if self._runtime_save_job is not None:
self.after_cancel(self._runtime_save_job)
self._runtime_save_job = None
self._save_runtime_settings(silent=True)
self._running = False
if self._thread is not None and self._thread.is_alive():
self._thread.stop()
if sys.stdout is self._stdout_proxy:
sys.stdout = self._original_stdout
self._close_customer_service_portal()
if hasattr(self, "_capsule"):
self._capsule.destroy()
self.destroy()
def main():
if "--classic-ui" not in sys.argv and os.environ.get("WECOM_RPA_CLASSIC_UI") != "1":
try:
from wechat_gui_qt import main as qt_main
except ImportError as exc:
if not (exc.name or "").startswith("PySide6"):
raise
else:
qt_main()
return
app = App()
app.protocol("WM_DELETE_WINDOW", app.on_close)
app.mainloop()
if __name__ == "__main__":
main()