4130 lines
149 KiB
Python
4130 lines
149 KiB
Python
# -*- 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()
|