Files
kefu/wechat_rpa/wechat_gui.py
2026-08-27 14:04:28 +08:00

4130 lines
149 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""企业微信 RPA 桌面控制台。"""
import glob
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
from app_version import APP_VERSION, release_status
from runtime_paths import application_data_dir, resource_path
SCRIPT_DIR = str(application_data_dir())
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 = str(resource_path("edge_light_theme"))
APP_ICON_PNG_PATH = resource_path("assets", "brand", "zhenyangtang-icon.png")
APP_ICON_ICO_PATH = resource_path("assets", "brand", "zhenyangtang-icon.ico")
WIN_TITLE = f"甄养堂 · 企微客服助手 v{APP_VERSION}"
AUTO_REPLY_TEXT = "在的,您慢慢说,我这边看着呢。"
POLL_INTERVAL = 2.0
MOUSE_IDLE_ENABLED = True
MOUSE_IDLE_SECONDS = 5.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"
# 监听胶囊除了状态和计时,还要给每一道自动化步骤留出独立的一整行。
# 436×96 在 200% DPI 下约为 872×192,仍然紧凑,但不会再把进程文字裁掉。
CAPSULE_WIDTH = 436
CAPSULE_HEIGHT = 96
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"),
)
from gui_runtime import ( # noqa: F401 # 供旧代码与测试通过 wechat_gui 引用
MESSAGE_BATCH_WINDOW_MAX_SECONDS,
MESSAGE_BATCH_WINDOW_MIN_SECONDS,
MESSAGE_BATCH_WINDOW_SECONDS,
SEND_DELAY_SECONDS,
SEND_MODE_AUTO,
BotThread,
LogQueue,
normalize_message_batch_window_seconds,
normalize_send_delay_seconds,
normalize_send_mode,
)
class ActionButton(tk.Button):
"""统一的按钮样式,包含悬停、按下和禁用状态。"""
def __init__(self, parent, text, command=None, *, kind="secondary", **kwargs):
self.kind = kind
self._enabled = True
colors = self._palette(False)
super().__init__(
parent,
text=text,
command=command,
bg=colors[0],
fg=colors[1],
activebackground=colors[2],
activeforeground=colors[3],
disabledforeground=TEXT_FAINT,
font=TYPE_BODY_BOLD if kind == "primary" else TYPE_BODY,
relief="flat",
bd=0,
highlightthickness=0,
cursor="hand2",
padx=16,
pady=9,
**kwargs,
)
self.bind("<Enter>", self._on_enter)
self.bind("<Leave>", self._on_leave)
def _palette(self, hovered):
if self.kind == "primary":
return (
ACCENT_HOVER if hovered else ACCENT,
ON_ACCENT,
ACCENT_HOVER,
ON_ACCENT,
)
if self.kind == "danger":
return (
DANGER if hovered else DANGER_SOFT,
ON_ACCENT if hovered else DANGER,
DANGER,
ON_ACCENT,
)
return (
ELEVATED if hovered else PANEL_ALT,
TEXT if hovered else TEXT_MUTED,
ELEVATED,
TEXT,
)
def _on_enter(self, _event=None):
if self._enabled:
bg, fg, active_bg, active_fg = self._palette(True)
self.configure(
bg=bg,
fg=fg,
activebackground=active_bg,
activeforeground=active_fg,
)
def _on_leave(self, _event=None):
if self._enabled:
bg, fg, active_bg, active_fg = self._palette(False)
self.configure(
bg=bg,
fg=fg,
activebackground=active_bg,
activeforeground=active_fg,
)
def set_enabled(self, enabled):
self._enabled = bool(enabled)
self.configure(
state="normal" if enabled else "disabled",
cursor="hand2" if enabled else "arrow",
)
if enabled:
self._on_leave()
else:
self.configure(bg=PANEL_ALT, fg=TEXT_FAINT)
class Toggle(tk.Frame):
"""不依赖第三方组件的布尔开关。"""
def __init__(self, parent, variable, text, command=None, *, bg=PANEL):
super().__init__(parent, bg=bg)
self.variable = variable
self.command = command
self.background = bg
self.canvas = tk.Canvas(
self,
width=42,
height=24,
bg=bg,
highlightthickness=0,
cursor="hand2",
)
self.canvas.pack(side="left")
self.label = tk.Label(
self,
text=text,
bg=bg,
fg=TEXT,
font=TYPE_BODY,
cursor="hand2",
)
self.label.pack(side="left", padx=(10, 0))
self.canvas.bind("<Button-1>", self._toggle)
self.label.bind("<Button-1>", self._toggle)
self.variable.trace_add("write", self._redraw)
self._redraw()
def _toggle(self, _event=None):
self.variable.set(not self.variable.get())
if self.command:
self.command()
def _redraw(self, *_args):
self.canvas.delete("all")
enabled = bool(self.variable.get())
track = ACCENT if enabled else TOGGLE_OFF
self.canvas.create_oval(0, 0, 24, 24, fill=track, outline="")
self.canvas.create_oval(18, 0, 42, 24, fill=track, outline="")
self.canvas.create_rectangle(12, 0, 30, 24, fill=track, outline="")
knob_x = 20 if enabled else 2
self.canvas.create_oval(
knob_x,
3,
knob_x + 18,
21,
fill="#FFFFFF",
outline="",
)
class Card(tk.Frame):
"""带细边框的内容区块。"""
def __init__(self, parent, title=None, subtitle=None, *, bg=CONTENT_BG, pad=18):
super().__init__(parent, bg=bg)
self.shell = tk.Frame(
self,
bg=PANEL,
highlightthickness=1,
highlightbackground=BORDER,
)
self.shell.pack(fill="both", expand=True)
if title:
header = tk.Frame(self.shell, bg=PANEL)
header.pack(fill="x", padx=pad, pady=(16, 0))
tk.Label(
header,
text=title,
bg=PANEL,
fg=TEXT,
font=TYPE_TITLE,
).pack(anchor="w")
if subtitle:
tk.Label(
header,
text=subtitle,
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_SMALL,
justify="left",
).pack(anchor="w", pady=(4, 0))
self.body = tk.Frame(self.shell, bg=PANEL)
self.body.pack(fill="both", expand=True, padx=pad, pady=(12, 18))
def _set_window_icon(window):
try:
if sys.platform == "win32":
window.iconbitmap(default=str(APP_ICON_ICO_PATH))
return
except tk.TclError:
pass
try:
icon = tk.PhotoImage(file=str(APP_ICON_PNG_PATH))
window.iconphoto(True, icon)
window._app_icon_image = icon
except tk.TclError:
pass
class App(tk.Tk):
def __init__(self):
super().__init__()
self._configure_default_fonts()
self.title(WIN_TITLE)
_set_window_icon(self)
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._capsule_progress_text = ""
self._capsule_progress_step = 0
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)
def _start_backend_sync(self):
"""经典界面启动后也会自动获取固定云端配置。"""
def worker():
try:
import backend_client
result = backend_client.startup_sync_config(timeout=3.0)
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,
"enable_engine_b": True,
"engine_b_poll_interval": 2.0,
"mouse_idle_enabled": MOUSE_IDLE_ENABLED,
"mouse_idle_seconds": MOUSE_IDLE_SECONDS,
"send_delay_seconds": SEND_DELAY_SECONDS,
"send_mode": SEND_MODE_AUTO,
"message_batch_window_seconds": MESSAGE_BATCH_WINDOW_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"]))
send_delay = normalize_send_delay_seconds(
saved.get("send_delay_seconds", settings["send_delay_seconds"])
)
send_mode = normalize_send_mode(
saved.get("send_mode", settings["send_mode"])
)
batch_window = normalize_message_batch_window_seconds(
saved.get(
"message_batch_window_seconds",
settings["message_batch_window_seconds"],
)
)
if poll <= 0 or idle < 0:
raise ValueError
settings.update({
"auto_reply_text": reply or AUTO_REPLY_TEXT,
"poll_interval": poll,
"enable_engine_b": bool(
saved.get("enable_engine_b", settings["enable_engine_b"])
),
"engine_b_poll_interval": float(
saved.get("engine_b_poll_interval", settings["engine_b_poll_interval"])
),
"mouse_idle_enabled": bool(
saved.get("mouse_idle_enabled", settings["mouse_idle_enabled"])
),
"mouse_idle_seconds": idle,
"send_delay_seconds": send_delay,
"send_mode": send_mode,
"message_batch_window_seconds": batch_window,
})
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",
"_batch_window_var",
"_mouse_idle_var",
)):
return False
try:
poll = float(self._poll_var.get().strip())
idle = float(self._idle_seconds_var.get().strip())
send_delay = normalize_send_delay_seconds(
getattr(self, "_runtime_settings", {}).get(
"send_delay_seconds",
SEND_DELAY_SECONDS,
)
)
send_mode = normalize_send_mode(
getattr(self, "_runtime_settings", {}).get(
"send_mode",
SEND_MODE_AUTO,
)
)
batch_window = float(self._batch_window_var.get().strip())
if (
poll <= 0
or idle < 0
or not MESSAGE_BATCH_WINDOW_MIN_SECONDS
<= batch_window
<= MESSAGE_BATCH_WINDOW_MAX_SECONDS
):
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,
"send_delay_seconds": send_delay,
"send_mode": send_mode,
"message_batch_window_seconds": batch_window,
}
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)
thread = getattr(self, "_thread", None)
if thread is not None and thread.is_alive():
thread.set_message_batch_window_seconds(batch_window)
thread.set_send_delay_seconds(send_delay)
thread.set_send_mode(send_mode)
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 hasattr(self, "_desk_input"):
try:
self._desk_input.focus_set()
return True
except tk.TclError:
return False
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, "_desk_reload"):
self._desk_reload()
self._focus_customer_service_portal()
return
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=74)
self._capsule_content = content
self._capsule_content_window_id = canvas.create_window(
18,
9,
anchor="nw",
width=400,
height=74,
window=content,
)
top_row = tk.Frame(content, bg=PANEL, height=46)
self._capsule_top_row = top_row
top_row.pack(side="top", fill="x")
top_row.pack_propagate(False)
handle = tk.Label(
top_row,
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(
top_row,
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(top_row, bg=PANEL, width=126)
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(
top_row,
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(
top_row,
"□",
"展开控制台",
self._expand_console,
)
self._capsule_expand_button = expand_button
expand_button.pack(side="left", padx=(0, 5))
stop_button = self._capsule_button(
top_row,
"×",
"停止监听",
self._on_stop,
danger=True,
)
self._capsule_stop_button = stop_button
stop_button.pack(side="left")
# 这行是真正的执行进程,不再借用“安全监听中”那句静态提示。
# 进程编号每变化一次递增,肉眼可以确认后台是否仍在向前走。
self._capsule_progress = tk.Label(
content,
text="步骤 000 · 等待启动",
bg=PANEL_ALT,
fg=TEXT_MUTED,
font=self._capsule_fonts["hint"],
anchor="w",
padx=9,
)
self._capsule_progress.pack(side="bottom", fill="x", pady=(3, 0))
drag_widgets = (
canvas,
content,
top_row,
handle,
dot,
status_box,
self._capsule_status,
self._capsule_hint,
self._capsule_timer,
self._capsule_progress,
)
for widget in drag_widgets:
widget.bind("<ButtonPress-1>", self._capsule_drag_start)
widget.bind("<B1-Motion>", self._capsule_drag_motion)
widget.bind("<Double-Button-1>", lambda _event: self._expand_console())
self._apply_capsule_scaling(self._console_dpi)
def _apply_capsule_scaling(self, dpi):
dpi = max(96, int(dpi or 96))
if self._capsule_dpi == dpi:
return
scale = dpi / 96.0
px = lambda value: max(1, round(value * scale))
for name, font in self._capsule_fonts.items():
spec = self._capsule_font_specs[name]
font.configure(
family=spec[0],
size=-px(abs(spec[1])),
weight=spec[2] if len(spec) > 2 else "normal",
)
width = px(CAPSULE_WIDTH)
height = px(CAPSULE_HEIGHT)
canvas = self._capsule_canvas
canvas.configure(width=width, height=height)
for item_id in (
self._capsule_shadow_id,
self._capsule_body_id,
self._capsule_line_id,
):
canvas.delete(item_id)
self._capsule_shadow_id = self._rounded_rectangle(
canvas,
px(5),
px(7),
width - px(3),
height - px(1),
px(28),
fill=CAPSULE_SHADOW,
outline="",
)
self._capsule_body_id = self._rounded_rectangle(
canvas,
px(2),
px(2),
width - px(6),
height - px(6),
px(28),
fill=PANEL,
outline=BORDER,
width=px(1),
)
self._capsule_line_id = canvas.create_line(
px(28),
px(3),
width - px(34),
px(3),
fill=CAPSULE_HIGHLIGHT,
width=px(1),
)
canvas.coords(self._capsule_content_window_id, px(18), px(9))
canvas.itemconfigure(
self._capsule_content_window_id,
width=px(400),
height=px(74),
)
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(74))
self._capsule_top_row.configure(height=px(46))
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(126))
self._capsule_hint.pack_configure(pady=(px(1), 0))
self._capsule_timer.pack_configure(padx=(px(6), px(7)))
self._capsule_progress.configure(padx=px(9))
self._capsule_progress.pack_configure(pady=(px(3), 0))
self._capsule_expand_button.configure(padx=px(3), pady=px(4))
self._capsule_expand_button.pack_configure(padx=(0, px(5)))
self._capsule_stop_button.configure(padx=px(3), pady=px(4))
self._capsule_dpi = dpi
self._capsule.update_idletasks()
def _sync_capsule_dpi(self):
monitor = self._monitor_info_for(self._capsule)
dpi = int((monitor or {}).get("dpi") or 96)
if dpi != self._capsule_dpi:
self._apply_capsule_scaling(dpi)
return monitor
def _capsule_button(self, parent, text, tooltip, command, danger=False):
normal_bg = DANGER_SOFT if danger else PANEL_ALT
normal_fg = DANGER if danger else TEXT_MUTED
hover_bg = DANGER if danger else ELEVATED
hover_fg = ON_ACCENT if danger else ACCENT_HOVER
button = tk.Label(
parent,
text=text,
bg=normal_bg,
fg=normal_fg,
font=self._capsule_fonts["icon"],
width=2,
height=1,
cursor="hand2",
padx=3,
pady=4,
)
button.bind("<Enter>", lambda _event: button.configure(bg=hover_bg, fg=hover_fg))
button.bind("<Leave>", lambda _event: button.configure(bg=normal_bg, fg=normal_fg))
button.bind("<ButtonPress-1>", lambda _event: button.configure(relief="sunken"))
def release(_event=None):
button.configure(relief="flat")
command()
button.bind("<ButtonRelease-1>", release)
self._create_tooltip(button, tooltip)
return button
def _create_tooltip(self, widget, text):
state = {"window": None, "job": None, "hover": False}
def show(_event=None):
state["job"] = None
if not state["hover"] or state["window"] is not None:
return
tip = tk.Toplevel(widget)
tip.overrideredirect(True)
tip.attributes("-topmost", True)
x = widget.winfo_rootx() + widget.winfo_width() // 2
y = widget.winfo_rooty() + widget.winfo_height() + 8
tip.geometry(f"{x:+d}{y:+d}")
tk.Label(
tip,
text=text,
bg=ELEVATED,
fg=TEXT,
font=self._capsule_fonts["tooltip"],
padx=8,
pady=5,
relief="solid",
bd=1,
).pack()
state["window"] = tip
def hide(_event=None):
state["hover"] = False
if state["job"] is not None:
widget.after_cancel(state["job"])
state["job"] = None
if state["window"] is not None:
state["window"].destroy()
state["window"] = None
def enter(event=None):
state["hover"] = True
widget.configure(
bg=DANGER if text == "停止监听" else ELEVATED,
fg=ON_ACCENT if text == "停止监听" else ACCENT_HOVER,
)
state["job"] = widget.after(450, lambda: show(event))
widget.bind("<Enter>", enter)
widget.bind("<Leave>", lambda event: (hide(event), widget.configure(
bg=DANGER_SOFT if text == "停止监听" else PANEL_ALT,
fg=DANGER if text == "停止监听" else TEXT_MUTED,
)))
def _capsule_drag_start(self, event):
self._capsule_drag_offset = (
event.x_root - self._capsule.winfo_x(),
event.y_root - self._capsule.winfo_y(),
)
def _capsule_drag_motion(self, event):
offset_x, offset_y = self._capsule_drag_offset
x = event.x_root - offset_x
y = event.y_root - offset_y
self._capsule_position = (x, y)
self._move_native_window(self._capsule, x, y)
self._sync_capsule_dpi()
self._move_native_window(self._capsule, x, y)
def _native_handle_for(self, widget):
hwnd = int(widget.winfo_id())
try:
get_parent = ctypes.windll.user32.GetParent
get_parent.argtypes = (wintypes.HWND,)
get_parent.restype = wintypes.HWND
parent = int(get_parent(wintypes.HWND(hwnd)) or 0)
return parent or hwnd
except Exception:
return hwnd
def _native_window_handle(self):
return self._native_handle_for(self)
def _native_rect_for(self, widget):
try:
user32 = ctypes.windll.user32
get_rect = user32.GetWindowRect
get_rect.argtypes = (wintypes.HWND, ctypes.POINTER(wintypes.RECT))
get_rect.restype = wintypes.BOOL
rect = wintypes.RECT()
hwnd = self._native_handle_for(widget)
if get_rect(wintypes.HWND(hwnd), ctypes.byref(rect)):
return (int(rect.left), int(rect.top), int(rect.right), int(rect.bottom))
except Exception:
pass
return None
def _move_native_window(self, widget, x, y):
try:
widget.update_idletasks()
rect = self._native_rect_for(widget)
width = max(1, rect[2] - rect[0]) if rect else widget.winfo_reqwidth()
height = max(1, rect[3] - rect[1]) if rect else widget.winfo_reqheight()
user32 = ctypes.windll.user32
set_pos = user32.SetWindowPos
set_pos.argtypes = (
wintypes.HWND,
wintypes.HWND,
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
wintypes.UINT,
)
set_pos.restype = wintypes.BOOL
set_pos(
wintypes.HWND(self._native_handle_for(widget)),
wintypes.HWND(0),
int(x),
int(y),
int(width),
int(height),
0x0004 | 0x0010,
)
return
except Exception:
pass
widget.geometry(f"+{max(0, int(x))}+{max(0, int(y))}")
def _monitor_info_for(self, widget):
try:
user32 = ctypes.windll.user32
monitor_from_window = user32.MonitorFromWindow
monitor_from_window.argtypes = (wintypes.HWND, wintypes.DWORD)
monitor_from_window.restype = wintypes.HANDLE
get_monitor_info = user32.GetMonitorInfoW
get_monitor_info.argtypes = (wintypes.HANDLE, ctypes.POINTER(_MonitorInfo))
get_monitor_info.restype = wintypes.BOOL
hwnd = self._native_handle_for(widget)
monitor_handle = monitor_from_window(wintypes.HWND(hwnd), 2)
info = _MonitorInfo()
info.size = ctypes.sizeof(_MonitorInfo)
if not monitor_handle or not get_monitor_info(monitor_handle, ctypes.byref(info)):
return None
dpi = 96
try:
get_dpi = user32.GetDpiForWindow
get_dpi.argtypes = (wintypes.HWND,)
get_dpi.restype = wintypes.UINT
dpi = int(get_dpi(wintypes.HWND(hwnd)) or 96)
except Exception:
pass
return {
"handle": int(monitor_handle),
"dpi": max(96, dpi),
"work": (
int(info.work.left),
int(info.work.top),
int(info.work.right),
int(info.work.bottom),
),
}
except Exception:
return None
def _font_scale_for_window(self, dpi):
rect = self._native_rect_for(self)
if not rect:
return 1.0
width = max(1, rect[2] - rect[0])
height = max(1, rect[3] - rect[1])
logical_width = width * 96.0 / max(96, dpi)
logical_height = height * 96.0 / max(96, dpi)
available_scale = min(logical_width / 1260.0, logical_height / 820.0)
return min(1.2, max(1.0, available_scale))
def _scaled_geometry(self, geometry):
width, height = geometry.lower().split("x", 1)
return (
f"{round(int(width) * self._layout_scale)}x"
f"{round(int(height) * self._layout_scale)}"
)
def _apply_tk_scaling(self, dpi, font_scale=None):
try:
dpi = max(96, int(dpi or 96))
font_scale = (
self._font_scale_for_window(dpi)
if font_scale is None
else min(1.2, max(1.0, float(font_scale)))
)
self.tk.call("tk", "scaling", max(1.0, float(dpi) / 72.0))
self._layout_scale = max(1.0, dpi / 96.0)
self.minsize(
round(BASE_MIN_WIDTH * self._layout_scale),
round(BASE_MIN_HEIGHT * self._layout_scale),
)
if hasattr(self, "_sidebar"):
self._sidebar.configure(
width=round(BASE_SIDEBAR_WIDTH * self._layout_scale),
)
for name, font in self._ui_fonts.items():
spec = UI_FONT_SPECS[name]
font.configure(
family=spec[0],
size=max(1, round(spec[1] * font_scale)),
weight=spec[2] if len(spec) > 2 else "normal",
)
self._console_dpi = dpi
self._console_font_scale = font_scale
except tk.TclError:
pass
def _sync_console_dpi(self):
if self._capsule_mode or self.state() in ("withdrawn", "iconic"):
return
monitor = self._monitor_info_for(self)
dpi = int((monitor or {}).get("dpi") or 96)
font_scale = self._font_scale_for_window(dpi)
if dpi != self._console_dpi or abs(font_scale - self._console_font_scale) >= 0.04:
self._apply_tk_scaling(dpi, font_scale)
@staticmethod
def _scaled_rect_for_monitor(native, target, use_normal=False):
source_rect = native.get("normal_rect") if use_normal else native.get("rect")
source_rect = source_rect or native["rect"]
source_work = native.get("monitor_work") or source_rect
target_work = target.get("work") or source_work
source_dpi = max(96, int(native.get("dpi") or 96))
target_dpi = max(96, int(target.get("dpi") or 96))
if tuple(source_work) == tuple(target_work) and source_dpi == target_dpi:
return tuple(source_rect)
left, top, right, bottom = source_rect
source_width = max(1, right - left)
source_height = max(1, bottom - top)
target_work_width = max(1, target_work[2] - target_work[0])
target_work_height = max(1, target_work[3] - target_work[1])
scale = target_dpi / source_dpi
width = min(target_work_width, max(1, round(source_width * scale)))
height = min(target_work_height, max(1, round(source_height * scale)))
source_free_x = max(1, (source_work[2] - source_work[0]) - source_width)
source_free_y = max(1, (source_work[3] - source_work[1]) - source_height)
x_ratio = min(1.0, max(0.0, (left - source_work[0]) / source_free_x))
y_ratio = min(1.0, max(0.0, (top - source_work[1]) / source_free_y))
target_free_x = max(0, target_work_width - width)
target_free_y = max(0, target_work_height - height)
x = target_work[0] + round(target_free_x * x_ratio)
y = target_work[1] + round(target_free_y * y_ratio)
return (x, y, x + width, y + height)
def _capture_console_window_state(self):
self.update_idletasks()
self._console_geometry = self.geometry()
self._console_was_zoomed = self.state() == "zoomed"
self._console_native_state = None
try:
user32 = ctypes.windll.user32
hwnd = self._native_window_handle()
get_rect = user32.GetWindowRect
get_rect.argtypes = (wintypes.HWND, ctypes.POINTER(wintypes.RECT))
get_rect.restype = wintypes.BOOL
get_placement = user32.GetWindowPlacement
get_placement.argtypes = (wintypes.HWND, ctypes.POINTER(_WindowPlacement))
get_placement.restype = wintypes.BOOL
rect = wintypes.RECT()
placement = _WindowPlacement()
placement.length = ctypes.sizeof(_WindowPlacement)
if not get_rect(wintypes.HWND(hwnd), ctypes.byref(rect)):
return
show_cmd = 3 if self._console_was_zoomed else 1
normal_rect = (int(rect.left), int(rect.top), int(rect.right), int(rect.bottom))
if get_placement(wintypes.HWND(hwnd), ctypes.byref(placement)):
show_cmd = int(placement.show_cmd)
normal_rect = (
int(placement.normal_position.left),
int(placement.normal_position.top),
int(placement.normal_position.right),
int(placement.normal_position.bottom),
)
monitor = self._monitor_info_for(self)
self._console_native_state = {
"rect": (int(rect.left), int(rect.top), int(rect.right), int(rect.bottom)),
"normal_rect": normal_rect,
"show_cmd": show_cmd,
"dpi": (monitor or {}).get("dpi", 96),
"monitor_work": (monitor or {}).get("work"),
}
except Exception:
self._console_native_state = None
def _restore_console_window_state(self, target_monitor=None):
native = self._console_native_state
if native:
try:
user32 = ctypes.windll.user32
hwnd = self._native_window_handle()
show_window = user32.ShowWindow
show_window.argtypes = (wintypes.HWND, ctypes.c_int)
show_window.restype = wintypes.BOOL
set_pos = user32.SetWindowPos
set_pos.argtypes = (
wintypes.HWND,
wintypes.HWND,
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
ctypes.c_int,
wintypes.UINT,
)
set_pos.restype = wintypes.BOOL
target = target_monitor or {
"dpi": native.get("dpi", 96),
"work": native.get("monitor_work"),
}
self._apply_tk_scaling(target.get("dpi", 96))
use_normal = native["show_cmd"] == 3
left, top, right, bottom = self._scaled_rect_for_monitor(
native,
target,
use_normal=use_normal,
)
width = max(1, right - left)
height = max(1, bottom - top)
if native["show_cmd"] == 3:
show_window(wintypes.HWND(hwnd), 9)
set_pos(
wintypes.HWND(hwnd),
wintypes.HWND(0),
left,
top,
width,
height,
0x0004 | 0x0010,
)
if native["show_cmd"] == 3:
show_window(wintypes.HWND(hwnd), 3)
return
except Exception:
pass
if self._console_was_zoomed:
try:
self.state("zoomed")
return
except tk.TclError:
pass
self.geometry(self._console_geometry)
def _enter_capsule_mode(self):
if self._capsule_mode:
return
if self.state() != "withdrawn":
self._capture_console_window_state()
source_dpi = int((self._console_native_state or {}).get("dpi") or 96)
self._apply_capsule_scaling(source_dpi)
if self._capsule_position is None:
work = (self._console_native_state or {}).get("monitor_work")
scale = source_dpi / 96.0
capsule_width = round(CAPSULE_WIDTH * scale)
margin = round(28 * scale)
if work:
x = work[2] - capsule_width - margin
y = work[1] + margin
else:
x = self.winfo_screenwidth() - capsule_width - margin
y = margin
self._capsule_position = (x, y)
x, y = self._capsule_position
self._set_customer_service_portal_visible(False)
self.withdraw()
self._capsule.deiconify()
self._capsule.update_idletasks()
self._move_native_window(self._capsule, x, y)
self._sync_capsule_dpi()
self._move_native_window(self._capsule, x, y)
self._capsule.lift()
self._capsule.attributes("-topmost", True)
self._capsule_mode = True
def _expand_console(self):
target_monitor = None
if hasattr(self, "_capsule"):
target_monitor = self._monitor_info_for(self._capsule)
self._capsule.withdraw()
self._capsule_mode = False
self.deiconify()
self._restore_console_window_state(target_monitor)
self.after_idle(lambda target=target_monitor: self._restore_console_window_state(target))
self.lift()
self._set_customer_service_portal_visible(
getattr(self, "_current_page", "AI 客服") == "AI 客服"
)
if getattr(self, "_current_page", "AI 客服") == "AI 客服":
for delay in (80, 350, 700):
self.after(delay, self._focus_customer_service_portal)
if self._running and self._collapse_button.winfo_manager() == "":
self._collapse_button.pack(side="right", padx=(0, 8))
def _update_capsule_status(self, state, text):
if not hasattr(self, "_capsule_status"):
return
colors = {
"connecting": WARNING,
"waiting": WARNING,
"running": SUCCESS,
"stopping": WARNING,
"error": DANGER,
"verification": DANGER,
"stopped": TEXT_FAINT,
}
hints = {
"connecting": "正在挂载窗口",
"waiting": "正在切到企业微信前台",
"running": "安全监听中",
"stopping": "正在停止",
"error": "打开控制台查看",
"verification": "请先用手机扫码验证",
"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, "安全监听"))
defaults = {
"connecting": "正在查找并挂载企业微信窗口",
"waiting": "等待企业微信窗口可见",
"running": "等待新消息",
"stopping": "正在停止监听线程",
"error": "运行失败,请展开控制台查看",
"verification": "检测到安全验证,自动操作已停止",
"stopped": "监听已停止",
}
if state in defaults:
self._update_capsule_progress(defaults[state])
def _update_capsule_progress(self, text):
"""在经典界面的胶囊里显示真实操作步骤,而不是只显示静态状态。"""
if not hasattr(self, "_capsule_progress"):
return
message = " ".join(str(text or "").split())
if not message:
message = "等待新消息" if self._running else "监听已停止"
if message == getattr(self, "_capsule_progress_text", ""):
return
self._capsule_progress_text = message
self._capsule_progress_step = int(
getattr(self, "_capsule_progress_step", 0) or 0
) + 1
rendered = f"步骤 {self._capsule_progress_step:03d} · {message}"
# Tk Label 在固定画布里不会自动省略;主动裁剪,完整内容仍保存在属性中,
# 双击胶囊展开控制台后可从运行日志查看。
if len(rendered) > 54:
rendered = rendered[:53] + "…"
self._capsule_progress.configure(text=rendered)
def _build_sidebar(self, parent):
sidebar = tk.Frame(
parent,
bg=PANEL,
width=round(BASE_SIDEBAR_WIDTH * self._layout_scale),
highlightthickness=1,
highlightbackground=BORDER,
)
self._sidebar = sidebar
sidebar.pack(side="left", fill="y")
sidebar.pack_propagate(False)
brand = tk.Frame(sidebar, bg=PANEL)
brand.pack(fill="x", padx=18, pady=(20, 14))
tk.Frame(brand, width=4, height=38, bg=ACCENT).pack(side="left")
brand_text = tk.Frame(brand, bg=PANEL)
brand_text.pack(side="left", padx=(10, 0))
tk.Label(
brand_text,
text="ZHEN AI DESK",
bg=PANEL,
fg=ACCENT,
font=TYPE_BRAND,
).pack(anchor="w")
tk.Label(
brand_text,
text="AI 客服 · 企业微信自动回复",
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_SMALL,
).pack(anchor="w", pady=(2, 0))
status_card = tk.Frame(
sidebar,
bg=PANEL_ALT,
highlightthickness=1,
highlightbackground=BORDER,
)
status_card.pack(fill="x", padx=14, pady=(0, 20))
self._status_dot = tk.Canvas(
status_card,
width=12,
height=12,
bg=PANEL_ALT,
highlightthickness=0,
)
self._status_dot.pack(side="left", padx=(12, 9), pady=12)
self._status_dot_id = self._status_dot.create_oval(
1,
1,
11,
11,
fill=TEXT_FAINT,
outline="",
)
status_text = tk.Frame(status_card, bg=PANEL_ALT)
status_text.pack(side="left", fill="x", expand=True, pady=9)
self._status_label = tk.Label(
status_text,
text="已停止",
bg=PANEL_ALT,
fg=TEXT_MUTED,
font=TYPE_BODY_BOLD,
)
self._status_label.pack(anchor="w")
self._status_hint = tk.Label(
status_text,
text="等待连接企业微信",
bg=PANEL_ALT,
fg=TEXT_FAINT,
font=TYPE_CAPTION,
)
self._status_hint.pack(anchor="w", pady=(2, 0))
tk.Label(
sidebar,
text="CONTROL CENTER",
bg=PANEL,
fg=TEXT_FAINT,
font=TYPE_MONO_LABEL,
).pack(anchor="w", padx=18, pady=(0, 8))
nav = tk.Frame(sidebar, bg=PANEL)
nav.pack(fill="x", padx=10)
for name, code in PAGES:
row = tk.Frame(nav, bg=PANEL, cursor="hand2")
row.pack(fill="x", pady=2)
bar = tk.Frame(row, bg=PANEL, width=3)
bar.pack(side="left", fill="y")
code_label = tk.Label(
row,
text=code,
bg=PANEL,
fg=TEXT_FAINT,
font=TYPE_MONO_BODY,
width=3,
cursor="hand2",
)
code_label.pack(side="left", padx=(10, 3), pady=11)
text_label = tk.Label(
row,
text=name,
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_BODY,
anchor="w",
cursor="hand2",
)
text_label.pack(side="left", fill="x", expand=True, pady=11)
for widget in (row, bar, code_label, text_label):
widget.bind("<Button-1>", lambda _event, page=name: self._show_page(page))
self._nav_items[name] = (row, bar, code_label, text_label)
footer = tk.Frame(sidebar, bg=PANEL)
footer.pack(side="bottom", fill="x", padx=14, pady=14)
self._stop_button = ActionButton(
footer,
"停止监听",
self._on_stop,
kind="danger",
)
self._stop_button.pack(fill="x")
self._stop_button.set_enabled(False)
tk.Label(
footer,
text="RPA DESKTOP / LOCAL",
bg=PANEL,
fg=TEXT_FAINT,
font=TYPE_MONO_LABEL,
).pack(anchor="w", pady=(14, 0))
def _build_header(self, parent):
header = tk.Frame(
parent,
bg=PANEL,
highlightthickness=1,
highlightbackground=BORDER,
)
header.pack(fill="x", padx=14, pady=(14, 12))
self._header = header
inner = tk.Frame(header, bg=PANEL)
inner.pack(fill="x", padx=20, pady=14)
left = tk.Frame(inner, bg=PANEL)
left.pack(side="left")
self._page_code = tk.Label(
left,
text="01",
bg=ACCENT_SOFT,
fg=ACCENT,
font=TYPE_MONO_BODY_BOLD,
padx=9,
pady=5,
)
self._page_code.pack(side="left")
self._page_title = tk.Label(
left,
text="自动回复",
bg=PANEL,
fg=TEXT,
font=TYPE_HEADLINE,
)
self._page_title.pack(side="left", padx=(12, 0))
actions = tk.Frame(inner, bg=PANEL)
actions.pack(side="right")
ActionButton(actions, "AI 配置", self._open_ai_config).pack(side="right")
ActionButton(actions, "MCP", self._open_mcp_config).pack(side="right", padx=(0, 8))
self._start_button = ActionButton(
actions,
"开始监听",
self._on_start,
kind="primary",
)
self._start_button.pack(side="right", padx=(0, 8))
self._collapse_button = ActionButton(
actions,
"收起胶囊",
self._enter_capsule_mode,
)
def _show_page(self, name):
self._current_page = name
is_customer_service = name == "AI 客服"
if is_customer_service:
self._header.pack_forget()
self._stage.pack_configure(padx=0, pady=0)
self._stage.configure(highlightthickness=0)
else:
if not self._header.winfo_manager():
self._header.pack(
fill="x",
padx=14,
pady=(14, 12),
before=self._stage,
)
self._stage.pack_configure(padx=14, pady=(0, 14))
self._stage.configure(highlightthickness=1)
for page_name, frame in self._page_frames.items():
if page_name == name:
frame.pack(fill="both", expand=True)
else:
frame.pack_forget()
for page_name, widgets in self._nav_items.items():
row, bar, code_label, text_label = widgets
active = page_name == name
background = PANEL_ALT if active else PANEL
row.configure(bg=background)
bar.configure(bg=ACCENT if active else PANEL)
code_label.configure(
bg=background,
fg=ACCENT if active else TEXT_FAINT,
)
text_label.configure(
bg=background,
fg=TEXT if active else TEXT_MUTED,
font=TYPE_BODY_BOLD if active else TYPE_BODY,
)
code = dict(PAGES).get(name, "--")
self._page_code.configure(text=code)
self._page_title.configure(text=name)
if is_customer_service:
self.after_idle(self._focus_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):
page.configure(bg=CONTENT_BG)
header = tk.Frame(page, bg=CONTENT_BG)
header.pack(fill="x", padx=18, pady=(16, 8))
tk.Label(
header,
text="AI 客服工作台",
bg=CONTENT_BG,
fg=TEXT,
font=TYPE_BODY_BOLD,
).pack(anchor="w")
self._desk_backend_label = tk.Label(
header,
text="",
bg=CONTENT_BG,
fg=TEXT_MUTED,
font=TYPE_CAPTION,
)
self._desk_backend_label.pack(anchor="w", pady=(2, 0))
toolbar = tk.Frame(page, bg=CONTENT_BG)
toolbar.pack(fill="x", padx=18, pady=(0, 8))
tk.Button(
toolbar,
text="+ 开始新对话",
command=self._desk_new_session,
bg=ACCENT,
fg="#FFFFFF",
relief="flat",
font=TYPE_SMALL_BOLD,
padx=12,
pady=6,
).pack(side="left")
self._desk_status = tk.Label(
toolbar,
text="Enter 发送",
bg=CONTENT_BG,
fg=TEXT_MUTED,
font=TYPE_CAPTION,
)
self._desk_status.pack(side="left", padx=12)
self._desk_transcript = tk.Text(
page,
bg=PANEL,
fg=TEXT,
relief="flat",
wrap="word",
font=TYPE_BODY,
padx=12,
pady=12,
state="disabled",
)
self._desk_transcript.pack(fill="both", expand=True, padx=18, pady=(0, 8))
composer = tk.Frame(page, bg=CONTENT_BG)
composer.pack(fill="x", padx=18, pady=(0, 16))
self._desk_input = tk.Text(
composer,
height=4,
bg=PANEL,
fg=TEXT,
relief="flat",
wrap="word",
font=TYPE_BODY,
)
self._desk_input.pack(side="left", fill="both", expand=True, padx=(0, 8))
self._desk_input.bind("<Return>", self._desk_return)
tk.Button(
composer,
text="发送",
command=self._desk_send,
bg=ACCENT,
fg="#FFFFFF",
relief="flat",
font=TYPE_SMALL_BOLD,
padx=16,
pady=8,
).pack(side="right")
self._desk_session_id = ""
self._desk_busy = False
self.after_idle(self._desk_reload)
def _desk_return(self, event):
if event.state & 0x0001:
return None
self._desk_send()
return "break"
def _desk_reload(self):
from dsh_agent import get_desk
desk = get_desk()
self._desk_backend_label.configure(text=desk.backend_label())
session_id = self._desk_session_id or desk.current_session_id()
session = desk.set_current_session(session_id) or desk.get_session(session_id)
if session is None:
session = desk.create_session()
self._desk_session_id = session.id
self._desk_render(session.messages)
def _desk_render(self, messages):
widget = self._desk_transcript
widget.configure(state="normal")
widget.delete("1.0", "end")
if not messages:
widget.insert("end", "一个输入框,完成客服起草与对话。\n复用同一会话会保留上下文。\n")
else:
for message in messages:
role = {"user": "坐席", "assistant": "Agent", "system": "系统"}.get(
str(message.get("role") or ""),
str(message.get("role") or ""),
)
widget.insert("end", f"{role}\n{message.get('content') or ''}\n\n")
widget.configure(state="disabled")
widget.see("end")
def _desk_new_session(self):
if self._desk_busy:
return
from dsh_agent import get_desk
session = get_desk().create_session()
self._desk_session_id = session.id
self._desk_reload()
def _desk_send(self):
if self._desk_busy:
return
prompt = self._desk_input.get("1.0", "end").strip()
if not prompt:
return
from dsh_agent import get_desk
desk = get_desk()
session_id = self._desk_session_id or desk.current_session_id()
self._desk_input.delete("1.0", "end")
self._desk_busy = True
self._desk_status.configure(text="Agent 正在处理…")
session = desk.get_session(session_id)
pending = list(session.messages if session is not None else [])
pending.append({"role": "user", "content": prompt})
pending.append({"role": "system", "content": "Agent 正在处理当前任务…"})
self._desk_render(pending)
def worker():
try:
result = desk.run(prompt, session_id=session_id)
self._queue.put(("desk_done", (session_id, result, "")))
except Exception as exc:
self._queue.put(("desk_done", (session_id, None, str(exc))))
threading.Thread(target=worker, daemon=True, name="dsh-desk-tk").start()
def _desk_finished(self, session_id, _result, error):
self._desk_busy = False
self._desk_session_id = session_id
self._desk_status.configure(text=error or "Enter 发送")
self._desk_reload()
def _build_dashboard_page(self, page):
wrap = tk.Frame(page, bg=CONTENT_BG)
wrap.pack(fill="both", expand=True, padx=20, pady=20)
metrics = tk.Frame(wrap, bg=CONTENT_BG)
metrics.pack(fill="x", pady=(0, 14))
for column in range(4):
metrics.grid_columnconfigure(column, weight=1)
self._metric_card(metrics, 0, "本次已回复", "_replied_label", "自动回复数量")
self._metric_card(metrics, 1, "识别误判", "_false_label", "已跳过的会话")
self._metric_card(metrics, 2, "待处理登记", "_registration_count_label", "挂号与回访线索")
self._metric_card(metrics, 3, "会话档案", "_session_count_label", "已保存上下文")
control = Card(
wrap,
"企业微信监听",
"连接已登录的企业微信窗口,自动识别新消息并按当前规则处理。",
)
control.pack(fill="x", pady=(0, 14))
control_row = tk.Frame(control.body, bg=PANEL)
control_row.pack(fill="x")
left = tk.Frame(control_row, bg=PANEL)
left.pack(side="left", fill="x", expand=True)
self._dashboard_state = tk.Label(
left,
text="待命",
bg=PANEL,
fg=TEXT,
font=TYPE_STATUS,
)
self._dashboard_state.pack(anchor="w")
self._dashboard_notice = tk.Label(
left,
text=WECOM_WAITING_MESSAGE,
bg=PANEL,
fg=WARNING,
font=TYPE_BODY_BOLD,
justify="left",
anchor="w",
)
self._timer_label = tk.Label(
left,
text="运行时长 --:--:--",
bg=PANEL,
fg=TEXT_FAINT,
font=TYPE_MONO_BODY,
)
self._timer_label.pack(anchor="w", pady=(4, 0))
ActionButton(
control_row,
"开始监听",
self._on_start,
kind="primary",
).pack(side="right")
flow = Card(
wrap,
"实际工作流",
"设计稿中的自动化规则已映射到本软件现有的可执行能力。",
)
flow.pack(fill="both", expand=True)
rows = (
("A1", "消息监听", "未读识别 → 会话提取 → 自动回复", "通用设置", lambda: self._show_page("通用设置")),
("A2", "AI 决策", "上下文记忆 → 医疗客服人格 → MCP 工具", "AI 人格", lambda: self._show_page("AI 人格")),
("A3", "业务沉淀", "挂号登记 → 会话档案 → 运行记录", "查看数据", lambda: self._show_page("业务数据")),
)
for index, (code, title, description, action_text, action) in enumerate(rows):
row = tk.Frame(
flow.body,
bg=PANEL_ALT,
highlightthickness=1,
highlightbackground=BORDER_SOFT,
)
row.pack(fill="x", pady=(0 if index == 0 else 9, 0))
tk.Label(
row,
text=code,
bg=ACCENT_SOFT,
fg=ACCENT,
font=TYPE_MONO_BODY_BOLD,
padx=10,
pady=13,
).pack(side="left", fill="y")
text_box = tk.Frame(row, bg=PANEL_ALT)
text_box.pack(side="left", fill="x", expand=True, padx=14, pady=9)
tk.Label(
text_box,
text=title,
bg=PANEL_ALT,
fg=TEXT,
font=TYPE_BODY_BOLD,
).pack(anchor="w")
tk.Label(
text_box,
text=description,
bg=PANEL_ALT,
fg=TEXT_MUTED,
font=TYPE_SMALL,
).pack(anchor="w", pady=(2, 0))
ActionButton(row, action_text, action).pack(side="right", padx=10)
def _show_workbench_tab(self, tab):
self._show_page("AI 客服" if tab == "ai" else "自动回复")
def _entry(self, parent, variable, *, show=None):
shell = tk.Frame(
parent,
bg=PANEL_ALT,
highlightthickness=1,
highlightbackground=BORDER,
)
entry = tk.Entry(
shell,
textvariable=variable,
show=show,
bg=PANEL_ALT,
fg=TEXT,
insertbackground=ACCENT,
selectbackground=ACCENT_SOFT,
selectforeground=TEXT,
relief="flat",
bd=0,
highlightthickness=0,
font=TYPE_BODY,
)
entry.pack(fill="x", padx=11, pady=9)
entry.bind("<FocusIn>", lambda _event: shell.configure(highlightbackground=ACCENT))
entry.bind("<FocusOut>", lambda _event: shell.configure(highlightbackground=BORDER))
return shell, entry
def _field(self, parent, label, variable, *, column=0, show=None):
box = tk.Frame(parent, bg=PANEL)
box.grid(row=0, column=column, sticky="ew", padx=(0 if column == 0 else 10, 0))
tk.Label(
box,
text=label,
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_SMALL,
).pack(anchor="w")
entry_shell, entry = self._entry(box, variable, show=show)
entry_shell.pack(fill="x", pady=(6, 0))
return entry
def _build_general_page(self, page):
wrap = tk.Frame(page, bg=CONTENT_BG)
wrap.pack(fill="both", expand=True, padx=20, pady=20)
settings = Card(
wrap,
"运行参数",
"这些参数直接参与企业微信监听,不是仅用于展示。",
)
settings.pack(fill="x", pady=(0, 14))
self._reply_var = tk.StringVar(
value=self._runtime_settings["auto_reply_text"],
)
tk.Label(
settings.body,
text="固定回复(关闭 AI 时使用)",
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_SMALL,
).pack(anchor="w")
entry_shell, _entry = self._entry(settings.body, self._reply_var)
entry_shell.pack(fill="x", pady=(6, 14))
number_row = tk.Frame(settings.body, bg=PANEL)
number_row.pack(fill="x")
number_row.grid_columnconfigure(0, weight=1)
number_row.grid_columnconfigure(1, weight=1)
number_row.grid_columnconfigure(2, 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._batch_window_var = tk.StringVar(
value=str(self._runtime_settings["message_batch_window_seconds"]),
)
self._field(number_row, "轮询间隔(秒)", self._poll_var, column=0)
self._field(number_row, "鼠标静止等待(秒)", self._idle_seconds_var, column=1)
self._field(
number_row,
"连续消息合并等待(秒)",
self._batch_window_var,
column=2,
)
tk.Label(
settings.body,
text="同一客户连续发送多条消息时,等待后合并为一次回复(可设置 1–120 秒,默认 20 秒)。",
bg=PANEL,
fg=TEXT_FAINT,
font=TYPE_SMALL,
).pack(anchor="w", pady=(10, 0))
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._batch_window_var,
self._mouse_idle_var,
):
variable.trace_add("write", self._schedule_runtime_settings_save)
tk.Label(
safety.body,
text="启动后请手动切回企业微信。切换到其他窗口时,机器人会自动暂停。",
bg=PANEL,
fg=TEXT_FAINT,
font=TYPE_SMALL,
).pack(anchor="w", pady=(10, 0))
note = Card(wrap, "启动前检查")
note.pack(fill="both", expand=True)
checks = (
"企业微信 PC 端已登录,主窗口没有最小化到托盘。",
"如启用 AI,API 地址、密钥与模型配置已保存。",
"监听期间不要改变企业微信窗口缩放比例或侧栏宽度。",
)
for index, check in enumerate(checks, start=1):
row = tk.Frame(note.body, bg=PANEL)
row.pack(fill="x", pady=(0, 9))
tk.Label(
row,
text=f"{index:02d}",
bg=ACCENT_SOFT,
fg=ACCENT,
font=TYPE_MONO_BODY,
padx=7,
pady=3,
).pack(side="left")
tk.Label(
row,
text=check,
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_BODY,
).pack(side="left", padx=(10, 0))
def _data_card(self, parent, column, title, attr_name, unit, action_text, action):
card = tk.Frame(
parent,
bg=PANEL,
highlightthickness=1,
highlightbackground=BORDER,
)
card.grid(row=0, column=column, sticky="nsew", padx=(0 if column == 0 else 12, 0))
tk.Label(
card,
text=title,
bg=PANEL,
fg=TEXT,
font=TYPE_TITLE,
).pack(anchor="w", padx=18, pady=(18, 0))
count = tk.Label(
card,
text="0",
bg=PANEL,
fg=TEXT,
font=TYPE_METRIC_LARGE,
)
count.pack(anchor="w", padx=18, pady=(12, 0))
tk.Label(
card,
text=unit,
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_SMALL,
).pack(anchor="w", padx=18, pady=(0, 16))
ActionButton(card, action_text, action, kind="primary" if column == 0 else "secondary").pack(
anchor="w",
padx=18,
pady=(0, 18),
)
setattr(self, attr_name, count)
def _build_business_page(self, page):
wrap = tk.Frame(page, bg=CONTENT_BG)
wrap.pack(fill="both", expand=True, padx=20, pady=20)
data_row = tk.Frame(wrap, bg=CONTENT_BG)
data_row.pack(fill="x", pady=(0, 14))
data_row.grid_columnconfigure(0, weight=1)
data_row.grid_columnconfigure(1, weight=1)
self._data_card(
data_row,
0,
"挂号与回访登记",
"_registration_business_label",
"条待处理线索",
"打开登记",
self._open_registration_leads,
)
self._data_card(
data_row,
1,
"客户会话档案",
"_session_business_label",
"份上下文档案",
"管理档案",
self._open_sessions,
)
platform = Card(
wrap,
"服务连接",
"设计稿中的多平台区已按当前软件真实能力映射。",
)
platform.pack(fill="both", expand=True)
services = (
("企业微信桌面端", "窗口监听与自动回复", "_wecom_service_state", self._on_start),
("AI 回复服务", "OpenAI 兼容接口", "_ai_service_state", self._open_ai_config),
("MCP 工具服务", "按需调用外部工具", "_mcp_service_state", self._open_mcp_config),
)
for index, (title, description, attr_name, action) in enumerate(services):
row = tk.Frame(
platform.body,
bg=PANEL_ALT,
highlightthickness=1,
highlightbackground=BORDER_SOFT,
)
row.pack(fill="x", pady=(0 if index == 0 else 9, 0))
dot = tk.Label(
row,
text="■",
bg=PANEL_ALT,
fg=SUCCESS if index == 0 else TEXT_FAINT,
font=TYPE_SMALL,
)
dot.pack(side="left", padx=(14, 10))
text_box = tk.Frame(row, bg=PANEL_ALT)
text_box.pack(side="left", fill="x", expand=True, pady=10)
tk.Label(
text_box,
text=title,
bg=PANEL_ALT,
fg=TEXT,
font=TYPE_BODY_BOLD,
).pack(anchor="w")
tk.Label(
text_box,
text=description,
bg=PANEL_ALT,
fg=TEXT_MUTED,
font=TYPE_SMALL,
).pack(anchor="w", pady=(2, 0))
state = tk.Label(
row,
text="待连接" if index == 0 else "未启用",
bg=PANEL_ALT,
fg=TEXT_FAINT,
font=TYPE_SMALL,
)
state.pack(side="right", padx=(10, 8))
ActionButton(row, "配置" if index else "启动", action).pack(side="right", pady=8)
setattr(self, attr_name, state)
def _build_persona_page(self, page):
wrap = tk.Frame(page, bg=CONTENT_BG)
wrap.pack(fill="both", expand=True, padx=20, pady=20)
self._ai_var = tk.BooleanVar(value=False)
self._context_var = tk.BooleanVar(value=True)
self._counter_insult_var = tk.BooleanVar(value=False)
self._mcp_var = tk.BooleanVar(value=False)
try:
import ai_config
self._ai_var.set(bool(ai_config.AI_ENABLED))
self._context_var.set(bool(ai_config.AI_CONTEXT_ENABLED))
self._counter_insult_var.set(bool(ai_config.AI_COUNTER_INSULT_ENABLED))
self._mcp_var.set(bool(ai_config.AI_MCP_ENABLED))
except Exception:
pass
profile = Card(
wrap,
"AI 人格核心",
"当前软件使用医疗客服提示词,身份与机构信息由高级配置生成。",
)
profile.pack(fill="x", pady=(0, 14))
summary = tk.Frame(
profile.body,
bg=ACCENT_SOFT,
highlightthickness=1,
highlightbackground=ACCENT,
)
summary.pack(fill="x")
tk.Label(
summary,
text="MEDICAL SERVICE",
bg=ACCENT_SOFT,
fg=ACCENT,
font=TYPE_MONO_BODY_BOLD,
).pack(anchor="w", padx=14, pady=(12, 2))
self._persona_summary = tk.Label(
summary,
text="正在读取 AI 配置...",
bg=ACCENT_SOFT,
fg=TEXT,
font=TYPE_TITLE,
)
self._persona_summary.pack(anchor="w", padx=14)
self._persona_model = tk.Label(
summary,
text="--",
bg=ACCENT_SOFT,
fg=TEXT_MUTED,
font=TYPE_MONO_BODY,
)
self._persona_model.pack(anchor="w", padx=14, pady=(3, 12))
capability = Card(wrap, "能力开关", "修改后立即写入 ai_settings.json。")
capability.pack(fill="x", pady=(0, 14))
toggles = (
("启用 AI 自动回复", self._ai_var),
("携带同一客户的会话上下文", self._context_var),
("启用强硬回应模式", self._counter_insult_var),
("允许 AI 调用 MCP 工具", self._mcp_var),
)
for index, (text, variable) in enumerate(toggles):
row = tk.Frame(capability.body, bg=PANEL)
row.pack(fill="x", pady=(0 if index == 0 else 11, 0))
Toggle(
row,
variable,
text,
command=self._save_ai_switches,
).pack(anchor="w")
actions = Card(wrap, "配置入口")
actions.pack(fill="both", expand=True)
action_row = tk.Frame(actions.body, bg=PANEL)
action_row.pack(fill="x")
ActionButton(
action_row,
"编辑 AI 高级配置",
self._open_ai_config,
kind="primary",
).pack(side="left")
ActionButton(
action_row,
"管理 MCP 服务器",
self._open_mcp_config,
).pack(side="left", padx=(9, 0))
self._ai_status = tk.Label(
actions.body,
text="",
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_SMALL,
justify="left",
anchor="w",
)
self._ai_status.pack(fill="x", pady=(14, 0))
def _build_log_page(self, page):
wrap = tk.Frame(page, bg=CONTENT_BG)
wrap.pack(fill="both", expand=True, padx=20, pady=20)
log_card = Card(
wrap,
"运行日志",
"显示窗口连接、消息识别、AI 调用和业务登记过程。",
)
log_card.pack(fill="both", expand=True)
meta = tk.Frame(log_card.body, bg=PANEL)
meta.pack(fill="x", pady=(0, 10))
for column, (label, attr_name) in enumerate((
("窗口句柄", "_hwnd_label"),
("窗口尺寸", "_window_size_label"),
("输入位置", "_input_position_label"),
)):
meta.grid_columnconfigure(column, weight=1)
box = tk.Frame(
meta,
bg=PANEL_ALT,
highlightthickness=1,
highlightbackground=BORDER_SOFT,
)
box.grid(row=0, column=column, sticky="ew", padx=(0 if column == 0 else 8, 0))
tk.Label(
box,
text=label,
bg=PANEL_ALT,
fg=TEXT_FAINT,
font=TYPE_CAPTION,
).pack(anchor="w", padx=11, pady=(8, 1))
value = tk.Label(
box,
text="--",
bg=PANEL_ALT,
fg=TEXT_MUTED,
font=TYPE_MONO_BODY,
)
value.pack(anchor="w", padx=11, pady=(0, 8))
setattr(self, attr_name, value)
log_shell = tk.Frame(
log_card.body,
bg=LOG_BG,
highlightthickness=1,
highlightbackground=BORDER,
)
log_shell.pack(fill="both", expand=True)
self._log = scrolledtext.ScrolledText(
log_shell,
bg=LOG_BG,
fg=LOG_TEXT,
insertbackground=ACCENT,
selectbackground=ACCENT_SOFT,
selectforeground=TEXT,
relief="flat",
bd=0,
wrap="word",
state="disabled",
font=TYPE_MONO_CONTENT,
padx=13,
pady=11,
)
self._log.pack(fill="both", expand=True)
self._log.tag_config("ok", foreground=SUCCESS)
self._log.tag_config("warn", foreground=WARNING)
self._log.tag_config("err", foreground=DANGER)
self._log.tag_config("notify", foreground=ACCENT_HOVER)
self._log.tag_config("dim", foreground=TEXT_FAINT)
footer = tk.Frame(log_card.body, bg=PANEL)
footer.pack(fill="x", pady=(10, 0))
ActionButton(footer, "清空日志", self._clear_log).pack(side="right")
ActionButton(footer, "会话档案", self._open_sessions).pack(side="right", padx=(0, 8))
ActionButton(footer, "挂号登记", self._open_registration_leads).pack(side="right", padx=(0, 8))
def _style_tree(self, window):
style = ttk.Style(window)
try:
style.theme_use("clam")
except tk.TclError:
pass
style.configure(
"Console.Treeview",
background=PANEL_ALT,
fieldbackground=PANEL_ALT,
foreground=TEXT,
rowheight=38,
borderwidth=0,
relief="flat",
font=TYPE_BODY,
)
style.configure(
"Console.Treeview.Heading",
background=PANEL,
foreground=TEXT_MUTED,
relief="flat",
borderwidth=0,
font=TYPE_SMALL_BOLD,
padding=(10, 9),
)
style.map(
"Console.Treeview",
background=[("selected", ACCENT_SOFT)],
foreground=[("selected", TEXT)],
)
def _manager_window(self, title, subtitle, geometry="1120x720"):
window = tk.Toplevel(self)
window.title(title)
window.geometry(self._scaled_geometry(geometry))
window.minsize(
round(900 * self._layout_scale),
round(600 * self._layout_scale),
)
window.configure(bg=BG)
window.transient(self)
self._style_tree(window)
shell = tk.Frame(window, bg=BG)
shell.pack(fill="both", expand=True, padx=18, pady=18)
card = tk.Frame(
shell,
bg=PANEL,
highlightthickness=1,
highlightbackground=BORDER,
)
card.pack(fill="both", expand=True)
header = tk.Frame(card, bg=PANEL)
header.pack(fill="x", padx=20, pady=(18, 10))
tk.Label(
header,
text=title,
bg=PANEL,
fg=TEXT,
font=TYPE_HEADLINE,
).pack(anchor="w")
tk.Label(
header,
text=subtitle,
bg=PANEL,
fg=TEXT_MUTED,
font=TYPE_SMALL,
).pack(anchor="w", pady=(4, 0))
body = tk.Frame(card, bg=PANEL)
body.pack(fill="both", expand=True, padx=20, pady=(0, 10))
footer = tk.Frame(card, bg=PANEL)
footer.pack(fill="x", padx=20, pady=(0, 18))
self._apply_dark_titlebar(window)
return window, body, footer
@staticmethod
def _format_time(timestamp):
if not timestamp:
return "--"
try:
return time.strftime("%Y-%m-%d %H:%M", time.localtime(timestamp))
except Exception:
return "--"
def _conversation_store(self):
from conversation_store import ConversationStore
return ConversationStore(os.path.join(SCRIPT_DIR, "conversations.json"))
def _open_sessions(self):
try:
store = self._conversation_store()
except Exception as exc:
messagebox.showerror("无法打开会话档案", str(exc), parent=self)
return
window, body, footer = self._manager_window(
"会话档案",
"查看 AI 上下文记录;删除后不可恢复。",
)
body.grid_columnconfigure(0, weight=3)
body.grid_columnconfigure(1, weight=2)
body.grid_rowconfigure(0, weight=1)
tree = ttk.Treeview(
body,
columns=("updated", "messages", "preview"),
show="headings",
selectmode="extended",
style="Console.Treeview",
)
tree.heading("updated", text="最近更新")
tree.heading("messages", text="消息数")
tree.heading("preview", text="内容预览")
tree.column("updated", width=140, anchor="w")
tree.column("messages", width=70, anchor="center")
tree.column("preview", width=430, anchor="w")
tree.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
scroll = ttk.Scrollbar(body, orient="vertical", command=tree.yview)
scroll.grid(row=0, column=0, sticky="nse", padx=(0, 10))
tree.configure(yscrollcommand=scroll.set)
detail = scrolledtext.ScrolledText(
body,
bg=LOG_BG,
fg=TEXT,
relief="flat",
bd=0,
state="disabled",
wrap="word",
font=TYPE_BODY,
padx=12,
pady=12,
)
detail.grid(row=0, column=1, sticky="nsew")
detail.tag_config("user", foreground=ACCENT_HOVER)
detail.tag_config("assistant", foreground=SUCCESS)
detail.tag_config("meta", foreground=TEXT_FAINT)
status = tk.Label(
footer,
text="",
bg=PANEL,
fg=TEXT_FAINT,
font=TYPE_SMALL,
)
status.pack(side="right")
def reload_data():
tree.delete(*tree.get_children())
sessions = store.list_sessions(limit=500)
for item in sessions:
tree.insert(
"",
"end",
iid=item["session_id"],
values=(
self._format_time(item.get("updated")),
item.get("message_count", 0),
item.get("preview") or "--",
),
)
status.configure(text=f"共 {len(sessions)} 份档案")
self._refresh_counts()
def show_detail(_event=None):
selection = tree.selection()
detail.configure(state="normal")
detail.delete("1.0", "end")
if len(selection) == 1:
session_id = selection[0]
detail.insert("end", f"会话编号\n{session_id}\n\n", "meta")
for message in store.history(session_id):
role = message.get("role") or "unknown"
label = "客户" if role == "user" else "客服"
tag = "user" if role == "user" else "assistant"
detail.insert("end", f"{label}\n", tag)
detail.insert("end", f"{message.get('content') or ''}\n\n")
elif len(selection) > 1:
detail.insert("end", f"已选择 {len(selection)} 份档案。", "meta")
else:
detail.insert("end", "选择一份档案查看完整上下文。", "meta")
detail.configure(state="disabled")
def delete_selected():
selection = list(tree.selection())
if not selection:
messagebox.showinfo("提示", "请先选择要删除的档案。", parent=window)
return
if not messagebox.askyesno(
"确认删除",
f"确定删除选中的 {len(selection)} 份会话档案吗?",
parent=window,
):
return
deleted = sum(1 for session_id in selection if store.delete(session_id))
self._append(f"已删除 {deleted} 份会话档案", "ok")
reload_data()
show_detail()
def clear_all():
if not messagebox.askyesno(
"清空全部档案",
"确定清空全部会话档案吗?此操作不可恢复。",
parent=window,
):
return
count = store.clear_all()
self._append(f"已清空 {count} 份会话档案", "warn")
reload_data()
show_detail()
tree.bind("<<TreeviewSelect>>", show_detail)
tree.bind("<Delete>", lambda _event: delete_selected())
ActionButton(footer, "刷新", reload_data).pack(side="left")
ActionButton(footer, "删除所选", delete_selected, kind="danger").pack(side="left", padx=(8, 0))
ActionButton(footer, "清空全部", clear_all, kind="danger").pack(side="left", padx=(8, 0))
reload_data()
show_detail()
def _open_registration_leads(self):
try:
from registration_store import RegistrationStore
store = RegistrationStore()
except Exception as exc:
messagebox.showerror("无法打开挂号登记", str(exc), parent=self)
return
window, body, footer = self._manager_window(
"挂号与回访登记",
"集中处理 AI 识别出的挂号意向、症状和后续联系状态。",
)
body.grid_columnconfigure(0, weight=3)
body.grid_columnconfigure(1, weight=2)
body.grid_rowconfigure(0, weight=1)
tree = ttk.Treeview(
body,
columns=("updated", "contact", "symptom", "status"),
show="headings",
selectmode="extended",
style="Console.Treeview",
)
tree.heading("updated", text="最近更新")
tree.heading("contact", text="微信客户")
tree.heading("symptom", text="症状 / 诉求")
tree.heading("status", text="状态")
tree.column("updated", width=140, anchor="w")
tree.column("contact", width=120, anchor="w")
tree.column("symptom", width=330, anchor="w")
tree.column("status", width=90, anchor="center")
tree.grid(row=0, column=0, sticky="nsew", padx=(0, 10))
scrollbar = ttk.Scrollbar(body, orient="vertical", command=tree.yview)
scrollbar.grid(row=0, column=0, sticky="nse", padx=(0, 10))
tree.configure(yscrollcommand=scrollbar.set)
detail = scrolledtext.ScrolledText(
body,
bg=LOG_BG,
fg=TEXT,
relief="flat",
bd=0,
state="disabled",
wrap="word",
font=TYPE_BODY,
padx=12,
pady=12,
)
detail.grid(row=0, column=1, sticky="nsew")
detail.tag_config("label", foreground=ACCENT_HOVER, font=TYPE_SMALL_BOLD)
detail.tag_config("meta", foreground=TEXT_FAINT)
status_map = {
"pending_symptom": "待补症状",
"booked": "待联系",
"done": "已联系",
}
leads_by_id = {}
footer_status = tk.Label(
footer,
text="",
bg=PANEL,
fg=TEXT_FAINT,
font=TYPE_SMALL,
)
footer_status.pack(side="right")
def reload_data():
nonlocal leads_by_id
tree.delete(*tree.get_children())
leads = store.list_leads(include_done=True)
leads_by_id = {item.get("id"): item for item in leads}
for item in leads:
lead_id = item.get("id")
tree.insert(
"",
"end",
iid=lead_id,
values=(
self._format_time(item.get("updated")),
item.get("contact") or "未知客户",
(item.get("symptom") or "待补充").replace("\n", " ")[:80],
status_map.get(item.get("status"), item.get("status") or "--"),
),
)
footer_status.configure(text=f"共 {len(leads)} 条登记")
self._refresh_counts()
def show_detail(_event=None):
selection = tree.selection()
detail.configure(state="normal")
detail.delete("1.0", "end")
if len(selection) == 1:
item = leads_by_id.get(selection[0], {})
fields = (
("客户", item.get("contact") or "未知客户"),
("症状 / 诉求", item.get("symptom") or "待补充"),
("状态", status_map.get(item.get("status"), item.get("status") or "--")),
("最近客户消息", item.get("last_user") or "--"),
("最近客服回复", item.get("last_reply") or "--"),
)
for label, value in fields:
detail.insert("end", f"{label}\n", "label")
detail.insert("end", f"{value}\n\n")
elif len(selection) > 1:
detail.insert("end", f"已选择 {len(selection)} 条登记。", "meta")
else:
detail.insert("end", "选择一条登记查看详情。", "meta")
detail.configure(state="disabled")
def mark_done():
selection = list(tree.selection())
if not selection:
messagebox.showinfo("提示", "请先选择登记记录。", parent=window)
return
count = store.set_status_many(selection, "done")
self._append(f"已将 {count} 条登记标记为已联系", "ok")
reload_data()
show_detail()
def delete_selected():
selection = list(tree.selection())
if not selection:
messagebox.showinfo("提示", "请先选择要删除的登记。", parent=window)
return
if not messagebox.askyesno(
"确认删除",
f"确定删除选中的 {len(selection)} 条登记吗?",
parent=window,
):
return
count = store.delete_many(selection)
self._append(f"已删除 {count} 条挂号登记", "warn")
reload_data()
show_detail()
tree.bind("<<TreeviewSelect>>", show_detail)
tree.bind("<Delete>", lambda _event: delete_selected())
ActionButton(footer, "刷新", reload_data).pack(side="left")
ActionButton(footer, "标记已联系", mark_done, kind="primary").pack(side="left", padx=(8, 0))
ActionButton(footer, "删除所选", delete_selected, kind="danger").pack(side="left", padx=(8, 0))
reload_data()
show_detail()
def _open_ai_config(self):
try:
import ai_config
except Exception as exc:
messagebox.showerror("无法读取 AI 配置", str(exc), parent=self)
return
window = tk.Toplevel(self)
window.title("AI 高级配置")
window.geometry(self._scaled_geometry("720x690"))
window.resizable(False, False)
window.configure(bg=BG)
window.transient(self)
window.grab_set()
self._apply_dark_titlebar(window)
card = Card(window, "AI 高级配置", "保存后立即生效,并写入 ai_settings.json。", bg=BG)
card.pack(fill="both", expand=True, padx=16, pady=16)
body = card.body
fields = (
("本机客服名称(留空跟随云端)", "AI_AGENT_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)
initial = (
ai_config.local_agent_name_override()
if key == "AI_AGENT_NAME"
else str(getattr(ai_config, key, ""))
)
variable = tk.StringVar(value=initial)
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))
ui_guard_var = tk.BooleanVar(
value=bool(getattr(ai_config, "AI_UI_GUARD_ENABLED", True))
)
Toggle(
body,
ui_guard_var,
"启用 AI 页面守护(仅在页面异常时调用视觉模型)",
).grid(row=len(fields) + 1, column=0, columnspan=2, sticky="w", pady=(8, 0))
status = tk.Label(
body,
text="",
bg=PANEL,
fg=DANGER,
font=TYPE_SMALL,
anchor="w",
)
status.grid(row=len(fields) + 2, column=0, columnspan=2, sticky="ew", pady=(10, 0))
buttons = tk.Frame(body, bg=PANEL)
buttons.grid(row=len(fields) + 3, 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()
if key == "AI_AGENT_NAME":
ai_config.set_local_agent_name_override(raw)
continue
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_UI_GUARD_ENABLED = ui_guard_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
try:
batch_window_seconds = float(self._batch_window_var.get().strip())
if not (
MESSAGE_BATCH_WINDOW_MIN_SECONDS
<= batch_window_seconds
<= MESSAGE_BATCH_WINDOW_MAX_SECONDS
):
raise ValueError
except ValueError:
messagebox.showerror(
"参数错误",
"连续消息合并等待必须是 1–120 秒之间的数字。",
parent=self,
)
self._show_page("通用设置")
return
self._save_ai_switches()
self._stdout_proxy = LogQueue(
self._queue,
retention_days=self._runtime_settings.get("log_retention_days"),
)
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,
message_batch_window_seconds=batch_window_seconds,
send_delay_seconds=normalize_send_delay_seconds(
self._runtime_settings.get("send_delay_seconds", SEND_DELAY_SECONDS)
),
send_mode=normalize_send_mode(
self._runtime_settings.get("send_mode", SEND_MODE_AUTO)
),
enable_engine_b=bool(
self._runtime_settings.get("enable_engine_b", True)
),
engine_b_poll_interval=float(
self._runtime_settings.get("engine_b_poll_interval", 2.0)
),
)
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
if self._stdout_proxy is not None:
self._stdout_proxy.close()
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,
"verification": 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": "请查看运行日志",
"verification": "请用手机企业微信扫码验证,完成后重新开始监听",
"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)
# 界面日志只留近况,全量在磁盘副本里;不裁的话跑一整天后每次追加
# 都要重排几万行文本,界面越用越卡。
overflow = int(self._log.index("end-1c").split(".")[0]) - 2000
if overflow > 0:
self._log.delete("1.0", f"{overflow + 1}.0")
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 == "verification":
self._finish_thread("verification")
self._set_status("verification", "需要扫码验证")
self._dashboard_state.configure(text="需要扫码验证", fg=DANGER)
elif data == "stopped":
self._finish_thread("stopped")
elif kind == "progress":
self._update_capsule_progress(data)
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 == "desk_done":
session_id, result, error = data
self._desk_finished(session_id, result, error)
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")
for diagnostic in data.get("diagnostics") or []:
self._append(str(diagnostic), "notify")
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 handle_startup_update(release):
"""经典界面的启动升级提示;False 表示本次应退出。"""
status = release_status(release)
if not status["update_available"]:
return True
forced = status["force_upgrade"]
root = tk.Tk()
_set_window_icon(root)
root.withdraw()
try:
text = (
f"当前版本:v{status['local_version']}\n"
f"云端版本:v{status['latest_version']}\n\n"
)
if status["release_notes"]:
text += f"更新内容:\n{status['release_notes'][:1200]}\n\n"
text += (
"管理员已设置强制升级,升级前无法继续使用。\n\n"
"选择“是”前往下载,选择“否”退出软件。"
if forced
else "是否现在前往下载新版本?选择“否”可继续使用当前版本。"
)
upgrade = messagebox.askyesno(
"必须升级" if forced else "发现新版本", text, parent=root
)
if upgrade:
if not status["download_url"]:
messagebox.showwarning(
"无法打开下载地址", "管理员尚未配置升级下载地址。", parent=root
)
else:
webbrowser.open(status["download_url"])
return not forced
finally:
root.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
run_classic_ui()
def run_classic_ui():
"""Tk 经典界面;仅在显式要求或 PySide6 不可用时才走到这里。"""
startup_result = {}
try:
import backend_client
startup_result = backend_client.startup_sync_config(timeout=3.0)
except Exception:
# 云端暂时不可用时仍执行上次成功同步的强制升级策略。
startup_result = {"release": backend_client.cached_release_status()}
if not handle_startup_update(startup_result.get("release")):
return
app = App()
for diagnostic in startup_result.get("diagnostics") or []:
app._append(str(diagnostic), "notify")
from archive_auto_backup import start_auto_backup
start_auto_backup()
app.protocol("WM_DELETE_WINDOW", app.on_close)
app.mainloop()
if __name__ == "__main__":
main()