大家好,我是张大鹏,10年全栈开发经验。之前写了架构、自主行动、桌面宠物和记忆系统四篇,今天聊一个被很多读者问到的问题------GenericAgent 有没有桌面版?有,而且还做得很精致。这篇文章我把
qtapp.py这 2000+ 行 PySide6 桌面应用从头拆到尾。
一、为什么需要一个原生桌面版?
GenericAgent 默认的启动方式是 Streamlit Web 界面 + pywebview 包装。这个方案挺好,浏览器访问也方便,但是:
| 痛点 | Web 版 | Qt 原生版 |
|---|---|---|
| 资源占用 | 浏览器 + Streamlit Server ~400MB 内存起 | 单个 Qt 进程 ~80MB |
| 启动速度 | Streamlit 冷启动 3-5 秒 | Qt 窗口秒出 |
| 窗口控制 | 依赖 pywebview 的窗口系统 | Qt 原生窗口,拖拽/最大化/置顶都精确 |
| 系统感知 | 通过 JS 注入 Indirect | QTimer 直接集成事件循环 |
| 离线使用 | 依赖 localhost 网络栈 | 无网络依赖 |
如果你只是想在桌面上有个 AI 助手,不需要开浏览器,不需要启动 Streamlit,那 Qt 版就是最佳选择。
启动命令一行就够了:
bash
pip install PySide6
python frontends/qtapp.py
二、整体架构:一个 App,两个窗口
qtapp.py 的架构非常清晰------没有 MainWindow,直接用了 QApplication + 两个独立 QWidget:
QApplication
├── FloatingButton ← 悬浮按钮(始终显示)
└── ChatPanel ← 聊天面板(可关闭/打开)
├── 标题栏(搜索、最小化、最大化、关闭)
├── 标签栏(对话 | 历史 | SOP | 设置)
├── 内容区(QStackedWidget 切换)
│ ├── ChatPage:消息列表 + 输入框
│ ├── HistoryPage:历史会话列表
│ ├── SOPPage:标准操作流程查看器
│ └── SettingsPage:模型切换 + 控制面板
└── 状态栏(模型名、流式指示器)
入口函数 main() 只有 70 行,做的事情非常直白:
python
def main():
app = QApplication(sys.argv)
app.setQuitOnLastWindowClosed(False) # 关键:关面板不退出
agent = GeneraticAgent()
threading.Thread(target=agent.run, daemon=True).start()
panel = ChatPanel(agent)
button = FloatingButton(panel) # 传入面板引用
button.show()
panel.show()
idle_timer = QTimer()
idle_timer.timeout.connect(idle_check)
idle_timer.start(5000)
sys.exit(app.exec())
注意 setQuitOnLastWindowClosed(False) 这一行------它保证了用户关闭聊天面板后,悬浮按钮还在,点击就能唤醒面板。这是一个"常驻桌面"应用的灵魂设置。
FloatingButton 持有 ChatPanel 的引用 ,点击时 panel.show() / panel.hide() 来切换。这是最简单的观察者模式------没有引入任何框架级别的状态管理。
三、悬浮按钮:怎么让 60 像素的圆圈"发光"
先说悬浮按钮。这是用户看到的第一个东西------一个 60×60 的紫色圆形按钮,固定在屏幕右下角。
3.1 自绘圆形窗口
python
class FloatingButton(QWidget):
SIZE = 60
MARGIN = 14
def __init__(self, panel):
super().__init__()
self.setWindowFlags(
Qt.FramelessWindowHint | Qt.Tool |
Qt.WindowStaysOnTopHint
)
self.setAttribute(Qt.WA_TranslucentBackground)
self.setFixedSize(SIZE + 2 * MARGIN, SIZE + 2 * MARGIN)
三个关键点:
- 无边框 + 置顶:窗口没有标题栏,始终在最上层
- 透明背景 :
WA_TranslucentBackground让窗口区域除了绘制的圆形外全部透明 - 尺寸是 SIZE + 2×MARGIN:额外 14px 的 margin 给外发光留出空间
3.2 paintEvent 里的发光渐变
整个悬浮按钮的视觉效果全靠 paintEvent 手绘:
python
def paintEvent(self, event):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
# 外发光:三层同心圆 + 径向渐变
for i, (r_mult, alpha) in enumerate([
(0.68, 25), (0.82, 18), (1.0, 10)
]):
radius = half_sz * r_mult
grad = QRadialGradient(cx, cy, radius)
grad.setColorAt(0.0, QColor(139, 92, 246, alpha))
grad.setColorAt(1.0, QColor(139, 92, 246, 0))
p.setBrush(grad)
p.setPen(Qt.NoPen)
p.drawEllipse(center, radius, radius)
# 主体圆形
p.setBrush(QColor(124, 58, 237))
p.drawEllipse(center, half_sz - 2, half_sz - 2)
# 内部高光(让按钮看起来有玻璃质感)
highlight = QRadialGradient(cx - half_sz * 0.2, cy - half_sz * 0.25, half_sz * 0.5)
highlight.setColorAt(0.0, QColor(255, 255, 255, 45))
highlight.setColorAt(1.0, QColor(255, 255, 255, 0))
p.setBrush(highlight)
p.drawEllipse(center, half_sz - 3, half_sz - 3)
这个绘制逻辑做了五层效果:三层外发光光晕 → 紫色主体 → 高光渐变。全部在代码里生成,没有任何外部图片资源。
鼠标 hover 时主体颜色从 #7C3AED 变为 #8B5CF6,press 时椭圆轻微收缩 2px------这些都在 _update_style() 方法中用 flag 控制 update() 重绘实现。
3.3 拖拽 + 吸附定位
悬浮按钮支持拖拽。但比较巧妙的是初始定位逻辑:
python
def _position_panel(self):
scr = QApplication.primaryScreen().availableGeometry()
# 按钮固定在右下角
btn_x = scr.right() - self.width() - 16
btn_y = scr.bottom() - self.height() - 16
self.move(btn_x, btn_y)
# 面板定位在按钮上方
panel_w, panel_h = 530, 700
panel_x = btn_x + self.width() // 2 - panel_w // 2
panel_y = btn_y - panel_h - 8
self._panel.move(panel_x, panel_y)
面板定位在按钮正上方,通过 btn_x + half_width - panel_w // 2 计算面板的居中位置。而且:
- 按钮拖拽时面板同步移动(
mouseMoveEvent中panel.move(delta)) - 首次启动面板自动显示,关闭后只留按钮
四、聊天面板:四页标签 + 手绘窗口装饰
4.1 ChatPanel 的窗口设计
python
class ChatPanel(QWidget):
def __init__(self, agent):
super().__init__()
self.setWindowFlags(
Qt.FramelessWindowHint | Qt.Window |
Qt.WindowStaysOnTopHint
)
self.setAttribute(Qt.WA_TranslucentBackground)
self.resize(530, 700)
同样是无边框 + 透明背景 ,窗口背景完全由 paintEvent 绘制:
python
def paintEvent(self, _event):
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
path = QPainterPath()
path.addRect(0.5, 0.5, self.width() - 1.0, self.height() - 1.0)
# 线性渐变背景
grad = QLinearGradient(0, 0, 0, self.height())
grad.setColorAt(0.0, QColor(20, 20, 28, 228))
grad.setColorAt(1.0, QColor(10, 10, 14, 242))
p.fillPath(path, grad)
# 1px 边框
p.setPen(QPen(QColor(99, 102, 241, 80), 1.0))
p.drawPath(path)
同时还设置了 QRegion mask 来切出圆角(通过 resizeEvent 动态更新)。
4.2 标签栏设计
四个标签页:对话 / 历史 / SOP / 设置。每个标签是一个 QPushButton,带 SVG 图标 + 文字:
python
tab_defs = [
(_SVG_CHAT, "对话"),
(_SVG_CLOCK, "历史"),
(_SVG_BOOK, "SOP"),
(_SVG_GEAR, "设置"),
]
标签切换通过 QStackedWidget.setCurrentIndex() 完成。SVG 图标全部用字符串常量内嵌------不再需要加载外部图标文件,这在打包分发时特别方便。
4.3 标题栏的自定义拖拽
因为没有系统标题栏,拖拽是手动实现的:
python
def _tb_press(self, e):
if e.button() == Qt.LeftButton:
self._drag_pos = e.globalPosition().toPoint() - self.pos()
def _tb_move(self, e):
if e.buttons() == Qt.LeftButton and self._drag_pos is not None:
self.move(e.globalPosition().toPoint() - self._drag_pos)
三个事件分别绑在标题栏 widget 上:mousePressEvent 记录起始偏移,mouseMoveEvent 实时移动窗口,mouseReleaseEvent 清空拖拽状态。
五、流式输出:跨线程的实时渲染
这是整个 Qt 前端最有技术含量的一环。
5.1 问题
GeneraticAgent 在后台线程运行,LLM 的文本是逐 token 产生的。Qt 的 UI 只能在主线程更新。怎么把流式文本实时推到 UI 上?
5.2 方案:Queue + QTimer 轮询
python
def _handle_send(self):
# 1. 发送消息到 Agent(后台线程)
self._display_queue = self.agent.put_task(full_prompt, source="user")
# 2. 启动 40ms 定时器轮询队列
self._poll_timer.start(40)
def _poll_queue(self):
try:
while True:
item = self._display_queue.get_nowait()
if "next" in item:
# 增量文本
self._streaming_text = item["next"]
self._streaming_row.set_text(self._streaming_text + " ▌")
if "done" in item:
# 最终结果
final = item["done"]
self._streaming_row.set_text(final)
self._poll_timer.stop()
except queue.Empty:
pass
关键设计点:
- 40ms 轮询间隔:对应约 25 FPS 的刷新率,人眼感受不到延迟
get_nowait()非阻塞:不卡住 Qt 的事件循环- 光标闪烁 :流式输出时文本末尾加
" ▌"显示一个闪烁的光标,给用户"还在打字"的直观感受
5.3 发送/停止双态按钮
输入框旁边的圆形按钮有两种状态:
| 状态 | 图标 | 颜色 | 行为 |
|---|---|---|---|
| 空闲 | ↑ 发送箭头 | 白色底 | 发送消息 |
| 流式中 | ■ 停止方块 | 红色底 | 中止生成 |
通过 _set_send_mode() / _set_stop_mode() 切换样式,_is_streaming flag 控制行为分支。
六、历史 & SOP & 设置:标签页的细节
6.1 历史页
历史记录使用 QListWidget 渲染,每个 item 存储完整会话数据:
python
item = QListWidgetItem(f" {title} ({n} 条)")
item.setData(Qt.UserRole, session) # 整个 session dict 存在 UserRole 里
双击恢复会话:从 item.data(Qt.UserRole) 取回 session dict,重建 _messages 列表,调用 _rebuild_messages() 重绘全部消息气泡。
保存时机是自动的------每次 AI 回复完成,_auto_save() 自动把消息列表写回 session JSON 文件。
6.2 SOP 页(标准操作流程)
左侧 SOP 目录树,右侧 Markdown 渲染器。用 QSplitter 实现可拖拽分栏:
python
splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(sop_list) # 左侧目录
splitter.addWidget(sop_viewer) # 右侧内容
splitter.setSizes([165, 340])
SOP 文件来自 memory/*.md,扫描后按文件名显示。选中时用 _md_to_html() 转换成 HTML 渲染。
6.3 设置页
设置页的核心是模型列表 + 健康检查。
每个模型一行,带一个状态指示灯(●),点击切换模型:
python
def _do_switch_to(self, idx):
self.agent.next_llm(n=idx)
self._add_system_notice(f"已切换至 {name},对话上下文已保留")
健康检查用后台线程逐个 ping 每个模型后端,结果回到主线程更新指示灯颜色:
- 绿色 ● = 正常
- 红色 ● = 异常
- 灰色 ◌ = 等待检测
七、搜索功能:跨标签页的全文检索
搜索框隐藏在标题栏的搜索按钮后面,点击展开。支持两个维度的检索:
对话内搜索 :遍历当前所有消息 widget,用 keyword.lower() in text.lower() 匹配,匹配到的关键词高亮,并自动滚动到第一个匹配项。
python
def _search_current_chat(self, keyword: str):
for i in range(self._msg_layout.count() - 1):
w = self._msg_layout.itemAt(i).widget()
if isinstance(w, _MsgRow):
if keyword.lower() in w._text.lower():
kw_y = w.highlight(keyword)
if first_found is None:
first_found = w
first_keyword_y = kw_y
# 滚动到第一个匹配项
if first_found:
self._scroll_to_widget(first_found, first_keyword_y or 0)
历史记录搜索 :遍历 QListWidget 的 item,不匹配的 setHidden(True),匹配的高亮为金色背景。
按 Escape 关闭搜索并恢复所有隐藏项。这个交互做得比很多商业应用都流畅。
八、自主行动在 Qt 中的实现
这在第二篇文章里写过,但这里再完整展示一下 Qt 版本的实现,和 launch.pyw 的 JS 注入版本对照着看:
python
_last_trigger = [0.0]
def idle_check():
if not panel.autonomous_enabled:
return
now = time.time()
if now - _last_trigger[0] < 120: # 至少间隔2分钟
return
idle = now - panel.last_reply_time
if idle > 1800: # 超过30分钟
_last_trigger[0] = now
panel.inject_message(
"[AUTO]🤖 用户已经离开超过30分钟,作为自主智能体,"
"请阅读自动化sop,执行自动任务。"
)
idle_timer = QTimer()
idle_timer.timeout.connect(idle_check)
idle_timer.start(5000) # 每5秒检查一次
QTimer 直接接入 Qt 事件循环,不需要独立线程、不需要 JS 注入、不需要操作 DOM。这就是原生方案相比 Web 包装最大的优势------控制流程更短,出错概率更低。
inject_message() 方法直接操作 QTextEdit:
python
def inject_message(self, text: str):
self._input.setPlainText(text)
self._handle_send()
这在 Web 版里要绕四层:Python → JS 注入 → DOM 操作 → React 事件,每层都可能出问题。在 Qt 版里只需要一行 setPlainText + 一行 _handle_send()。
九、几个让我印象深刻的细节
9.1 所有 SVG 图标内嵌为字符串常量
qtapp.py 没有加载任何外部图标文件。所有图标------搜索、发送、停止、聊天、历史、SOP、设置、加号、附件、回收站、闪电------全部用 _SVG_XXX 字符串常量定义,通过 _svg_icon() 函数转成 QIcon:
python
def _svg_icon(name: str, svg: str, color: str | None = None) -> QIcon:
data = svg
if color:
data = data.replace('currentColor', color)
pix = QPixmap()
pix.loadFromData(QByteArray(data.encode()))
return QIcon(pix)
这样做的好处:单文件部署 。python frontends/qtapp.py 就是完整的应用,不需要带着 icons/ 目录到处跑。
9.2 高 DPI 支持
python
QApplication.setHighDpiScaleFactorRoundingPolicy(
Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
)
一行设置搞定 4K 屏幕上的清晰渲染,圆形按钮也不会在缩放下出现锯齿。
9.3 EventFilter 实现快捷键
ChatPanel 上安装了 eventFilter,拦截两个关键事件:
- Enter 发送:在输入框中按 Enter(不加 Shift)自动发送
- Escape 关闭搜索:搜索框获焦时按 Escape 关闭搜索条
python
def eventFilter(self, obj, event):
if event.type() == QEvent.KeyPress:
if obj is self._input and event.key() in (Qt.Key_Return, Qt.Key_Enter):
if not (event.modifiers() & Qt.ShiftModifier):
self._handle_send()
return True
9.4 滚动智能锁
用户在 AI 输出过程中如果手动滚上去了,自动滚动暂停------避免用户正在看前面的消息,被新输出硬拉到底部:
python
def _on_scroll(self, value):
sb = self._scroll.verticalScrollBar()
self._user_scrolled_up = value < sb.maximum() - 30 # 30px 容差
十、和 Web 版的对比总结
| 维度 | launch.pyw(Web) |
qtapp.py(Qt) |
|---|---|---|
| 窗口技术 | pywebview + Streamlit | PySide6 原生 |
| 渲染引擎 | Chromium(WebView) | Qt 控件树 |
| 代码行数 | 145 行 | 2023 行 |
| 内存占用 | ~400MB | ~80MB |
| 启动速度 | 3-5 秒(含 Streamlit) | < 1 秒 |
| UI 组件 | Streamlit 组件库 | QWidget 手写 |
| 流式输出 | Streamlit 原生支持 | Queue + QTimer 轮询 |
| 自主行动注入 | JS 注入 textarea | setPlainText() 一行搞定 |
| 桌面宠物集成 | 按钮在侧边栏 | 无(独立 .pyw 启动) |
| 部署 | 需要 pip install streamlit pywebview | 只需 pip install PySide6 |
| 适用场景 | 日常使用,功能最全 | 轻量桌面,极低资源占用 |
总结
qtapp.py 是一个很典型的"小而美"桌面应用------2000 行代码,没有任何资源文件,启动即用。它解决的核心问题是:在不需要浏览器和 Web 服务器的情况下,给 AI Agent 一个原生桌面交互界面。
技术上看,悬浮按钮 + 聊天面板的双窗口架构、Queue + QTimer 的跨线程流式渲染、内嵌 SVG 的单文件部署策略,都值得在类似场景中参考。
| 维度 | 内容 |
|---|---|
| 核心技术 | PySide6 (Qt 6) + QPainter 自绘 + 多线程 |
| 架构亮点 | 双独立窗口、QStackedWidget 多标签、Queue 流式渲染 |
| 关键技巧 | WA_TranslucentBackground 透明窗口、EventFilter 快捷键、SVG 内嵌单文件部署 |
| 适用场景 | 需要低资源占用、快速启动的桌面 AI 助手 |
作者 :张大鹏
团队 :大鹏 AI 教育
源码 :GenericAgent/frontends/qtapp.py
日期:2026-05-01