在实际开发中,桌宠通常被看作“娱乐项目”,但如果把 AI 能力接入进去,它就不再只是桌面上的一个小挂件。对于一个需要频繁切换任务、容易分心、又依赖外部提醒才能维持节奏的 ADHD 人群来说,桌宠可以同时承担解压陪伴、任务提醒、AI 问答和专注计时四种角色。这篇文章会围绕“AI 情绪陪伴 + 效率提醒”这条主线,拆解一个桌面 AI 桌宠的完整实现过程,从需求设计、技术选型、环境准备,到本地大模型接入、桌面悬浮窗口、语音交互、专注提醒,再到常见的踩坑排查和生产环境建议。
读者对象是已经掌握 Python 基础、了解 PySide6 或 Electron 基本用法,想把 AI 功能做成桌面产品的开发者。读完这篇文章,你能得到一个可以本地运行的 AI 桌宠最小闭环,并且能够根据自己的需求扩展出更多功能模块。
1. 先理解 ADHD 场景下 AI 桌宠到底在解决什么问题
桌宠不是新鲜概念,早在 Flash 时代就出现过桌面宠物。但和当年的“纯动画挂件”相比,今天的 AI 桌宠多了一个核心能力:它能理解上下文、能主动回复、能根据时间提醒任务。对于 ADHD 人群和使用场景而言,这个差异是决定性的。
1.1 ADHD 人群使用桌宠的核心需求
ADHD 的执行功能障碍通常表现为启动困难、注意力保持短、时间感知偏差、容易沉迷某一件事而忘记其他安排。因此一个好的数字陪伴工具需要满足以下要求:
- 低启动门槛:打开电脑就能看到,不需要先打开某个应用再去操作。
- 高容错交互:用户可能中断对话、随时离开、过一会又回来,系统要能接受这种不连续行为。
- 主动提醒能力:不能只等人来问,它要根据设定的时间主动说话。
- 情绪价值:在用户焦躁、拖延、无法启动任务时,给出安抚或拆解任务的提示,而不是冷冰冰的指令。
- 离线可用:ADHD 用户在注意力脆弱的时候,如果网络请求失败或者等待时间过长,很容易放弃工具本身。
这些需求决定了架构设计方向:客户端必须在本地完成主要交互,AI 能力需要优先考虑本地模型,而不是完全依赖云端接口。
1.2 “解压 + 效率”两条产品线的差异
这个产品实际上有两条完全不同的功能线:
| 功能线 | 代表功能 | 核心体验要求 | 技术关键词 |
|---|---|---|---|
| 解压陪伴 | 点击气泡、拖动桌宠、抚摸互动、随机小动作 | 低延迟、高反馈、动画自然 | 动画系统、事件响应、音频反馈 |
| 效率辅助 | 待办提醒、专注计时、AI 拆解任务、快捷问答 | 准点、内容准确、上下文可追溯 | 定时器、本地数据库、LLM 上下文 |
解压功能要求反馈即时,毫秒级响应;效率功能要求逻辑严谨,时间、内容、状态都不能丢失。两条功能线共享桌面窗口和交互入口,但底层模块必须分离开。
2. AI 桌宠整体架构和技术方案选型
在写代码之前,先确定整体架构。一个可维护的 AI 桌宠至少包含五个模块:桌面窗口模块、交互输入模块、AI 对话模块、定时任务模块、持久化存储模块。
2.1 桌面客户端技术选型对比
桌面客户端的实现有多种选择,需要根据目标场景做取舍。常见的方案对比见下表:
| 方案 | 开发语言 | 窗口透明与置顶能力 | AI 生态集成便捷度 | 适用场景 |
|---|---|---|---|---|
| PySide6 / PyQt6 | Python | 支持无边框、透明、置顶 | 高,Python 生态丰富 | 快速原型、中小型桌宠 |
| Electron + Web | JavaScript/TypeScript | 支持透明窗口,但有性能开销 | 中,需要桥接 Node 层 | Web 前端团队、复杂 UI |
| Tauri + Web | Rust + Web | 性能好,包体积小 | 中,Rust 生态相对门槛高 | 追求体积和性能的团队 |
| WPF / WinUI | C# | Windows 原生支持好 | 中,依赖 .NET 生态 | 仅限 Windows 场景 |
这里选择 PySide6 作为示例,理由有三点:一是 Python 接入本地大模型非常方便,Ollama、Transformers、LangChain 都有现成接口;二是 PySide6 的 QSystemTrayIcon、QPropertyAnimation、QtMultimedia 模块可以覆盖桌宠的托盘、动画和音效需求;三是原型开发迭代速度快,适合个人开发者先验证产品逻辑。
2.2 AI 能力选择:本地模型优先
ADHD 场景对延迟非常敏感。如果每一句对话都要经过云端大模型,网络抖动和排队都会让用户产生“这个工具不行”的负面感受。优先选择本地模型方案。
目前比较成熟的本地大模型运行工具是 Ollama。它支持多种开源模型,并提供 HTTP API,默认端口是11434。对于普通对话和任务拆解需求,qwen2.5:7b或llama3.1:8b都够用。计算机配置较低的可以选qwen2.5:3b。
选择本地模型同时要接受一个事实:本地模型的能力上限低于云端大模型,尤其是复杂推理和长文本理解。因此架构上要做双通道设计,默认走本地模型,当检测到复杂任务时提示用户是否切换到云端模型。
2.3 模块划分和消息流转链路
整个系统的消息流转可以理解为:
用户操作(点击、拖动、文本输入、语音输入) -> 输入层(PySide6 信号 / 语音识别结果) -> 行为分发器(判断是互动操作、命令操作还是对话操作) -> 功能执行器(动画模块 / 提醒模块 / AI 对话模块 / 计时模块) -> 输出层(气泡文本、窗口动画、系统提示音、任务状态变更) -> 持久化层(SQLite 保存对话记录、任务列表和用户配置)以“用户输入一句话”为例,行为分发器会把这句话送给意图识别模块。如果命中“提醒我 30 分钟后开会”,则进入定时模块;如果只是普通句子,则进入 AI 对话模块。这个过程要控制在 100ms 内完成意图判断,否则对话延迟会变明显。
3. 环境准备与依赖配置
在开始写代码之前,先把环境和依赖准备好。这一步不要偷懒,版本不一致会在后面产生大量隐性错误。
3.1 Python 环境与虚拟环境
建议使用 Python 3.10 及以上版本。桌面应用依赖较多,必须使用虚拟环境隔离:
python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate激活虚拟环境后,升级 pip 并安装核心依赖:
python -m pip install --upgrade pip pip install PySide6 requests openai这里openai库的作用不是必须连接 OpenAI 官方服务,而是因为它提供了兼容的客户端接口。Ollama 的 API 与 OpenAI 格式兼容,所以可以用OpenAI客户端指向本地地址,省去手动构造 HTTP 请求的代码。
如果计划加入语音交互,还需要额外安装:
pip install SpeechRecognition pyttsx3 pyaudio注意pyaudio在 Windows 上直接安装经常失败,可以从pipwin或预编译 wheel 中安装,或者使用sounddevice替代。
3.2 安装 Ollama 并下载本地模型
Ollama 的安装方式官网有详细说明,这里只说关键点。安装完成后,先验证服务是否启动:
ollama list如果显示模型列表或没有报错,说明服务正常。然后下载一个适合对话的基础模型:
ollama pull qwen2.5:7b下载完成后,本地就能通过命令行测试模型:
ollama run qwen2.5:7b "用一句话鼓励我"看到模型回复后,再确认 HTTP API 是否可用:
curl http://localhost:11434/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "qwen2.5:7b", "messages": [ {"role": "user", "content": "你好"} ] }'这一步返回正常的 JSON 响应后,Python 代码里的网络部分就不会有悬念了。
3.3 项目目录结构设计
一个清晰的目录结构对后续扩展非常重要。推荐按模块拆分,而不是把所有逻辑写在一个文件里:
ai_desktop_pet/ ├── main.py # 应用入口 ├── config.py # 全局配置 ├── core/ │ ├── pet_window.py # 桌宠透明窗口 │ ├── bubble.py # 气泡对话框 │ ├── animator.py # 动画控制器 │ └── tray.py # 系统托盘 ├── services/ │ ├── ai_service.py # 大模型对话服务 │ ├── scheduler.py # 定时提醒服务 │ └── voice.py # 语音输入输出 ├── storage/ │ └── database.py # SQLite 封装 ├── assets/ │ ├── idle.png # 待机动画帧 │ ├── click.png # 点击动画帧 │ └── sound/ # 提示音效 └── requirements.txt这个结构把界面、业务、数据三层分离。后续替换动画资源、更换大模型后端、增加新的提醒规则,都不会牵动全部代码。
4. 第一阶段:先让桌宠窗口在桌面上“活”起来
不要一上来就接 AI,先把桌宠的窗口工程跑通。透明的无边框窗口、拖拽、置顶、托盘这些基础能力是桌宠的地基。
4.1 创建透明无边框窗口
桌宠窗口不需要系统标题栏,也不需要普通窗口背景。PySide6 中通过设置窗口标志和属性实现:
# core/pet_window.py from PySide6.QtWidgets import QWidget from PySide6.QtCore import Qt, QPoint from PySide6.QtGui import QPainter, QPixmap class PetWindow(QWidget): def __init__(self): super().__init__() # 无边框、窗口置顶、工具窗口不抢焦点 self.setWindowFlags( Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool ) # 背景透明 self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setFixedSize(160, 160) self.image = QPixmap("assets/idle.png") self.drag_position = None def paintEvent(self, event): painter = QPainter(self) painter.drawPixmap(self.rect(), self.image) def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self.drag_position = event.globalPosition().toPoint() - self.frameGeometry().topLeft() event.accept() def mouseMoveEvent(self, event): if event.buttons() and Qt.MouseButton.LeftButton and self.drag_position is not None: self.move(event.globalPosition().toPoint() - self.drag_position) event.accept() def mouseReleaseEvent(self, event): self.drag_position = None关键点有三个。FramelessWindowHint去掉标题栏;WA_TranslucentBackground让窗口背景透明;Tool标志让窗口不抢焦点,这是桌宠不干扰用户打字的关键。
4.2 添加气泡对话框和文字输出
点击桌宠时,需要弹出气泡显示文字。气泡可以做成同一个窗口内的子控件,也可以单独做一个半透明圆角面板。这里做一个简单的 QLabel 子控件:
# core/bubble.py from PySide6.QtWidgets import QLabel from PySide6.QtCore import Qt, QTimer, QPropertyAnimation, QEasingCurve class Bubble(QLabel): def __init__(self, parent=None): super().__init__(parent) self.setObjectName("bubble") self.setWordWrap(True) self.setMaximumWidth(240) self.setStyleSheet( "#bubble {" "background: rgba(0, 0, 0, 200);" "color: white;" "border-radius: 12px;" "padding: 12px;" "font-size: 14px;" "}" ) self.hide() self.timer = QTimer(self) self.timer.timeout.connect(self.hide) def show_text(self, text, duration=5000): self.setText(text) self.adjustSize() # 气泡位置在桌宠窗口上方 self.move(20, -self.height() - 10) self.show() self.timer.start(duration)这里用了adjustSize根据内容自动调整气泡大小。位置通过move放到桌宠上方,保证不遮挡桌宠本体。
4.3 使用动画切换待机与点击状态
桌宠的“活”主要体现在动画上。PySide6 的QPropertyAnimation可以设置位置、透明度和大小动画。一个简单的做法是做多帧切换:
# core/animator.py from PySide6.QtCore import QObject, QTimer, QPropertyAnimation, QEasingCurve class PetAnimator(QObject): def __init__(self, pet_window): super().__init__() self.window = pet_window self.frame_index = 0 self.frames = ["assets/idle_1.png", "assets/idle_2.png", "assets/idle_3.png"] self.timer = QTimer() self.timer.timeout.connect(self.next_frame) self.timer.start(400) # 每 400ms 切换一帧 def next_frame(self): self.frame_index = (self.frame_index + 1) % len(self.frames) self.window.image.load(self.frames[self.frame_index]) self.window.update() def jump(self): # 点击后的弹跳动画 self.animation = QPropertyAnimation(self.window, b"pos") current = self.window.pos() self.animation.setDuration(300) self.animation.setStartValue(current) self.animation.setEndValue(current + QPoint(0, -30)) self.animation.setEasingCurve(QEasingCurve.Type.OutBounce) self.animation.finished.connect( lambda: self._back_to_origin(current) ) self.animation.start() def _back_to_origin(self, origin): self.back_animation = QPropertyAnimation(self.window, b"pos") self.back_animation.setDuration(300) self.back_animation.setStartValue(self.window.pos()) self.back_animation.setEndValue(origin) self.back_animation.setEasingCurve(QEasingCurve.Type.InBounce) self.back_animation.start()动画帧图片可以用简单的 PNG 序列。没有美术资源的可以直接用文字或不同颜色的圆角矩形占位,关键是先把动画渲染链路跑通。
5. 第二阶段:把本地大模型接入桌宠对话
窗口跑通后,开始接入 AI 对话。这是整个项目从“普通桌宠”升级到“AI 桌宠”的关键一步。
5.1 配置 Ollama 客户端
使用 OpenAI 兼容接口连接本地 Ollama 服务。配置类单独放在config.py中:
# config.py import os class Config: # 本地 Ollama 服务地址 OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1") OLLAMA_API_KEY = os.getenv("OLLAMA_API_KEY", "ollama") OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen2.5:7b") # 对话参数 TEMPERATURE = 0.7 MAX_TOKENS = 1024 TIMEOUT = 30 # 数据库路径 DB_PATH = os.getenv("DB_PATH", "pet_data.db")环境变量预留了配置外置的入口。即使现在直接写死,也建议保留这个结构,方便后面切换到云端模型时只修改环境变量。
创建 AI 对话服务:
# services/ai_service.py from openai import OpenAI from config import Config class AIService: def __init__(self): self.client = OpenAI( base_url=Config.OLLAMA_BASE_URL, api_key=Config.OLLAMA_API_KEY, timeout=Config.TIMEOUT, ) self.system_prompt = ( "你是一个生活助理桌宠,名字叫小灵。" "用户可能是 ADHD 人群,需要在鼓励、提醒、拆解任务方面提供帮助。" "回答要简洁,最多 80 个字。语气友好耐心,不要长篇大论。" ) def chat(self, user_message: str, history: list) -> str: messages = [{"role": "system", "content": self.system_prompt}] messages.extend(history) messages.append({"role": "user", "content": user_message}) response = self.client.chat.completions.create( model=Config.OLLAMA_MODEL, messages=messages, temperature=Config.TEMPERATURE, max_tokens=Config.MAX_TOKENS, ) return response.choices[0].message.contentOpenAI客户端的base_url指向本地地址,api_key随便填一个非空值即可,因为 Ollama 本地服务不校验 key。但要注意,如果你用的是远程 Ollama 服务或兼容网关,key 必须真实填写。
5.2 处理对话上下文和超时问题
桌宠对话必须是多轮的,否则用户每说一句话它都无法理解上文。简单做法是把最近 6 条对话记录保存到列表里,随请求一起发给模型:
class ConversationManager: def __init__(self, max_history=6): self.max_history = max_history self.history = [] def add(self, role: str, content: str): self.history.append({"role": role, "content": content}) if len(self.history) > self.max_history: self.history = self.history[-self.max_history:] def clear(self): self.history = []这里的坑在于:如果直接拿用户输入和 AI 返回拼接历史,用户消息和 AI 消息需要成对保存。顺序错误会导致模型上下文混乱。
超时问题也需要处理。本地模型在低配置机器上可能要 10 到 30 秒才生成回复,如果放在 UI 主线程里,窗口会直接卡死。必须把 AI 调用放进线程池:
# services/ai_service.py from PySide6.QtCore import QThread, Signal class ChatWorker(QThread): finished = Signal(str) failed = Signal(str) def __init__(self, ai_service, user_message, history): super().__init__() self.ai_service = ai_service self.user_message = user_message self.history = history def run(self): try: reply = self.ai_service.chat(self.user_message, self.history) self.finished.emit(reply) except Exception as exc: self.failed.emit(str(exc))在窗口层点击发送后,启动ChatWorker,等到finished信号时再刷新气泡。这是桌宠交互流畅的最低要求。
5.3 在气泡处理中识别“提醒意图”
除了普通对话,桌宠还需要识别用户是否在设置提醒。在把消息交给大模型之前,先做一个轻量级规则判断:
# services/intent.py import re from datetime import datetime, timedelta def parse_remind(text: str): # 匹配 “N 分钟后……” match = re.search(r"(\d+)\s*分钟后\s*(.*)", text) if match: minutes = int(match.group(1)) content = match.group(2) or "未指定事项" remind_time = datetime.now() + timedelta(minutes=minutes) return {"type": "remind", "time": remind_time, "content": content} return None这个规则很简单,但非常直接。命中“提醒”意图后,就走定时任务模块,不再送进大模型。这样既省了模型调用延迟,也让“提醒”这类关键动作不受模型输出稳定性影响。
6. 第三阶段:加入专注计时和定时提醒
效率工具的核心是定时任务。这里使用 QTimer 做倒计时,SQLite 做任务持久化。
6.1 设计任务表结构
提醒任务需要有开始时间、执行时间、内容、状态和创建时间。SQLite 表结构如下:
CREATE TABLE IF NOT EXISTS reminders ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, remind_at TEXT NOT NULL, status TEXT DEFAULT 'pending', created_at TEXT DEFAULT CURRENT_TIMESTAMP );针对 ADHD 场景,还可以增加一个repeat_type字段,用于后续扩展重复提醒(每天、每周、工作日)。第一版先不做重复提醒,但要留字段位。
6.2 使用 QTimer 周期性扫描到期任务
桌宠不需要为每个任务单独创建一个定时器,那样资源开销太大。更好的方案是每隔 10 秒查询一次数据库,找到所有到期且未被处理的任务:
# services/scheduler.py from PySide6.QtCore import QObject, QTimer from datetime import datetime class ReminderScheduler(QObject): def __init__(self, db, on_remind): super().__init__() self.db = db self.on_remind = on_remind self.timer = QTimer() self.timer.timeout.connect(self.check_due_reminders) self.timer.start(10000) def check_due_reminders(self): now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") reminders = self.db.fetch_due(now) for item in reminders: self.on_remind(item["content"]) self.db.mark_done(item["id"])这种轮询方式简单可靠,不会因为任务数量增加而产生大量线程。10 秒的扫描间隔对提醒场景足够,因为桌宠不是秒级闹钟。
6.3 专注计时模块
专注计时是 ADHD 场景中非常有用的功能。实现一个简单的双状态计时器:
# services/focus_timer.py from PySide6.QtCore import QObject, QTimer, Signal class FocusTimer(QObject): tick = Signal(int) completed = Signal() def __init__(self, duration_minutes: int = 25): super().__init__() self.duration = duration_minutes * 60 self.remaining = self.duration self.timer = QTimer() self.timer.timeout.connect(self._on_tick) def start(self): self.remaining = self.duration self.timer.start(1000) self.tick.emit(self.remaining) def pause(self): self.timer.stop() def reset(self): self.timer.stop() self.remaining = self.duration self.tick.emit(self.remaining) def _on_tick(self): self.remaining -= 1 if self.remaining <= 0: self.timer.stop() self.completed.emit() else: self.tick.emit(self.remaining)每次 tick 信号通知界面刷新剩余时间。倒计时结束后触发completed,桌宠可以弹出气泡提示“专注结束,休息一下”。这个模块后续可以和番茄钟算法、待办列表关联,形成完整的效率闭环。
7. 完整串联:从点击桌宠到 AI 回应的主流程
各模块已经就绪,现在把它们串进主窗口。核心目标是让整个链路在用户视角下自然流畅:点击桌宠 -> 输入框出现 -> 提交文本 -> 气泡显示等待状态 -> AI 回复出现在气泡中。
7.1 主窗口的组件协作
主窗口负责创建桌宠窗口、气泡、托盘、动画器、AI 服务和提醒调度器。一个简化版的main.py:
# main.py import sys from PySide6.QtWidgets import QApplication, QSystemTrayIcon, QMenu from PySide6.QtGui import QAction, QIcon from core.pet_window import PetWindow from core.bubble import Bubble from core.animator import PetAnimator from core.tray import TrayIcon from services.ai_service import AIService, ChatWorker from services.scheduler import ReminderScheduler from services.intent import parse_remind from storage.database import Database from config import Config class DesktopPetApp: def __init__(self): self.app = QApplication(sys.argv) self.pet = PetWindow() self.bubble = Bubble(self.pet) self.animator = PetAnimator(self.pet) self.ai_service = AIService() self.db = Database(Config.DB_PATH) self.scheduler = ReminderScheduler(self.db, self.on_remind) self._setup_tray() self._setup_interactions() def _setup_interactions(self): self.pet.mousePressEvent = self.pet.mousePressEvent # 保留原生拖拽 # 双击打开对话输入框 self.pet.mouseDoubleClickEvent = lambda e: self.pet.show_text_input() def _setup_tray(self): self.tray = TrayIcon(self.pet, self) def on_remind(self, content: str): self.bubble.show_text(f"提醒:{content}", duration=8000) def handle_user_input(self, text: str): if not text.strip(): return # 先判断提醒意图 remind = parse_remind(text) if remind: self.db.add_reminder(remind["content"], remind["time"].strftime("%Y-%m-%d %H:%M:%S")) self.bubble.show_text(f"已设置提醒:{remind['content']}", duration=4000) return # 普通对话走 AI 线程 self.bubble.show_text("让我想想……", duration=100000) self.worker = ChatWorker(self.ai_service, text, []) self.worker.finished.connect(lambda reply: self.bubble.show_text(reply, duration=8000)) self.worker.failed.connect(lambda err: self.bubble.show_text(f"出错了:{err}", duration=8000)) self.worker.start() def run(self): self.pet.show() return self.app.exec() if __name__ == "__main__": app = DesktopPetApp() sys.exit(app.run())这里没有把所有信号槽都展开,但主流程已经完整。双击桌宠弹出输入框,输入文本后先判断是否是提醒意图,是则写入数据库,否则交给 AI 线程处理,完成后通过信号更新气泡。
7.2 添加到系统托盘
桌宠窗口可以被隐藏,但应用不能消失。托盘图标提供了显示/隐藏、退出、清空历史记录等操作:
# core/tray.py from PySide6.QtWidgets import QSystemTrayIcon, QMenu from PySide6.QtGui import QAction, QIcon class TrayIcon: def __init__(self, pet_window, app): self.pet = pet_window self.app = app self.tray = QSystemTrayIcon(QIcon("assets/icon.png")) self.tray.setToolTip("AI 桌宠") menu = QMenu() show_action = QAction("显示桌宠", None) show_action.triggered.connect(self.pet.show) menu.addAction(show_action) hide_action = QAction("隐藏桌宠", None) hide_action.triggered.connect(self.pet.hide) menu.addAction(hide_action) menu.addSeparator() quit_action = QAction("退出", None) quit_action.triggered.connect(self.app.app.quit) menu.addAction(quit_action) self.tray.setContextMenu(menu) self.tray.show()如果桌宠进程退出时没有隐藏托盘图标,Windows 上会出现图标残留。退出前调用self.tray.hide()可以规避这个问题。
8. 运行验证与调试方法
代码写完不是结束,必须验证各条主链路是否正常。
8.1 最小验证清单
按依赖顺序逐项验证:
| 验证项 | 操作 | 预期结果 |
|---|---|---|
| Ollama 服务 | curl http://localhost:11434/api/tags | 返回模型 JSON |
| 模型对话 | ollama run qwen2.5:7b "你好" | 模型正常回复 |
| Python 导入 | python -c "import PySide6; print(PySide6.__version__)" | 输出版本号 |
| 窗口启动 | python main.py | 桌宠悬浮显示、无边框、可拖动 |
| AI 对话 | 双击桌宠输入“你好” | 气泡显示模型回复 |
| 提醒任务 | 输入“提醒我 1 分钟后喝水” | 气泡确认,1 分钟后提醒 |
出现问题时,不要直接看界面,先检查命令行输出和stderr日志。PySide6 的很多错误不会弹窗,而是打印到标准错误流。
8.2 常见报错排查路径
现象 1:窗口启动后闪退
可能原因通常是资源路径错误。比如assets/idle.png不存在,QPixmap加载空图片导致绘制异常。检查方式是在paintEvent里打印self.image.isNull()。解决方式是使用os.path.join(os.path.dirname(__file__), "assets", "idle.png")绝对路径加载。
现象 2:AI 对话很久不回复
先测试模型本身是否正常。如果模型加载需要 10 秒以上,可以考虑减少MAX_TOKENS,或者换qwen2.5:3b。另外确认ChatWorker是否真的启动,代码里容易把self.worker写成局部变量导致线程被垃圾回收,从而丢失信号。
现象 3:定时提醒不触发
先看数据库表是否正确创建,再确认remind_at的时间格式和fetch_due查询是否匹配。最容易踩的坑是时区不一致:datetime.now()生成的是本地时间,如果之前手动插入过 UTC 时间,到期判断就会出错。
现象 4:托盘图标消失但进程没退出
正常情况下,右键菜单选择退出应同时结束QApplication。如果是直接关闭桌宠窗口只隐藏不退出,需要确认隐藏动作不会导致QSystemTrayIcon被回收。
8.3 加入线程安全防护
PySide6 的 UI 操作必须在主线程执行。ChatWorker中不能直接调用self.bubble.show_text(),否则在高频操作下会出现段错误。上面的代码通过信号传递结果,实际使用中如果要在工作线程里写数据库,也要使用独立连接或在主线程里执行写入。
给数据库操作加上锁也是一种防御方式。SQLite 默认串行写入,但多个线程同时调用fetch_due和mark_done时可能会遇到database is locked。可以在Database类内部使用threading.Lock:
# storage/database.py import sqlite3 import threading class Database: def __init__(self, db_path): self.lock = threading.Lock() self.conn = sqlite3.connect(db_path, check_same_thread=False) self._create_tables() def fetch_due(self, now): with self.lock: cur = self.conn.execute( "SELECT id, content FROM reminders WHERE status='pending' AND remind_at <= ?", (now,), ) return [{"id": r[0], "content": r[1]} for r in cur.fetchall()] def mark_done(self, reminder_id): with self.lock: self.conn.execute( "UPDATE reminders SET status='done' WHERE id=?", (reminder_id,), ) self.conn.commit()check_same_thread=False允许跨线程使用同一个连接,但必须配合锁保护写入操作。实际生产环境也可以考虑每个线程独立连接,但对桌宠这种轻量应用,一个连接加锁已经足够。
9. 落地阶段的排错与细节校准
进入实际使用后,还有很多从“能跑”到“好用”之间的问题需要处理。这些细节往往决定了用户是否愿意长期使用桌宠。
9.1 AI 回复内容不可控怎么办
本地模型没有云端模型的指令跟随能力稳定。即使设定了 system prompt,在用户输入“你是什么模型”“讲个超长故事”等请求时,它仍然可能输出过长内容。应对方案:
- 在代码层面强制截断:如果回复超过 200 个字符,气泡里只显示前 200 个字符加省略号。
- 在 system prompt 里强调“每条回复最多 80 字”,但不要完全依赖它。
- 在界面上增加一个“完整回复”按钮,点击才展开大模型原始输出。
不要试图完全控制模型输出,这不是桌宠应用应该做的事情。应用层负责展示边界,模型负责内容生成,职责分离最安全。
9.2 桌宠窗口交互干扰正常桌面操作
桌宠窗口虽然设置了Tool标志,但拖动过程中仍然可能与正常窗口产生遮挡问题。针对 ADHD 用户,桌宠必须比普通应用更安静,不能突然跳动或抢焦点。
设置策略如下:
self.pet.setWindowFlag(Qt.WindowType.WindowDoesNotAcceptFocus, True) self.pet.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)第一个标志让桌宠不接受键盘焦点,用户打字时不会被桌宠打断。第二个属性让桌宠显示时不会激活窗口自身。这两个配置对保持桌面安静非常重要。
9.3 开机自启动和资源占用
作为桌宠产品,开机自启几乎是刚需。Windows 上可以通过在注册表启动项中添加当前程序实现,也可以更规范地做成安装器配置。手动注册表的写法:
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run添加一个字符串值,指向当前 Python 脚本或打包后的 exe。macOS 和 Linux 则分别使用 LaunchAgent 和 autostart desktop 文件。
资源占用方面,Python + PySide6 桌宠启动后内存通常在 80MB 到 150MB 之间。如果不做优化,多帧动画和频繁的本地模型调用会显著增加 CPU 占用。建议:
- 空闲状态使用低帧率动画,例如每 800ms 一帧。
- 用户未点击时不要频繁重绘。
- 本地模型使用 Ollama 时,模型默认加载到内存后会常驻,对内存占用敏感的用户可以设置
OLLAMA_KEEP_ALIVE环境变量调整释放时间。
10. 缺陷与防护:桌面应用最容易忽略的问题
桌宠长期运行,很多潜在问题不会在开发时暴露,而是在开机后几天才出现。这里梳理实际使用中最值得关注的五类问题。
10.1 长时间运行导致的动画卡顿
多帧动画如果使用QPixmap频繁加载文件,长时间运行后会累积内存碎片。建议启动时把所有动画帧加载进内存,而不是每帧都读磁盘。
10.2 本地大模型响应过长导致的线程堆积
用户连续提问时,如果前一个ChatWorker还没结束,用户又发起新请求,就会同时存在多个线程等待模型回复。内存和 CPU 都会被拖垮。解决方式是:新请求发起前,把之前的worker设置为不可执行,或者使用队列机制,同一时间只允许一个对话请求。
10.3 提醒任务长时间不执行
前面说过轮询机制本身可靠,但如果用户把系统休眠、桌宠窗口被隐藏、或者 QTimer 回调中出现了未捕获异常,定时器会静默停止。关键位置要加 try-except 并把异常写入日志文件。
10.4 隐私数据持久化风险
对话记录和任务提醒会包含用户的生活细节。数据库文件不能明文存放在普通目录中。至少要做数据库文件加密或存储到用户数据目录并设置文件权限。常见的 SQLite 加密方案有 SQLCipher,但对桌宠项目来说,第一步先把数据库文件放到系统用户目录下,避免程序目录被第三方修改读取。
10.5 未处理全局异常导致桌宠悄悄退出
桌宠不像服务器可以随时查看控制台。一旦某个线程抛异常,可能整个应用消失,而用户毫不知情。在入口位置注册全局异常钩子,并把错误写入日志文件:
# utils/logger.py import sys import traceback def init_global_exception_logger(): def hook(exc_type, exc_value, exc_tb): with open("pet_error.log", "a", encoding="utf-8") as f: f.write("".join(traceback.format_exception(exc_type, exc_value, exc_tb))) sys.excepthook = hook这样即使应用崩溃,也能留下排查线索。
11. 生产化部署与后续扩展
桌宠项目从原型到长期可用,还有几条值得持续投入的扩展方向。
11.1 从 Python 脚本到独立产品的路径
本地直接运行python main.py依赖用户电脑安装 Python 环境,不适合发给普通用户。至少要做到两步:
- 使用 PyInstaller 或 Nuitka 把应用打包成独立可执行文件。
- 把 Ollama 作为可选安装项,初始版本对话能力直接通过内置 API 接一个默认的轻量模型,用户后续可以自行切换。
打包时要注意 PySide6 的资源路径问题。脚本运行正常的代码在打包后经常因为找不到assets目录而崩。使用PyInstaller的--add-data参数把资源文件打进去,并在代码中通过sys._MEIPASS兼容路径处理。
11.2 多模态交互扩展
当前示例只实现了文本交互。生产级桌宠还应支持:
- 语音输入:通过麦克风识别用户说了什么,适合 ADHD 用户在分心时快速记录。
- 语音输出:使用
pyttsx3或本地 TTS 引擎朗读提醒内容。 - 表情动画:根据大模型输出情感标签切换桌宠表情。
- 应用联动:检测到用户长时间使用某个软件时,桌宠主动弹窗提醒休息。
其中应用联动是效率工具的关键升级。通过系统 API 获取当前前台窗口标题,结合时间策略判断用户是否处于超长专注状态。
11.3 开放接口和插件化设计
如果桌宠想继续发展,不能把功能写死在核心代码里。把“技能”做成插件,例如:
- “任务插件”负责从待办软件同步今日任务。
- “天气插件”在早上问候时附带天气提醒。
- “健康插件”根据久坐时间提醒用户喝水、伸展。
每个插件通过简单的 Python 接口实现,由桌宠主体动态加载。这样即使不增加模型能力,桌宠也能通过规则引擎完成大量效率辅助工作。
11.4 ADHD 场景的长期使用建议
技术只是工具,真正对用户有效的,是让工具融入日常节奏。桌宠产品在 ADHD 场景落地时,建议遵循三原则:
- 少即是多:默认关闭非必要的弹窗提醒,避免形成“提醒疲劳”。
- 信息可回溯:对话记录和任务记录要有历史页面,帮助用户在状态不好时回顾自己做过什么。
- 允许失败:不要设计成“必须做到”的打卡工具,而是提供“鼓励再试一次”的心理缓冲。
这三点决定了产品定位:桌宠不是监督者,而是陪伴者和辅助者。开发时也应保持这种产品心态,技术选型和交互设计都不应该给用户增加额外认知负担。
12. 常见开发误区与最佳实践汇总
12.1 六个高频误区
| 误区 | 后果 | 正确做法 |
|---|---|---|
| 把所有逻辑写进 main.py | 后期寸步难行 | 拆分为窗口、服务、存储三层 |
| 用全局变量保存对话历史 | 状态混乱 | 用 ConversationManager 管理 |
| AI 请求放主线程 | UI 卡死 | 使用 QThread 或线程池 |
| 给每个提醒创建 QTimer | 资源浪费 | 统一轮询数据库 |
| 忽略全局异常处理 | 程序静默退出 | 注册 sys.excepthook 和日志 |
| 直接在程序目录写数据库 | 权限和数据安全风险 | 使用用户数据目录 |
12.2 代码审查清单
在提交代码或打包前,自查以下项目:
- 启动时是否检查 Ollama 服务和模型可用性,不可用时是否给出友好提示。
- 是否限制对话历史长度,防止 token 超限。
- 所有耗时操作是否在非主线程执行。
- 数据库写入是否有锁保护。
- 拖拽窗口时是否频繁触发布局计算,是否有必要降低重绘频率。
- 退出逻辑是否完整,包括隐藏托盘图标、停止定时器、关闭数据库连接。
- 语音模块是否适配了无麦克风环境。
- 错误日志是否包含时间和异常栈,是否循环写入避免无限膨胀。
12.3 进阶学习路径
完成一个基础版 AI 桌宠后,下一步建议按这个顺序深入:
- 学习 PySide6 的 Graphics View 框架,实现更丰富的桌宠动画。
- 理解 RAG 的基本概念,给桌宠接入用户自己的知识库,让它能回答“我上次说的那个问题”等需要记忆的请求。
- 学习本地模型的微调方法,针对 ADHD 场景定制更贴合的回复风格。
- 使用 WHISPER 本地语音识别,代替云端的语音接口。
- 研究长期运行服务的稳定性设计,包括内存监控、自动重启和性能日志分析。
每一步都能独立成为一篇技术博客,也是把 AI 桌宠从“玩具”做成“工具”的必然路径。