Adds the full documentation set for the planned desktop pet feature under docs/features/desktop-pet: README, PRD, user stories, logic design, UI design, and technical design. The pet is scoped as a lightweight always-on entry point for quick drafting, drag-and-drop staging, and task status at a glance; it does not introduce a second Ask/Execute channel, task execution path, or independent task-state store. Design only, no implementation yet.
19 KiB
桌面宠物技术设计
文档信息
| 项目 | 内容 |
|---|---|
| 状态 | 设计中 |
| 版本 | 0.1 |
| 日期 | 2026-09-03 |
| 关联 PRD | 桌面宠物 PRD |
| 功能逻辑 | 功能逻辑设计 |
| UI 设计 | UI 设计 |
1. 进程与窗口边界
Main
-> createMainWindow() (既有,src/main/window.ts)
-> createPetWindow() (新增)
-> 独立 BrowserWindow:无边框、透明、置顶、不入任务栏
-> 独立 preload:src/preload/pet.ts(新增,窄 API)
-> 加载独立渲染入口:src/renderer/pet.html + src/renderer/src/pet/
Renderer(主窗口,既有)
-> window.goodbuddy(既有 DesktopApi,不变)
Renderer(宠物窗口,新增)
-> window.goodbuddyPet(新增窄 API,与既有 window.goodbuddy 完全隔离)
宠物窗口是与主窗口平级的第二个 BrowserWindow,不是主窗口的子窗口、不是 WebContentsView
叠加层。理由:
- 主窗口现有
setWindowOpenHandler拒绝一切子窗口(src/main/window.ts),沿用该策略 比引入例外更简单。 - 宠物窗口需要独立于主窗口的显示/隐藏、位置和置顶状态,作为子窗口会继承父窗口的显示 状态,不满足“主窗口隐藏时宠物仍可见”的产品要求。
不复用主窗口的 preload 和渲染入口:主窗口 preload 暴露的 DesktopApi 面积很大(对话、
知识库、设备共享等)。给宠物窗口加载同一 preload 会让一个新增的、长期运行、暴露给
远程可控网页内容风险更低但攻击面更广的窗口拥有不必要的能力。新增窄 preload 遵循仓库
现有的“只暴露显式方法”约束。
2. 模块职责
| 模块 | 位置 | 职责 |
|---|---|---|
PetWindowService |
src/main/pet/pet-window-service.ts(新增) |
创建/销毁宠物窗口、位置持久化与越界校验、置顶与贴边、显示/隐藏 |
PetSettingsStore |
src/main/pet/pet-settings-store.ts(新增) |
总开关、外观、位置、安静模式的持久化,遵循既有 settings-file-utils 模式 |
PetStatusProjector |
src/main/pet/pet-status-projector.ts(新增) |
从既有 Task 查询结果计算状态镜面取值,不引入新状态源 |
PetDraftBridge |
src/main/pet/pet-draft-bridge.ts(新增) |
把宠物提交的文本/附件转交主窗口渲染进程,驱动会话激活与草稿写入 |
PetNotificationCoordinator |
src/main/pet/pet-notification-coordinator.ts(新增) |
与既有 desktop-notification.ts 协商同一事件只走一条提示路径 |
既有 context-manager.ts |
不变 | 宠物附件校验复用其 selectFiles/校验规则,不新增第二套限制 |
既有 assistant-database.ts / Task 查询 |
不变 | 状态镜面查询的唯一数据来源 |
PetStatusProjector 只读取既有 Task 查询结果并按
功能逻辑第 3 节的优先级归并,不写入、不缓存跨进程
重启的状态;重启后状态镜面从当前 Task 集合重新计算。
3. 宠物窗口创建
// src/main/pet/pet-window-service.ts(示意)
function createPetWindow(): BrowserWindow {
return new BrowserWindow({
width: PET_SIZE.width,
height: PET_SIZE.height,
show: false,
frame: false,
transparent: true,
resizable: false,
skipTaskbar: true,
alwaysOnTop: true, // 由“始终置顶”设置决定实际值
hasShadow: false,
backgroundColor: '#00000000',
webPreferences: {
preload: join(currentDirectory, '../preload/pet.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
}
transparent: true依赖操作系统合成器。按 PRD 6 节明确限制,在不支持透明合成的 Linux 桌面环境(例如未运行合成器的部分窗口管理器)下,Electron/Chromium 会把窗口 渲染为不透明;PetWindowService不检测合成器能力,只在渲染进程内把背景色设置为 当前主题的画布色作为不透明回退,保证内容仍然可读,不尝试探测或声明支持特定合成器。alwaysOnTop的实际值由“始终置顶”Switch 驱动,通过window.setAlwaysOnTop(bool)在设置变更时更新,不需要重建窗口。- 复用既有
resolveWindowIcon/任务栏排除逻辑作为参考,但宠物窗口skipTaskbar: true固定为真,不提供用户设置。 - 宠物窗口不设置
parent,与主窗口是同级独立窗口;应用单实例锁和second-instance处理(src/main/index.ts)不需要改动,因为宠物窗口的存在与否由PetWindowService内部状态决定,而不是第二个 Electron 实例。
4. 拖放实现
Electron 渲染进程原生支持 HTML5 拖放;宠物渲染进程监听 dragenter/dragover/drop
即可获得视觉反馈和释放事件,不需要 Main 参与拖放事件本身。文件路径解析复用既有
webUtils.getPathForFile(已用于 src/preload/index.ts 的知识库拖放导入):
// src/preload/pet.ts(示意)
import { contextBridge, ipcRenderer, webUtils } from 'electron'
contextBridge.exposeInMainWorld('goodbuddyPet', {
submitDraft: (input: PetDraftSubmission) =>
ipcRenderer.invoke(petIpcChannels.submitDraft, input),
resolveDroppedPaths: (files: File[]) =>
files.map((file) => webUtils.getPathForFile(file)).filter(Boolean),
// ...状态订阅、设置读写等方法
})
拖入的文本(非文件)通过 dataTransfer.getData('text/plain') 直接读取,随普通文本
字段一起提交,不经过路径解析。
宠物渲染进程把解析出的路径和文本打包为 PetDraftSubmission,通过 IPC 交给 Main;Main
侧的路径校验、类型判断、大小限制和读取,全部复用既有 ContextManager 的规则(src/main/context-manager.ts
中的 supportedExtensions、supportedImageExtensions、supportedDocumentExtensions、
maximumContextBytes、maximumAttachmentsPerMessage),不新增校验逻辑。ContextManager
目前的公开入口是围绕原生对话框(selectFiles)设计的;实现时需要新增一个接受任意
canonical 路径数组的方法(例如 stageFromPaths),内部复用现有扩展名/大小/文档解析
分支,而不是复制一份判断逻辑。文件夹路径(fs.stat 判定为目录)在该方法入口即被拒绝,
不递归遍历。
5. 快速起草提交与主窗口联动
宠物渲染进程 submitDraft(input)
-> Main: PetDraftBridge.handleSubmit(input)
-> ContextManager.stageFromPaths(input.filePaths) (复用既有校验)
-> 若主窗口不存在:createMainWindow() + loadMainWindow()(复用既有函数)
-> showWindow(mainWindow)(既有函数)
-> mainWindow.webContents.send(petIpcChannels.draftReceived, {
targetConversationId, text, stagedContextIds
})
-> 主窗口渲染进程(App.tsx 新增一个事件监听,与既有 conversation:new 监听模式一致)
-> 若 targetConversationId 无效或已删除:回退为新建会话(既有新建会话路径)
-> 激活目标会话、把 text 写入该会话输入框状态、把 stagedContextIds 追加到该
会话的附件状态(既有 App.tsx 附件状态更新路径)
-> 聚焦输入框
- 草稿只在主窗口渲染进程内变成“输入框状态”和“待发送附件列表”,与用户手动打字/手动
点击附件按钮产生的状态是同一份 React state,不建立并行的草稿存储。这直接满足
INV-1:宠物不产生消息、不产生 Task。 stagedContextIds复用现有context:*IPC 产生的 Context ID;主窗口把这些 ID 加入 附件列表的方式,与用户通过“附加文件”按钮选择文件后的状态更新完全一致。- 若提交发生时主窗口曾被销毁(用户从未打开过,或者提前研究表明当前架构不销毁主窗口
只隐藏),
PetDraftBridge调用既有createMainWindow/loadMainWindow,等待ready-to-show完成后再发送draftReceived,避免竞态。
6. IPC 边界
新增独立 IPC 通道命名空间 pet:*,与既有 ipcChannels 并列定义在
src/shared/ipc-channels.ts:
pet:get-snapshot (设置 + 当前状态镜面取值 + 可见性)
pet:update-settings (总开关、外观、贴边、置顶、安静模式)
pet:submit-draft (快速起草/拖放提交)
pet:dismiss-bubble (关闭完成/失败气泡)
pet:open-task (点击状态镜面/气泡,打开 Task Center 对应任务)
pet:toggle-visibility (托盘“隐藏/显示桌面宠物”)
- 所有输入使用
src/shared/pet-contracts.ts(新增)中的 Zod schema 校验,与既有约定 一致。 trusted-ipc-sender.ts的assertTrustedSender按窗口实例参数化使用;宠物窗口的 IPC handler 校验event.sender === petWindow.webContents,不能用主窗口的 trusted sender 校验函数验证宠物窗口的请求,也不能反过来。- 宠物 preload 不暴露任何既有
DesktopApi方法;需要的能力(提交草稿、读取/写入宠物 设置、订阅状态镜面)都通过pet:*通道单独暴露为window.goodbuddyPet的具体方法。 - Main 侧广播状态镜面变化时,只向宠物窗口发送派生后的枚举值(第 3 节的六种状态之一) 和最小必要的任务标题/ID,不发送完整 Task 或 Conversation 对象。
7. 持久化
新增设置文件,遵循既有 settings-file-utils.ts 的原子写入、版本校验和损坏隔离模式,
不复用 ApplicationSettingsStore 或 ShortcutSettingsStore 的 schema:
pet-settings.json
{
version: 1,
enabled: boolean,
quietMode: boolean,
alwaysOnTop: boolean,
snapToEdge: boolean,
appearance: { character: string, size: 'small'|'medium'|'large',
opacity: number, motion: 'off'|'reduced'|'full' },
position: { displayId: number, x: number, y: number } | null
}
position为null表示尚未被用户拖动过,使用默认位置计算规则(第 8 节)。displayId使用 Electronscreen.getAllDisplays()返回的id;加载时按第 8 节校验, 不假设跨重启保持稳定的显示器 id 一定仍然存在。- 独立 schema 版本号(从 1 开始),与主设置版本号互不影响;未来变更只需要迁移这一个 文件。
- 是否需要独立数据库表:不需要。宠物没有需要查询、筛选或跨设备同步的历史数据,一个 JSON 文件足够,符合仓库 KISS/YAGNI 约束。
8. 位置与多显示器校验
function resolvePetPosition(
saved: PetPosition | null,
displays: Display[]
): { x: number; y: number } {
if (saved) {
const display = displays.find((d) => d.id === saved.displayId)
if (display && pointWithinWorkArea(saved, display.workArea)) {
return saved
}
}
const primary = screen.getPrimaryDisplay()
return defaultPositionWithin(primary.workArea) // 例如右下角,留出安全边距
}
- 该函数在宠物窗口创建时以及
display-added/display-removed/display-metrics-changed事件触发时调用;越界立即调用window.setPosition校正,不等待用户下一次拖动。 - 用户拖动结束(
moved事件)时把当前getBounds()和screen.getDisplayNearestPoint得到的displayId写入PetSettingsStore,去抖后原子写入,避免拖动过程中高频写盘。 - 贴边吸附在渲染进程拖动过程中做视觉预判(不移动实际窗口,只显示吸附提示),在
moved事件的最终位置上由 Main 一次性计算是否落入吸附阈值并调用setPosition吸附到边缘,阈值和默认位置的具体像素值作为实现细节调优,不在此固定。
9. 状态镜面查询
PetStatusProjector 不新增事件源,而是订阅既有 Task 变化通知(主窗口渲染进程已经
用于更新 Task Center 和会话内任务卡片的同一套 Main → Renderer 事件)在 Main 侧的
对应版本,或者在 Main 侧直接查询 assistant-database.ts 暴露的 Task 读取方法。具体
选择两者之一属于实现阶段的技术选择,约束条件是:
- 不引入宠物专属的任务状态表或缓存;查询/订阅的结果必须与 Task Center 在同一时刻 展示的状态一致。
- 查询开销必须是有界的(只统计活跃与最近终态 Task 的数量和状态,不拉取消息正文)。
- Runtime/网络不可达导致查询失败时返回
unavailable,不静默返回idle。
“最近完成”“最近失败”窗口时长和“已查看”判定:复用 Task 已有的已读/查看时间戳字段 (若既有模型已提供);若当前模型没有这类字段,实现时以“该 Task 终态发生后,用户是否 已经点击过对应的宠物气泡或状态镜面,或已经在 Task Center 中打开过该 Task”作为已查看 判定,窗口时长建议与现有原生通知的展示逻辑保持同量级(分钟级),具体数值在实现时 根据既有 Task 事件粒度确定。
10. 提示与原生通知协商
PetNotificationCoordinator 与 desktop-notification.ts 的 showDesktopNotificationWhenUnfocused
共享同一个“主窗口是否可见并聚焦”判定:
function shouldSuppressBecauseMainWindowVisible(mainWindow: BrowserWindow): boolean {
return !mainWindow.isDestroyed() && mainWindow.isFocused()
}
- 现有
showDesktopNotificationWhenUnfocused已经使用等价条件(window.isFocused()) 决定是否弹出原生通知;PetNotificationCoordinator复用同一判定函数(如需要,提取为 共享工具),保证两者的“可见性”定义永远一致,不出现原生通知判定为可见但宠物判定为 不可见(或反之)导致同一事件同时出现两条提示或完全没有提示的情况。 - 实现层面:调用触发通知的代码路径在决定“原生通知 vs 宠物气泡”时二选一,而不是
两者分别独立判断后都可能触发;例如由现有触发点先判断安静模式和宠物启用状态,
为真则调用
PetNotificationCoordinator,否则调用现有showDesktopNotificationWhenUnfocused。 - 安静模式开启时,两者都不触发;状态镜面仍照常更新(状态镜面更新走独立的
pet:get-snapshot/推送通道,不经过通知协商)。
11. 与既有模块集成
- 快速起草和拖放最终只调用主窗口渲染进程已有的“设置输入框文本”“追加附件”“激活 会话”“创建新会话”状态更新函数,不新建与 Ask/Execute、模型选择、Runtime 选择相关 的任何路径。
- 点击状态镜面/气泡打开 Task Center 复用现有 Task Center 导航(既有 IPC/路由,与托盘
“新建对话”发送
conversation:new事件属于同一类模式:Main 发送一个事件,主窗口 渲染进程消费并完成导航)。 - 托盘菜单新增项复用现有
Menu.buildFromTemplate结构(src/main/index.ts的buildTray),插入一个受当前可见性状态驱动的条目,不重写整个菜单构建函数。 - 全局快捷键(
src/shared/shortcut.ts、shortcut-settings-service.ts)不受影响, 桌面宠物不注册新的全局快捷键,因为激活方式是点击窗口本身或系统级窗口切换,不需要 额外抢占全局按键空间。
12. 生命周期
- 应用启动读取
PetSettingsStore;enabled: true时在既有服务和 IPC 注册完成后创建 宠物窗口(与主窗口创建顺序一致,不阻塞主窗口ready-to-show)。 - 宠物窗口创建失败(例如极端情况下操作系统拒绝创建透明窗口):记录原因,不阻塞主窗口 启动,托盘“显示桌面宠物”下次点击时重试创建。
- 应用退出(
before-quit/既有isQuitting标记)时,宠物窗口与主窗口一并销毁,不需要 独立的关闭确认或延迟清理,因为宠物窗口没有正在进行的网络连接或未保存状态:草稿已经 转交主窗口的那一刻起就属于主窗口生命周期。 - “暂时隐藏”只调用
window.hide(),不销毁窗口实例,重新显示直接showWindow复用 既有实例,避免频繁创建/销毁透明窗口带来的闪烁。
13. 打包
- 宠物渲染入口(HTML、内置角色素材)加入
electron.vite.config.ts的 renderer 构建 输入(现有配置已有input多入口结构,参考现有主渲染入口的声明方式新增一项)。 - 内置角色素材(数量有限的静态图片/精灵图)作为打包资源加入
package.json的files/extraResources,不通过运行时网络下载,符合仓库“不新增远程资源请求”的字体/资源 约束的同一精神。 - 新增 preload 产物(
pet.cjs)需要加入electron.vite.config.ts的 main/preload 构建entryFileNames规则,与现有主 preload 产物并列。 - 六个标准打包目标(Windows/macOS/Linux × x64/arm64)均需验证宠物窗口可创建、透明或 回退不透明背景符合第 3 节预期;LoongArch 预览目标不单独验证,遵循仓库现有策略。
14. 验证策略
14.1 自动化
PetSettingsStore的默认值、原子写入、损坏隔离和版本校验(沿用settings-file-utils.ts现有测试模式)。resolvePetPosition在显示器存在/不存在/坐标越界三种输入下的返回值。PetStatusProjector按第 3 节优先级表在各种 Task 组合输入下的输出。PetNotificationCoordinator与desktop-notification.ts共享判定函数在“可见并聚焦” “不可见”“安静模式开启”组合下只触发一条路径或都不触发。ContextManager.stageFromPaths(新增方法)对合法文件、超限文件、不支持类型、文件夹 路径的分支覆盖,复用现有context-manager测试基础设施。pet:*IPC handler 的 trusted sender、schema 校验和错误码。- 快速起草提交后主窗口渲染进程状态更新的单元测试:目标会话激活、文本写入、附件追加、 草稿被丢弃时不产生 Task 或消息记录。
14.2 真实环境
- Windows、macOS、Linux 至少各一次验证宠物窗口创建、拖动、贴边吸附、透明或回退背景的 实际渲染效果。
- 真实拖放一个合法文件、一个超限文件、一个文件夹到宠物窗口,确认 UI 设计第 4.3 节的 反馈和最终主窗口状态符合预期。
- 真实触发一次任务完成和一次任务失败,在“主窗口隐藏”“主窗口可见”“安静模式开启” 三种组合下确认气泡和原生通知的出现符合第 10 节的互斥规则。
- 多显示器插拔场景下确认位置回退逻辑(断开记忆显示器后宠物仍可见)。
- 打包后的应用在六个标准目标上完成启用、拖动、快速起草、退出流程的一次冒烟验证。
设计文档完成不代表功能实施或上述验证通过。