1. 项目概述:透明对话框到底解决了什么问题
1.1 一个透明悬浮窗的日常使用场景
用 Python + Qt 做 AI 透明对话框,这个想法并不是突发奇想。我平时有大量工作要在屏幕前完成,经常遇到一种尴尬:一边开着文档或代码编辑器,一边需要查资料、翻译句子、整理会议重点。传统的做法是来回切换窗口,或者开一个浏览器小窗放在旁边,但无论怎么摆,它都会占一块屏幕空间,遮挡后面的内容。
透明对话框解决的就是这个痛点。窗口本身是半透明的,悬浮在桌面上,平时看过去几乎只有一圈淡淡的边界,不会挡住视线。需要用到 AI 时,用快捷键把它呼出来,输入问题,它在原地给出回答。不用了就隐藏,或者干脆开启点击穿透,让鼠标直接穿过窗口操作下面的内容。整套交互下来,屏幕空间没有浪费,流程也顺畅很多。
我最初的目标很简单:做一个能全局置顶的透明小窗,支持多行输入,回车后调用大模型接口,流式输出答案。但越做越发现,透明窗口这件事本身有不少讲究,比如背景怎么真正做到半透明、圆角阴影怎么处理、鼠标事件怎么穿透、Linux 和 Windows 行为差异很大。后来我又加了托盘菜单、全局快捷键、上下文记忆、语音输入预留接口,整个项目才真正变成一个可用的效率工具。
1.2 为什么是 Python + Qt,而不是 Electron 或者 Web 方案
这个问题我纠结过一阵子。市面上很多类似悬浮窗工具用 Electron 做,界面漂亮,生态丰富,但内存占用和启动速度实在让人头疼。我的场景里,这个对话框要常驻后台,随时唤起,如果它本身就吃掉几百 MB 内存,那就本末倒置了。
Python 搭配 Qt(我用的是 PySide6,PyQt6 逻辑几乎一样)优势很明显:
- Qt 的窗口系统非常成熟,对透明窗体、无边框、置顶、鼠标穿透这些底层能力支持得很细致,跨平台表现也相对一致。
- Python 生态对接 AI 接口非常方便,requests、httpx、websocket、本地模型推理库都能直接调用,不用像 C++ 那样处理一堆内存和回调。
- Qt 的 QSS 样式表可以快速做出精致界面,圆角、渐变、半透明都可以通过样式控制,不需要额外引入前端构建链。
当然也有代价:Python 的 GIL 会影响多线程密集型任务,网络请求也必须放到后台线程,这对初学者是个门槛。但如果只是做一个对话框,这个代价完全可控。后面我会详细讲线程模型。
1.3 功能规划与预期效果
动手之前,我列了一张简单的功能清单,避免做到一半跑偏。最终版本的效果如下:
| 功能 | 状态 | 说明 |
|---|---|---|
| 全局置顶半透明窗口 | 完成 | 无边框,圆角,菜单栏自动隐藏,可拖拽 |
| 点击穿透开关 | 完成 | 开启后鼠标事件直接穿透窗口,不影响底层操作 |
| AI 对话 | 完成 | 支持流式回答,可配置模型 API 地址和密钥 |
| 多轮上下文 | 完成 | 保留最近 N 轮对话,超出后自动裁剪 |
| 托盘菜单 | 完成 | 显示/隐藏、开启穿透、退出 |
| 全局快捷键 | 完成 | 快捷键呼出/隐藏,快捷键切换穿透状态 |
| 语音输入 | 预留 | 接口已预留,可接离线语音识别或在线语音服务 |
这套功能做完之后,我自己连续用了一个多月,最大的感受是:工具一旦足够透明,存在感很低,反而更容易形成使用习惯。接下来我从底层原理开始拆解每个环节。
2. 透明窗口的几个底层难点
2.1 窗口透明不等于背景透明
第一次做透明窗口的时候,我踩过一个特别典型的坑:窗口确实是“透明”了,但整个窗体变成一块黑底,里面的文字和控件全部看不见了。折腾了好一会儿才明白,问题出在窗口的绘制方式上。
普通窗口的绘制流程是:系统先填充背景色,再绘制子控件,最后整体显示。如果我直接把背景色设置成透明,系统不知道该怎么处理透明像素,有些平台就会用黑色填充。Qt 要做真正意义上的透明窗口,必须告诉窗口系统两件事:
第一,窗口本身不绘制不透明背景。这需要设置setAttribute(Qt.WA_TranslucentBackground, True),让窗口背景进入 alpha 混合模式。第二,如果窗口需要无边框,还要设置setWindowFlags(Qt.FramelessWindowHint)。这两个单独用都不行,必须配合。
有人会问:只设置半透明颜色不行吗?比如setStyleSheet("background-color: rgba(0,0,0,0.5)")。实际上这不行。QSS 的背景色是在窗口内容层上绘制的,如果底下的窗口不透明,那也只是把灰色画在不透明黑色上面,看起来像一块深色的板子,而不是透出桌面壁纸的玻璃。
还有一个容易忽略的点:透明窗口的刷新开销比普通窗口大。如果界面里的内容频繁刷新,比如流式打字区每几十毫秒更新一次,CPU 占用会明显升高。我后来把打字区域拆成独立控件,并且只在文本变化时才重绘,才把这个开销压下去。
2.2 三个关键标志位:无边框、置顶、点击穿透
透明对话框看起来复杂,核心其实就是几个标志位的组合。我把它们拆开逐一说明。
第一个是FramelessWindowHint,去掉系统标题栏和边框。这时候窗口依然可以显示内容,但用户没法通过系统菜单关闭或移动它,所以要在界面上自己实现拖拽逻辑,通常是用鼠标按下事件配合坐标计算。
第二个是WindowStaysOnTopHint,让窗口保持在所有普通窗口之上。这个标志在不同平台上的行为略有差异,但基本都能实现全局置顶。要注意的是,如果置顶窗口太多,系统可能会把它们归为一组,互相之间依然有层级关系,所以还要给这个悬浮窗设置一个较高的层级参考。
第三个是实现点击穿透。这在 Windows 上需要调用 Win32 的扩展样式,通过ctypes给窗口句柄设置一个特殊属性,让鼠标点击不会落到这个窗口上。代码可以用下面这样:
import ctypes from ctypes import wintypes GWL_EXSTYLE = -20 WS_EX_TRANSPARENT = 0x00000020 WS_EX_LAYERED = 0x00080000 def enable_click_through(hwnd: int, enabled: bool): style = ctypes.windll.user32.GetWindowLongPtrW(hwnd, GWL_EXSTYLE) if enabled: style = style | WS_EX_TRANSPARENT | WS_EX_LAYERED else: style = style & ~WS_EX_TRANSPARENT ctypes.windll.user32.SetWindowLongPtrW(hwnd, GWL_EXSTYLE, style)在 Linux 上,如果使用 X11,也有类似的方式,但 Wayland 下很多窗口属性受到合成器限制,点击穿透不一定能生效。如果你主要在 Linux 上用,建议先确认当前会话类型,再决定是否启用穿透功能。
2.3 圆角与阴影:透明窗口上最容易被忽略的细节
一个透明浮窗如果只是矩形,观感会比较生硬。我想给它加上圆角和阴影,但一开始用QGraphicsDropShadowEffect给整个窗体加阴影,效果并不好。原因是:阴影效果作用在窗口内容上,如果窗口本身已经是透明的,阴影会和透明像素混合,出现一圈脏脏的灰边。
后来我换了一种做法:内容区域用一个带圆角的 QWidget,外面再叠一层阴影。这个阴影其实不是真正的窗口阴影,而是我自己用 QPainter 在透明窗口边缘画一层渐变边框。这样做的好处是可控,而且不会触发系统合成器的重绘问题。
def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing) # 先绘制一层半透明黑边,模拟阴影 painter.setPen(QPen(QColor(0, 0, 0, 60), 6)) painter.drawRoundedRect(self.rect().adjusted(2, 2, -2, -2), 16, 16) # 再绘制白色圆角内容区 painter.setBrush(QColor(255, 255, 255, 230)) painter.setPen(Qt.NoPen) painter.drawRoundedRect(self.rect().adjusted(4, 4, -4, -4), 14, 14)有一个经验是:透明窗口上不要放太多带透明层级嵌套的控件,否则刷新时容易出现残影或者闪屏。如果必须嵌套,就尽量用同一个父窗口统一绘制,而不是让每个子控件都独立处理透明。
3. AI 能力接入:把大模型放进悬浮窗
3.1 统一模型接口层:只写一次,兼容多地
AI 对话框的核心还是调用大模型接口。现在主流大模型平台基本都提供 HTTP 接口,参数格式大同小异。我建议在最开始就封装一个统一的LLMClient,这样可以灵活切换服务商和模型。
我的客户端做了三件事:
- 从环境变量读取 API 地址、密钥和模型名字,不写死任何敏感信息。
- 支持流式输出,也就是 SSE 格式的数据流,这样回答可以一个字一个字地显示出来,体验接近原生对话软件。
- 把请求放到后台线程,避免阻塞 Qt 的事件循环。
一个简化版的请求骨架大概是这样的:
import os import json import requests class LLMClient: def __init__(self): self.api_base = os.getenv("LLM_API_BASE", "https://example-endpoint/v1") self.api_key = os.getenv("LLM_API_KEY", "") self.model = os.getenv("LLM_MODEL", "example-model") def stream_chat(self, messages): resp = requests.post( f"{self.api_base}/chat/completions", headers={"Authorization": f"Bearer {self.api_key}"}, json={ "model": self.model, "messages": messages, "stream": True, }, stream=True, timeout=30, ) for line in resp.iter_lines(decode_unicode=True): if not line: continue # 解析 SSE 格式的数据行 if line.startswith("data:"): data = line[5:].strip() if data == "[DONE]": break try: chunk = json.loads(data) delta = chunk["choices"][0]["delta"].get("content", "") if delta: yield delta except json.JSONDecodeError: continue这里我用stream=True拿到流式响应,再逐行解析。不同平台返回的字段略有差异,但核心结构差不多都是choices[0].delta.content。如果没有流式需求,也可以把stream关掉,一次性拿到完整内容。
3.2 线程与信号槽:为什么不能在 UI 线程里等网络
一开始我想偷懒,直接在按钮点击的槽函数里调用LLMClient.stream_chat,结果界面直接卡死,连窗口都无法拖动。原因很简单:网络请求是阻塞操作,如果放在 Qt 的主线程里,主线程的事件循环被卡住,所有 UI 事件都无法处理。
解决办法是把请求放到一个工作线程,执行完通过信号把内容回传。这里的关键点是:子线程里绝对不能直接操作 UI 控件,只能发信号。Qt 的信号槽机制会在线程之间安全地传递数据,所以正确做法是:
from PySide6.QtCore import QThread, Signal class AIWorker(QThread): content_received = Signal(str) finished_ok = Signal(str) failed = Signal(str) def __init__(self, client, messages): super().__init__() self.client = client self.messages = messages def run(self): try: full_text = "" # 调用流式接口,逐步发出信号 for delta in self.client.stream_chat(self.messages): self.content_received.emit(delta) full_text += delta self.finished_ok.emit(full_text) except Exception as exc: self.failed.emit(str(exc))在 UI 侧,只需要连接信号:
self.worker = AIWorker(client, messages) self.worker.content_received.connect(self.append_stream_text) self.worker.finished_ok.connect(self.on_finish) self.worker.failed.connect(self.on_fail) self.worker.start()注意每次点击发送都要重新创建AIWorker实例,不要复用同一个已经运行结束的 QThread 对象。否则 Qt 会提示 thread 正在运行,无法再次启动。
3.3 多轮对话的上下文管理
如果每次调用都把全部历史对话发给大模型,时间长了 token 会暴涨,响应速度变慢,费用也会失控。我设计的上下文管理规则很简单:
- 维护一个
messages列表,结构是[{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}]。 - 每轮对话结束后,把用户问题和助理回答追加进去。
- 发送前只取最后 N 条,总共控制在比如 20 条以内。
- 超过限制后,从最早的对话开始丢弃,但系统提示词始终保持在最前面。
MAX_HISTORY = 20 def build_messages(self, system_prompt, history, user_input): messages = [{"role": "system", "content": system_prompt}] history = history[-(MAX_HISTORY - 1):] for item in history: messages.append(item) messages.append({"role": "user", "content": user_input}) return messages这个设计足够日常使用。如果以后想加入“记忆”功能,可以再把关键信息提炼后放进系统提示词的额外字段,而不是无限拉长历史列表。
4. 完整实现:从空窗口到可用的透明助手
4.1 建立透明窗体基类
我先把窗口基础能力封装成一个基类,所有浮窗都继承它。这个基类负责透明背景、无边框、置顶、拖拽移动。
from PySide6.QtWidgets import QWidget from PySide6.QtCore import Qt, QPoint class TranslucentWindow(QWidget): def __init__(self): super().__init__() self.setWindowFlags( Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool ) self.setAttribute(Qt.WA_TranslucentBackground, True) self._drag_pos = None def mousePressEvent(self, event): if event.button() == Qt.LeftButton: self._drag_pos = event.globalPosition().toPoint() - self.frameGeometry().topLeft() event.accept() def mouseMoveEvent(self, event): if self._drag_pos is not None: self.move(event.globalPosition().toPoint() - self._drag_pos) event.accept() def mouseReleaseEvent(self, event): self._drag_pos = None这里有两个细节。一是Qt.Tool比较重要,它让窗口不出现在任务栏,行为更像一个悬浮工具而不是一个主窗口。二是拖拽用的坐标必须用全局坐标,如果直接用局部坐标,窗口会被一条看不见的偏移量拽走。
4.2 设置点击穿透的开关函数
点击穿透开关要能在运行时动态切换,所以我把 Win32 调用封装成一个独立函数,并加入缓存,避免每次切换都查一次窗口句柄。
class Translator: pass class TranslucentWindow(QWidget): def set_click_through(self, enabled: bool): if not sys.platform.startswith("win"): # 非 Windows 平台可留空或使用 X11 方案 return hwnd = int(self.winId()) if enabled: # 进入穿透模式,窗口不再接收鼠标事件 self._old_cursor = self.cursor() self.setAttribute(Qt.WA_TransparentForMouseEvents, True) else: self.setAttribute(Qt.WA_TransparentForMouseEvents, False) self._apply_win32_transparency(hwnd, enabled)上面这段代码中,我用了WA_TransparentForMouseEvents这个 Qt 自带的属性。它虽然能实现鼠标事件穿透,但在 Windows 下如果只设置这个属性,有时候区域命中判断会不彻底,所以我两种情况都设置。
完整切换逻辑还要考虑:穿透状态下,用户没法点击窗口里的按钮和输入框了,所以要提供一个全局快捷键或托盘菜单来恢复。我默认设置为Ctrl+Shift+T切换穿透状态。
4.3 对话框内容区与 QSS 样式
透明窗口的内部布局我分为三块:顶部是标题栏和功能按钮,中间是对话记录区,底部是输入框和发送按钮。为了让半透明窗口有“玻璃感”,我用了 QSS 控制背景和圆角,但真正的背景是在paintEvent里画的半透明白色,QSS 只负责处理内部控件的边框和间距。
self.setStyleSheet(""" QPlainTextEdit#inputEdit { background: rgba(255, 255, 255, 180); border: 1px solid rgba(128, 128, 128, 80); border-radius: 8px; padding: 6px; font-size: 14px; } QLabel#titleLabel { color: #333; font-size: 13px; font-weight: bold; } QPushButton { background: rgba(255, 255, 255, 200); border: 1px solid rgba(128, 128, 128, 100); border-radius: 6px; padding: 4px 10px; } """)一个常见问题是:半透明背景的文字阅读吃力。解决办法是在文字区域下面再垫一层近乎不透明的白色,比如rgba(255, 255, 255, 245),这样窗口整体依然是半透明,但文字区域保持高对比度。
4.4 托盘与全局快捷键
透明窗口平时是隐藏的,所以必须提供一个常驻的托盘图标来管理它。Qt 的QSystemTrayIcon配合QMenu就可以实现。
from PySide6.QtWidgets import QSystemTrayIcon, QMenu, QApplication class TrayController: def __init__(self, window): self.window = window self.tray = QSystemTrayIcon() menu = QMenu() menu.addAction("显示 / 隐藏", window.toggle_visible) menu.addAction("开启点击穿透", window.set_click_through, True) menu.addAction("退出", QApplication.instance().quit) self.tray.setContextMenu(menu) self.tray.show()全局快捷键则依赖 Qt 的QShortcut,但需要注意,QShortcut默认只在应用处于激活状态时生效。如果窗口隐藏,快捷键可能失效。更可靠的方式是使用系统的全局快捷键库,不过为了减少依赖,我的做法是让窗口始终处于“可见但穿透”的状态,快捷键绑定依然有效,快捷键处理只负责改变状态,不依赖窗口焦点。
4.5 组装最终的启动脚本
启动流程很短:创建应用、创建窗口、创建托盘、进入事件循环。
import sys from PySide6.QtWidgets import QApplication def main(): app = QApplication(sys.argv) app.setQuitOnLastWindowClosed(False) win = MainWindow() win.show() tray = TrayController(win) tray.show() sys.exit(app.exec())这里setQuitOnLastWindowClosed(False)很重要。因为窗口关闭或隐藏时,如果没有任何可见窗口,Qt 默认会退出应用,而你其实希望程序继续在托盘驻留。
5. 常见问题与排查实录
5.1 问题速查表
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| 窗口是黑色块 | 没设置 WA_TranslucentBackground | 在窗口初始化时设置该属性 |
| 圆角边缘发白或发灰 | 阴影与内容区混合不当 | 统一在 paintEvent 中绘制 |
| 点击穿透不生效 | 平台或合成器限制 | Windows 用 WS_EX_TRANSPARENT,Linux 检查 Wayland/X11 |
| 窗口隐藏后无法唤醒 | QShortcut 依赖窗口焦点 | 改为全局快捷键注册 |
| 网络请求时界面卡死 | 请求阻塞 UI 线程 | 用 QThread 或信号槽 |
| 代码文字缩进跳动 | 等宽字体未设置 | 给输入框设置 monospace 字体族 |
5.2 窗口是黑色块,怎么回事
这个我在第一节提到过,最直接的根源是透明背景属性没有设置成功。特别容易出问题的是:如果你在某次show()之后再动态调用setAttribute(Qt.WA_TranslucentBackground, True),窗口系统可能不会重新处理透明合成,所以这个属性必须在__init__里、窗口显示之前设置。
如果确认设置了透明属性但依然黑底,请检查一下显卡驱动。某些老旧的 Windows 驱动对分层窗口支持不好,会导致透明窗口变成黑块。更新显卡驱动基本能解决。Linux 下则要确认窗口管理器是否开启合成器,在没有合成器的环境下,透明窗口大概率失效。
5.3 点击穿透之后窗口真的“点不到”了,但想恢复怎么办
这听起来像废话,但很多人真的会卡住。开启穿透后,鼠标事件全部穿过窗口,你自然点不到窗口里的按钮。如果这时候没有全局快捷键,就只能在任务管理器里强制结束进程。
我的经验是:无论如何都要给穿透状态设计一个非鼠标的出口,比如托盘菜单、全局快捷键,或者鼠标移动到屏幕指定角落时自动退出穿透。我最后采用的是全局快捷键加自动检测:当鼠标悬停在窗口上方且按住 Shift 超过一秒,则临时退出穿透。这样既不会误触,又能快速恢复。
5.4 流式输出卡顿,或者一个字一个字往外蹦得像打字机一样慢
流式输出的体验取决于网络延迟和服务器返回策略。如果每个字都是一个独立数据包,界面接收到一个content_received信号就刷新一下文本框,那刷新频率可能高达每秒几十次,反而加重 UI 负担。
我后来加了一个简单的缓冲:收到内容后不立即追加到文本框,而是先把字符串放进一个临时缓冲区,用QTimer每 100 毫秒批量刷新一次。这样视觉上依然是连贯输出,性能却好了很多。
self._buffer = "" self._flush_timer = QTimer() self._flush_timer.setInterval(100) self._flush_timer.timeout.connect(self.flush_buffer) def append_stream_text(self, delta): self._buffer += delta def flush_buffer(self): if not self._buffer: return self.output_text.moveCursor(QTextCursor.End) self.output_text.insertPlainText(self._buffer) self._buffer = ""5.5 DPI 缩放之后圆角边缘出现毛边
在高分屏上,Windows 的缩放比例可能是 125% 或 150%。如果 Qt 程序没有启用 DPI 缩放感知,界面就会模糊,圆角边缘尤其明显。
解决方法是在入口文件最开头,创建 QApplication 之前设置环境变量:
import os os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "1"市场上大部分新版 Qt 对高分屏已经有不错的默认支持,但如果你的界面还是模糊,检查是否设置了Qt.AA_EnableHighDpiScaling这个应用属性。设置的位置必须是程序最早的代码之一,最好在 import 其他 Qt 模块之前。
6. 我建议你继续扩展的几个方向
6.1 从“输入框对话框”变成“划词 AI 助手”
透明对话框做出来之后,最大的扩展空间是形态。如果你想让它更顺手,可以再加一个全局鼠标监听:当你在任意应用里选中一段文字,按下快捷键,悬浮窗自动移动到选中文字附近,并把这段文字作为问题上下文直接发送给 AI。
实现思路是用系统级钩子,Windows 下可以用某个跨平台输入库或者结合 Qt 的全局事件过滤。前端部分不需要大改,因为对话框本身已经支持透明和置顶,只需要把窗口坐标动态更新即可。
6.2 加入语音输入,让悬浮窗真正“免手操作”
我预留了语音接口,但还没做成正式功能。语音输入的完整链路是:麦克风采集音频 > 语音识别转文字 > 把文字填入输入框 > 自动发送。识别引擎可以选择本地离线模型,也可以选择在线语音识别接口。离线模型的优点是隐私好、延迟低,缺点是体积大、对中文长句效果可能一般。
如果你打算做,务必把音频采集也放到单独线程,并且处理好麦克风权限问题。语音转文字的结果回传后,最好先显示在输入框里让用户确认,再决定是否发送,否则识别错误率会极大影响体验。
6.3 本地模型优先,云端模型兜底
现在的桌面工具越来越倾向于在本地跑一个轻量模型处理简单任务,比如改写、总结、分类,然后再把复杂任务发送到云端大模型。Qt 和 Python 的组合在这方面有天然优势,因为本地模型推理库都提供 Python 接口,调用逻辑和普通函数差不多。
本地模型占用的内存和显存要注意控制,我建议给模型推理单独开一个进程或者线程池,避免和 UI 线程抢资源。如果模型加载时间太长,可以在托盘菜单里加一个“模型状态”选项,显示加载进度和显存占用。
这个项目的后续潜力其实就是围绕一个点展开:让 AI 帮助不打断你当前的工作流。透明对话框只是第一步,它证明了在桌面环境下,AI 助手可以做到既在场,又不打扰。如果你也想做一个类似的工具,我的建议是从最小可用版本开始,先跑通透明窗口加一个最简单的模型调用,再逐步加花活。先把基础体验打磨好,后面的一切扩展都会顺理成章。