# -*- 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("", self._on_enter) self.bind("", 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("", self._toggle) self.label.bind("", 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("", self._capsule_drag_start) widget.bind("", self._capsule_drag_motion) widget.bind("", 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("", lambda _event: button.configure(bg=hover_bg, fg=hover_fg)) button.bind("", lambda _event: button.configure(bg=normal_bg, fg=normal_fg)) button.bind("", lambda _event: button.configure(relief="sunken")) def release(_event=None): button.configure(relief="flat") command() button.bind("", 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) widget.bind("", 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("", 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( "", self._schedule_customer_service_portal_resize, ) self.bind( "", 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("", lambda _event: shell.configure(highlightbackground=ACCENT)) entry.bind("", 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("<>", show_detail) tree.bind("", 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("<>", show_detail) tree.bind("", 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()