#!/usr/bin/env python3

from __future__ import annotations

import configparser
import json
import os
import subprocess
import sys
import threading
import time
import tkinter as tk
import urllib.error
import urllib.request
from pathlib import Path
from tkinter import messagebox, simpledialog, ttk


IS_WINDOWS = sys.platform.startswith("win")
IS_MAC = sys.platform == "darwin"
IS_FROZEN = bool(getattr(sys, "frozen", False))
APP_NAME = "只属于你"
ROOT = Path(sys.executable).resolve().parent if IS_FROZEN else Path(__file__).resolve().parent
if IS_MAC:
    STATE_ROOT = Path.home() / "Library" / "Application Support" / "桌宠"
elif IS_WINDOWS and IS_FROZEN:
    appdata = os.getenv("APPDATA", "").strip()
    STATE_ROOT = Path(appdata) / "桌宠" if appdata else Path.home() / "AppData" / "Roaming" / "桌宠"
else:
    STATE_ROOT = ROOT
STATE_ROOT.mkdir(parents=True, exist_ok=True)
CONFIG_PATH = (STATE_ROOT if IS_FROZEN else ROOT) / "pet_config.ini"
LOG_ROOT = ROOT / "logs" / "execution-log" if IS_WINDOWS and not IS_FROZEN else STATE_ROOT
LOG_ROOT.mkdir(parents=True, exist_ok=True)
LOG_PATH = LOG_ROOT / "start_desktop_pet.log"
ERR_PATH = LOG_ROOT / "start_desktop_pet.err"
FORMAL_DIR = ROOT / "发布包"
PREVIEW_DIR = (
    STATE_ROOT / "动作预览"
    if IS_FROZEN
    else ROOT / ".tmp" / "动作预览"
)
ACTIVITY_LABELS = {
    "quiet": "安静",
    "normal": "陪伴",
    "lively": "活泼",
}
ACTIVITY_VALUES = {label: key for key, label in ACTIVITY_LABELS.items()}
ACTIVITY_INTERVALS = {
    "quiet": ("900", "1800"),
    "normal": ("420", "900"),
    "lively": ("180", "420"),
}
MOTION_DETAIL_LABELS = {
    "stable": "稳定",
    "natural": "自然",
    "expressive": "明显",
}
MOTION_DETAIL_VALUES = {label: key for key, label in MOTION_DETAIL_LABELS.items()}
ACTION_LABELS = {
    "rest": "站姿",
    "breathe": "轻呼吸",
    "blink": "眨眼",
    "happy": "开心",
    "wave": "挥手",
    "sleep": "睡觉",
    "nod": "点头",
    "shake": "摇头",
    "curious": "歪头看看",
    "listen": "认真听",
    "shy": "害羞",
    "stretch": "伸懒腰",
    "walk": "走两步",
    "drag": "拖动",
}
REPLY_STYLE_LABELS = {
    "warm": "温柔",
    "lively": "活泼",
    "short": "简短",
    "clingy": "黏人",
    "useful": "实用",
}
REPLY_STYLE_VALUES = {label: key for key, label in REPLY_STYLE_LABELS.items()}
REPLY_LENGTH_LABELS = {
    "short": "很短",
    "normal": "正常",
    "long": "多说一点",
}
REPLY_LENGTH_VALUES = {label: key for key, label in REPLY_LENGTH_LABELS.items()}
REPLY_LENGTH_TOKENS = {
    "short": "80",
    "normal": "120",
    "long": "180",
}


def mac_state_root() -> Path:
    return STATE_ROOT if IS_MAC else Path.home() / "Library" / "Application Support" / "桌宠"


def read_config() -> configparser.ConfigParser:
    cfg = configparser.ConfigParser()
    cfg.read(CONFIG_PATH, encoding="utf-8-sig")
    if not cfg.has_section("DEFAULT") and "DEFAULT" not in cfg:
        cfg["DEFAULT"] = {}
    return cfg


def read_config_text() -> str:
    if CONFIG_PATH.exists():
        return CONFIG_PATH.read_text(encoding="utf-8-sig")
    return "[DEFAULT]\n"


def write_config_text(text: str) -> None:
    CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
    CONFIG_PATH.write_text(text, encoding="utf-8")


def set_ini_value(text: str, key: str, value: str) -> str:
    lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
    prefix = f"{key}="
    out: list[str] = []
    found = False
    has_default = any(line.strip().lower() == "[default]" for line in lines)
    if not has_default:
        out.append("[DEFAULT]")
    for line in lines:
        if line.lstrip().startswith(prefix):
            if not found:
                out.append(f"{key}={value}")
                found = True
            continue
        if line or out:
            out.append(line)
    if not found:
        out.append(f"{key}={value}")
    return "\n".join(out).rstrip() + "\n"


def config_value(key: str, fallback: str = "") -> str:
    cfg = read_config()
    return cfg.get("DEFAULT", key, fallback=fallback).strip()


def config_bool(key: str, fallback: bool = False) -> bool:
    value = config_value(key, "true" if fallback else "false").lower()
    if value in {"1", "true", "yes", "on", "y"}:
        return True
    if value in {"0", "false", "no", "off", "n"}:
        return False
    return fallback


def opacity_percent() -> int:
    raw = config_value("PET_OPACITY", "98")
    try:
        value = float(raw)
    except ValueError:
        return 98
    if value <= 1:
        value *= 100
    return max(35, min(100, int(round(value))))


def activity_mode() -> str:
    value = config_value("PET_ACTIVITY_MODE", "normal").lower()
    return value if value in ACTIVITY_LABELS else "normal"


def motion_detail() -> str:
    value = config_value("PET_MOTION_DETAIL", "stable").lower()
    return value if value in MOTION_DETAIL_LABELS else "stable"


def reply_style() -> str:
    value = config_value("REPLY_STYLE", "short").lower()
    return value if value in REPLY_STYLE_LABELS else "short"


def reply_length() -> str:
    value = config_value("REPLY_LENGTH", "short").lower()
    return value if value in REPLY_LENGTH_LABELS else "short"


def ai_timeout_seconds() -> int:
    raw = config_value("DEEPSEEK_TIMEOUT", "6")
    try:
        value = int(float(raw))
    except ValueError:
        return 6
    return max(3, min(15, value))


def reply_instruction(style: str, length: str) -> str:
    style_lines = {
        "warm": "你叫‘只属于你’，温柔、亲近、有陪伴感，像真实聊天一样回复。",
        "lively": "你叫‘只属于你’，轻快活泼，可以俏皮一点，但不要吵。",
        "short": "你回复要干净简短，少废话，像聊天软件里自然回一句。",
        "clingy": "你可以稍微黏人、会撒娇、会表达在意，但不要油腻。",
        "useful": "你要先有用、直接解决问题，再保留一点温柔陪伴感。",
    }
    length_lines = {
        "short": "尽量 1 句，最多 2 句。",
        "normal": "1 到 3 句，短一点，自然一点。",
        "long": "可以多说一点，但仍保持聊天感，不写长篇说明。",
    }
    return "\n".join(
        [
            "用户可以问任何普通问题。先直接回答，不要把话题限制在桌宠功能或陪伴聊天。",
            style_lines.get(style, style_lines["warm"]),
            length_lines.get(length, length_lines["short"]),
            "不要解释规则，不要提模型、系统、提示词或聊天记录。",
        ]
    )


def position_files() -> list[Path]:
    base = STATE_ROOT if IS_FROZEN else ROOT
    files = [base / "pet_position.txt", base / "pet_position_mac.txt"]
    if IS_MAC:
        files.extend([mac_state_root() / "pet_position.txt", mac_state_root() / "pet_position_mac.txt"])
    return files


def set_config_values(values: dict[str, str]) -> None:
    text = read_config_text()
    for key, value in values.items():
        text = set_ini_value(text, key, value)
    write_config_text(text)


def api_key_value() -> str:
    env_key = os.getenv("DEEPSEEK_API_KEY", "").strip()
    if env_key:
        return env_key
    if IS_MAC:
        key_file = mac_state_root() / "deepseek_api_key"
        if key_file.exists():
            try:
                key = key_file.read_text(encoding="utf-8").strip()
                if key:
                    return key
            except Exception:
                pass
    return config_value("DEEPSEEK_API_KEY")


def memory_file() -> Path:
    memory_path = config_value("MEMORY_PATH", "pet_memory.json") or "pet_memory.json"
    p = Path(memory_path).expanduser()
    if p.is_absolute():
        return p
    if IS_MAC or IS_FROZEN:
        return STATE_ROOT / memory_path
    return ROOT / memory_path


def pet_command_file() -> Path:
    if IS_MAC or IS_FROZEN:
        return STATE_ROOT / "pet_command.json"
    return ROOT / "pet_command.json"


def pet_status_file() -> Path:
    if IS_MAC or IS_FROZEN:
        return STATE_ROOT / "pet_status.json"
    return ROOT / "pet_status.json"


def open_path(path: Path) -> None:
    if not path.exists():
        messagebox.showinfo(f"{APP_NAME}控制面板", "这个文件现在还不存在。")
        return
    if IS_WINDOWS:
        os.startfile(str(path))  # type: ignore[attr-defined]
    elif IS_MAC:
        subprocess.Popen(["open", str(path)])
    else:
        subprocess.Popen(["xdg-open", str(path)])


class ControlPanel:
    def __init__(self) -> None:
        self.root = tk.Tk()
        self.root.title(f"{APP_NAME}控制面板")
        self.root.geometry("560x780")
        self.root.minsize(540, 720)
        self.root.configure(bg="#f4f4f2")

        self.status_var = tk.StringVar()
        self.runtime_var = tk.StringVar()
        self.ai_var = tk.StringVar()
        self.chat_var = tk.StringVar()
        self.memory_var = tk.StringVar()
        self.size_var = tk.StringVar()
        self.opacity_var = tk.StringVar()
        self.topmost_var = tk.BooleanVar(value=True)
        self.activity_var = tk.StringVar(value=ACTIVITY_LABELS["normal"])
        self.motion_detail_var = tk.StringVar(value=MOTION_DETAIL_LABELS["stable"])
        self.auto_big_action_var = tk.BooleanVar(value=False)
        self.idle_breathe_var = tk.BooleanVar(value=False)
        self.reply_style_var = tk.StringVar(value=REPLY_STYLE_LABELS["short"])
        self.reply_length_var = tk.StringVar(value=REPLY_LENGTH_LABELS["short"])
        self.auto_chat_var = tk.BooleanVar(value=True)
        self.memory_enabled_var = tk.BooleanVar(value=True)
        self.ai_timeout_var = tk.StringVar(value="6")
        self.test_var = tk.StringVar(value="AI 连接还没有测试。")
        self.quick_chat_var = tk.StringVar()

        self._build_ui()
        self.refresh_status()
        self._schedule_runtime_refresh()

    def _build_ui(self) -> None:
        header = tk.Frame(self.root, bg="#222222")
        header.pack(fill="x")
        tk.Label(
            header,
            text=f"{APP_NAME}控制面板",
            bg="#222222",
            fg="#ffffff",
            font=("Microsoft YaHei UI", 18, "bold"),
            padx=18,
            pady=14,
        ).pack(anchor="w")

        scroll_shell = tk.Frame(self.root, bg="#f4f4f2")
        scroll_shell.pack(fill="both", expand=True)
        self._scroll_canvas = tk.Canvas(scroll_shell, bg="#f4f4f2", highlightthickness=0)
        scrollbar = tk.Scrollbar(scroll_shell, orient="vertical", command=self._scroll_canvas.yview)
        self._scroll_canvas.configure(yscrollcommand=scrollbar.set)
        scrollbar.pack(side="right", fill="y")
        self._scroll_canvas.pack(side="left", fill="both", expand=True)

        body = tk.Frame(self._scroll_canvas, bg="#f4f4f2", padx=18, pady=16)
        self._scroll_window = self._scroll_canvas.create_window((0, 0), window=body, anchor="nw")
        body.bind("<Configure>", self._sync_scroll_region)
        self._scroll_canvas.bind("<Configure>", self._sync_scroll_width)
        self.root.bind_all("<MouseWheel>", self._on_mousewheel, add="+")
        self.root.bind_all("<Button-4>", self._on_mousewheel, add="+")
        self.root.bind_all("<Button-5>", self._on_mousewheel, add="+")

        info = tk.Frame(body, bg="#ffffff", bd=0, highlightthickness=1, highlightbackground="#dddddd")
        info.pack(fill="x", pady=(0, 14))
        for text_var in (self.status_var, self.runtime_var, self.ai_var, self.chat_var, self.memory_var):
            tk.Label(
                info,
                textvariable=text_var,
                bg="#ffffff",
                fg="#222222",
                font=("Microsoft YaHei UI", 10),
                anchor="w",
                justify="left",
                wraplength=500,
                padx=12,
                pady=5,
            ).pack(fill="x")

        actions = tk.Frame(body, bg="#f4f4f2")
        actions.pack(fill="x")
        self._button(actions, f"启动{APP_NAME}", self.start_pet, 0, 0, primary=True)
        self._button(actions, f"停止{APP_NAME}", self.stop_pet, 0, 1)
        self._button(actions, f"重启{APP_NAME}", self.restart_pet, 0, 2)
        self._button(actions, "设置 AI Key", self.set_api_key, 1, 0)
        self._button(actions, "清除 AI Key", self.clear_api_key, 1, 1)
        self._button(actions, "测试 AI 连接", self.test_ai, 1, 2)
        self._button(actions, "查看记忆", self.view_memory, 2, 0)
        self._button(actions, "清除记忆", self.clear_memory, 2, 1)
        self._button(actions, "打开日志", self.open_logs, 2, 2)
        self._button(actions, "打开文件夹", lambda: open_path(STATE_ROOT if IS_FROZEN else ROOT), 3, 0)
        self._button(actions, "立即安静", lambda: self.set_runtime_mode("quiet"), 3, 1)
        self._button(actions, "恢复陪伴", lambda: self.set_runtime_mode("normal"), 3, 2)
        self._button(actions, "活泼一下", lambda: self.set_runtime_mode("lively"), 4, 0)
        self._button(actions, "站好", lambda: self.set_runtime_mode("stand"), 4, 1)
        for col in range(3):
            actions.grid_columnconfigure(col, weight=1, uniform="actions")

        size_box = tk.Frame(body, bg="#ffffff", bd=0, highlightthickness=1, highlightbackground="#dddddd")
        size_box.pack(fill="x", pady=14)
        tk.Label(
            size_box,
            text="外观和动作",
            bg="#ffffff",
            fg="#222222",
            font=("Microsoft YaHei UI", 11, "bold"),
            padx=12,
            pady=8,
        ).pack(anchor="w")
        row = tk.Frame(size_box, bg="#ffffff", padx=12, pady=0)
        row.pack(fill="x", pady=(0, 12))
        tk.Label(
            row,
            text="大小",
            bg="#ffffff",
            fg="#333333",
            font=("Microsoft YaHei UI", 10),
        ).grid(row=0, column=0, sticky="w", pady=4)
        self.size_entry = tk.Spinbox(
            row,
            from_=100,
            to=320,
            increment=10,
            textvariable=self.size_var,
            width=7,
            font=("Microsoft YaHei UI", 11),
        )
        self.size_entry.grid(row=0, column=1, sticky="w", padx=(8, 18), pady=4)
        tk.Label(
            row,
            text="透明度",
            bg="#ffffff",
            fg="#333333",
            font=("Microsoft YaHei UI", 10),
        ).grid(row=0, column=2, sticky="w", pady=4)
        self.opacity_entry = tk.Spinbox(
            row,
            from_=35,
            to=100,
            increment=5,
            textvariable=self.opacity_var,
            width=7,
            font=("Microsoft YaHei UI", 11),
        )
        self.opacity_entry.grid(row=0, column=3, sticky="w", padx=(8, 4), pady=4)
        tk.Label(
            row,
            text="%",
            bg="#ffffff",
            fg="#666666",
            font=("Microsoft YaHei UI", 10),
        ).grid(row=0, column=4, sticky="w", pady=4)
        tk.Checkbutton(
            row,
            text="始终置顶",
            variable=self.topmost_var,
            bg="#ffffff",
            fg="#333333",
            activebackground="#ffffff",
            font=("Microsoft YaHei UI", 10),
        ).grid(row=1, column=0, columnspan=2, sticky="w", pady=4)
        tk.Label(
            row,
            text="动作模式",
            bg="#ffffff",
            fg="#333333",
            font=("Microsoft YaHei UI", 10),
        ).grid(row=1, column=2, sticky="w", pady=4)
        mode_menu = tk.OptionMenu(row, self.activity_var, *ACTIVITY_VALUES.keys())
        mode_menu.configure(font=("Microsoft YaHei UI", 10), width=6)
        mode_menu.grid(row=1, column=3, columnspan=2, sticky="w", padx=(8, 0), pady=4)
        tk.Label(
            row,
            text="动作细腻度",
            bg="#ffffff",
            fg="#333333",
            font=("Microsoft YaHei UI", 10),
        ).grid(row=2, column=0, sticky="w", pady=4)
        detail_menu = tk.OptionMenu(row, self.motion_detail_var, *MOTION_DETAIL_VALUES.keys())
        detail_menu.configure(font=("Microsoft YaHei UI", 10), width=6)
        detail_menu.grid(row=2, column=1, sticky="w", padx=(8, 18), pady=4)
        tk.Checkbutton(
            row,
            text="自动大动作（低频）",
            variable=self.auto_big_action_var,
            bg="#ffffff",
            fg="#333333",
            activebackground="#ffffff",
            font=("Microsoft YaHei UI", 10),
        ).grid(row=2, column=2, columnspan=3, sticky="w", pady=4)
        tk.Checkbutton(
            row,
            text="空闲慢呼吸",
            variable=self.idle_breathe_var,
            bg="#ffffff",
            fg="#333333",
            activebackground="#ffffff",
            font=("Microsoft YaHei UI", 10),
        ).grid(row=3, column=0, columnspan=2, sticky="w", pady=4)
        button_row = tk.Frame(size_box, bg="#ffffff", padx=12, pady=0)
        button_row.pack(fill="x", pady=(0, 12))
        tk.Button(
            button_row,
            text="保存外观和动作",
            command=self.save_appearance,
            font=("Microsoft YaHei UI", 10),
            padx=14,
            pady=4,
        ).pack(side="left")
        tk.Label(
            button_row,
            text=f"保存后重启{APP_NAME}生效。",
            bg="#ffffff",
            fg="#666666",
            font=("Microsoft YaHei UI", 9),
        ).pack(side="left", padx=10)
        tk.Button(
            button_row,
            text="重置位置",
            command=self.reset_position,
            font=("Microsoft YaHei UI", 10),
            padx=14,
            pady=4,
        ).pack(side="right")
        test_row = tk.Frame(size_box, bg="#ffffff", padx=12, pady=0)
        test_row.pack(fill="x", pady=(0, 4))
        tk.Label(
            test_row,
            text="动作测试",
            bg="#ffffff",
            fg="#333333",
            font=("Microsoft YaHei UI", 10),
        ).pack(side="left", padx=(0, 8))
        for label, action_name in [
            ("轻呼吸", "breathe"),
            ("眨眼", "blink"),
            ("开心", "happy"),
            ("挥手", "wave"),
        ]:
            tk.Button(
                test_row,
                text=label,
                command=lambda name=action_name: self.test_pet_action(name),
                font=("Microsoft YaHei UI", 9),
                padx=10,
                pady=3,
            ).pack(side="left", padx=3)
        test_row_2 = tk.Frame(size_box, bg="#ffffff", padx=12, pady=0)
        test_row_2.pack(fill="x", pady=(0, 12))
        tk.Label(
            test_row_2,
            text="",
            bg="#ffffff",
            font=("Microsoft YaHei UI", 10),
            width=7,
        ).pack(side="left", padx=(0, 8))
        for label, action_name in [
            ("睡觉", "sleep"),
            ("点头", "nod"),
            ("摇头", "shake"),
            ("歪头", "curious"),
        ]:
            tk.Button(
                test_row_2,
                text=label,
                command=lambda name=action_name: self.test_pet_action(name),
                font=("Microsoft YaHei UI", 9),
                padx=10,
                pady=3,
            ).pack(side="left", padx=3)
        test_row_extra = tk.Frame(size_box, bg="#ffffff", padx=12, pady=0)
        test_row_extra.pack(fill="x", pady=(0, 12))
        tk.Label(
            test_row_extra,
            text="",
            bg="#ffffff",
            font=("Microsoft YaHei UI", 10),
            width=7,
        ).pack(side="left", padx=(0, 8))
        for label, action_name in [
            ("认真听", "listen"),
            ("害羞", "shy"),
            ("伸懒腰", "stretch"),
            ("走两步", "walk"),
        ]:
            tk.Button(
                test_row_extra,
                text=label,
                command=lambda name=action_name: self.test_pet_action(name),
                font=("Microsoft YaHei UI", 9),
                padx=10,
                pady=3,
            ).pack(side="left", padx=3)
        test_row_3 = tk.Frame(size_box, bg="#ffffff", padx=12, pady=0)
        test_row_3.pack(fill="x", pady=(0, 12))
        tk.Label(
            test_row_3,
            text="组合动作",
            bg="#ffffff",
            fg="#333333",
            font=("Microsoft YaHei UI", 10),
        ).pack(side="left", padx=(0, 8))
        tk.Button(
            test_row_3,
            text="随机互动",
            command=lambda: self.test_pet_sequence("random"),
            font=("Microsoft YaHei UI", 9),
            padx=10,
            pady=3,
        ).pack(side="left", padx=3)
        for label, sequence_name in [
            ("打招呼", "greet"),
            ("收到啦", "ack"),
            ("慢慢呼吸", "calm_breathe"),
            ("晚安", "goodnight"),
        ]:
            tk.Button(
                test_row_3,
                text=label,
                command=lambda name=sequence_name: self.test_pet_sequence(name),
                font=("Microsoft YaHei UI", 9),
                padx=10,
                pady=3,
            ).pack(side="left", padx=3)
        test_row_4 = tk.Frame(size_box, bg="#ffffff", padx=12, pady=0)
        test_row_4.pack(fill="x", pady=(0, 12))
        tk.Label(
            test_row_4,
            text="",
            bg="#ffffff",
            font=("Microsoft YaHei UI", 10),
            width=7,
        ).pack(side="left", padx=(0, 8))
        for label, sequence_name in [
            ("动作巡检", "patrol"),
        ]:
            tk.Button(
                test_row_4,
                text=label,
                command=lambda name=sequence_name: self.test_pet_sequence(name),
                font=("Microsoft YaHei UI", 9),
                padx=10,
                pady=3,
            ).pack(side="left", padx=3)
        test_row_5 = tk.Frame(size_box, bg="#ffffff", padx=12, pady=0)
        test_row_5.pack(fill="x", pady=(0, 12))
        tk.Label(
            test_row_5,
            text="",
            bg="#ffffff",
            font=("Microsoft YaHei UI", 10),
            width=7,
        ).pack(side="left", padx=(0, 8))
        for label, sequence_name in [
            ("摸摸头", "head_pat"),
        ]:
            tk.Button(
                test_row_5,
                text=label,
                command=lambda name=sequence_name: self.test_pet_sequence(name),
                font=("Microsoft YaHei UI", 9),
                padx=10,
                pady=3,
            ).pack(side="left", padx=3)
        test_row_6 = tk.Frame(size_box, bg="#ffffff", padx=12, pady=0)
        test_row_6.pack(fill="x", pady=(0, 12))
        tk.Label(
            test_row_6,
            text="等待动作",
            bg="#ffffff",
            fg="#333333",
            font=("Microsoft YaHei UI", 10),
        ).pack(side="left", padx=(0, 8))
        for label, reaction_name in [
            ("普通等待", "neutral"),
            ("问题等待", "question"),
            ("帮忙等待", "help"),
            ("夸奖等待", "praise"),
            ("感谢等待", "thanks"),
        ]:
            tk.Button(
                test_row_6,
                text=label,
                command=lambda name=reaction_name: self.test_wait_motion(name),
                font=("Microsoft YaHei UI", 9),
                padx=10,
                pady=3,
            ).pack(side="left", padx=3)
        test_row_7 = tk.Frame(size_box, bg="#ffffff", padx=12, pady=0)
        test_row_7.pack(fill="x", pady=(0, 12))
        tk.Label(
            test_row_7,
            text="回复动作",
            bg="#ffffff",
            fg="#333333",
            font=("Microsoft YaHei UI", 10),
        ).pack(side="left", padx=(0, 8))
        for label, reaction_name in [
            ("普通回复", "neutral"),
            ("夸奖回复", "praise"),
            ("开心回复", "happy"),
            ("安慰回复", "comfort"),
            ("纠正回复", "correct"),
        ]:
            tk.Button(
                test_row_7,
                text=label,
                command=lambda name=reaction_name: self.test_reply_motion(name),
                font=("Microsoft YaHei UI", 9),
                padx=10,
                pady=3,
            ).pack(side="left", padx=3)
        preview_row = tk.Frame(size_box, bg="#ffffff", padx=12, pady=0)
        preview_row.pack(fill="x", pady=(0, 12))
        tk.Button(
            preview_row,
            text="生成动作预览图",
            command=self.generate_action_preview,
            font=("Microsoft YaHei UI", 10),
            padx=14,
            pady=4,
        ).pack(side="left")
        tk.Button(
            preview_row,
            text="打开预览",
            command=self.open_action_preview,
            font=("Microsoft YaHei UI", 10),
            padx=12,
            pady=4,
        ).pack(side="left", padx=(8, 0))
        tk.Button(
            preview_row,
            text="打开组合预览",
            command=self.open_sequence_preview,
            font=("Microsoft YaHei UI", 10),
            padx=12,
            pady=4,
        ).pack(side="left", padx=(8, 0))
        tk.Button(
            preview_row,
            text="打开聊天预览",
            command=self.open_chat_motion_preview,
            font=("Microsoft YaHei UI", 10),
            padx=12,
            pady=4,
        ).pack(side="left", padx=(8, 0))
        tk.Button(
            preview_row,
            text="打开切换预览",
            command=self.open_transition_preview,
            font=("Microsoft YaHei UI", 10),
            padx=12,
            pady=4,
        ).pack(side="left", padx=(8, 0))
        tk.Button(
            preview_row,
            text="打开体检数据",
            command=self.open_action_report,
            font=("Microsoft YaHei UI", 10),
            padx=12,
            pady=4,
        ).pack(side="left", padx=(8, 0))
        tk.Label(
            preview_row,
            text="检查动画、锚点和脚底是否平稳。",
            bg="#ffffff",
            fg="#666666",
            font=("Microsoft YaHei UI", 9),
        ).pack(side="left", padx=10)

        chat_box = tk.Frame(body, bg="#ffffff", bd=0, highlightthickness=1, highlightbackground="#dddddd")
        chat_box.pack(fill="x", pady=(0, 14))
        tk.Label(
            chat_box,
            text="聊天体验",
            bg="#ffffff",
            fg="#222222",
            font=("Microsoft YaHei UI", 11, "bold"),
            padx=12,
            pady=8,
        ).pack(anchor="w")
        chat_row = tk.Frame(chat_box, bg="#ffffff", padx=12, pady=0)
        chat_row.pack(fill="x", pady=(0, 12))
        tk.Label(
            chat_row,
            text="回复风格",
            bg="#ffffff",
            fg="#333333",
            font=("Microsoft YaHei UI", 10),
        ).grid(row=0, column=0, sticky="w", pady=4)
        style_menu = tk.OptionMenu(chat_row, self.reply_style_var, *REPLY_STYLE_VALUES.keys())
        style_menu.configure(font=("Microsoft YaHei UI", 10), width=7)
        style_menu.grid(row=0, column=1, sticky="w", padx=(8, 18), pady=4)
        tk.Label(
            chat_row,
            text="回复长度",
            bg="#ffffff",
            fg="#333333",
            font=("Microsoft YaHei UI", 10),
        ).grid(row=0, column=2, sticky="w", pady=4)
        length_menu = tk.OptionMenu(chat_row, self.reply_length_var, *REPLY_LENGTH_VALUES.keys())
        length_menu.configure(font=("Microsoft YaHei UI", 10), width=8)
        length_menu.grid(row=0, column=3, sticky="w", padx=(8, 18), pady=4)
        tk.Label(
            chat_row,
            text="AI 等待",
            bg="#ffffff",
            fg="#333333",
            font=("Microsoft YaHei UI", 10),
        ).grid(row=0, column=4, sticky="w", pady=4)
        self.ai_timeout_entry = tk.Spinbox(
            chat_row,
            from_=3,
            to=15,
            increment=1,
            textvariable=self.ai_timeout_var,
            width=5,
            font=("Microsoft YaHei UI", 11),
        )
        self.ai_timeout_entry.grid(row=0, column=5, sticky="w", padx=(8, 4), pady=4)
        tk.Label(
            chat_row,
            text="秒",
            bg="#ffffff",
            fg="#666666",
            font=("Microsoft YaHei UI", 10),
        ).grid(row=0, column=6, sticky="w", pady=4)
        tk.Checkbutton(
            chat_row,
            text="主动发言",
            variable=self.auto_chat_var,
            bg="#ffffff",
            fg="#333333",
            activebackground="#ffffff",
            font=("Microsoft YaHei UI", 10),
        ).grid(row=1, column=0, columnspan=2, sticky="w", pady=4)
        tk.Checkbutton(
            chat_row,
            text="记忆功能",
            variable=self.memory_enabled_var,
            bg="#ffffff",
            fg="#333333",
            activebackground="#ffffff",
            font=("Microsoft YaHei UI", 10),
        ).grid(row=1, column=2, columnspan=2, sticky="w", pady=4)
        chat_button_row = tk.Frame(chat_box, bg="#ffffff", padx=12, pady=0)
        chat_button_row.pack(fill="x", pady=(0, 12))
        tk.Button(
            chat_button_row,
            text="保存聊天体验",
            command=self.save_chat_settings,
            font=("Microsoft YaHei UI", 10),
            padx=14,
            pady=4,
        ).pack(side="left")
        tk.Label(
            chat_button_row,
            text=f"保存后重启{APP_NAME}生效。",
            bg="#ffffff",
            fg="#666666",
            font=("Microsoft YaHei UI", 9),
        ).pack(side="left", padx=10)
        tk.Button(
            chat_button_row,
            text="测试一句话",
            command=self.test_one_reply,
            font=("Microsoft YaHei UI", 10),
            padx=14,
            pady=4,
        ).pack(side="right")

        direct_chat_row = tk.Frame(chat_box, bg="#ffffff", padx=12, pady=0)
        direct_chat_row.pack(fill="x", pady=(0, 12))
        tk.Label(
            direct_chat_row,
            text="直接和她说",
            bg="#ffffff",
            fg="#333333",
            font=("Microsoft YaHei UI", 10),
        ).pack(side="left")
        self.quick_chat_entry = tk.Entry(
            direct_chat_row,
            textvariable=self.quick_chat_var,
            font=("Microsoft YaHei UI", 11),
            relief="solid",
            bd=1,
        )
        self.quick_chat_entry.pack(side="left", fill="x", expand=True, padx=8)
        self.quick_chat_entry.bind("<Return>", lambda _event: self.send_chat_to_pet())
        tk.Button(
            direct_chat_row,
            text="发送",
            command=self.send_chat_to_pet,
            font=("Microsoft YaHei UI", 10),
            padx=12,
            pady=3,
        ).pack(side="left", padx=(0, 6))
        tk.Button(
            direct_chat_row,
            text="打开输入框",
            command=self.open_pet_chat_entry,
            font=("Microsoft YaHei UI", 10),
            padx=12,
            pady=3,
        ).pack(side="left")

        test_box = tk.Frame(body, bg="#ffffff", bd=0, highlightthickness=1, highlightbackground="#dddddd")
        test_box.pack(fill="both", expand=True)
        tk.Label(
            test_box,
            text="状态提示",
            bg="#ffffff",
            fg="#222222",
            font=("Microsoft YaHei UI", 11, "bold"),
            padx=12,
            pady=8,
        ).pack(anchor="w")
        tk.Label(
            test_box,
            textvariable=self.test_var,
            bg="#ffffff",
            fg="#333333",
            font=("Microsoft YaHei UI", 10),
            anchor="nw",
            justify="left",
            wraplength=500,
            padx=12,
            pady=6,
        ).pack(fill="both", expand=True)

        footer = tk.Frame(body, bg="#f4f4f2")
        footer.pack(fill="x", pady=(12, 0))
        tk.Button(
            footer,
            text="打开正式包目录",
            command=lambda: open_path(FORMAL_DIR),
            font=("Microsoft YaHei UI", 10),
            padx=12,
            pady=5,
        ).pack(side="left")
        tk.Button(
            footer,
            text="刷新状态",
            command=self.refresh_status,
            font=("Microsoft YaHei UI", 10),
            padx=12,
            pady=5,
        ).pack(side="right")

    def _button(self, parent, text, command, row, col, primary: bool = False) -> None:
        bg = "#222222" if primary else "#ffffff"
        fg = "#ffffff" if primary else "#222222"
        button = tk.Button(
            parent,
            text=text,
            command=command,
            bg=bg,
            fg=fg,
            activebackground="#444444" if primary else "#eeeeee",
            activeforeground="#ffffff" if primary else "#222222",
            font=("Microsoft YaHei UI", 10, "bold" if primary else "normal"),
            padx=10,
            pady=9,
            relief="flat",
            highlightthickness=1,
            highlightbackground="#cccccc",
        )
        button.grid(row=row, column=col, padx=4, pady=4, sticky="ew")

    def _sync_scroll_region(self, _event=None) -> None:
        if hasattr(self, "_scroll_canvas"):
            self._scroll_canvas.configure(scrollregion=self._scroll_canvas.bbox("all"))

    def _sync_scroll_width(self, event) -> None:
        if hasattr(self, "_scroll_canvas") and hasattr(self, "_scroll_window"):
            self._scroll_canvas.itemconfigure(self._scroll_window, width=event.width)

    def _on_mousewheel(self, event) -> None:
        if not hasattr(self, "_scroll_canvas"):
            return
        try:
            if event.widget.winfo_toplevel() is not self.root:
                return
        except Exception:
            return
        if getattr(event, "num", None) == 4:
            self._scroll_canvas.yview_scroll(-1, "units")
        elif getattr(event, "num", None) == 5:
            self._scroll_canvas.yview_scroll(1, "units")
        elif getattr(event, "delta", 0):
            self._scroll_canvas.yview_scroll(int(-event.delta / 120), "units")

    def _runtime_status_text(self) -> str:
        path = pet_status_file()
        if not path.exists():
            return f"运行：未检测到{APP_NAME}状态"
        try:
            mtime = path.stat().st_mtime
            data = json.loads(path.read_text(encoding="utf-8-sig"))
            if not isinstance(data, dict):
                return "运行：状态文件格式异常"
            updated_at = float(data.get("updated_at", mtime))
            age = max(0.0, time.time() - updated_at)
            if age > 10:
                return f"运行：未运行或状态过期（{int(age)} 秒前）"
            action = str(data.get("action", "rest"))
            mode = str(data.get("mode", "normal"))
            detail = str(data.get("motion_detail", "stable"))
            queue_count = int(data.get("queue_count", 0) or 0)
            sequence_label = str(data.get("sequence_label", "")).strip()
            sequence_caption = str(data.get("sequence_caption", "")).strip()
            ai_busy = bool(data.get("ai_busy", False))
            chat_visible = bool(data.get("chat_visible", False))
            idle_breathe = bool(data.get("idle_breathe", False))
            pid = data.get("pid", "")
            parts = [
                f"运行：已运行",
                f"动作 {ACTION_LABELS.get(action, action)}",
                f"模式 {ACTIVITY_LABELS.get(mode, mode)}",
                f"细腻度 {MOTION_DETAIL_LABELS.get(detail, detail)}",
            ]
            if sequence_label:
                parts.append(f"流程 {sequence_label}")
            if sequence_caption:
                parts.append(f"步骤 {sequence_caption}")
            if queue_count > 0:
                parts.append(f"队列剩余 {queue_count}")
            if idle_breathe and action == "rest" and mode != "quiet":
                parts.append("慢呼吸")
            if ai_busy:
                parts.append("AI 回复中")
            if chat_visible:
                parts.append("气泡显示中")
            if pid:
                parts.append(f"PID {pid}")
            return "    ".join(parts)
        except Exception:
            return "运行：状态读取失败"

    def _refresh_runtime_status(self) -> None:
        self.runtime_var.set(self._runtime_status_text())

    def _schedule_runtime_refresh(self) -> None:
        self._refresh_runtime_status()
        self.root.after(3000, self._schedule_runtime_refresh)

    def refresh_status(self) -> None:
        size = config_value("PET_SIZE", "160") or "160"
        opacity = opacity_percent()
        topmost = config_bool("PET_TOPMOST", True)
        mode = activity_mode()
        detail = motion_detail()
        auto_big_actions = config_bool("PET_AUTO_BIG_ACTIONS", False)
        idle_breathe = config_bool("PET_IDLE_BREATHE", False)
        style = reply_style()
        length = reply_length()
        auto_chat = config_bool("AUTO_CHAT_ENABLED", True)
        memory_enabled = config_bool("MEMORY_ENABLED", True)
        timeout = ai_timeout_seconds()
        key_set = bool(api_key_value())
        mem = memory_file()
        memory_text = "记忆：暂无"
        if mem.exists():
            try:
                data = json.loads(mem.read_text(encoding="utf-8-sig"))
                if isinstance(data, dict):
                    turns_value = data.get("recent_turns", [])
                    turns = len(turns_value) if isinstance(turns_value, list) else (1 if turns_value else 0)
                    facts = 0
                    for key in ("facts", "preferences", "notes"):
                        value = data.get(key, [])
                        facts += len(value) if isinstance(value, list) else (1 if value else 0)
                    memory_text = f"记忆：{turns} 条最近对话，{facts} 条偏好/备注"
                else:
                    memory_text = "记忆：文件格式不正确"
            except Exception:
                memory_text = "记忆：文件存在，但读取失败"
        self.status_var.set(
            f"外观：大小 {size}    透明度 {opacity}%    动作模式：{ACTIVITY_LABELS[mode]}    "
            f"动作细腻度：{MOTION_DETAIL_LABELS[detail]}    自动大动作：{'开' if auto_big_actions else '关'}    "
            f"慢呼吸：{'开' if idle_breathe else '关'}    角色：{APP_NAME}"
        )
        self._refresh_runtime_status()
        self.ai_var.set("AI：已设置" if key_set else "AI：未设置，会使用本地回复")
        self.chat_var.set(
            "聊天：回复风格 "
            f"{REPLY_STYLE_LABELS[style]} / {REPLY_LENGTH_LABELS[length]}    "
            f"主动发言：{'开' if auto_chat else '关'}    "
            f"记忆功能：{'开' if memory_enabled else '关'}    "
            f"AI 等待：{timeout} 秒"
        )
        self.memory_var.set(memory_text)
        self.size_var.set(size)
        self.opacity_var.set(str(opacity))
        self.topmost_var.set(topmost)
        self.activity_var.set(ACTIVITY_LABELS[mode])
        self.motion_detail_var.set(MOTION_DETAIL_LABELS[detail])
        self.auto_big_action_var.set(auto_big_actions)
        self.idle_breathe_var.set(idle_breathe)
        self.reply_style_var.set(REPLY_STYLE_LABELS[style])
        self.reply_length_var.set(REPLY_LENGTH_LABELS[length])
        self.auto_chat_var.set(auto_chat)
        self.memory_enabled_var.set(memory_enabled)
        self.ai_timeout_var.set(str(timeout))

    def start_pet(self) -> None:
        try:
            if IS_FROZEN:
                subprocess.Popen([sys.executable, "--renderer", "bibi"], cwd=str(ROOT))
            elif IS_WINDOWS:
                launcher = ROOT / "启动只属于你.vbs"
                if launcher.exists():
                    subprocess.Popen(["wscript.exe", str(launcher)], cwd=str(ROOT))
                else:
                    subprocess.Popen(["cmd.exe", "/c", str(ROOT / "start_desktop_pet.bat")], cwd=str(ROOT))
            elif IS_MAC:
                launcher = ROOT / "点我启动只属于你.command"
                subprocess.Popen(["/bin/bash", str(launcher)], cwd=str(ROOT))
            else:
                subprocess.Popen([sys.executable, str(ROOT / "launch_pet.py"), "--renderer", "bibi"], cwd=str(ROOT))
            self.test_var.set(f"已发送启动命令。几秒内应该会显示{APP_NAME}。")
        except Exception as exc:
            messagebox.showerror("启动失败", str(exc))

    def stop_pet(self) -> None:
        self.test_var.set(f"正在停止{APP_NAME}。")
        threading.Thread(target=self._stop_pet_worker, daemon=True).start()

    def _run_stop_command(self) -> bool:
        if IS_FROZEN:
            self._write_pet_command("quit")
            pid_file = STATE_ROOT / "pet_process.pid"
            for _ in range(30):
                if not pid_file.exists():
                    return True
                time.sleep(0.1)
            return not pid_file.exists()
        common = {
            "cwd": str(ROOT),
            "timeout": 20,
            "capture_output": True,
            "text": True,
            "encoding": "utf-8",
            "errors": "replace",
        }
        if IS_WINDOWS:
            flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
            if flags:
                common["creationflags"] = flags
            launcher = ROOT / "stop_desktop_pet.bat"
            if launcher.exists():
                completed = subprocess.run(["cmd.exe", "/c", str(launcher)], **common)
            else:
                completed = subprocess.run(["wscript.exe", str(ROOT / "停止只属于你.vbs")], **common)
        elif IS_MAC:
            completed = subprocess.run(["/bin/bash", str(ROOT / "停止只属于你.command")], **common)
        else:
            return False
        return completed.returncode == 0

    def _stop_pet_worker(self) -> None:
        try:
            ok = self._run_stop_command()
            text = f"已停止{APP_NAME}。" if ok else "当前系统暂未配置停止脚本。"
            self.root.after(0, lambda: self.test_var.set(text))
        except Exception as exc:
            message = str(exc)
            self.root.after(0, lambda: messagebox.showerror("停止失败", message))

    def restart_pet(self) -> None:
        self.test_var.set(f"正在重启{APP_NAME}。")
        threading.Thread(target=self._restart_pet_worker, daemon=True).start()

    def _restart_pet_worker(self) -> None:
        try:
            self._run_stop_command()
            time.sleep(0.8)
            self.root.after(0, self.start_pet)
        except Exception as exc:
            message = str(exc)
            self.root.after(0, lambda: messagebox.showerror("重启失败", message))

    def set_api_key(self) -> None:
        key = simpledialog.askstring("设置 AI Key", "请粘贴 DeepSeek API Key：", show="*")
        if not key:
            return
        key = key.strip()
        if IS_MAC:
            root = mac_state_root()
            root.mkdir(parents=True, exist_ok=True)
            key_file = root / "deepseek_api_key"
            key_file.write_text(key, encoding="utf-8")
            try:
                key_file.chmod(0o600)
            except Exception:
                pass
            set_config_values({"DEEPSEEK_API_KEY": ""})
        else:
            set_config_values(
                {
                    "DEEPSEEK_API_KEY": key,
                    "DEEPSEEK_BASE_URL": "https://api.deepseek.com",
                    "DEEPSEEK_MODEL": "deepseek-v4-flash",
                }
            )
        self.refresh_status()
        messagebox.showinfo(f"{APP_NAME}控制面板", f"AI Key 已保存。重启{APP_NAME}后生效。")

    def clear_api_key(self) -> None:
        if not messagebox.askyesno("清除 AI Key", "确定要清除 AI Key 吗？"):
            return
        if IS_MAC:
            key_file = mac_state_root() / "deepseek_api_key"
            if key_file.exists():
                key_file.unlink()
        set_config_values({"DEEPSEEK_API_KEY": ""})
        self.refresh_status()
        messagebox.showinfo(f"{APP_NAME}控制面板", "AI Key 已清除。")

    def save_appearance(self) -> None:
        value = self.size_var.get().strip()
        try:
            size = int(value)
        except ValueError:
            messagebox.showerror("大小不正确", "请输入 100 到 320 之间的数字。")
            return
        if not 100 <= size <= 320:
            messagebox.showerror("大小不正确", "请输入 100 到 320 之间的数字。")
            return
        try:
            opacity = int(float(self.opacity_var.get().strip()))
        except ValueError:
            messagebox.showerror("透明度不正确", "请输入 35 到 100 之间的数字。")
            return
        if not 35 <= opacity <= 100:
            messagebox.showerror("透明度不正确", "请输入 35 到 100 之间的数字。")
            return
        mode = ACTIVITY_VALUES.get(self.activity_var.get(), "normal")
        detail = MOTION_DETAIL_VALUES.get(self.motion_detail_var.get(), "stable")
        interval_min, interval_max = ACTIVITY_INTERVALS[mode]
        set_config_values(
            {
                "PET_SIZE": str(size),
                "PET_OPACITY": str(opacity),
                "PET_TOPMOST": "true" if self.topmost_var.get() else "false",
                "PET_ACTIVITY_MODE": mode,
                "PET_MOTION_DETAIL": detail,
                "PET_AUTO_BIG_ACTIONS": "true" if self.auto_big_action_var.get() else "false",
                "PET_IDLE_BREATHE": "true" if self.idle_breathe_var.get() else "false",
                "AUTO_CHAT_INTERVAL_MIN": interval_min,
                "AUTO_CHAT_INTERVAL_MAX": interval_max,
            }
        )
        self.refresh_status()
        messagebox.showinfo(f"{APP_NAME}控制面板", f"外观和动作已保存。重启{APP_NAME}后生效。")

    def save_size(self) -> None:
        self.save_appearance()

    def reset_position(self) -> None:
        removed = 0
        for path in position_files():
            try:
                if path.exists():
                    path.unlink()
                    removed += 1
            except Exception as exc:
                messagebox.showerror("重置失败", str(exc))
                return
        try:
            self._write_pet_command("reset_position")
        except Exception as exc:
            messagebox.showerror("重置失败", f"位置记录已清理，但通知当前桌宠失败：{exc}")
            return
        self.test_var.set("已请求桌宠立刻回到屏幕内。下次启动也会使用默认位置。")
        if removed:
            messagebox.showinfo(f"{APP_NAME}控制面板", f"位置已重置。正在运行的{APP_NAME}会立刻回到屏幕内。")
        else:
            messagebox.showinfo(f"{APP_NAME}控制面板", f"已通知{APP_NAME}回到屏幕内。")

    def save_chat_settings(self) -> None:
        style = REPLY_STYLE_VALUES.get(self.reply_style_var.get(), "short")
        length = REPLY_LENGTH_VALUES.get(self.reply_length_var.get(), "short")
        try:
            timeout = int(float(self.ai_timeout_var.get().strip()))
        except ValueError:
            messagebox.showerror("AI 等待时间不正确", "请输入 3 到 15 之间的数字。")
            return
        if not 3 <= timeout <= 15:
            messagebox.showerror("AI 等待时间不正确", "请输入 3 到 15 之间的数字。")
            return
        set_config_values(
            {
                "REPLY_STYLE": style,
                "REPLY_LENGTH": length,
                "AUTO_CHAT_ENABLED": "true" if self.auto_chat_var.get() else "false",
                "MEMORY_ENABLED": "true" if self.memory_enabled_var.get() else "false",
                "DEEPSEEK_TIMEOUT": str(timeout),
                "DEEPSEEK_MAX_TOKENS": REPLY_LENGTH_TOKENS[length],
            }
        )
        self.refresh_status()
        messagebox.showinfo(f"{APP_NAME}控制面板", f"聊天体验已保存。重启{APP_NAME}后生效。")

    def _write_pet_command(self, action: str, text: str = "", **extra) -> None:
        path = pet_command_file()
        path.parent.mkdir(parents=True, exist_ok=True)
        payload = {
            "action": action,
            "text": text,
            "created_at": time.time(),
        }
        payload.update(extra)
        tmp = path.with_suffix(".tmp")
        tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
        tmp.replace(path)

    def send_chat_to_pet(self) -> None:
        text = self.quick_chat_var.get().strip()
        if not text:
            self.quick_chat_entry.focus_set()
            return
        try:
            self._write_pet_command("chat", text)
            self.quick_chat_var.set("")
            self.test_var.set("已发送给桌宠。她会在桌面气泡里回复。")
        except Exception as exc:
            messagebox.showerror("发送失败", str(exc))

    def open_pet_chat_entry(self) -> None:
        try:
            self._write_pet_command("open_chat")
            self.test_var.set("已让桌宠打开输入框。")
        except Exception as exc:
            messagebox.showerror("打开失败", str(exc))

    def set_runtime_mode(self, mode: str) -> None:
        labels = {
            "quiet": "安静模式",
            "normal": "陪伴模式",
            "lively": "活泼一下",
            "stand": "站好",
        }
        label = labels.get(mode, "陪伴模式")
        try:
            self._write_pet_command("set_runtime_mode", mode=mode)
            self.test_var.set(f"已切换为{label}。")
        except Exception as exc:
            messagebox.showerror("切换失败", str(exc))

    def test_pet_action(self, action_name: str) -> None:
        try:
            self._write_pet_command("play_action", pet_action=action_name)
            self.test_var.set(f"已触发动作：{ACTION_LABELS.get(action_name, action_name)}。")
        except Exception as exc:
            messagebox.showerror("动作测试失败", str(exc))

    def test_pet_sequence(self, sequence_name: str) -> None:
        labels = {
            "random": "随机互动",
            "greet": "打招呼",
            "ack": "收到啦",
            "calm_breathe": "慢慢呼吸",
            "goodnight": "晚安",
            "patrol": "动作巡检",
            "head_pat": "摸摸头",
        }
        try:
            self._write_pet_command("play_sequence", sequence=sequence_name)
            self.test_var.set(f"已触发组合动作：{labels.get(sequence_name, sequence_name)}。")
        except Exception as exc:
            messagebox.showerror("组合动作失败", str(exc))

    def test_wait_motion(self, reaction_name: str) -> None:
        labels = {
            "neutral": "普通等待",
            "question": "问题等待",
            "thanks": "感谢等待",
            "ack": "确认等待",
            "praise": "夸奖等待",
            "affection": "亲近等待",
            "happy": "开心等待",
            "comfort": "安慰等待",
            "help": "帮忙等待",
            "correct": "纠正等待",
            "night": "晚安等待",
        }
        try:
            self._write_pet_command("play_wait_motion", reaction=reaction_name)
            self.test_var.set(f"已触发等待动作：{labels.get(reaction_name, reaction_name)}。")
        except Exception as exc:
            messagebox.showerror("等待动作失败", str(exc))

    def test_reply_motion(self, reaction_name: str) -> None:
        labels = {
            "neutral": "普通回复",
            "question": "问题回复",
            "thanks": "感谢回复",
            "ack": "确认回复",
            "praise": "夸奖回复",
            "affection": "亲近回复",
            "happy": "开心回复",
            "comfort": "安慰回复",
            "help": "帮忙回复",
            "correct": "纠正回复",
            "night": "晚安回复",
        }
        samples = {
            "neutral": "我在。",
            "question": "这个我想一下。",
            "thanks": "不用谢，我愿意。",
            "ack": "收到啦。",
            "praise": "这句我收下啦。",
            "affection": "我也想你呀。",
            "happy": "那我也跟着开心啦。",
            "comfort": "我在呢，你慢慢说。",
            "help": "我帮你一起看。",
            "correct": "好，我重新听你说。",
            "night": "晚安，早点休息。",
        }
        try:
            self._write_pet_command("play_reply_motion", reaction=reaction_name, reply=samples.get(reaction_name, "我在。"))
            self.test_var.set(f"已触发回复动作：{labels.get(reaction_name, reaction_name)}。")
        except Exception as exc:
            messagebox.showerror("回复动作失败", str(exc))

    def open_action_preview(self) -> None:
        png_path = PREVIEW_DIR / "动作预览.png"
        gif_path = PREVIEW_DIR / "动作动图.gif"
        target = png_path if png_path.exists() else gif_path
        if target.exists():
            open_path(target)
            self.test_var.set(f"已打开动作预览：{target}")
        else:
            messagebox.showinfo("还没有预览", "请先点“生成动作预览图”。")

    def open_sequence_preview(self) -> None:
        png_path = PREVIEW_DIR / "组合动作预览.png"
        if png_path.exists():
            open_path(png_path)
            self.test_var.set(f"已打开组合动作预览：{png_path}")
        else:
            messagebox.showinfo("还没有组合预览", "请先点“生成动作预览图”。")

    def open_chat_motion_preview(self) -> None:
        png_path = PREVIEW_DIR / "聊天动作预览.png"
        if png_path.exists():
            open_path(png_path)
            self.test_var.set(f"已打开聊天动作预览：{png_path}")
        else:
            messagebox.showinfo("还没有聊天预览", "请先点“生成动作预览图”。")

    def open_transition_preview(self) -> None:
        png_path = PREVIEW_DIR / "动作切换预览.png"
        if png_path.exists():
            open_path(png_path)
            self.test_var.set(f"已打开动作切换预览：{png_path}")
        else:
            messagebox.showinfo("还没有切换预览", "请先点“生成动作预览图”。")

    def open_action_report(self) -> None:
        json_path = PREVIEW_DIR / "动作体检.json"
        if json_path.exists():
            open_path(json_path)
            self.test_var.set(f"已打开动作体检数据：{json_path}")
        else:
            messagebox.showinfo("还没有体检数据", "请先点“生成动作预览图”。")

    def generate_action_preview(self) -> None:
        script = ROOT / "tools" / "preview_actions.py"
        if not script.exists():
            messagebox.showerror("生成失败", "找不到动作预览工具。")
            return
        try:
            size = int(float(self.size_var.get().strip() or config_value("PET_SIZE", "155")))
        except ValueError:
            size = 155
        size = max(100, min(320, size))
        self.test_var.set("正在生成动作预览图。")

        def finish(png_path: Path | None, message: str) -> None:
            self.test_var.set(message)
            if png_path and png_path.exists():
                open_path(png_path)

        def worker() -> None:
            try:
                common = {
                    "cwd": str(ROOT),
                    "timeout": 45,
                    "capture_output": True,
                    "text": True,
                    "encoding": "utf-8",
                    "errors": "replace",
                }
                flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
                if IS_WINDOWS and flags:
                    common["creationflags"] = flags
                completed = subprocess.run(
                    [
                        sys.executable,
                        str(script),
                        "--size",
                        str(size),
                        "--renderer",
                        "bibi",
                        "--check",
                        "--fail-on-unstable",
                    ],
                    **common,
                )
                output = (completed.stdout or "") + "\n" + (completed.stderr or "")
                if completed.returncode != 0:
                    raise RuntimeError(output.strip() or "动作预览工具运行失败。")
                png_path = None
                gif_path = None
                sequence_png_path = None
                chat_motion_png_path = None
                transition_png_path = None
                summary_line = ""
                for line in output.splitlines():
                    if line.startswith("动作预览图:"):
                        png_path = Path(line.split(":", 1)[1].strip())
                    elif line.startswith("动作动态图:"):
                        gif_path = Path(line.split(":", 1)[1].strip())
                    elif line.startswith("组合动作预览图:"):
                        sequence_png_path = Path(line.split(":", 1)[1].strip())
                    elif line.startswith("聊天动作预览图:"):
                        chat_motion_png_path = Path(line.split(":", 1)[1].strip())
                    elif line.startswith("动作切换预览图:"):
                        transition_png_path = Path(line.split(":", 1)[1].strip())
                    elif line.startswith("动作体检摘要:"):
                        summary_line = line.split(":", 1)[1].strip()
                        break
                if png_path is None:
                    raise RuntimeError(output.strip() or "没有找到生成的预览图路径。")
                if (
                    gif_path
                    and gif_path.exists()
                    and sequence_png_path
                    and sequence_png_path.exists()
                    and chat_motion_png_path
                    and chat_motion_png_path.exists()
                    and transition_png_path
                    and transition_png_path.exists()
                ):
                    message = f"动作预览图、组合预览、聊天预览、切换预览和动图已生成：{png_path}"
                elif gif_path and gif_path.exists():
                    message = f"动作预览图和动图已生成：{png_path}"
                elif sequence_png_path and sequence_png_path.exists() and chat_motion_png_path and chat_motion_png_path.exists():
                    message = f"动作预览图、组合预览和聊天预览已生成：{png_path}"
                else:
                    message = f"动作预览图已生成：{png_path}"
                if summary_line:
                    message = f"{message}；{summary_line}"
                self.root.after(0, lambda: finish(png_path, message))
            except Exception as exc:
                message = str(exc)
                self.root.after(0, lambda: messagebox.showerror("生成失败", message))
                self.root.after(0, lambda: self.test_var.set("动作预览图生成失败。"))

        threading.Thread(target=worker, daemon=True).start()

    def test_one_reply(self) -> None:
        key = api_key_value()
        if not key:
            messagebox.showinfo("测试一句话", "还没有设置 AI Key。")
            return
        user_text = simpledialog.askstring("测试一句话", "输入一句话，看看她会怎么回：")
        if not user_text or not user_text.strip():
            return
        style = REPLY_STYLE_VALUES.get(self.reply_style_var.get(), "short")
        length = REPLY_LENGTH_VALUES.get(self.reply_length_var.get(), "short")
        try:
            timeout = int(float(self.ai_timeout_var.get().strip()))
        except ValueError:
            timeout = ai_timeout_seconds()
        timeout = max(3, min(15, timeout))
        self.test_var.set("正在测试她的回复，稍等一下。")

        def worker() -> None:
            result = self._test_reply_request(key, user_text.strip(), style, length, timeout)
            self.root.after(0, lambda: self.test_var.set(result))

        threading.Thread(target=worker, daemon=True).start()

    def _test_reply_request(self, key: str, user_text: str, style: str, length: str, timeout: int) -> str:
        cfg = read_config()
        base_url = cfg.get("DEFAULT", "DEEPSEEK_BASE_URL", fallback="https://api.deepseek.com").rstrip("/")
        model = cfg.get("DEFAULT", "DEEPSEEK_MODEL", fallback="deepseek-v4-flash")
        if model == "deepseek-chat":
            model = "deepseek-v4-flash"
        url = f"{base_url}/chat/completions"
        body = json.dumps(
            {
                "model": model,
                "messages": [
                    {"role": "system", "content": reply_instruction(style, length)},
                    {"role": "user", "content": user_text},
                ],
                "max_tokens": int(REPLY_LENGTH_TOKENS.get(length, "120")),
                "thinking": {"type": "disabled"},
                "temperature": 0.8,
            }
        ).encode("utf-8")
        req = urllib.request.Request(url, data=body)
        req.add_header("Content-Type", "application/json; charset=utf-8")
        req.add_header("Authorization", f"Bearer {key}")
        start = time.time()
        try:
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                data = json.loads(resp.read())
            elapsed = time.time() - start
            reply = data.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
            return f"测试完成，用时 {elapsed:.1f} 秒。\n我：{user_text}\n她：{reply or '没有返回内容'}"
        except urllib.error.HTTPError as exc:
            detail = exc.read().decode("utf-8", errors="replace")[:300]
            return f"测试失败：HTTP {exc.code}\n{detail}"
        except Exception as exc:
            return f"测试失败：{exc}"

    def clear_memory(self) -> None:
        mem = memory_file()
        if not mem.exists():
            try:
                self._write_pet_command("clear_memory")
                self.refresh_status()
                messagebox.showinfo(f"{APP_NAME}控制面板", f"现在没有记忆文件；已通知正在运行的{APP_NAME}清空内存里的记忆。")
            except Exception as exc:
                messagebox.showinfo(f"{APP_NAME}控制面板", f"现在没有记忆文件。通知{APP_NAME}时失败：{exc}")
            return
        if not messagebox.askyesno("清除记忆", "确定要清除她的本地记忆吗？"):
            return
        try:
            mem.unlink()
            try:
                self._write_pet_command("clear_memory")
            except Exception:
                pass
            self.refresh_status()
            messagebox.showinfo(f"{APP_NAME}控制面板", "记忆已清除。")
        except Exception as exc:
            messagebox.showerror("清除失败", str(exc))

    @staticmethod
    def memory_rows(data) -> list[dict]:
        if not isinstance(data, dict):
            return []
        rows: list[dict] = []
        labels = {
            "preferences": "偏好",
            "facts": "近况/事实",
            "notes": "明确记住",
        }
        for bucket, label in labels.items():
            values = data.get(bucket) or []
            if not isinstance(values, list):
                continue
            for index, value in enumerate(values):
                if isinstance(value, dict):
                    text = str(value.get("text", "")).strip()
                    updated = str(value.get("updated_at") or value.get("created_at") or "").strip()
                    expires = str(value.get("expires_at") or "永久").strip()
                    importance = int(value.get("importance", 3) or 3)
                    pinned = bool(value.get("pinned", False))
                    source = str(value.get("source") or "自动").strip()
                    previous_values = value.get("previous_values", [])
                else:
                    text = str(value).strip()
                    updated = ""
                    expires = "永久"
                    importance = 3
                    pinned = False
                    source = "旧版"
                    previous_values = []
                if text:
                    rows.append(
                        {
                            "bucket": bucket,
                            "index": index,
                            "category": label,
                            "text": text,
                            "updated": updated,
                            "expires": expires,
                            "importance": importance,
                            "pinned": pinned,
                            "source": source,
                            "previous_values": previous_values if isinstance(previous_values, list) else [],
                        }
                    )
        turns = data.get("recent_turns") or []
        if isinstance(turns, list):
            for index, turn in enumerate(turns):
                if not isinstance(turn, dict):
                    continue
                user = str(turn.get("user", "")).strip()
                pet = str(turn.get("pet", "")).strip()
                content = "\n".join(
                    line for line in (f"你：{user}" if user else "", f"{APP_NAME}：{pet}" if pet else "") if line
                )
                if content:
                    rows.append(
                        {
                            "bucket": "recent_turns",
                            "index": index,
                            "category": "最近对话",
                            "text": content,
                            "updated": str(turn.get("time", "")).strip(),
                            "expires": "按数量保留",
                            "importance": 0,
                            "pinned": False,
                            "source": "对话",
                            "previous_values": [],
                        }
                    )
        return rows

    def view_memory(self) -> None:
        mem = memory_file()
        if not mem.exists():
            messagebox.showinfo("查看记忆", "现在还没有记忆内容。")
            return
        try:
            raw = mem.read_text(encoding="utf-8-sig")
            data = json.loads(raw) if raw.strip() else {}
            if not isinstance(data, dict):
                raise ValueError("记忆文件不是有效对象")
        except Exception as exc:
            messagebox.showerror("查看失败", f"记忆文件读取失败：{exc}")
            return

        win = tk.Toplevel(self.root)
        win.title("查看与管理记忆")
        win.geometry("980x640")
        win.minsize(780, 520)
        win.configure(bg="#f4f4f2")
        win.transient(self.root)
        win.grid_rowconfigure(1, weight=1)
        win.grid_columnconfigure(0, weight=1)

        status_var = tk.StringVar(value="选择一条后可查看完整内容、编辑或删除。")
        row_map: dict[str, dict] = {}

        def save_data(message: str) -> bool:
            try:
                data["version"] = 3
                mem.parent.mkdir(parents=True, exist_ok=True)
                temp = mem.with_suffix(mem.suffix + ".tmp")
                temp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
                os.replace(temp, mem)
                try:
                    self._write_pet_command("reload_memory")
                except Exception:
                    pass
                status_var.set(message)
                self.refresh_status()
                return True
            except Exception as exc:
                messagebox.showerror("保存失败", str(exc), parent=win)
                return False

        toolbar = tk.Frame(win, bg="#f4f4f2", padx=12, pady=10)
        toolbar.grid(row=0, column=0, sticky="ew")
        toolbar.grid_columnconfigure(0, weight=1)
        title_box = tk.Frame(toolbar, bg="#f4f4f2")
        title_box.grid(row=0, column=0, sticky="w")
        tk.Label(
            title_box,
            text="本地记忆",
            bg="#f4f4f2",
            fg="#222222",
            font=("Microsoft YaHei UI", 11, "bold"),
            anchor="w",
        ).pack(anchor="w")
        tk.Label(
            title_box,
            textvariable=status_var,
            bg="#f4f4f2",
            fg="#68706b",
            font=("Microsoft YaHei UI", 9),
            anchor="w",
        ).pack(anchor="w")

        frame = tk.Frame(win, bg="#f4f4f2", padx=12)
        frame.grid(row=1, column=0, sticky="nsew")
        frame.grid_rowconfigure(0, weight=1)
        frame.grid_columnconfigure(0, weight=1)
        style = ttk.Style(win)
        style.configure("Memory.Treeview", rowheight=28, font=("Microsoft YaHei UI", 9))
        style.configure("Memory.Treeview.Heading", font=("Microsoft YaHei UI", 9, "bold"))
        tree = ttk.Treeview(
            frame,
            columns=("category", "content", "importance", "source", "updated", "expires"),
            show="headings",
            selectmode="extended",
            style="Memory.Treeview",
        )
        for column, label, width, stretch in (
            ("category", "类型", 90, False),
            ("content", "内容", 360, True),
            ("importance", "重要", 72, False),
            ("source", "来源", 76, False),
            ("updated", "更新时间", 132, False),
            ("expires", "有效期", 132, False),
        ):
            tree.heading(column, text=label)
            tree.column(column, width=width, minwidth=70, stretch=stretch, anchor="w")
        y_scroll = ttk.Scrollbar(frame, orient="vertical", command=tree.yview)
        x_scroll = ttk.Scrollbar(frame, orient="horizontal", command=tree.xview)
        tree.configure(yscrollcommand=y_scroll.set, xscrollcommand=x_scroll.set)
        tree.grid(row=0, column=0, sticky="nsew")
        y_scroll.grid(row=0, column=1, sticky="ns")
        x_scroll.grid(row=1, column=0, sticky="ew")

        detail = tk.Text(
            win,
            height=5,
            wrap="word",
            font=("Microsoft YaHei UI", 10),
            bg="#ffffff",
            fg="#222222",
            relief="flat",
            padx=10,
            pady=8,
        )
        detail.grid(row=2, column=0, sticky="ew", padx=12, pady=10)
        detail.configure(state="disabled")

        def set_detail(text: str) -> None:
            detail.configure(state="normal")
            detail.delete("1.0", "end")
            detail.insert("1.0", text)
            detail.configure(state="disabled")

        def reload_rows() -> None:
            tree.delete(*tree.get_children())
            row_map.clear()
            for row in self.memory_rows(data):
                preview = row["text"].replace("\n", "  ")
                iid = tree.insert(
                    "",
                    "end",
                    values=(
                        row["category"],
                        preview,
                        "长期" if row["pinned"] else (str(row["importance"]) if row["importance"] else "-"),
                        row["source"],
                        row["updated"],
                        row["expires"],
                    ),
                )
                row_map[iid] = row
            set_detail("选择一条记忆，这里会显示完整内容。")
            if not row_map:
                status_var.set("现在没有记忆内容。")

        def selected_rows() -> list[dict]:
            return [row_map[iid] for iid in tree.selection() if iid in row_map]

        def on_select(_event=None) -> None:
            rows = selected_rows()
            if not rows:
                return
            details = []
            for row in rows:
                metadata = [
                    f"类型：{row['category']}",
                    f"来源：{row['source']}",
                    f"重要程度：{'长期记忆' if row['pinned'] else row['importance']}",
                    f"有效期：{row['expires']}",
                ]
                previous = row.get("previous_values") or []
                history = [str(item.get("text", "")).strip() for item in previous if isinstance(item, dict)]
                history_text = f"\n历史内容：{' / '.join(history)}" if history else ""
                details.append(row["text"] + "\n" + "    ".join(metadata) + history_text)
            set_detail("\n\n".join(details))

        def edit_selected() -> None:
            rows = selected_rows()
            if len(rows) != 1:
                messagebox.showinfo("编辑记忆", "请只选择一条需要编辑的记忆。", parent=win)
                return
            row = rows[0]
            if row["bucket"] == "recent_turns":
                messagebox.showinfo("编辑记忆", "最近对话可以逐条删除，不直接改写。", parent=win)
                return
            new_text = simpledialog.askstring(
                "编辑记忆",
                "修改内容：",
                initialvalue=row["text"],
                parent=win,
            )
            if new_text is None or not new_text.strip():
                return
            values = data.get(row["bucket"], [])
            value = values[row["index"]]
            if isinstance(value, dict):
                value["text"] = new_text.strip()
                value["topic"] = ""
                value["source"] = "manual"
                value["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
            else:
                values[row["index"]] = new_text.strip()
            if save_data("这条记忆已更新，并已通知正在运行的桌宠。"):
                reload_rows()

        def toggle_pinned() -> None:
            rows = selected_rows()
            rows = [row for row in rows if row["bucket"] != "recent_turns"]
            if len(rows) != 1:
                messagebox.showinfo("长期记忆", "请选择一条偏好、事实或备注。", parent=win)
                return
            row = rows[0]
            values = data.get(row["bucket"], [])
            value = values[row["index"]]
            if not isinstance(value, dict):
                value = {
                    "text": str(value),
                    "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
                    "source": "manual",
                }
                values[row["index"]] = value
            pinned = not bool(value.get("pinned", False))
            value["pinned"] = pinned
            value["importance"] = max(4, int(value.get("importance", 3) or 3)) if pinned else 3
            value["expires_at"] = None if pinned else value.get("expires_at")
            value["updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
            if save_data("已设为长期记忆。" if pinned else "已取消长期记忆。"):
                reload_rows()

        def delete_selected() -> None:
            rows = selected_rows()
            if not rows:
                messagebox.showinfo("删除记忆", "请先选择要删除的内容。", parent=win)
                return
            if not messagebox.askyesno("删除记忆", f"确定删除选中的 {len(rows)} 条内容吗？", parent=win):
                return
            grouped: dict[str, list[int]] = {}
            for row in rows:
                grouped.setdefault(row["bucket"], []).append(int(row["index"]))
            for bucket, indices in grouped.items():
                values = data.get(bucket, [])
                if not isinstance(values, list):
                    continue
                for index in sorted(set(indices), reverse=True):
                    if 0 <= index < len(values):
                        values.pop(index)
            if save_data(f"已删除 {len(rows)} 条内容。"):
                reload_rows()

        def copy_text() -> None:
            self.root.clipboard_clear()
            self.root.clipboard_append(self.format_memory_text(data))
            status_var.set("记忆内容已复制。")

        def clear_all() -> None:
            if not messagebox.askyesno("清空记忆", "确定清空全部记忆和最近对话吗？", parent=win):
                return
            data.clear()
            data.update({"version": 3, "facts": [], "preferences": [], "notes": [], "recent_turns": []})
            if save_data("全部记忆已清空。"):
                reload_rows()

        def add_toolbar_button(text: str, command, column: int) -> None:
            tk.Button(
                toolbar,
                text=text,
                command=command,
                font=("Microsoft YaHei UI", 9),
                padx=10,
                pady=5,
            ).grid(row=0, column=column, padx=(7, 0))

        add_toolbar_button("编辑选中", edit_selected, 1)
        add_toolbar_button("设为长期", toggle_pinned, 2)
        add_toolbar_button("删除选中", delete_selected, 3)
        add_toolbar_button("复制全部", copy_text, 4)
        add_toolbar_button("清空全部", clear_all, 5)
        add_toolbar_button("关闭", win.destroy, 6)

        tree.bind("<<TreeviewSelect>>", on_select)
        tree.bind("<Double-Button-1>", lambda _event: edit_selected())
        win.bind("<Escape>", lambda _event: win.destroy())
        reload_rows()
        win.lift()
        win.focus_force()

    def format_memory_text(self, data) -> str:
        if not isinstance(data, dict):
            return "记忆文件格式不正确。"

        sections: list[str] = []
        warning = "如果这里出现很多乱码，可以关闭窗口后点“清除记忆”，让她重新开始记。"

        def add_list(title: str, values) -> None:
            if values is None:
                source = []
            elif isinstance(values, (list, tuple)):
                source = values
            elif isinstance(values, dict):
                source = [f"{key}：{value}" for key, value in values.items()]
            else:
                source = [values]
            items = []
            for item in source:
                if isinstance(item, dict):
                    value = str(item.get("text", "")).strip()
                    expires = str(item.get("expires_at") or "").strip()
                    if value and item.get("pinned"):
                        value = f"{value}（长期记忆）"
                    if value and expires:
                        value = f"{value}（有效至 {expires}）"
                else:
                    value = str(item).strip()
                if value:
                    items.append(value)
            if not items:
                return
            sections.append(title + "\n" + "\n".join(f"- {item}" for item in items[-30:]))

        add_list("偏好", data.get("preferences"))
        add_list("近况/事实", data.get("facts"))
        add_list("明确要求记住的事", data.get("notes"))

        turns = data.get("recent_turns") or []
        if isinstance(turns, list) and turns:
            lines: list[str] = []
            for turn in turns[-20:]:
                if not isinstance(turn, dict):
                    continue
                when = str(turn.get("time", "")).strip()
                user = str(turn.get("user", "")).strip()
                pet = str(turn.get("pet", "")).strip()
                if when:
                    lines.append(f"[{when}]")
                if user:
                    lines.append(f"我：{user}")
                if pet:
                    lines.append(f"她：{pet}")
                if user or pet:
                    lines.append("")
            if lines:
                sections.append("最近对话\n" + "\n".join(lines).rstrip())

        if not sections:
            return "现在没有记忆内容。\n\n" + warning
        return "\n\n".join(sections) + "\n\n" + warning

    def open_logs(self) -> None:
        if ERR_PATH.exists() and ERR_PATH.stat().st_size > 0:
            open_path(ERR_PATH)
        elif LOG_PATH.exists():
            open_path(LOG_PATH)
        else:
            messagebox.showinfo(f"{APP_NAME}控制面板", "现在还没有启动日志。")

    def test_ai(self) -> None:
        key = api_key_value()
        if not key:
            messagebox.showinfo("测试 AI 连接", "还没有设置 AI Key。")
            return
        self.test_var.set("正在测试 AI 连接，稍等一下。")

        def worker() -> None:
            result = self._test_ai_request(key)
            self.root.after(0, lambda: self.test_var.set(result))

        threading.Thread(target=worker, daemon=True).start()

    def _test_ai_request(self, key: str) -> str:
        cfg = read_config()
        base_url = cfg.get("DEFAULT", "DEEPSEEK_BASE_URL", fallback="https://api.deepseek.com").rstrip("/")
        model = cfg.get("DEFAULT", "DEEPSEEK_MODEL", fallback="deepseek-v4-flash")
        if model == "deepseek-chat":
            model = "deepseek-v4-flash"
        url = f"{base_url}/chat/completions"
        body = json.dumps(
            {
                "model": model,
                "messages": [
                    {"role": "system", "content": "只回复两个字：正常"},
                    {"role": "user", "content": "测试"},
                ],
                "max_tokens": 10,
                "thinking": {"type": "disabled"},
                "temperature": 0.1,
            }
        ).encode("utf-8")
        req = urllib.request.Request(url, data=body)
        req.add_header("Content-Type", "application/json; charset=utf-8")
        req.add_header("Authorization", f"Bearer {key}")
        start = time.time()
        try:
            with urllib.request.urlopen(req, timeout=8) as resp:
                data = json.loads(resp.read())
            elapsed = time.time() - start
            reply = data.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
            return f"AI 连接正常，用时 {elapsed:.1f} 秒。\n返回：{reply or '正常'}"
        except urllib.error.HTTPError as exc:
            if exc.code in {401, 403}:
                return "AI 连接失败：Key 不正确或没有权限。"
            if exc.code == 429:
                return "AI 连接失败：请求太频繁或额度限制。"
            return f"AI 连接失败：接口返回 {exc.code}。"
        except TimeoutError:
            return "AI 连接超时：网络或 DeepSeek 响应较慢。桌宠会自动走本地兜底回复。"
        except Exception as exc:
            text = str(exc)
            if "timed out" in text.lower():
                return "AI 连接超时：网络或 DeepSeek 响应较慢。桌宠会自动走本地兜底回复。"
            return f"AI 连接失败：{type(exc).__name__}。"

    def run(self) -> None:
        self.root.mainloop()


def main() -> int:
    app = ControlPanel()
    app.run()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
