上一篇主要记录了 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. 输入框和发送按钮

底部输入区由 QLineEditQPushButton 组成:

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 已经不只是一个空项目,而是有了第一个可以运行、可以交互、可以继续扩展的小版本。