AICompanion 开发日志 02:用 PySide6 搭建聊天窗口
上一篇主要记录了 AICompanion 的项目搭建、技术选型和目录结构。这一篇开始进入真正的功能实现。
当前阶段的目标很明确:先用 PySide6 做出一个可以运行的聊天窗口。它不需要一开始就很华丽,也不需要马上接入 Live2D,但必须把后续扩展需要的基础结构搭好。
也就是说,这一篇的重点不是“画一个窗口”,而是做出一个可以继续生长的桌面聊天程序骨架。
1. 当前阶段目标
AICompanion 的整体计划分成几个阶段:
| 阶段 | 目标 |
|---|---|
| 第一阶段 | Python + PySide6 做聊天窗口 |
| 第二阶段 | 接入大模型,实现多轮聊天 |
| 第三阶段 | SQLite 保存聊天记录和记忆 |
| 第四阶段 | 主动消息、角色设定、表情切换 |
| 第五阶段 | 加入 TTS 语音回复 |
| 第六阶段 | 尝试 Live2D |
这篇文章对应第一阶段的核心内容:聊天窗口。
不过在实现窗口时,我没有把所有逻辑都写进一个文件里,而是提前拆成了几个层次:
ui:负责界面显示。controllers:负责聊天流程。llm:负责大模型接口。storage:负责 SQLite 数据读写。services:负责记忆、主动消息等功能。domain:负责角色、情绪等领域模型。
这样做是为了避免项目后期变成一个巨大的窗口类。桌面应用最容易犯的错误之一,就是把界面、数据库、模型调用、业务逻辑全部塞进一个 MainWindow 里。前期看起来写得快,后期就会很难维护。
2. 主窗口的基础结构
聊天窗口目前使用 QMainWindow 作为主窗口。
核心文件是:
src/aicompanion/ui/main_window.py
窗口中暂时包含三个主要部分:
- 顶部标题:显示项目名
AICompanion。 - 中间聊天列表:显示用户和 AI 的对话。
- 底部输入区:输入框 + 发送按钮。
整体布局使用 QVBoxLayout,输入区域再用一层 QHBoxLayout:
root = QWidget()
layout = QVBoxLayout(root)
title = QLabel("AICompanion")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
input_row = QHBoxLayout()
input_row.addWidget(self.input_box, stretch=1)
input_row.addWidget(self.send_button)
layout.addWidget(title)
layout.addWidget(self.chat_list, stretch=1)
layout.addLayout(input_row)
self.setCentralWidget(root)
这个结构很简单,但是已经能支撑最基础的聊天体验。
3. 聊天区域的选择
当前聊天记录区域使用的是 QListWidget。
self.chat_list = QListWidget()
它的好处是上手快,每一条消息都可以作为一个 QListWidgetItem 加进去:
item = QListWidgetItem(f"{sender}: {content}")
self.chat_list.addItem(item)
self.chat_list.scrollToBottom()
用户消息和 AI 消息通过左右对齐来区分:
item.setTextAlignment(
Qt.AlignmentFlag.AlignRight if align_right else Qt.AlignmentFlag.AlignLeft
)
目前的显示效果还比较朴素,只是先满足“能清楚显示对话”的需求。后续可以继续升级成更像聊天软件的气泡样式,例如:
- 用户消息靠右显示。
- AI 消息靠左显示。
- 不同角色使用不同颜色。
- 支持头像、时间、消息状态。
- 支持 Markdown 或富文本显示。
但这些都不是第一步最重要的事情。现在更重要的是先把消息发送、回复、保存和加载流程跑通。
4. 输入框和发送按钮
底部输入区由 QLineEdit 和 QPushButton 组成:
self.input_box = QLineEdit()
self.input_box.setPlaceholderText("和她说点什么...")
self.send_button = QPushButton("发送")
发送消息支持两种方式:
self.send_button.clicked.connect(self._send_current_message)
self.input_box.returnPressed.connect(self._send_current_message)
这样既可以点击按钮发送,也可以直接按回车发送。
发送前会先做一次简单处理:
content = self.input_box.text().strip()
if not content:
return
这里的 .strip() 用来去掉前后空格。如果输入为空,就不发送。
5. 为什么 UI 不直接调用大模型
一开始做聊天窗口时,很容易直接在按钮事件里调用 API:
def on_send():
reply = call_llm_api(user_text)
show_reply(reply)
但这样会带来一个明显问题:如果 API 请求比较慢,整个窗口就会卡住。
桌面应用的界面线程需要保持流畅。如果在界面线程里直接做网络请求、数据库慢操作或模型调用,用户会感觉程序“假死”。
所以当前项目中加入了一个后台工作对象:
src/aicompanion/ui/chat_worker.py
它运行在 QThread 中,负责调用聊天控制器,然后把结果通过 Signal 发回主窗口。
当前涉及到的信号大致有三个:
token_received = Signal(str)
finished = Signal()
failed = Signal(str)
这样主窗口只负责更新界面,不需要关心模型调用的细节。
6. 流式回复的显示
为了让 AI 回复看起来不是“一整段突然出现”,项目里预留了流式输出。
窗口中会先显示一条临时消息:
self.current_assistant_item = self._append_message(
"Emilia",
"正在思考...",
align_right=False,
)
当后台线程不断返回 token 时,主窗口逐段追加:
self.current_assistant_text += token
self.current_assistant_item.setText(
f"Emilia: {self.current_assistant_text}"
)
self.chat_list.scrollToBottom()
这样后续接入真实大模型的 streaming API 时,界面层不需要大改。
这也是我在第一阶段就处理流式显示的原因:它虽然暂时不是必须功能,但它会影响整个聊天体验,也会影响 UI 和模型调用层之间的接口设计。
7. 聊天流程交给 Controller
主窗口中并不直接保存聊天记录,也不直接拼接 prompt,而是把用户输入交给 ChatController。
核心文件是:
src/aicompanion/controllers/chat_controller.py
当前发送一条消息的大致流程是:
MainWindow
-> ChatWorker(QThread)
-> ChatController.stream_user_message()
-> 保存用户消息
-> 更新情绪状态
-> 尝试提取长期记忆
-> 读取最近聊天历史
-> 构建 system prompt
-> 调用 LLMClient.generate_reply_stream()
-> UI 逐段显示回复
-> 保存完整 AI 回复
这样拆分以后,MainWindow 的职责就比较单纯:
- 显示聊天记录。
- 收集用户输入。
- 禁用或恢复输入框。
- 接收后台线程返回的 token。
- 把消息显示到列表中。
至于“这句话是否要保存为长期记忆”“当前角色情绪如何变化”“要把哪些历史对话发给模型”,这些都不应该由窗口来决定。
8. 启动时加载历史记录
聊天程序不能每次打开都是空白的。
所以主窗口初始化时会调用:
self._load_history()
对应逻辑是:
for turn in self.controller.load_recent_history():
sender = "你" if turn.role == "user" else "Emilia"
self._append_message(sender, turn.content, align_right=turn.role == "user")
这里的历史记录来自 Controller,Controller 再从数据库中读取最近消息。
虽然这一篇的重点是 PySide6 窗口,但可以看到,聊天窗口已经和后面的 SQLite 存储衔接起来了。这样第三阶段做聊天记录保存时,界面层不用重新设计。
9. 当前的界面样式
目前界面只做了很基础的样式:
self.setStyleSheet(
"""
QMainWindow {
background: #f7f4ef;
}
QLabel#titleLabel {
font-size: 22px;
font-weight: 700;
color: #31343a;
padding: 10px;
}
QListWidget {
background: #fffaf2;
border: 1px solid #ded6c8;
border-radius: 8px;
padding: 8px;
font-size: 14px;
}
QLineEdit {
border: 1px solid #cfc8bb;
border-radius: 6px;
padding: 9px;
font-size: 14px;
background: #ffffff;
}
QPushButton {
background: #446a72;
color: white;
border: none;
border-radius: 6px;
padding: 9px 18px;
font-size: 14px;
}
"""
)
现在还没有加入头像、气泡、Live2D 模型区域,所以整体看起来更像一个轻量聊天工具。
后面如果加入 Live2D,界面可能会调整成两栏:
- 左侧或中间是 Live2D 角色。
- 右侧是聊天记录。
- 底部保留输入框。
- 角色表情根据情绪状态切换。
但在当前阶段,先保留简单结构更利于调试。
10. 当前可运行方式
项目根目录是:
D:\Study\AICompanion
运行方式如下:
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
.\.venv\Scripts\python.exe -m pip install -e .
.\.venv\Scripts\python.exe main.py
如果使用默认配置,项目会使用本地 mock 模型:
LLM_PROVIDER=mock
也就是说,不配置 API Key 也能先打开窗口测试聊天流程。
这一点对早期开发很重要。因为 UI、数据库、线程和消息流程都可以先在本地验证,不必每次调试界面都消耗 API 调用。
11. 目前完成了什么
到这一篇为止,AICompanion 已经具备了一个桌面聊天程序的基本雏形:
- 可以打开 PySide6 主窗口。
- 可以输入消息并发送。
- 可以用回车或按钮触发发送。
- 可以区分用户消息和 AI 消息。
- 可以通过后台线程处理回复。
- 可以逐段显示流式回复。
- 可以启动时加载最近聊天记录。
- UI 层和业务逻辑已经初步分离。
虽然界面还不复杂,但这个阶段的重点是把“消息流”打通。
只要消息流稳定,后续接入真实大模型、长期记忆、主动消息、TTS 和 Live2D 时,就可以在这个基础上逐层扩展。
12. 下一步计划
下一篇准备记录大模型接入。
主要会包括:
- 如何抽象
LLMClient。 - 为什么先做
mock_client。 - 如何接入 OpenAI-compatible API。
- 如何构造多轮聊天上下文。
- 如何处理流式响应。
- API Key 为什么要放到
.env中。
从现在开始,AICompanion 已经不只是一个空项目,而是有了第一个可以运行、可以交互、可以继续扩展的小版本。
