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 两个独立 QWidgetQApplication ├── FloatingButton ← 悬浮按钮始终显示 └── ChatPanel ← 聊天面板可关闭/打开 ├── 标题栏搜索、最小化、最大化、关闭 ├── 标签栏对话 | 历史 | SOP | 设置 ├── 内容区QStackedWidget 切换 │ ├── ChatPage消息列表 输入框 │ ├── HistoryPage历史会话列表 │ ├── SOPPage标准操作流程查看器 │ └── SettingsPage模型切换 控制面板 └── 状态栏模型名、流式指示器入口函数main()只有 70 行做的事情非常直白defmain():appQApplication(sys.argv)app.setQuitOnLastWindowClosed(False)# 关键关面板不退出agentGeneraticAgent()threading.Thread(targetagent.run,daemonTrue).start()panelChatPanel(agent)buttonFloatingButton(panel)# 传入面板引用button.show()panel.show()idle_timerQTimer()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):SIZE60MARGIN14def__init__(self,panel):super().__init__()self.setWindowFlags(Qt.FramelessWindowHint|Qt.Tool|Qt.WindowStaysOnTopHint)self.setAttribute(Qt.WA_TranslucentBackground)self.setFixedSize(SIZE2*MARGIN,SIZE2*MARGIN)三个关键点无边框 置顶窗口没有标题栏始终在最上层透明背景WA_TranslucentBackground让窗口区域除了绘制的圆形外全部透明尺寸是 SIZE 2×MARGIN额外 14px 的 margin 给外发光留出空间3.2 paintEvent 里的发光渐变整个悬浮按钮的视觉效果全靠paintEvent手绘defpaintEvent(self,event):pQPainter(self)p.setRenderHint(QPainter.Antialiasing)# 外发光三层同心圆 径向渐变fori,(r_mult,alpha)inenumerate([(0.68,25),(0.82,18),(1.0,10)]):radiushalf_sz*r_mult gradQRadialGradient(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)# 内部高光让按钮看起来有玻璃质感highlightQRadialGradient(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变为#8B5CF6press 时椭圆轻微收缩 2px——这些都在_update_style()方法中用 flag 控制update()重绘实现。3.3 拖拽 吸附定位悬浮按钮支持拖拽。但比较巧妙的是初始定位逻辑def_position_panel(self):scrQApplication.primaryScreen().availableGeometry()# 按钮固定在右下角btn_xscr.right()-self.width()-16btn_yscr.bottom()-self.height()-16self.move(btn_x,btn_y)# 面板定位在按钮上方panel_w,panel_h530,700panel_xbtn_xself.width()//2-panel_w//2panel_ybtn_y-panel_h-8self._panel.move(panel_x,panel_y)面板定位在按钮正上方通过btn_x half_width - panel_w // 2计算面板的居中位置。而且按钮拖拽时面板同步移动mouseMoveEvent中panel.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):pQPainter(self)p.setRenderHint(QPainter.Antialiasing)pathQPainterPath()path.addRect(0.5,0.5,self.width()-1.0,self.height()-1.0)# 线性渐变背景gradQLinearGradient(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_pose.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_queueself.agent.put_task(full_prompt,sourceuser)# 2. 启动 40ms 定时器轮询队列self._poll_timer.start(40)def_poll_queue(self):try:whileTrue:itemself._display_queue.get_nowait()ifnextinitem:# 增量文本self._streaming_textitem[next]self._streaming_row.set_text(self._streaming_text ▌)ifdoneinitem:# 最终结果finalitem[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 存储完整会话数据itemQListWidgetItem(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实现可拖拽分栏splitterQSplitter(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(nidx)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):wself._msg_layout.itemAt(i).widget()ifisinstance(w,_MsgRow):ifkeyword.lower()inw._text.lower():kw_yw.highlight(keyword)iffirst_foundisNone:first_foundw first_keyword_ykw_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:returnnowtime.time()ifnow-_last_trigger[0]120:# 至少间隔2分钟returnidlenow-panel.last_reply_timeifidle1800:# 超过30分钟_last_trigger[0]now panel.inject_message([AUTO] 用户已经离开超过30分钟作为自主智能体请阅读自动化sop执行自动任务。)idle_timerQTimer()idle_timer.timeout.connect(idle_check)idle_timer.start(5000)# 每5秒检查一次QTimer直接接入 Qt 事件循环不需要独立线程、不需要 JS 注入、不需要操作 DOM。这就是原生方案相比 Web 包装最大的优势——控制流程更短出错概率更低。inject_message()方法直接操作QTextEditdefinject_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()函数转成QIcondef_svg_icon(name:str,svg:str,color:str|NoneNone)-QIcon:datasvgifcolor:datadata.replace(currentColor,color)pixQPixmap()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()returnTrue9.4 滚动智能锁用户在 AI 输出过程中如果手动滚上去了自动滚动暂停——避免用户正在看前面的消息被新输出硬拉到底部def_on_scroll(self,value):sbself._scroll.verticalScrollBar()self._user_scrolled_upvaluesb.maximum()-30# 30px 容差十、和 Web 版的对比总结维度launch.pywWebqtapp.pyQt窗口技术pywebview StreamlitPySide6 原生渲染引擎ChromiumWebViewQt 控件树代码行数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