当前位置: 首页 > news >正文

GenericAgent PySide6 桌面应用深度解析:悬浮按钮 + 聊天面板的原生 Qt 方案

大家好,我是张大鹏,10年全栈开发经验。之前写了架构、自主行动、桌面宠物和记忆系统四篇,今天聊一个被很多读者问到的问题——GenericAgent 有没有桌面版?有,而且还做得很精致。这篇文章我把qtapp.py这 2000+ 行 PySide6 桌面应用从头拆到尾。


一、为什么需要一个原生桌面版?

GenericAgent 默认的启动方式是 Streamlit Web 界面 + pywebview 包装。这个方案挺好,浏览器访问也方便,但是:

痛点Web 版Qt 原生版
资源占用浏览器 + Streamlit Server ~400MB 内存起单个 Qt 进程 ~80MB
启动速度Streamlit 冷启动 3-5 秒Qt 窗口秒出
窗口控制依赖 pywebview 的窗口系统Qt 原生窗口,拖拽/最大化/置顶都精确
系统感知通过 JS 注入 IndirectQTimer 直接集成事件循环
离线使用依赖 localhost 网络栈无网络依赖

如果你只是想在桌面上有个 AI 助手,不需要开浏览器,不需要启动 Streamlit,那 Qt 版就是最佳选择。

启动命令一行就够了:

pipinstallPySide6 python frontends/qtapp.py

二、整体架构:一个 App,两个窗口

qtapp.py 的架构非常清晰——没有 MainWindow,直接用了QApplication + 两个独立 QWidget

QApplication ├── FloatingButton ← 悬浮按钮(始终显示) └── ChatPanel ← 聊天面板(可关闭/打开) ├── 标题栏(搜索、最小化、最大化、关闭) ├── 标签栏(对话 | 历史 | SOP | 设置) ├── 内容区(QStackedWidget 切换) │ ├── ChatPage:消息列表 + 输入框 │ ├── HistoryPage:历史会话列表 │ ├── SOPPage:标准操作流程查看器 │ └── SettingsPage:模型切换 + 控制面板 └── 状态栏(模型名、流式指示器)

入口函数main()只有 70 行,做的事情非常直白:

defmain():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 自绘圆形窗口

classFloatingButton(QWidget):SIZE=60MARGIN=14def__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手绘:

defpaintEvent(self,event):p=QPainter(self)p.setRenderHint(QPainter.Antialiasing)# 外发光:三层同心圆 + 径向渐变fori,(r_mult,alpha)inenumerate([(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 拖拽 + 吸附定位

悬浮按钮支持拖拽。但比较巧妙的是初始定位逻辑

def_position_panel(self):scr=QApplication.primaryScreen().availableGeometry()# 按钮固定在右下角btn_x=scr.right()-self.width()-16btn_y=scr.bottom()-self.height()-16self.move(btn_x,btn_y)# 面板定位在按钮上方panel_w,panel_h=530,700panel_x=btn_x+self.width()//2-panel_w//2panel_y=btn_y-panel_h-8self._panel.move(panel_x,panel_y)

面板定位在按钮正上方,通过btn_x + half_width - panel_w // 2计算面板的居中位置。而且:

  • 按钮拖拽时面板同步移动(mouseMoveEventpanel.move(delta)
  • 首次启动面板自动显示,关闭后只留按钮

四、聊天面板:四页标签 + 手绘窗口装饰

4.1 ChatPanel 的窗口设计

classChatPanel(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绘制:

defpaintEvent(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)

同时还设置了QRegionmask 来切出圆角(通过resizeEvent动态更新)。

4.2 标签栏设计

四个标签页:对话 / 历史 / SOP / 设置。每个标签是一个QPushButton,带 SVG 图标 + 文字:

tab_defs=[(_SVG_CHAT,"对话"),(_SVG_CLOCK,"历史"),(_SVG_BOOK,"SOP"),(_SVG_GEAR,"设置"),]

标签切换通过QStackedWidget.setCurrentIndex()完成。SVG 图标全部用字符串常量内嵌——不再需要加载外部图标文件,这在打包分发时特别方便。

4.3 标题栏的自定义拖拽

因为没有系统标题栏,拖拽是手动实现的:

def_tb_press(self,e):ife.button()==Qt.LeftButton:self._drag_pos=e.globalPosition().toPoint()-self.pos()def_tb_move(self,e):ife.buttons()==Qt.LeftButtonandself._drag_posisnotNone: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 轮询

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:whileTrue:item=self._display_queue.get_nowait()if"next"initem:# 增量文本self._streaming_text=item["next"]self._streaming_row.set_text(self._streaming_text+" ▌")if"done"initem:# 最终结果final=item["done"]self._streaming_row.set_text(final)self._poll_timer.stop()exceptqueue.Empty:pass

关键设计点:

  • 40ms 轮询间隔:对应约 25 FPS 的刷新率,人眼感受不到延迟
  • get_nowait()非阻塞:不卡住 Qt 的事件循环
  • 光标闪烁:流式输出时文本末尾加" ▌"显示一个闪烁的光标,给用户"还在打字"的直观感受

5.3 发送/停止双态按钮

输入框旁边的圆形按钮有两种状态:

状态图标颜色行为
空闲↑ 发送箭头白色底发送消息
流式中■ 停止方块红色底中止生成

通过_set_send_mode()/_set_stop_mode()切换样式,_is_streamingflag 控制行为分支。


六、历史 & SOP & 设置:标签页的细节

6.1 历史页

历史记录使用QListWidget渲染,每个 item 存储完整会话数据:

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实现可拖拽分栏:

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 设置页

设置页的核心是模型列表 + 健康检查

每个模型一行,带一个状态指示灯(●),点击切换模型:

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()匹配,匹配到的关键词高亮,并自动滚动到第一个匹配项。

def_search_current_chat(self,keyword:str):foriinrange(self._msg_layout.count()-1):w=self._msg_layout.itemAt(i).widget()ifisinstance(w,_MsgRow):ifkeyword.lower()inw._text.lower():kw_y=w.highlight(keyword)iffirst_foundisNone:first_found=w first_keyword_y=kw_y# 滚动到第一个匹配项iffirst_found:self._scroll_to_widget(first_found,first_keyword_yor0)

历史记录搜索:遍历QListWidget的 item,不匹配的setHidden(True),匹配的高亮为金色背景。

按 Escape 关闭搜索并恢复所有隐藏项。这个交互做得比很多商业应用都流畅。


八、自主行动在 Qt 中的实现

这在第二篇文章里写过,但这里再完整展示一下 Qt 版本的实现,和launch.pyw的 JS 注入版本对照着看:

_last_trigger=[0.0]defidle_check():ifnotpanel.autonomous_enabled:returnnow=time.time()ifnow-_last_trigger[0]<120:# 至少间隔2分钟returnidle=now-panel.last_reply_timeifidle>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

definject_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

def_svg_icon(name:str,svg:str,color:str|None=None)->QIcon:data=svgifcolor:data=data.replace('currentColor',color)pix=QPixmap()pix.loadFromData(QByteArray(data.encode()))returnQIcon(pix)

这样做的好处:单文件部署python frontends/qtapp.py就是完整的应用,不需要带着icons/目录到处跑。

9.2 高 DPI 支持

QApplication.setHighDpiScaleFactorRoundingPolicy(Qt.HighDpiScaleFactorRoundingPolicy.PassThrough)

一行设置搞定 4K 屏幕上的清晰渲染,圆形按钮也不会在缩放下出现锯齿。

9.3 EventFilter 实现快捷键

ChatPanel 上安装了eventFilter,拦截两个关键事件:

  • Enter 发送:在输入框中按 Enter(不加 Shift)自动发送
  • Escape 关闭搜索:搜索框获焦时按 Escape 关闭搜索条
defeventFilter(self,obj,event):ifevent.type()==QEvent.KeyPress:ifobjisself._inputandevent.key()in(Qt.Key_Return,Qt.Key_Enter):ifnot(event.modifiers()&Qt.ShiftModifier):self._handle_send()returnTrue

9.4 滚动智能锁

用户在 AI 输出过程中如果手动滚上去了,自动滚动暂停——避免用户正在看前面的消息,被新输出硬拉到底部:

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 + StreamlitPySide6 原生
渲染引擎Chromium(WebView)Qt 控件树
代码行数145 行2023 行
内存占用~400MB~80MB
启动速度3-5 秒(含 Streamlit)< 1 秒
UI 组件Streamlit 组件库QWidget 手写
流式输出Streamlit 原生支持Queue + QTimer 轮询
自主行动注入JS 注入 textareasetPlainText()一行搞定
桌面宠物集成按钮在侧边栏无(独立 .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

http://www.jsqmd.com/news/738216/

相关文章:

  • Thorium-Win性能调优:10个简单设置让浏览器飞起来
  • SfM重建总失败?可能是RANSAC参数没调对!深入聊聊特征匹配的稳定性与调参实战
  • VinXiangQi象棋助手终极指南:3分钟配置你的AI象棋分析工具
  • BBDown完全指南:从入门到精通的7个关键步骤
  • 完美箭头绘制利器:Perfect Arrows 项目完全指南
  • Maven多模块项目里,Jacoco插件配置了为啥不生成.exec文件?一个pluginManagement的坑
  • 医疗IoT设备C代码实测优化指南:如何在ARM Cortex-M4平台将ECG数据吞吐量提升3.8倍而不丢帧?
  • 开发者在面对突发流量时如何依赖 Taotoken 的稳定性与弹性路由
  • 知乎内容备份神器:用Python+Selenium构建个人知识库
  • 2026年4月评价高的防爆干燥箱供应商推荐,国内防爆干燥箱公司 - 品牌推荐师
  • 如何用example-node-server快速掌握现代JavaScript开发:ES6+模块化与Babel转译完整指南
  • 抖音下载器终极指南:三步批量下载视频音乐,效率提升90% [特殊字符]
  • 从TIA博图到SIMATIC AX:一个自控工程师的IDE切换实战与心路历程
  • 保姆级教程:在Ubuntu 22.04上从零部署Jumpserver堡垒机(含端口冲突解决)
  • 独立开发者如何借助Taotoken的按Token计费模式精细控制项目成本
  • QTTabBar:终极Windows文件管理革命,3个简单步骤告别窗口混乱
  • 2026年5月宁波设计型装修公司横评:谁才是真正的“审美天花板”? - 疯一样的风
  • 手把手教你用Netron分析Vitis AI量化后的YOLOv5模型,搞定输入输出反量化
  • PotatoNV终极指南:华为设备Bootloader解锁的完整教程
  • 为内部知识问答系统集成 Taotoken 的多模型能力
  • 3步掌握英雄联盟回放管理:ReplayBook让你的比赛复盘效率翻倍
  • 终极指南:如何为Artemis开源MEV框架贡献代码并成为社区明星
  • 当你的ROG笔记本遇到色彩困境:G-Helper如何成为你的显示管家
  • 如何在3分钟内完成Windows包管理器的终极安装配置
  • PhotoMaker终极指南:快速定制真实人像的AI神器
  • Trickster安全配置指南:TLS、HTTP/2和认证最佳实践
  • Skill Forge:AI技能工程化发布流水线,从草稿到产品的自动化锻造
  • ctfileGet终极指南:3分钟掌握城通网盘快速下载技巧 [特殊字符]
  • 长上下文语言模型中的可复用推理模板研究
  • 终极TensorFlow循环神经网络教程:从零掌握温度预测与文本生成的AI模型