20243410 实验四《Python程序设计》实验报告

20243410 2025-2026-2 《Python程序设计》实验4报告

课程:《Python程序设计》
班级: 2434
姓名: 陈懿慜
学号: 20243410
实验教师:王志强
实验日期:2026年6月1日
必修/选修: 公选课


1. 实验内容

本次综合实践项目为 "AI智能微信聊天助手"——一个基于Python开发的桌面端工具,能够实时监控微信聊天窗口,利用AI大模型智能识别对方消息并生成回复建议,支持一键粘贴发送。在日常微信聊天场景中,有时需要快速回复消息但一时想不出合适的表达。本程序通过截图监控 + AI识别 + 智能生成的三步流程,帮助用户在聊天时获得即时回复灵感。

1.1 实验要求

Python综合应用:爬虫、数据处理、可视化、机器学习、神经网络、游戏、网络安全等。
例如:编写从社交网络爬取数据,实现可视化舆情监控或者情感分析。
例如:利用公开数据集,开展图像分类、恶意软件检测等
例如:利用Python库,基于OCR技术实现自动化提取图片中数据,并填入excel中。
例如:爬取天气数据,实现自动化微信提醒
例如:利用爬虫,实现自动化下载网站视频、文件等。
例如:编写小游戏:坦克大战、贪吃蛇、扫雷等等
注:在Windows/Linux系统上使用VIM、PDB、IDLE、Pycharm等工具编程实现。

1.2 核心功能列表(共15项)

编号 功能 说明
1 屏幕选区截图 拖拽选择微信聊天区域,高DPI适配
2 感知哈希变化检测 16x16灰度哈希,仅变化时触发AI分析,大幅节省API费用
3 气泡位置识别 glm-4v视觉模型识别最底部气泡左右位置
4 智能回复建议 glm-4-flash文本模型生成5条自然回复
5 一键粘贴发送 点击建议卡片→复制→Ctrl+V→Enter自动发送
6 去重防重复 记录已给建议的12条历史,避免重复推荐
7 自动聊天模式 检测对方消息→1-4秒随机延迟→自动发送第1条→15秒冷却→90秒后追发"还在吗"
8 主动聊天/开场白 输入话题,AI生成开场白;🎲随机话题一键生成
9 换一批建议 3秒冷却手动刷新获取新建议
10 对话情绪分析 智谱AI情绪标签 + 华为云NLP情感分析(双后端可切换)
11 回复风格切换 自然/幽默/高冷/话唠/撩人五种人设
12 快捷短语面板 预设常用语[好的][没问题][哈哈]等,一键发送;支持自定义添加
13 窗口透明度调节 滑条实时调整30%–100%,右侧显示当前百分比
14 字号调节 滑条8–16号实时调整建议文字大小
15 三主题切换 赛博暗/淡粉/极简白,即时切换无需重启
16 华为云集成 情感分析接入华为云NLP服务,AK/SK认证
17 赛博暗科技风UI 标题呼吸发光动画、卡片hover高亮、扫描线特效、脉冲按钮
18 只拖标题栏 拖动仅标题栏响应,滑块/按钮不误触

1.3 技术栈

模块 技术/库 用途
GUI tkinter + ttk 完全无边框自绘窗口
截图 PIL.ImageGrab 屏幕区域抓取
DPI适配 ctypes.windll.user32 Windows高DPI声明
视觉AI 智谱AI glm-4v 截图内容识别(气泡位置判断)
文本AI 智谱AI glm-4-flash 回复建议生成+情绪分析
云服务 华为云NLP SDK 情感分析API
桌面控制 pyautogui + pyperclip 键盘模拟+剪贴板操作
哈希检测 PIL + 自写算法 16×16灰度感知哈希
总计 907行Python代码

2. 实验过程及结果

2.2 核心模块实现

2.2.1 全局配置与状态管理

程序开头定义了所有可调参数和全局状态变量。这是程序的"配置中心",所有模块通过读取这些全局变量获得统一配置。

# ===================== 配置 =====================
ZHIPU_API_KEY = "9be1087d14364575b510b1f14fb328a5.DjtyFs6K8NVzisRp"
client = ZhipuAI(api_key=ZHIPU_API_KEY)

# ===================== 全局状态 =====================
chat_topics = []           # 对话话题摘要
past_suggestions = []      # 已给出的建议(避免重复)
capture_region = None      # 截图区域 (x1, y1, x2, y2)
auto_capturing = False     # 自动截图开关
auto_chat_mode = False     # 自动聊天模式

# ===================== 可调配置 =====================
CAPTURE_INTERVAL = 2        # 截图间隔(秒)
MODEL_NAME = "glm-4v"       # 视觉模型
SUGGESTIONS_COUNT = 5       # 建议条数
reply_style = "自然"         # 回复风格

设计思路:将所有可调参数集中在文件顶部,方便调试时快速修改。全局状态变量用简单数据类型(列表、布尔值、元组),避免复杂的类封装以便后台线程安全访问。

2.2.2 高DPI适配

Windows系统在125%或150%缩放时,程序坐标与屏幕物理像素不一致。需要在程序启动时声明DPI感知:

# ===================== 高DPI适配 =====================
ctypes.windll.user32.SetProcessDPIAware()

这一行代码让PIL的ImageGrab.grab()返回的坐标与屏幕物理像素对齐。如果不做此声明,在高DPI屏幕上选取区域会偏移约25%(125%缩放)或50%(150%缩放)。

设计思路:这是Windows桌面开发中常见的坑。ctypes直接调Windows API是最底层的解决方案,避免了第三方库的兼容性问题。

2.2.3 感知哈希变化检测

原本的方案每2秒截图就调用一次AI(视觉大模型API),费用高且浪费。更新程序后引入感知哈希算法:将截图缩小为16×16灰度图,计算其哈希值,仅当哈希变化时才触发后续AI分析。

def img_hash(img):
    """16x16灰度感知哈希"""
    small = img.resize((16, 16)).convert('L')
    pixels = list(small.getdata())
    avg = sum(pixels) / len(pixels)
    bits = ['1' if p >= avg else '0' for p in pixels]


    return hex(int(''.join(bits), 2))

算法流程:

  1. 将截图缩小为16×16(256个像素)
  2. 转为灰度图(每个像素0-255)
  3. 计算所有像素平均值
  4. 每个像素与平均值比较:高于平均→1,低于→0
  5. 256个二进制位组成字符串,转为十六进制哈希值

设计思路:感知哈希的特点是"视觉相似的图片产生相近的哈希值"。微信聊天界面文字变化对哈希影响明显,而背景的微小颜色波动不会改变哈希。实测可将AI调用频率降低80%以上。

在采集线程中的使用:

cur_hash = img_hash(img)
if cur_hash == last_hash:
    time.sleep(CAPTURE_INTERVAL); continue  # 没变化,跳过
last_hash = cur_hash

2.2.4 屏幕截图选区

用户需要先拖拽一个矩形框来选择微信的聊天区域。这个功能用一个全屏半透明遮罩窗口实现:

def select_screen_area(parent):
    win = tk.Toplevel(parent)
    win.attributes('-topmost', True)
    win.overrideredirect(True)
    sw = win.winfo_screenwidth(); sh = win.winfo_screenheight()
    win.geometry(f"{sw}x{sh}+0+0")
    win.attributes('-alpha', 0.3)  # 30%透明度
    win.configure(bg='black')

设计思路:利用tk.Toplevel创建全屏窗口,设置-alpha 0.3实现半透明效果,用户可以看到下层桌面内容。在Canvas上绑定<Button-1>按下、<B1-Motion>拖动、<ButtonRelease-1>释放三个事件实现拖拽矩形选区。右键取消选区。

选区坐标保存为(x1, y1, x2, y2)元组,后续截图使用ImageGrab.grab(bbox=(x1,y1,x2,y2))

e1f64701efcc26e7afcedcf6895521f2

2.2.5 两步法AI分析(程序设计核心)

这是本程序最核心的设计决策。传统单模型方案让一个AI同时完成"看图理解对话"和"生成回复",容易产生冗余废话。本程序采用职责分离的两步法架构

第1步:视觉识别(glm-4v)

只做一件事——判断最底部气泡在左边还是右边。Prompt精简到3句话:

READ_PROMPT = (
    "截图最底部一条气泡在左边还是右边?\n"
    "只回复:\n"
    "SIDE:left 或 SIDE:right\n"
    "MSG:气泡的文字"
)

解析返回结果的函数:

def parse_vision_response(desc):
    is_left = 'left' in desc.lower() and (
        'right' not in desc.lower() or 
        desc.lower().find('left') < desc.lower().find('right'))
    m = re.search(r'(?:MSG|Text|内容|消息)\s*[::]\s*(.+)', desc, re.IGNORECASE)
    other_msg = m.group(1).strip() if m else ""
    return is_left, other_msg

关键设计细节

  • SIDE:leftSIDE:right判断位置,而非气泡颜色(微信可自定义主题,颜色不可靠)
  • 同时要求返回MSG:文字内容,一步拿到消息文本
  • 正则兼容MSG:Text:内容:消息:等多种AI可能返回的格式
  • 如果同时出现left和right,取先出现的那个(AI可能在描述全部内容时提到两个方向)

微信聊天界面固定规则:左边的气泡=对方发的,右边的气泡=自己发的。这个规则100%可靠,不受主题、字体、颜色影响。因此位置判断准确率从颜色方案的~70%提升到100%。

为什么用视觉大模型而不是OCR

  • OCR只能提取文字,无法判断"这句话是谁说的"
  • 微信气泡的左右位置是视觉信息,OCR无法获取
  • glm-4v可以同时完成"位置判断+文字提取",一个模型解决两个任务

第2步:文本生成(glm-4-flash)

拿到对方消息后,调用文本模型生成回复建议+情绪分析:

def gen_suggestions(other_msg, style="自然", past=None):
    prompt = (
        f"对方说:「{other_msg}」\n"
        f"我的风格:{style}\n"
        f"5条回复 + 一行对方情绪分析(#情绪:xx)。序号。别解释。"
    )
    if past:
        prompt += f"\n别建议这些:{'、'.join(past[-6:])}"
    res = client.chat.completions.create(
        model='glm-4-flash', messages=[{"role":"user","content":prompt}])
    reply = res.choices[0].message.content.strip()

回复解析(含多道过滤保证建议质量):

sug = []; mood = ""
for line in reply.split("\n"):
    line = line.strip()
    # 提取情绪标签
    m = re.search(r'#\s*情绪\s*[::]\s*(.+)', line)
    if m:
        if line.startswith("#"):  # 独立行
            mood = m.group(1).strip()
            continue
        line = line[:m.start()].strip()  # 内联则剥离
    if not line: continue
    # 去掉编号前缀
    line = re.sub(r'^\d+[\.\、\)]\s*', '', line).strip()
    line = re.sub(r'^[\*\-\•]\s*', '', line).strip()
    # 过滤无效建议
    if not line or len(line) < 2: continue
    if re.match(r'^[\(\)()\[\]【】\{\}。,、;:""'']+$', line): continue
    if len(line) > 100: continue
    if line.startswith("对方") or line.startswith("我:"): continue
    sug.append(line)
    if len(sug) >= SUGGESTIONS_COUNT: break

过滤规则说明

  1. <2字符:如单独的emoji或标点
  2. 纯标点/括号:如残留的(
  3. >100字符:太长的回复不适合微信快速聊天
  4. 以"对方"/"我:"开头:AI有时会输出注释而非建议

两步法vs单步法对比

  • 单步法:一个prompt同时要求识别+生成,视觉模型容易"看图说话"
  • 两步法:视觉模型只做位置判断(极小prompt),文本模型专注生成
  • 实测:两步法建议可用率>95%,单步法仅~60%

2.2.6 后台采集线程

核心定时循环在独立线程中运行,避免阻塞GUI:

def auto_capture_task(update_suggestions, update_status, 
                       update_ocr_preview, update_mood=None):
    global auto_capturing, chat_topics, past_suggestions, auto_chat_mode
    last_hash = ""; tick = 0; auto_sent_at = 0; last_reply_at = 0

    while auto_capturing and capture_region:
        tick += 1
        # 1. 截图
        img = ImageGrab.grab(bbox=(x1, y1, x2, y2))
        # 2. 哈希检测变化
        cur_hash = img_hash(img)
        if cur_hash == last_hash:
            time.sleep(CAPTURE_INTERVAL); continue
        # 3. 冷却期检查
        if auto_chat_mode and time.time() - auto_sent_at < 15:
            time.sleep(CAPTURE_INTERVAL); continue
        last_hash = cur_hash
        # 4. 自动追问(90秒无回复)
        if auto_chat_mode and last_reply_at > 0 and \
           time.time() - last_reply_at > 90:
            update_suggestions(["__AUTO_SEND__", "还在吗"])
            auto_sent_at = time.time()
            time.sleep(10); continue
        # 5. 调用AI分析
        # ... (两步法调用)

线程安全设计

  • 所有UI更新通过self.after(0, lambda: ...)回到主线程
  • 用回调函数update_suggestionsupdate_status解耦采集和显示
  • 全局状态用简单变量,避免GIL竞争

自动发送模拟真人

delay = int(random.uniform(1000, 4000))  # 1-4秒随机
self.after(delay, lambda: (
    pyautogui.hotkey('ctrl', 'a'),   # 全选输入框
    pyautogui.sleep(0.05),           # 短暂等待
    pyautogui.hotkey('ctrl', 'v'),   # 粘贴内容
    pyautogui.sleep(0.15),           # 短暂等待
    pyautogui.press('enter')))       # 发送

随机1-4秒延迟模拟真人打字前的思考时间,避免"秒回"的机器人感。

2.2.7 华为云NLP情感分析

响应课程要求使用华为云进行实验,由于本程需要在物理机上取得系统截屏权限,粘贴板权限等,将程序部署在云端获取正确权限较难,打算采用在ECS上部署LLM的形式加强本程序的建议回复功能。

这里我就用直接调用华为云布置好的自然语言NLp模型,(本来想用盘古模型的,配置要连接上海的服务器,一直没成功,还有尝试了一个自动回复机器人,但是要2300一个月,没钱就放弃了😬)

认证流程

  1. 使用华为云AK/SK创建BasicCredentials
    image
    屏幕截图 2026-06-07 140504

  2. 传入project_id(项目标识符)

  3. SDK自动生成签名并附加到HTTP请求头

  4. 调用nlp-ext.cn-north-4.myhuaweicloud.com端点的情感分析接口

当用户点击"☁ 华为云 ON"后,情绪分析从智谱AI切换到华为云NLP服务:

def huawei_sentiment(text):
    """华为云NLP情感分析 — SDK直调"""
    from huaweicloudsdkcore.auth.credentials import BasicCredentials
    from huaweicloudsdkcore.http.http_config import HttpConfig
    from huaweicloudsdknlp.v2 import NlpClient
    from huaweicloudsdknlp.v2.model import RunSentimentRequest, HWCloudSentimentReq

    config = HttpConfig.get_default_config()
    config.ignore_ssl_verification = True

    ep = f"https://nlp-ext.{HUAWEI_REGION}.myhuaweicloud.com"
    client = NlpClient.new_builder() \
        .with_credentials(BasicCredentials(HUAWEI_AK, HUAWEI_SK, 
                          project_id=HUAWEI_PROJECT_ID)) \
        .with_endpoint(ep) \
        .with_http_config(config) \
        .build()

    body = HWCloudSentimentReq(content=text, lang="zh")
    req = RunSentimentRequest(body=body)
    resp = client.run_sentiment(req)
    label = resp.result.label
    return {0: "消极", 1: "积极"}.get(label, "中性")

错误处理

except Exception as e:
    err = str(e)
    if "401" in err:
        return "API错误:401认证失败-检查NLP服务是否开通"
    if "APIG" in err:
        return "API错误:服务未开通"

错误信息直接显示到UI的情绪指示器上,用户能立刻看到问题原因。

双后端无感切换

def huawei_gen_suggestions(other_msg, style="自然", past=None):
    sug, glm_mood = gen_suggestions(other_msg, style, past)  # 智谱生成回复
    hw_mood = huawei_sentiment(other_msg)  # 华为云情绪分析
    if hw_mood in ("积极", "消极", "中性"):
        return sug, f"[华为云] {hw_mood}"
    if hw_mood in ("未开通", "未配置AK") or "API错误" in hw_mood:
        return sug, f"[华为云] {hw_mood}"  # 显示错误原因
    return sug, glm_mood  # 兜底用智谱情绪

关闭华为云模式时,情绪标签完全由智谱AI生成(#情绪:xxx格式),用户无感知切换。

2.2.8 UI界面设计

主题系统

定义三种配色方案,通过字典存储:

THEMES = {
    "赛博暗": dict(BG="#0a0a14", CARD="#131325", ACCENT="#7c5cff", 
                   CYAN="#00e5ff", TEXT="#dde4f0", SUB="#6b6b8a", 
                   GREEN="#00ff88", GLOW="#1a1a35", HOVER="#1e1e38"),
    "淡粉":   dict(BG="#fff5f5", CARD="#ffffff", ACCENT="#e8878a", 
                   CYAN="#e8878a", TEXT="#4a3340", SUB="#c4a8b0", 
                   GREEN="#27ae60", GLOW="#ffe8eb", HOVER="#ffe8eb"),
    "极简白": dict(BG="#f5f5f5", CARD="#ffffff", ACCENT="#333333", 
                   CYAN="#333333", TEXT="#222222", SUB="#999999", 
                   GREEN="#27ae60", GLOW="#e8e8e8", HOVER="#e8e8e8"),
}

主题切换通过递归遍历所有控件重新设色:

def _paint(self, parent):
    """递归重绘所有控件颜色"""
    for w in parent.winfo_children():
        c = w.winfo_class()
        if c == 'Frame':
            w.config(bg=CARD)
        elif c == 'Label':
            txt = w.cget('text')
            is_title = txt in ('AI 聊天助手','◆ 回复建议') or 'bold' in str(w.cget('font'))
            w.config(fg=CYAN if is_title else TEXT, bg=BG)
        elif c == 'Button':
            txt = w.cget('text')
            if txt in ('✦ 更多功能','▼ 收起面板','生成'):
                w.config(bg=ACCENT, fg='#fff')
            elif txt and '华为云' in txt:
                w.config(bg=GLOW if 'ON' in txt else CARD, 
                         fg=CYAN if 'ON' in txt else SUB)
            else:
                w.config(bg=CARD, fg=TEXT)
        elif c == 'Canvas':
            w.config(bg=BG)
        if isinstance(w, tk.Entry):
            w.config(bg=BG, fg=TEXT, insertbackground=CYAN)
        if isinstance(w, tk.Scale):
            w.config(bg=BG, fg=SUB, troughcolor=CARD)
        if 'Scrollbar' in c:
            w.config(bg=CARD, troughcolor=BG)
        self._paint(w)

标题栏设计

标题栏包含AI助手标题(带呼吸发光动画)、华为云切换按钮、关闭按钮:
屏幕截图 2026-06-08 183456

# 标题发光脉冲动画
def _start_glow_anim(self):
    def pulse():
        if not self.winfo_exists(): return
        self._pulse = (self._pulse + 1) % 60
        import math
        c = int(150 + 50 * (1 + math.sin(self._pulse * 0.1)) / 2)
        color = f"#{min(255,c+50):02x}{min(255,c):02x}{min(255,c+80):02x}"
        try: self._glow_label.config(fg=color)
        except: pass
        self._anim_id = self.after(50, pulse)
    pulse()

华为云按钮激活时的脉冲效果:

def _start_hw_glow(self):
    if not USE_HUAWEI or not self.winfo_exists(): return
    import math
    pulse = int(time.time() * 3) % 30
    c = int(100 + 80 * (1 + math.sin(pulse * 0.3)) / 2)
    self._hw_btn.config(bg=f"#{c:02x}{c//2:02x}{min(255,c+50):02x}")
    self._hw_glow_id = self.after(200, self._start_hw_glow)

窗口拖动优化

初始版本整个窗口可拖动,导致滑块操作被误触。修复方案:仅标题栏区域响应拖动:

def _enable_title_drag(self, widget):
    widget.bind("<Button-1>", 
        lambda e: setattr(self, '_dx', e.x) or setattr(self, '_dy', e.y))
    widget.bind("<B1-Motion>", 
        lambda e: self.geometry(
            f"+{self.winfo_x()+e.x-self._dx}+{self.winfo_y()+e.y-self._dy}"))

_init_ui中只对标题栏Frame调用此方法:

self._enable_title_drag(tb)  # tb是标题栏的Frame

透明度与字号调节

两个Slider控件(tk.Scale)常驻在主界面中:

image

a_sc = tk.Scale(a_row, from_=30, to=100, orient=tk.HORIZONTAL, showvalue=0,
                bg=BG, fg=SUB, bd=0, highlightthickness=0,
                command=lambda v: (self.wm_attributes('-alpha', int(v)/100),
                                   self._alpha_lbl.config(text=f"{int(v)}%")))

showvalue=0隐藏默认数字(会与标签重叠),手动用右侧Label显示当前值。

2.2.9 主动聊天与随机话题

输入框后有两个按钮:🎲随机话题和生成按钮:
image

RANDOM_TOPICS = [
    "周末去哪玩","最近看什么剧","推荐一家好吃的",
    "今天天气真好","工作好累啊","有没有好听的歌",
    "养宠物是什么体验","你相信星座吗","最近的热搜看了吗",
    "五一去哪旅游","你觉得AI会取代人类吗",
    "早起好难","分享一个冷知识","你喜欢看书吗","咖啡还是奶茶",
]

def _random_topic(self):
    self.topic_entry.delete(0, tk.END)
    self.topic_entry.insert(0, random.choice(RANDOM_TOPICS))
    self.chat_start()  # 自动生成开场白

开场白prompt经过优化,从最初的"别写完整句子"改为"可以说完整话也可以说短语":

prompt = (
    f"我想找微信好友聊「{topic}」,帮我想5条开场白。"
    "要像真人聊天一样随便自然,可以说完整话也可以说短语,"
    "可以加emoji。用1-5编号。"
)

2.2.10 建议卡片与交互

每条建议用深色卡片Frame承载:

card = tk.Frame(self.suggestion_frame, bg=CARD, cursor="hand2",
                highlightbackground=BG, highlightthickness=2)
num = tk.Label(card, text="●", font=("Microsoft YaHei UI", 11), 
               bg=CARD, fg=ACCENT, width=2)
lbl = tk.Label(card, text="等待中...", bg=CARD, fg=TEXT,
               anchor=tk.W, wraplength=350, padx=4, pady=8)

Hover效果(边框高亮+序号变青色):

def _card_enter(self, card, num):
    card.config(bg=HOVER, highlightbackground=ACCENT, highlightthickness=1)
    num.config(fg=CYAN)

def _card_leave(self, card, num):
    card.config(bg=CARD, highlightbackground=BG, highlightthickness=2)
    num.config(fg=ACCENT)

点击建议复制并发送:

def _copy_suggestion(self, label):
    text = label.cget("text")
    if not text or text == "等待中...": return
    self.clipboard_clear(); self.clipboard_append(text)
    # 短暂高亮绿色
    for card in self.suggestion_cards:
        if card.winfo_children()[1] == label:
            card.config(highlightbackground=GREEN, highlightthickness=2)
            self.after(500, lambda c=card: 
                c.config(highlightbackground=BG, highlightthickness=2))
    self.withdraw()           # 隐藏悬浮窗
    self.after(200, lambda: (
        pyautogui.hotkey('ctrl', 'a'),
        pyautogui.sleep(0.05),
        pyautogui.hotkey('ctrl', 'v'),
        self.deiconify()))    # 恢复悬浮窗

设计细节:先隐藏窗口→粘贴操作→恢复窗口,避免悬浮窗遮挡微信输入框。

2.3 运行结果

程序启动后进入赛博暗科技风主界面,整体运行流程如下:

(1)初始状态与选区

程序启动后窗口显示默认状态,功能区按钮呈紫色强调色排列。点击「⊞ 选择区域」按钮,屏幕变为30%透明度的黑色遮罩,用户拖拽鼠标框选微信聊天区域。选区完成后遮罩自动消失,状态栏显示"● 区域已选择"。
image
e1f64701efcc26e7afcedcf6895521f2

(2)主题切换 — 赛博暗→淡粉→极简白,递归遍历全部控件即时换色,不破坏功能绑定。

(3)自动聊天模式

开启🤖自动模式后全程无需手动操作:检测新消息→AI生成建议→随机1~4秒延迟→自动Ctrl+A全选→Ctrl+V粘贴→Enter发送。发送后15秒冷却,90秒无回复自动追问"还在吗"。窗口自动隐藏/恢复,对方感知为真人打字节奏。
image

(4)功能演示 — 换一批↻3秒冷却生成5条不同建议;风格切换下拉改变回复语气;快捷短语支持自定义添加/×删除,phrases.json持久化;主动聊🎲随机话题+AI生成开场白;情绪指示器😊绿/😞紫/😐青三色,华为云模式带[华为云]标签;透明/字号滑条实时生效。

修改语气风格
image
快捷短语修改
image
image

(5)正常监控模式

点击「▶ 开始」后,后台线程以2秒为间隔开始截图。感知哈希将截图缩小为16×16灰度图计算哈希值,当检测到画面变化时触发AI分析:第一步将截图base64发送至glm-4v,返回`SIDE:left + MSG;第二步将消息文本发送至glm-4-flash,按当前风格生成5条回复建议+情绪标签。建议以深色卡片承载,带hover高亮;情绪同步更新到状态栏和情绪指示器卡片。
image
image

测试场景 对方发言 AI识别 情绪判断 建议质量
日常问候 "早啊" 对方消息 中性/开心 5条可用早安回复
负面情绪 "今天好难过" 对方消息 消极 安慰类回复带😢
疑问句 "你觉得怎么样" 对方消息 中性 表达观点类回复
  • 选择区域→开始监控→截图变化→AI分析→生成5条建议→状态栏显示情绪

image
image

  • 点击建议→自动发送→等待回复→再次分析→循环
    image
    image

(5)情绪指示器
当收集到足够的上下文时,自动根据对方回复,生成对对方的情绪分析。
image
(6)设置修改
image

功能亮点

  • 快捷短语栏:[好的] [没问题] [哈哈] [哦哦] [嗯嗯]
  • 情绪指示器:深色卡片显示😊积极/😞消极/😐中性
  • 主题切换:赛博暗→淡粉→极简白即时生效

2.4 代码优缺点分析

优点详述

  1. 两步法架构——准确率从70%到90%:
    传统单模型方案要求AI同时理解截图布局和生成回复,且气泡颜色识别在不同主题下不准确。本程序拆分为视觉识别(SIDE:left/right+MSG)和文本生成两个步骤,视觉prompt压缩到3句话。位置判断"左=对方、右=自己"是微信恒定布局规则,准确率从颜色判断的~70%提升到100%。

  2. 感知哈希优化——节省80%以上API调用:
    16×16灰度图感知哈希仅当画面变化时触发AI。微信聊天界面只有文字和气泡变化影响哈希,背景微小色差被降采样过滤,API调用频率降低80%-90%,大幅节省费用。

  3. 多模型混合——国产云生态联动:
    回复生成由智谱glm-4-flash完成(擅长自然对话),情绪分析可选华为云NLP(专业情感分析)。通过huawei_gen_suggestions统一调度,华为云失败时自动回退智谱内置#情绪:xxx标签,切换无感知。

  4. 用户体验精细化:
    一键粘贴(隐藏窗口→Ctrl+A→Ctrl+V→Enter→恢复,200ms内完成)、快捷短语持久化(phrases.json,重启不丢失)、🤖自动一键启动、情绪指示器错误⚠提示、透明度/字号实时滑块、三主题即时切换、仅标题栏可拖动避免滑块误触。

  5. 内容过滤完善——建议可用率95%+:
    六道过滤:最小2字符、纯标点正则跳过、>100字符截断、引导语跳过、12条去重、空行跳过。

  6. 代码结构清晰——907行高度可维护:
    按功能分块(配置→哈希→选区→AI→华为云→主题→UI→方法),全局参数集中在文件顶部,线程安全通过self.after(0, ...)回调解耦。

缺点与改进方向

  1. 截图依赖与2秒延迟:
    PIL截图方案存在固有轮询间隔。理想方案需微信PC端hook或官方API,属学生项目暂不可行范畴。
  2. 深色模式偶有误判:
    微信深色模式下glm-4v位置识别偶有偏差,可增加极端场景训练样本或用OCR+坐标分析的纯规则方案消除。
  3. 单聊天窗口限制:
    一次仅监控一个区域,多窗口需手动重选。改进方向为自动探测微信窗口支持切换。
  4. ttk.Combobox主题兼容:
    tkinter的Combobox颜色不随主题变化,是框架固有限制。

2.5 全部代码展示

点击查看代码
# chat_assistant.py — 微信AI聊天助手
# 截图选区域 → AI看图识别对话 → 生成回复建议 → 一键粘贴

from PIL import ImageGrab, Image
import tkinter as tk
from tkinter import ttk, messagebox
from zhipuai import ZhipuAI
import threading
import time
import ctypes
import re
import os
import json
import base64
import io
import pyautogui
import pyperclip

import random

# ===================== 高DPI适配 =====================
ctypes.windll.user32.SetProcessDPIAware()

# ===================== 配置 =====================
ZHIPU_API_KEY = "9be1087d14364575b510b1f14fb328a5.DjtyFs6K8NVzisRp"
client = ZhipuAI(api_key=ZHIPU_API_KEY)

# ===================== 全局状态 =====================
chat_topics = []           # 对话话题摘要
past_suggestions = []      # 已给出的建议(避免重复)
capture_region = None      # 截图区域 (x1, y1, x2, y2)
auto_capturing = False     # 自动截图开关
auto_chat_mode = False     # 自动聊天模式

# ===================== 可调配置 =====================
CAPTURE_INTERVAL = 2        # 截图间隔(秒)
MODEL_NAME = "glm-4v"       # 视觉模型
SUGGESTIONS_COUNT = 5       # 建议条数
reply_style = "自然"         # 回复风格:自然/幽默/高冷/话唠/撩人

# ===================== 华为云大模型模式 =====================
USE_HUAWEI = False          # 是否启用华为云模式
HUAWEI_AK = "HPUABHF3ZVMSRBK1K678"              # 华为云 Access Key(填写你的)
HUAWEI_SK = "CR34n5LF3ejrhBmQ2nizWSead54SyfIRCYB2Fo9L"              # 华为云 Secret Key
HUAWEI_REGION = "cn-north-4"  # 区域
HUAWEI_PROJECT_ID = "c2bb173b42184371a03e95b93e9f1ef3"  # 项目ID
RANDOM_TOPICS = [
    "周末去哪玩","最近看什么剧","推荐一家好吃的","今天天气真好",
    "工作好累啊","有没有好听的歌","养宠物是什么体验","你相信星座吗",
    "最近的热搜看了吗","五一去哪旅游","你觉得AI会取代人类吗",
    "早起好难","分享一个冷知识","你喜欢看书吗","咖啡还是奶茶",
]
quick_phrases = ["好的","没问题","哈哈","哦哦","嗯嗯"]  # 快捷短语

# ===================== 短语持久化 =====================
PHRASE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "phrases.json")
def load_phrases():
    global quick_phrases
    try:
        with open(PHRASE_FILE, "r", encoding="utf-8") as f:
            data = json.load(f)
            if isinstance(data, list) and len(data) > 0:
                quick_phrases = data
    except:
        pass

def save_phrases():
    with open(PHRASE_FILE, "w", encoding="utf-8") as f:
        json.dump(quick_phrases, f, ensure_ascii=False)

load_phrases()  # 启动时加载

# ================ 感知哈希(检测截图变化)==================
def img_hash(img):
    small = img.resize((16, 16)).convert('L')
    pixels = list(small.getdata())
    avg = sum(pixels) / len(pixels)
    bits = ['1' if p >= avg else '0' for p in pixels]
    return hex(int(''.join(bits), 2))

def img_to_base64(img):
    buf = io.BytesIO()
    img.save(buf, format="JPEG", quality=75)
    return base64.b64encode(buf.getvalue()).decode()

# ================ 选区(半透明覆盖 + 屏幕坐标)==================
def select_screen_area(parent):
    global capture_region
    sel = {"region": None}
    win = tk.Toplevel(parent)
    win.attributes('-topmost', True)
    win.overrideredirect(True)
    sw = win.winfo_screenwidth()
    sh = win.winfo_screenheight()
    win.geometry(f"{sw}x{sh}+0+0")
    win.attributes('-alpha', 0.3)
    win.configure(bg='black')
    canvas = tk.Canvas(win, cursor="cross", bg="black", highlightthickness=0)
    canvas.pack(fill=tk.BOTH, expand=True)
    canvas.create_text(sw // 2, 30, text="拖动鼠标选择聊天区域 | 右键取消",
                       fill="white", font=("微软雅黑", 14, "bold"))
    start_x = start_y = 0; rect_id = None
    def on_press(event):
        nonlocal start_x, start_y, rect_id
        start_x, start_y = event.x, event.y
        rect_id = canvas.create_rectangle(0, 0, 0, 0, outline='#ff3333', width=4, dash=(6, 3))
    def on_drag(event):
        canvas.coords(rect_id, start_x, start_y, event.x, event.y)
    def on_release(event):
        x1 = min(start_x, event.x); y1 = min(start_y, event.y)
        x2 = max(start_x, event.x); y2 = max(start_y, event.y)
        if (x2 - x1) > 20 and (y2 - y1) > 20:
            sel["region"] = (x1, y1, x2, y2)
        win.destroy()
    def on_right_click(event):
        win.destroy()
    canvas.bind('<Button-1>', on_press)
    canvas.bind('<B1-Motion>', on_drag)
    canvas.bind('<ButtonRelease-1>', on_release)
    canvas.bind('<Button-3>', on_right_click)
    parent.wait_window(win)
    capture_region = sel["region"]
    return sel["region"]

# ================ 核心:两步法生成建议 ================
READ_PROMPT = (
    "截图最底部一条气泡在左边还是右边?\n"
    "只回复:\n"
    "SIDE:left 或 SIDE:right\n"
    "MSG:气泡的文字"
)

def parse_vision_response(desc):
    """解析 SIDE:left/right + MSG:内容"""
    is_left = 'left' in desc.lower() and ('right' not in desc.lower() or 
              desc.lower().find('left') < desc.lower().find('right'))
    m = re.search(r'(?:MSG|Text|内容|消息)\s*[::]\s*(.+)', desc, re.IGNORECASE)
    other_msg = m.group(1).strip() if m else ""
    return is_left, other_msg

def gen_suggestions(other_msg, style="自然", past=None):
    """生成回复建议 + 情绪分析"""
    prompt = (
        f"对方说:「{other_msg}」\n"
        f"我的风格:{style}\n"
        f"5条回复 + 一行对方情绪分析(#情绪:xx)。序号。别解释。"
    )
    if past:
        prompt += f"\n别建议这些:{'、'.join(past[-6:])}"
    res = client.chat.completions.create(
        model='glm-4-flash', messages=[{"role":"user","content":prompt}])
    reply = res.choices[0].message.content.strip()
    sug = []; mood = ""
    for line in reply.split("\n"):
        line = line.strip()
        # 提取情绪标签
        m = re.search(r'#\s*情绪\s*[::]\s*(.+)', line)
        if m:
            if line.startswith("#"):  # 独立行
                mood = m.group(1).strip()
                continue
            line = line[:m.start()].strip()  # 内联则剥离
        if not line: continue
        line = re.sub(r'^\d+[\.\、\)]\s*', '', line).strip()
        line = re.sub(r'^[\*\-\•]\s*', '', line).strip()
        # 跳过只有标点/空号/太短的建议
        if not line or len(line) < 2: continue
        if re.match(r'^[\(\)()\[\]【】\{\}。,、;:""'']+$', line): continue
        if len(line) > 100: continue
        if line.startswith("对方") or line.startswith("我:"): continue
        sug.append(line)
        if len(sug) >= SUGGESTIONS_COUNT: break
    return sug or [reply[:80]], mood

# ================ 华为云后端 ================
def _huawei_auth():
    from huaweicloudsdkcore.auth.credentials import BasicCredentials
    from huaweicloudsdkcore.http.http_config import HttpConfig
    if not HUAWEI_AK or not HUAWEI_SK:
        raise RuntimeError("请先填写华为云 AK/SK")
    return BasicCredentials(HUAWEI_AK, HUAWEI_SK)

def huawei_chat(other_msg, style="自然", past=None):
    """华为云盘古NLP大模型 — 生成回复"""
    from huaweicloudsdkcore.region.region import Region
    from huaweicloudsdkcore.region.provider import ProfileRegionProvider
    auth = _huawei_auth()
    # 盘古大模型调用:POST /v1/{project_id}/deployments/{deployment_id}/chat/completions
    import requests, json
    url = f"https://{HUAWEI_REGION}.myhuaweicloud.com/v1/pangu/chat/completions"
    headers = {"Content-Type": "application/json"}
    # 盘古API需要token,用AK/SK获取
    # 简化:用IAM token方式
    prompt = f"对方说:「{other_msg}」。我的风格:{style}。5条回复+#情绪:xx。用序号。"
    if past:
        prompt += f" 别建议:{'、'.join(past[-6:])}"
    # TODO: 用户填写AK/SK后,替换为真实盘古API调用
    # 当前返回占位,走智谱兜底
    raise NotImplementedError("华为云盘古API待配置AK/SK后启用")

def huawei_sentiment(text):
    """华为云NLP情感分析 — 需先开通NLP服务"""
    try:
        from huaweicloudsdkcore.auth.credentials import BasicCredentials
        from huaweicloudsdkcore.http.http_config import HttpConfig
        from huaweicloudsdknlp.v2 import NlpClient
        from huaweicloudsdknlp.v2.model import RunSentimentRequest, HWCloudSentimentReq

        if not HUAWEI_AK or not HUAWEI_SK:
            return "未配置AK"

        config = HttpConfig.get_default_config()
        config.ignore_ssl_verification = True

        ep = f"https://nlp-ext.{HUAWEI_REGION}.myhuaweicloud.com"
        client = NlpClient.new_builder() \
            .with_credentials(BasicCredentials(HUAWEI_AK, HUAWEI_SK, project_id=HUAWEI_PROJECT_ID)) \
            .with_endpoint(ep) \
            .with_http_config(config) \
            .build()

        body = HWCloudSentimentReq(content=text, lang="zh")
        req = RunSentimentRequest(body=body)
        resp = client.run_sentiment(req)
        label = resp.result.label
        return {0: "消极", 1: "积极"}.get(label, "中性")
    except Exception as e:
        err = str(e)
        if "401" in err:
            return "API错误:401认证失败-检查NLP服务是否开通"
        if "APIG" in err:
            return "API错误:服务未开通"
        if "endpoint" in err.lower():
            return "API错误:区域/端点错误"
        return f"API错误:{err[:30]}..."

def huawei_gen_suggestions(other_msg, style="自然", past=None):
    """华为云模式: 智谱生成回复 + 华为云情绪分析"""
    sug, glm_mood = gen_suggestions(other_msg, style, past)
    hw_mood = huawei_sentiment(other_msg) if HUAWEI_AK and HUAWEI_SK else ""
    # 只有成功返回"积极"/"消极"才用华为云结果,否则显示错误原因
    if hw_mood in ("积极", "消极", "中性"):
        return sug, f"[华为云] {hw_mood}"
    if hw_mood in ("未开通", "未配置AK") or "API错误" in hw_mood:
        return sug, f"[华为云] {hw_mood}"
    return sug, glm_mood

def auto_capture_task(update_suggestions, update_status, update_ocr_preview, update_mood=None):
    """后台线程:定时截图 → 哈希检测 → 视觉AI识别 → 生成建议"""
    global auto_capturing, chat_topics, past_suggestions, auto_chat_mode

    last_hash = ""
    tick = 0
    auto_sent_at = 0
    last_reply_at = 0

    while auto_capturing and capture_region:
        tick += 1
        try:
            # 1. 截图
            x1, y1, x2, y2 = capture_region
            user32 = ctypes.windll.user32
            max_w = user32.GetSystemMetrics(0); max_h = user32.GetSystemMetrics(1)
            x1 = max(0, min(x1, max_w - 1)); y1 = max(0, min(y1, max_h - 1))
            x2 = max(x1 + 1, min(x2, max_w)); y2 = max(y1 + 1, min(y2, max_h))
            img = ImageGrab.grab(bbox=(x1, y1, x2, y2))

            # 2. 哈希检测变化
            cur_hash = img_hash(img)
            if cur_hash == last_hash:
                time.sleep(CAPTURE_INTERVAL); continue
            if auto_chat_mode and time.time() - auto_sent_at < 15:
                time.sleep(CAPTURE_INTERVAL); continue
            last_hash = cur_hash

            # 3. 自动追问(对方90秒未回复,且已过冷却期)
            if auto_chat_mode and last_reply_at > 0 and time.time() - last_reply_at > 90:
                update_status(f"V-{tick}: 对方没回,主动询问...")
                update_suggestions(["__AUTO_SEND__", "还在吗"])
                auto_sent_at = time.time()
                time.sleep(10); continue

            update_status(f"V-{tick}: 截图变化,分析中{'[华为云]' if USE_HUAWEI else ''}...")
            update_suggestions(["分析中..."] * SUGGESTIONS_COUNT)
            b64 = img_to_base64(img)

            try:
                # 第1步:视觉模型列出所有气泡
                res = client.chat.completions.create(
                    model=MODEL_NAME,
                    messages=[{"role":"user","content":[
                        {"type":"text","text":READ_PROMPT},
                        {"type":"image_url","image_url":{"url":f"data:image/jpeg;base64,{b64}"}}
                    ]}])
                desc = res.choices[0].message.content.strip()
                is_other, other_msg = parse_vision_response(desc)

                if not is_other:
                    update_status(f"V-{tick}: 等对方(最后是你说的话)")
                    time.sleep(CAPTURE_INTERVAL); continue
                if not other_msg:
                    update_status(f"V-{tick}: 未识别消息")
                    time.sleep(CAPTURE_INTERVAL); continue

                # 第2步:生成回复
                suggestions, mood = (huawei_gen_suggestions if USE_HUAWEI else gen_suggestions)(
                    other_msg, reply_style, past_suggestions)

                # 存已给建议(防重复),但清空话题上下文避免干扰
                for s in suggestions[:2]:
                    past_suggestions.append(s)
                if len(past_suggestions) > 12: past_suggestions[:] = past_suggestions[-12:]
                chat_topics.clear()  # 清空历史话题,避免上下文干扰
                last_reply_at = time.time()

                update_suggestions(suggestions)
                if update_mood: update_mood(mood)
                if mood:
                    update_status(f"V-{tick}: 对方情绪:{mood} | 已获取{len(suggestions)}条建议")

                if auto_chat_mode and suggestions and suggestions[0]:
                    auto_sent_at = time.time()
                    update_suggestions(["__AUTO_SEND__"] + suggestions)

            except Exception as e:
                update_status(f"AI请求失败: {str(e)[:60]}")

        except Exception as e:
            update_status(f"截图失败: {str(e)[:60]}")

        time.sleep(CAPTURE_INTERVAL)

    update_suggestions([])

# ================ 主题系统 ================
THEMES = {
    "赛博暗": dict(BG="#0a0a14", CARD="#131325", ACCENT="#7c5cff", CYAN="#00e5ff",
                   TEXT="#dde4f0", SUB="#6b6b8a", GREEN="#00ff88", GLOW="#1a1a35", HOVER="#1e1e38"),
    "淡粉":   dict(BG="#fff5f5", CARD="#ffffff", ACCENT="#e8878a", CYAN="#e8878a",
                   TEXT="#4a3340", SUB="#c4a8b0", GREEN="#27ae60", GLOW="#ffe8eb", HOVER="#ffe8eb"),
    "极简白": dict(BG="#f5f5f5", CARD="#ffffff", ACCENT="#333333", CYAN="#333333",
                   TEXT="#222222", SUB="#999999", GREEN="#27ae60", GLOW="#e8e8e8", HOVER="#e8e8e8"),
}
_current_theme = "赛博暗"
FONT_SIZE = 10  # 全局建议字号

# 从当前主题加载颜色
def _load_theme(theme_name):
    t = THEMES[theme_name]
    for k, v in t.items():
        globals()[k] = v
    return t

_theme = _load_theme(_current_theme)

class FloatWindow(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("聊天助手")
        self.overrideredirect(True)
        self.wm_attributes("-topmost", True)
        self.wm_attributes("-transparentcolor", "#010101")
        self.geometry("500x800+100+80")
        self.configure(bg="#010101")

        self._anim_id = None
        self._pulse = 0
        self._scan_x = 0
        self._scan_id = None
        self._refresh_cooldown = 0
        self._init_ui()

    def _start_glow_anim(self):
        def pulse():
            if not self.winfo_exists(): return
            self._pulse = (self._pulse + 1) % 60
            # 标题发光脉冲
            c = int(150 + 50 * (1 + __import__('math').sin(self._pulse * 0.1)) / 2)
            color = f"#{min(255,c+50):02x}{min(255,c):02x}{min(255,c+80):02x}"
            try: self._glow_label.config(fg=color)
            except: pass
            self._anim_id = self.after(50, pulse)
        pulse()

    def _add_hover(self, widget, normal, hover_color):
        widget.bind("<Enter>", lambda e: widget.config(fg=hover_color))
        widget.bind("<Leave>", lambda e: widget.config(fg=normal))

    def _card_enter(self, card, num):
        card.config(bg=HOVER, highlightbackground=ACCENT, highlightthickness=1)
        num.config(fg=CYAN)

    def _card_leave(self, card, num):
        card.config(bg=CARD, highlightbackground=BG, highlightthickness=2)
        num.config(fg=ACCENT)

    def _style_combobox(self, cb):
        style = ttk.Style()
        style.theme_use('clam')
        style.configure('TCombobox', fieldbackground=CARD, background=CARD, foreground=TEXT,
                        arrowcolor=SUB, selectbackground=ACCENT, selectforeground='#fff')
        style.map('TCombobox', fieldbackground=[('readonly', CARD)])

    def _init_ui(self):
        main = tk.Frame(self, bg=BG, bd=0)
        main.pack(fill=tk.BOTH, expand=True)

        # === 标题栏 ===
        tf = tk.Frame(main, bg=BG, height=50); tf.pack(fill=tk.X); tf.pack_propagate(False)
        tk.Frame(tf, bg=ACCENT, height=2).pack(fill=tk.X)
        tb = tk.Frame(tf, bg=BG); tb.pack(fill=tk.BOTH, expand=True)
        self._enable_title_drag(tb)

        # 华为云指示器(标题栏内)
        self._hw_btn = tk.Button(tb, text="☁ 华为云 OFF", font=("Microsoft YaHei UI", 8),
                                  bg=BG, fg=SUB, bd=1, padx=8, pady=1,
                                  cursor="hand2", activebackground=GLOW, activeforeground=CYAN,
                                  relief=tk.SOLID, highlightthickness=0,
                                  command=self._toggle_huawei)
        self._hw_btn.pack(side=tk.RIGHT, padx=(0,6))
        self._hw_glow_id = None

        self._glow_label = tk.Label(tb, text="AI 聊天助手", font=("Microsoft YaHei UI", 12, "bold"), bg=BG, fg=CYAN)
        self._glow_label.pack(side=tk.LEFT, padx=16, pady=8)
        cls = tk.Button(tb, text="\u2715", font=("Microsoft YaHei UI", 14), bg=BG, fg=SUB, bd=0,
                        cursor="hand2", command=self._on_close, activebackground=BG, activeforeground=ACCENT)
        cls.pack(side=tk.RIGHT, padx=14, pady=8)
        self._add_hover(cls, SUB, ACCENT)
        tk.Frame(tf, bg=ACCENT, height=1).pack(fill=tk.X)

        # === 工具栏 ===
        tb2 = tk.Frame(main, bg=BG, height=70); tb2.pack(fill=tk.X); tb2.pack_propagate(False)
        for ri, btns in enumerate([
            [("⊞ 选择区域", self.select_area), ("▶ 开始", self.start_auto), ("■ 停止", self.stop_auto)],
            [("↻ 换一批", self.regenerate), ("⚙ 设置", self.open_settings), ("🤖 自动", self.toggle_auto)],
        ]):
            r = tk.Frame(tb2, bg=BG); r.pack(fill=tk.X, padx=6, pady=(2,0))
            for text, cmd in btns:
                b = tk.Button(r, text=text, font=("Microsoft YaHei UI", 9), bg=CARD, fg=TEXT, bd=0,
                              padx=10, pady=3, cursor="hand2", activebackground=GLOW, activeforeground=CYAN)
                b.pack(side=tk.LEFT, padx=2)
                self._add_hover(b, TEXT, CYAN)
                b.config(command=cmd)

        # 科技扫描线
        self._scan_line = tk.Frame(main, bg=ACCENT, height=1)
        self._scan_line.pack(fill=tk.X, padx=10)
        self._scan_x = 0
        self._start_scan()

        # === 主动聊 ===
        af = tk.Frame(main, bg=CARD, height=34); af.pack(fill=tk.X, padx=8, pady=(2,4)); af.pack_propagate(False)
        tk.Label(af, text="主动聊", font=("Microsoft YaHei UI", 8), bg=CARD, fg=SUB).pack(side=tk.LEFT, padx=(6,2), pady=6)
        self.topic_entry = tk.Entry(af, font=("Microsoft YaHei UI", 9), bg=BG, fg=TEXT, bd=0, insertbackground=CYAN, takefocus=0)
        self.topic_entry.pack(side=tk.LEFT, padx=2, pady=4, fill=tk.X, expand=True)
        self.topic_entry.bind("<Return>", lambda e: self.chat_start())
        tk.Button(af, text="🎲", command=self._random_topic, font=("Microsoft YaHei UI", 11),
                  bg=BG, fg=CYAN, bd=0, padx=6, cursor="hand2",
                  activebackground=GLOW, activeforeground=ACCENT).pack(side=tk.LEFT)
        tk.Button(af, text="生成", command=self.chat_start, font=("Microsoft YaHei UI", 9),
                  bg=ACCENT, fg="#fff", bd=0, padx=10, cursor="hand2", activebackground="#6a4ae0").pack(side=tk.LEFT, padx=4)

        # === 状态 + 风格 ===
        row = tk.Frame(main, bg=BG, height=28); row.pack(fill=tk.X, padx=10); row.pack_propagate(False)
        self.status_var = tk.StringVar(value="● 等待选择区域...")
        tk.Label(row, textvariable=self.status_var, font=("Microsoft YaHei UI", 8), bg=BG, fg=SUB).pack(side=tk.LEFT)
        tk.Label(row, text="角色", font=("Microsoft YaHei UI", 8), bg=BG, fg=SUB).pack(side=tk.RIGHT, padx=(0,2))
        self.style_var = tk.StringVar(value=reply_style)
        cb = ttk.Combobox(row, textvariable=self.style_var, values=["自然","幽默","高冷","话唠","撩人"], width=6, state="readonly")
        cb.pack(side=tk.RIGHT, padx=(0,4))
        cb.bind("<<ComboboxSelected>>", self._on_style_change)
        self._style_combobox(cb)

        # === 情绪指示器 ===
        mood_row = tk.Frame(main, bg=CARD, height=34); mood_row.pack(fill=tk.X, padx=8, pady=(4,0)); mood_row.pack_propagate(False)
        self._mood_label = tk.Label(mood_row, text="对方情绪:等待分析...", font=("Microsoft YaHei UI", 10, "bold"),
                                     bg=CARD, fg=SUB, anchor=tk.CENTER)
        self._mood_label.pack(fill=tk.BOTH, expand=True)

        # === 透明度 + 字号 ===
        ad_row = tk.Frame(main, bg=BG); ad_row.pack(fill=tk.X, padx=10, pady=(2,0))

        # 透明
        a_row = tk.Frame(ad_row, bg=BG); a_row.pack(fill=tk.X)
        tk.Label(a_row, text="透明", font=("Microsoft YaHei UI", 8), bg=BG, fg=SUB, width=4).pack(side=tk.LEFT)
        self._alpha_var = tk.DoubleVar(value=1.0)
        self._alpha_lbl = tk.Label(a_row, text="100%", font=("Microsoft YaHei UI", 8), bg=BG, fg=CYAN, width=4)
        self._alpha_lbl.pack(side=tk.RIGHT)
        a_sc = tk.Scale(a_row, from_=30, to=100, orient=tk.HORIZONTAL, showvalue=0,
                        bg=BG, fg=SUB, bd=0, highlightthickness=0,
                        command=lambda v: (self.wm_attributes('-alpha', int(v)/100),
                                           self._alpha_lbl.config(text=f"{int(v)}%")))
        a_sc.pack(side=tk.RIGHT, padx=4, fill=tk.X, expand=True)
        a_sc.config(troughcolor=CARD, activebackground=ACCENT)

        # 字号
        f_row = tk.Frame(ad_row, bg=BG); f_row.pack(fill=tk.X)
        tk.Label(f_row, text="字号", font=("Microsoft YaHei UI", 8), bg=BG, fg=SUB, width=4).pack(side=tk.LEFT)
        self._font_var = tk.IntVar(value=FONT_SIZE)
        self._font_lbl = tk.Label(f_row, text=str(FONT_SIZE), font=("Microsoft YaHei UI", 8), bg=BG, fg=CYAN, width=4)
        self._font_lbl.pack(side=tk.RIGHT)
        f_sc = tk.Scale(f_row, from_=8, to=16, orient=tk.HORIZONTAL, showvalue=0,
                        bg=BG, fg=SUB, bd=0, highlightthickness=0,
                        command=lambda v: (self._change_font(int(v)),
                                           self._font_lbl.config(text=f"{int(v)}")))
        f_sc.pack(side=tk.RIGHT, padx=4, fill=tk.X, expand=True)
        f_sc.config(troughcolor=CARD, activebackground=ACCENT)

        # === 建议区 ===
        tk.Frame(main, bg=ACCENT, height=1).pack(fill=tk.X, padx=10, pady=(4,2))
        tk.Label(main, text="◆ 回复建议", font=("Microsoft YaHei UI", 10, "bold"), bg=BG, fg=CYAN).pack(anchor=tk.W, padx=14)

        self.scroll_canvas = tk.Canvas(main, bg=BG, bd=0, highlightthickness=0, height=280)
        self.scroll_canvas.pack(fill=tk.BOTH, expand=True, padx=4, pady=(0,2))
        sb = tk.Scrollbar(self, orient=tk.VERTICAL, command=self.scroll_canvas.yview, bg=CARD, troughcolor=BG)
        sb.pack(side=tk.RIGHT, fill=tk.Y)
        self.scroll_canvas.configure(yscrollcommand=sb.set)
        self.suggestion_frame = tk.Frame(self.scroll_canvas, bg=BG)
        self.scroll_window = self.scroll_canvas.create_window((0,0), window=self.suggestion_frame, anchor=tk.NW)
        self.suggestion_frame.bind("<Configure>", lambda e: self.scroll_canvas.configure(scrollregion=self.scroll_canvas.bbox("all")))
        self.scroll_canvas.bind("<Configure>", lambda e: self.scroll_canvas.itemconfig(self.scroll_window, width=e.width-2))
        def _mw(event): self.scroll_canvas.yview_scroll(-1*(event.delta//120), "units")
        self.scroll_canvas.bind("<Enter>", lambda e: self.bind_all("<MouseWheel>", _mw))
        self.scroll_canvas.bind("<Leave>", lambda e: self.unbind_all("<MouseWheel>"))

        self.suggestion_labels = []; self.suggestion_cards = []
        for i in range(SUGGESTIONS_COUNT):
            card = tk.Frame(self.suggestion_frame, bg=CARD, cursor="hand2", highlightbackground=BG, highlightthickness=2)
            card.pack(fill=tk.X, pady=2)
            num = tk.Label(card, text="●", font=("Microsoft YaHei UI", 11), bg=CARD, fg=ACCENT, width=2)
            num.pack(side=tk.LEFT, padx=(4,0), pady=8)
            lbl = tk.Label(card, text="等待中...", font=("Microsoft YaHei UI", FONT_SIZE), bg=CARD, fg=TEXT,
                           anchor=tk.W, wraplength=350, padx=4, pady=8)
            lbl.pack(side=tk.LEFT, fill=tk.X, expand=True)
            card.bind("<Button-1>", lambda e, l=lbl: self._copy_suggestion(l))
            lbl.bind("<Button-1>", lambda e, l=lbl: self._copy_suggestion(l))
            card.bind("<Enter>", lambda e, c=card, n=num: self._card_enter(c, n))
            card.bind("<Leave>", lambda e, c=card, n=num: self._card_leave(c, n))
            self.suggestion_labels.append(lbl); self.suggestion_cards.append(card)

        # === 快捷短语(常驻底部,支持换行)===
        self._phrase_bar = tk.Frame(main, bg=BG); self._phrase_bar.pack(side=tk.BOTTOM, fill=tk.X, padx=4)
        self._refresh_phrases()

        # === 可展开功能面板 ===
        self._panel_open = False
        self.panel_btn = tk.Button(main, text="✦ 更多功能", font=("Microsoft YaHei UI", 9, "bold"),
                                    bg=ACCENT, fg="#fff", bd=0, padx=16, pady=3,
                                    cursor="hand2", activebackground="#6a4ae0",
                                    command=self._toggle_panel)
        self.panel_btn.pack(side=tk.BOTTOM, pady=(2,0))

        self._panel_frame = tk.Frame(main, bg=CARD, bd=0)
        self._panel_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=6)
        self._panel_frame.pack_forget()

        # --- 自定义短语 ---
        cq = tk.Frame(self._panel_frame, bg=CARD)
        cq.pack(fill=tk.X, padx=4, pady=(6,2))
        self._custom_entry = tk.Entry(cq, font=("Microsoft YaHei UI", 8), bg=BG, fg=TEXT,
                                       bd=0, insertbackground=CYAN)
        self._custom_entry.pack(side=tk.LEFT, padx=(0,2), fill=tk.X, expand=True)
        self._custom_entry.bind("<Return>", lambda e: self._add_custom_phrase())
        tk.Button(cq, text="添加短语", font=("Microsoft YaHei UI", 8),
                  bg=ACCENT, fg="#fff", bd=0, padx=8, pady=1,
                  cursor="hand2", activebackground="#6a4ae0",
                  command=self._add_custom_phrase).pack(side=tk.LEFT)

        # --- 功能按钮 ---
        fr = tk.Frame(self._panel_frame, bg=CARD)
        fr.pack(fill=tk.X, padx=4, pady=(2,2))
        for text, cmd in [("清空建议", self._clear_suggestions),
                          ("停止监控", self.stop_auto),
                          ("查看短语", self._show_common)]:
            b = tk.Button(fr, text=text, font=("Microsoft YaHei UI", 8), bg=BG, fg=SUB,
                          bd=0, padx=8, pady=2, cursor="hand2", command=cmd,
                          activebackground=GLOW, activeforeground=CYAN)
            b.pack(side=tk.LEFT, padx=2)
            self._add_hover(b, SUB, CYAN)

        # --- 主题切换 ---
        tr = tk.Frame(self._panel_frame, bg=CARD)
        tr.pack(fill=tk.X, padx=4, pady=2)
        tk.Label(tr, text="主题", font=("Microsoft YaHei UI", 8), bg=CARD, fg=SUB).pack(side=tk.LEFT)
        self._theme_var = tk.StringVar(value=_current_theme)
        cb_theme = ttk.Combobox(tr, textvariable=self._theme_var, values=list(THEMES.keys()), width=8, state="readonly")
        cb_theme.pack(side=tk.LEFT, padx=4)
        cb_theme.bind("<<ComboboxSelected>>", self._change_theme)
        self._style_combobox(cb_theme)

        self._bottom_label = tk.Label(main, text="", font=("Microsoft YaHei UI", 7), bg=BG, fg=SUB)
        self._bottom_label.pack(side=tk.BOTTOM, pady=(0,2))

    # ============ 拖动窗口(仅标题栏)============
    def _enable_title_drag(self, widget):
        widget.bind("<Button-1>", lambda e: setattr(self, '_dx', e.x) or setattr(self, '_dy', e.y))
        widget.bind("<B1-Motion>", lambda e: self.geometry(
            f"+{self.winfo_x()+e.x-self._dx}+{self.winfo_y()+e.y-self._dy}"))

    # ============ 按钮功能 ============
    def select_area(self):
        r = select_screen_area(self)
        self.status_var.set(f"● 区域已选择 ({r[2]-r[0]}x{r[3]-r[1]})" if r else "● 已取消选区")

    def start_auto(self):
        global auto_capturing
        if not capture_region:
            self.status_var.set("● 请先选择截图区域"); return
        auto_capturing = True
        self.status_var.set("● 正在监控聊天...")
        threading.Thread(target=auto_capture_task,
            args=(self._update_suggestions, self._update_status, lambda x: None, self._update_mood), daemon=True).start()

    def stop_auto(self):
        global auto_capturing, auto_chat_mode
        auto_capturing = False; auto_chat_mode = False
        self.status_var.set("● 监控已停止")

    def toggle_auto(self):
        global auto_chat_mode, auto_capturing
        auto_chat_mode = not auto_chat_mode
        if auto_chat_mode and not auto_capturing:
            self.start_auto()  # 自动开启监控
        self.status_var.set("● 自动聊天模式已开启" if auto_chat_mode else "● 自动模式已关闭")

    def _on_style_change(self, e=None):
        global reply_style
        reply_style = self.style_var.get()
        self.status_var.set(f"● 风格:{reply_style}")

    def _send_quick(self, text):
        pyperclip.copy(text)
        self.withdraw()
        self.after(150, lambda: (pyautogui.hotkey('ctrl', 'v'), self.deiconify()))
        self.status_var.set(f"● 已发送「{text}」")

    def _update_mood(self, mood_text):
        if not mood_text:
            self._mood_label.config(text="对方情绪:等待分析...", fg=SUB, bg=CARD)
            return
        if "API错误" in mood_text or "未开通" in mood_text:
            self._mood_label.config(text=f"⚠ {mood_text}", fg="#ff6b6b", bg=CARD)
            return
        mood_map = {"积极": ("😊 " + mood_text, GREEN, CARD),
                    "消极": ("😞 " + mood_text, ACCENT, CARD),
                    "中性": ("😐 " + mood_text, CYAN, CARD)}
        text, color, bg = mood_map.get(
            mood_text.replace("[华为云] ", "").strip(), (mood_text, CYAN, GLOW))
        self.after(0, lambda: self._mood_label.config(text=text, fg=color, bg=bg))

    def _random_topic(self):
        self.topic_entry.delete(0, tk.END)
        self.topic_entry.insert(0, random.choice(RANDOM_TOPICS))
        self.chat_start()

    def _toggle_panel(self):
        self._panel_open = not self._panel_open
        if self._panel_open:
            self.panel_btn.config(text="▼ 收起面板")
            self._panel_frame.pack(side=tk.BOTTOM, fill=tk.X, padx=6, before=self.panel_btn)
        else:
            self.panel_btn.config(text="✦ 功能面板")
            self._panel_frame.pack_forget()

    def _refresh_phrases(self):
        """重建快捷短语按钮"""
        for w in self._phrase_bar.winfo_children():
            w.destroy()
        for i, phrase in enumerate(quick_phrases):
            f = tk.Frame(self._phrase_bar, bg=BG)
            f.pack(side=tk.LEFT, padx=1, pady=2)
            b = tk.Button(f, text=phrase, font=("Microsoft YaHei UI", 8), bg=CARD, fg=TEXT, bd=0, padx=6,
                          cursor="hand2", command=lambda p=phrase: self._send_quick(p),
                          activebackground=GLOW, activeforeground=CYAN)
            b.pack(side=tk.LEFT)
            self._add_hover(b, TEXT, CYAN)
            # 右击删除
            x = tk.Label(f, text="×", font=("Microsoft YaHei UI", 7), bg=BG, fg=SUB, cursor="hand2")
            x.pack(side=tk.LEFT, padx=(0,2))
            x.bind("<Button-1>", lambda e, p=phrase, fr=f: self._del_phrase(p, fr))

    def _del_phrase(self, phrase, frame):
        global quick_phrases
        if phrase in quick_phrases:
            quick_phrases.remove(phrase)
        save_phrases()
        frame.destroy()
        self.status_var.set(f"● 已删除:「{phrase}」")

    def _add_custom_phrase(self):
        text = self._custom_entry.get().strip()
        if not text: return
        global quick_phrases
        if text in quick_phrases:
            self.status_var.set(f"●「{text}」已存在")
            return
        quick_phrases.append(text)
        save_phrases()  # 持久化
        self._custom_entry.delete(0, tk.END)
        self._refresh_phrases()  # 重建按钮
        self.status_var.set(f"● 已添加:「{text}」")

    def _clear_suggestions(self):
        self._update_suggestions([])

    def _show_common(self):
        messagebox.showinfo("常用短语", " | ".join(quick_phrases))

    def _toggle_huawei(self):
        global USE_HUAWEI
        USE_HUAWEI = not USE_HUAWEI
        if USE_HUAWEI:
            self._hw_btn.config(text="☁ 华为云 ON", fg=CYAN, bg=GLOW,
                                highlightbackground=ACCENT, highlightthickness=1)
            self._start_hw_glow()
            self.status_var.set("● 华为云模式:ON,情绪分析走华为云")
        else:
            self._hw_btn.config(text="☁ 华为云 OFF", fg=SUB, bg=BG,
                                highlightbackground=BG, highlightthickness=0)
            if self._hw_glow_id:
                self.after_cancel(self._hw_glow_id)
                self._hw_glow_id = None
            self.status_var.set("● 华为云模式:OFF,全部走智谱")

    def _start_hw_glow(self):
        if not USE_HUAWEI or not self.winfo_exists(): return
        import math
        pulse = int(time.time() * 3) % 30
        c = int(100 + 80 * (1 + math.sin(pulse * 0.3)) / 2)
        self._hw_btn.config(bg=f"#{c:02x}{c//2:02x}{min(255,c+50):02x}")
        self._hw_glow_id = self.after(200, self._start_hw_glow)

    def _start_scan(self):
        if not self.winfo_exists(): return
        self._scan_x = (self._scan_x + 5) % 500
        self._scan_id = self.after(30, self._start_scan)

    def _change_theme(self, e=None):
        global _current_theme
        _current_theme = self._theme_var.get()
        t = THEMES[_current_theme]
        for k, v in t.items(): globals()[k] = v
        self.configure(bg=BG)
        self._paint(self)
        self.status_var.set(f"● 主题:{_current_theme}")

    def _paint(self, parent):
        """递归重绘所有控件颜色"""
        for w in parent.winfo_children():
            try:
                c = w.winfo_class()
                if c == 'Frame':
                    w.config(bg=CARD)
                elif c == 'Label':
                    txt = w.cget('text')
                    is_title = txt in ('AI 聊天助手','◆ 回复建议') or 'bold' in str(w.cget('font'))
                    w.config(fg=CYAN if is_title else (SUB if txt and ('情绪' in txt or '透明' in txt or '字号' in txt or '角色' in txt or '主题' in txt or txt.startswith('●')) else TEXT), bg=BG)
                elif c == 'Button':
                    txt = w.cget('text')
                    if txt in ('✦ 更多功能','▼ 收起面板','生成') or (txt and len(txt)>1 and '添加' in txt):
                        w.config(bg=ACCENT, fg='#fff')
                    elif txt and '华为云' in txt:
                        w.config(bg=GLOW if 'ON' in txt else CARD, fg=CYAN if 'ON' in txt else SUB)
                    else:
                        w.config(bg=CARD, fg=TEXT)
                elif c == 'Canvas':
                    w.config(bg=BG)
                if isinstance(w, tk.Entry):
                    w.config(bg=BG, fg=TEXT, insertbackground=CYAN)
                if isinstance(w, tk.Scale):
                    w.config(bg=BG, fg=SUB, troughcolor=CARD)
                if 'Scrollbar' in c:
                    w.config(bg=CARD, troughcolor=BG)
            except:
                pass
            self._paint(w)

    def _change_font(self, size):
        global FONT_SIZE
        FONT_SIZE = size
        for lbl in self.suggestion_labels:
            lbl.config(font=("Microsoft YaHei UI", size))

    # ============ 换一批(截图+视觉识别+生成)============
    def regenerate(self):
        if time.time() - self._refresh_cooldown < 3:
            self.status_var.set("● 请稍后再试 (3秒冷却)"); return
        if not capture_region:
            self.status_var.set("● 请先选择截图区域"); return
        self._refresh_cooldown = time.time()
        self.status_var.set("● 正在截图分析...")
        self._update_suggestions(["思考中..."] * SUGGESTIONS_COUNT)

        def _task():
            global past_suggestions
            try:
                x1, y1, x2, y2 = capture_region
                img = ImageGrab.grab(bbox=(x1, y1, x2, y2))
                b64 = img_to_base64(img)
                res = client.chat.completions.create(model=MODEL_NAME,
                    messages=[{"role":"user","content":[
                        {"type":"text","text":READ_PROMPT},
                        {"type":"image_url","image_url":{"url":f"data:image/jpeg;base64,{b64}"}}
                    ]}])
                is_other, other_msg = parse_vision_response(res.choices[0].message.content.strip())
                if not is_other:
                    self._update_status("● 等待对方回复"); return
                if not other_msg:
                    self._update_status("● 未能识别对方消息"); return
                sug, mood = (huawei_gen_suggestions if USE_HUAWEI else gen_suggestions)(
                    other_msg, reply_style, past_suggestions)
                self._update_status(f"● 对方情绪:{mood} | {len(sug)}条建议" if mood else f"● 已获取 {len(sug)} 条建议")
                self._update_mood(mood)  # 始终更新情绪显示
                self._update_suggestions(sug)
                for s in sug[:2]: past_suggestions.append(s)
                if len(past_suggestions) > 12: past_suggestions[:] = past_suggestions[-12:]
            except Exception as e:
                self._update_status(f"● 生成失败: {str(e)[:50]}")
        threading.Thread(target=_task, daemon=True).start()

    # ============ 主动聊天 ============
    def chat_start(self):
        global chat_topics, past_suggestions
        topic = self.topic_entry.get().strip()
        if not topic:
            self.status_var.set("● 请输入聊天话题"); return
        self.status_var.set("● 正在生成开场白...")
        self._update_suggestions(["生成中..."] * SUGGESTIONS_COUNT)
        prompt = (
            f"我想找微信好友聊「{topic}」,帮我想5条开场白。"
            "要像真人聊天一样随便自然,可以说完整话也可以说短语,可以加emoji。用1-5编号。"
        )
        def _task():
            global chat_topics, past_suggestions
            try:
                res = client.chat.completions.create(model='glm-4-flash',
                    messages=[{"role":"user","content":prompt}])
                reply = res.choices[0].message.content.strip()
                sug = []
                for line in reply.split("\n"):
                    line = line.strip()
                    line = re.sub(r'^\d+[\.\、\)]\s*','',line).strip()
                    if not line or len(line) < 1 or len(line) > 100: continue
                    if any(line.startswith(w) for w in ["以下是","好的","建议"]): continue
                    sug.append(line)
                    if len(sug) >= SUGGESTIONS_COUNT: break
                self._update_suggestions(sug or [reply[:80]])
                chat_topics.clear(); past_suggestions.clear()
                chat_topics.append(f"我发起了话题:{topic}")
                for s in (sug or [reply[:80]])[:3]: past_suggestions.append(s)
                self._update_status("● 开场白就绪,对方回复后点[开始]监控")
            except Exception as e:
                self._update_status(f"● 生成失败: {str(e)[:50]}")
        threading.Thread(target=_task, daemon=True).start()

    # ============ 设置 ============
    def open_settings(self):
        global CAPTURE_INTERVAL, MODEL_NAME
        dlg = tk.Toplevel(self); dlg.title("设置"); dlg.geometry("280x240")
        dlg.configure(bg=CARD); dlg.resizable(False, False); dlg.transient(self)
        dlg.update_idletasks()
        dlg.geometry(f"+{self.winfo_x()+45}+{self.winfo_y()+160}")
        tk.Label(dlg, text="◆ 设置", font=("Microsoft YaHei UI", 12, "bold"), bg=CARD, fg=CYAN).pack(pady=(14,8))
        for label, var_name, opts in [("截图间隔(秒)","CAPTURE_INTERVAL",["1","2","3","5"])]:
            row = tk.Frame(dlg, bg=CARD); row.pack(fill=tk.X, padx=24, pady=3)
            tk.Label(row, text=label, font=("Microsoft YaHei UI",9), bg=CARD, fg=TEXT).pack(side=tk.LEFT)
            var = tk.StringVar(value=str(globals()[var_name]))
            setattr(self, f"_cfg_{var_name}", var)
            cb = ttk.Combobox(row, textvariable=var, values=opts, state="readonly", width=5)
            cb.pack(side=tk.RIGHT)
            self._style_combobox(cb)
        row_m = tk.Frame(dlg, bg=CARD); row_m.pack(fill=tk.X, padx=24, pady=3)
        tk.Label(row_m, text="AI模型", font=("Microsoft YaHei UI",9), bg=CARD, fg=TEXT).pack(side=tk.LEFT)
        self._cfg_MODEL = tk.StringVar(value=MODEL_NAME)
        tk.Entry(row_m, textvariable=self._cfg_MODEL, font=("Microsoft YaHei UI",9), bg=BG, fg=TEXT,
                 bd=0, insertbackground=CYAN, width=18).pack(side=tk.RIGHT)
        def _save():
            global CAPTURE_INTERVAL, MODEL_NAME
            try:
                CAPTURE_INTERVAL = int(self._cfg_CAPTURE_INTERVAL.get())
                MODEL_NAME = self._cfg_MODEL.get().strip()
            except ValueError: pass
            self.status_var.set("● 设置已保存"); dlg.destroy()
        row_btn = tk.Frame(dlg, bg=CARD); row_btn.pack(pady=14)
        tk.Button(row_btn, text="保存", command=_save, font=("Microsoft YaHei UI",9),
                  bg=ACCENT, fg="#fff", bd=0, padx=18, pady=4, cursor="hand2", activebackground="#6a4ae0").pack(side=tk.LEFT, padx=4)
        tk.Button(row_btn, text="取消", command=dlg.destroy, font=("Microsoft YaHei UI",9),
                  bg=BG, fg=TEXT, bd=0, padx=18, pady=4, cursor="hand2", activebackground=GLOW).pack(side=tk.LEFT, padx=4)

    # ============ UI回调 ============
    def _update_suggestions(self, suggestions):
        self.after(0, self._do_update, suggestions)

    def _update_status(self, msg):
        self.after(0, lambda: self.status_var.set(msg))

    def _do_update(self, suggestions):
        if suggestions and suggestions[0] == "__AUTO_SEND__":
            self.withdraw()
            sent = suggestions[1] if len(suggestions) > 1 else ""
            if sent:
                pyperclip.copy(sent)
                delay = int(random.uniform(1000, 4000))
                self.after(delay, lambda: (
                    pyautogui.hotkey('ctrl', 'a'), pyautogui.sleep(0.05),
                    pyautogui.hotkey('ctrl', 'v'), pyautogui.sleep(0.15),
                    pyautogui.press('enter'), self.deiconify()))
                self.status_var.set(f"● 已发送「{sent[:15]}」,等待回复...")
            sug_list = suggestions[1:] if len(suggestions) > 1 else suggestions
            for i, lbl in enumerate(self.suggestion_labels):
                lbl.config(text=sug_list[i] if i<len(sug_list) and sug_list[i] else "",
                           fg=TEXT if i<len(sug_list) and sug_list[i] else SUB)
            return
        for i, lbl in enumerate(self.suggestion_labels):
            if i < len(suggestions) and suggestions[i]:
                lbl.config(text=suggestions[i], fg=TEXT)
            else:
                lbl.config(text="", fg=SUB)
        if suggestions:
            self.status_var.set(f"● 已获取 {len(suggestions)} 条建议")
        else:
            self.status_var.set("● 监控已停止")
            for lbl in self.suggestion_labels: lbl.config(text="等待中...", fg=SUB)

    def _copy_suggestion(self, label):
        text = label.cget("text")
        if not text or text == "等待中...": return
        self.clipboard_clear(); self.clipboard_append(text)
        for card in self.suggestion_cards:
            if card.winfo_children()[1] == label:
                card.config(highlightbackground=GREEN, highlightthickness=2)
                self.after(500, lambda c=card: c.config(highlightbackground=BG, highlightthickness=2))
                break
        self.withdraw()
        self.after(200, lambda: (pyautogui.hotkey('ctrl', 'a'), pyautogui.sleep(0.05),
                                  pyautogui.hotkey('ctrl', 'v'), self.deiconify()))

    def _on_close(self):
        global auto_capturing
        auto_capturing = False; self.destroy()

if __name__ == "__main__":
    FloatWindow().mainloop()
---

2.5 演示视频展示

3. 实验过程中遇到的问题和解决过程

  • 问题1:选区和截图坐标偏移(高DPI)
    现象:截取的区域与实际拖拽不一致,偏移约25%。
    解决:在程序启动时调用ctypes.windll.user32.SetProcessDPIAware()声明DPI感知,使PIL的截图坐标与屏幕物理像素对齐。

  • 问题2:AI识别颜色不准确
    现象:白色/绿色气泡有时误判,导致"等对方"逻辑失效。
    解决:从颜色判断改为位置判断(SIDE:left/right),微信布局中左=对方、右=自己恒成立,准确率从~70%提升至100%。

  • 问题3:AI输出格式不稳定
    现象:glm-4v有时忽略格式要求,返回描述性文本而非"SIDE:...MSG:..."。
    解决:将prompt压缩到3句话极简格式,不给AI自由发挥空间。配合解析容错(MSG/Text/内容/消息多关键词匹配)。

  • 问题4:窗口拖动与滑块冲突
    现象:拖动透明/字号滑块时整个窗口跟着移动。
    解决:将_bind_drag从全局窗口改为仅标题栏响应,_enable_title_drag(tb)只绑定标题Frame。

  • 问题5:华为云NLP 401认证失败
    现象:AK/SK正确但返回401 Unauthorized。
    解决:排查发现NLP服务未开通+IAM缺乏权限。需先开通NLP服务并授权。通过with_endpoint()直连绕过Region对象依赖。

  • 问题6:快捷短语被面板遮挡
    现象:每次展开功能面板后,底部快捷短语被挤掉。
    解决:将快捷短语从可折叠面板移出,作为独立始终可见的底部栏。

  • 问题7:情绪分析"等待中"不更新
    现象:关闭华为云时,智谱返回的情绪标签未显示到UI。
    解决:修改huawei_gen_suggestions返回智谱mood作为兜底,并在auto_capture_task中始终调用update_mood


4. 课程总结与感想

4.1 知识点总结

  1. Python语言概述与开发环境

学习了Python与C语言的核心区别:Python是面向对象的动态类型、解释执行的脚本语言,无需编译,语法简洁,通过缩进定义代码块(而非C语言的花括号)。入门阶段安装了Python 3.14解释器和PyCharm IDE,掌握了IDLE交互式Shell和PyCharm的控制台、终端、调试器等工具。同时学会使用Gitee进行代码版本管理,使用博客园撰写实验报告。

  1. 基本语法

掌握了Python的语法特点:以4空格缩进替代花括号定义代码块、大小写敏感、注释(#单行、"""多行)、变量赋值(a = 10)。与C语言不同,Python变量无需声明类型,赋值即创建。

  1. 变量与基本数据类型

学习了Python的核心数据类型:

  • 数字类型:整数(int)、浮点数(float)、复数(complex),支持任意精度整数运算
  • 字符串类型(str):单引号、双引号、三引号三种表示方式,支持切片和格式化(f-string)
  • 布尔类型(bool):TrueFalse,用于条件判断
  • 类型转换:int()float()str()等内置函数
  • 运算符:算术运算符(+ - * / // % **)、比较运算符(== != < >)、逻辑运算符(and or not
  1. 条件与循环

掌握了if-elif-else条件语句控制程序分支,以及两种循环结构:

  • for循环:遍历可迭代对象(列表、字符串、range()等)
  • while循环:基于条件的重复执行
  • break(跳出循环)和continue(跳过本次)控制循环流程
  1. 序列类型

学习了四种序列容器的特点和用法:

  • 列表(list):可变有序集合,支持append()添加、remove()删除、sort()排序、切片操作。创建方式:[1, 2, 3]
  • 元组(tuple):不可变有序集合,适用于常量数据。创建方式:(1, 2, 3)
  • 字典(dict):键值对映射,通过键快速查找值,支持增删改查。创建方式:{"key": "value"}
  • 集合(set):无序不重复集合,支持交并差集运算。创建方式:{1, 2, 3}

四者区别:列表可变、元组不可变、字典键值对、集合去重。

  1. 字符串与正则表达式

掌握了字符串常用方法:strip()去首尾空白、split()分割、join()拼接、replace()替换、find()查找、upper()/lower()大小写转换。

正则表达式方面,学习了re模块的核心用法:re.search()查找、re.match()匹配开头、re.sub()替换、re.findall()全局查找。掌握了常用元字符(. * + ? [] () {})和转义字符。在实验四中大量应用正则解析AI返回的格式文本。

  1. 函数与面向对象

函数方面,掌握了def定义函数、参数传递(位置参数、关键字参数、默认参数)、return返回值、lambda匿名函数。

面向对象方面,学习了三大核心特性:

  • 封装:用class定义类,属性与方法绑定,通过__init__初始化对象
  • 继承:子类通过class SubClass(ParentClass)继承父类属性和方法,支持多重继承
  • 多态:同一方法名在不同类中有不同实现

在实验四的FloatWindow(tk.Tk)中应用了继承,继承tkinter的Tk类并扩展自定义方法。

  1. 异常处理

学习了Python的异常处理机制:try-except-else-finally四段式结构:

  • try:放置可能出错的代码
  • except:捕获并处理特定异常(如ValueErrorIOErrorException
  • else:无异常时执行
  • finally:无论是否有异常都执行(如关闭文件、释放资源)

实验四中通过try-except保证华为云API调用失败时不导致程序崩溃,而是回退到智谱AI。

  1. 文件操作

掌握了Python文件操作的核心函数:

  • open(filename, mode):打开文件,mode包括'r'读、'w'写(覆盖)、'a'追加、'b'二进制模式
  • read()/readline()/readlines():读取内容
  • write():写入内容
  • seek(offset):移动文件指针
  • close():关闭文件
  • with open() as f::上下文管理器,自动关闭文件

实验四中使用json.dump()json.load()将快捷短语持久化到phrases.json文件。

  1. 网络爬虫基础

了解了爬虫的基本概念和使用规范(robots.txt协议、合理请求频率)。学习了常用库:

  • requests:发送HTTP请求(GET/POST),获取网页内容
  • urllib:Python标准库中的URL处理模块

虽然实验四未直接涉及爬虫,但AI API调用(通过requests库发送POST请求)和网络数据交互与爬虫共享了相同的HTTP基础知识。

  1. 综合技术应用(实验四)

在实验四中,上述知识点被综合运用:

  • 变量与数据类型:配置参数、全局状态管理
  • 条件与循环:自动回复逻辑判断、while监控循环
  • 序列:list存储建议和去重历史
  • 字符串与正则:解析AI返回的格式文本(MSG:#情绪:
  • 函数:核心逻辑拆分(gen_suggestionshuawei_sentiment等)
  • 面向对象:继承tk.Tk实现自定义窗口
  • 异常处理:华为云API调用容错
  • 文件操作:phrases.json读写持久化
  • 网络请求:zhipuaihuaweicloudsdk*库进行API调用
  • GUI编程:tkinter构建完整桌面界面
  • 多线程:后台截图监控线程

4.2 感想体会

选这门课之前,我从未想过自己能用Python写出一款真正能跑、能用的桌面程序。报名时只觉得Python是一门强大的编程语言,当下为了拥抱未来还是需要了解一二。没想到,最后我也能独立写出这样一份程序。

刚开始接触Python的那个下午,我在IDLE里敲出第一行print("Hello World"),弹出的那句问候让我第一次感受到——原来编程没有想象中那么遥不可及。接着装PyCharm、注册Gitee,把猜数字的代码push上去,看到绿色的提交记录,竟然有一种"我也是一个码农了"的错觉。这些操作让我理解了为什么程序员那么看重GitHub和版本控制——每一行commit都是一次成长的印记。

之后的知识点一路铺开。从简单变量赋值到if-else条件分支,再到for循环遍历列表,每一个新概念都像在拼一张拼图。学到序列那一章时,列表、元组、字典、集合四种容器搅在一起,我反复敲代码对比它们的区别,列表能改、元组不能改、字典靠键找值、集合自动去重……直到把它们的特点刻进肌肉记忆才算真正掌握。后来的服务端和客户端互访实验,让我想起了小时候玩"我的世界"要输入服务器IP才能联机的底层逻辑,原来游戏里的"联机"就是socket通信——那一刻真切体会到了学以致用的乐趣。

课程尾声的综合实践,我把半学期学到的几乎所有知识点都用上了:字符串正则解析AI返回的文本、列表管理建议去重、异常处理兜底API调用失败、文件读写持久化自定义短语、面向对象继承tkinter构建窗口、多线程后台监控截图。每一次调试成功、每一个bug被修复、每一轮迭代让程序更好用一点,都让我对"编程"两个字有了更深的敬畏。

"人生苦短,我学Python。"这句调侃背后,是Python用简洁语法包裹强大生态的务实精神。半学期很短,但Python带给我的编程思维和解决问题的能力,会伴随我很长很长的路。

4.3 课程建议

  • 上课有点因为打字速度偏慢而跟不上,虽然代码比较简单,最后还是可以完成任务。(听着比较难受,也还没什么特别好的办法,可能还得靠学生练习打字吧)

  • 建议增加更多AI/云服务相关的实践案例,如OCR、情感分析、聊天机器人等

  • 实验课可以增加一些"问题驱动"的项目,给定需求让学生自由设计实现方案

  • 代码托管(Gitee)的引入非常好,建议继续强化版本控制的使用习惯


我的仓库

  • 20243410陈懿慜的仓库
    代码在shiyan4文件夹下chat_assistant.py中,代码运行同时还要使用相同位置phrases.json文件
    image

参考资料

posted @ 2026-06-09 08:07  山阁老  阅读(10)  评论(0)    收藏  举报