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 注入 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 计算面板的居中位置。而且:

  • 按钮拖拽时面板同步移动(mouseMoveEventpanel.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

相关推荐
矢志航天的阿洪1 小时前
用 MATLAB 控制 STK Aviator:从零搭建一个 AWACS 支援作战场景
开发语言·matlab
用户805533698031 小时前
现代Qt开发教程(新手篇)1.11——定时器
c++·qt
澈2071 小时前
STL迭代器:容器遍历的万能钥匙
开发语言·c++
byoass2 小时前
企业云盘与设计软件深度集成:AutoCAD/Revit/SolidWorks插件开发与API集成实战
服务器·网络·数据库·安全·oracle·云计算
AI人工智能+电脑小能手2 小时前
【大白话说Java面试题】【Java基础篇】第24题:Java面向对象有哪些特征
java·开发语言·后端·面试
爬山算法2 小时前
MongoDB(113)如何使用第三方工具进行MongoDB监控?
数据库·mongodb
geovindu2 小时前
go: Strategy Pattern
开发语言·设计模式·golang·策略模式
27669582923 小时前
阿里最新acw_sc__v2 分析
开发语言·python·acw_sc__v2·acw_sc__v2逆向·acw_sc__v2算法·acw_sc__v2算法分析·cookie逆向
dog2503 小时前
圆锥曲线和二次曲线
开发语言·网络·人工智能·算法·php