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.
336 lines
19 KiB
Markdown
336 lines
19 KiB
Markdown
# 桌面宠物技术设计
|
||
|
||
## 文档信息
|
||
|
||
| 项目 | 内容 |
|
||
| --- | --- |
|
||
| 状态 | 设计中 |
|
||
| 版本 | 0.1 |
|
||
| 日期 | 2026-09-03 |
|
||
| 关联 PRD | [桌面宠物 PRD](./prd.md) |
|
||
| 功能逻辑 | [功能逻辑设计](./logic-design.md) |
|
||
| UI 设计 | [UI 设计](./ui-design.md) |
|
||
|
||
## 1. 进程与窗口边界
|
||
|
||
```text
|
||
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 节](./logic-design.md#3-状态镜面优先级)的优先级归并,不写入、不缓存跨进程
|
||
重启的状态;重启后状态镜面从当前 Task 集合重新计算。
|
||
|
||
## 3. 宠物窗口创建
|
||
|
||
```ts
|
||
// 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` 的知识库拖放导入):
|
||
|
||
```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. 快速起草提交与主窗口联动
|
||
|
||
```text
|
||
宠物渲染进程 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`:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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` 使用 Electron `screen.getAllDisplays()` 返回的 `id`;加载时按第 8 节校验,
|
||
不假设跨重启保持稳定的显示器 id 一定仍然存在。
|
||
- 独立 schema 版本号(从 1 开始),与主设置版本号互不影响;未来变更只需要迁移这一个
|
||
文件。
|
||
- 是否需要独立数据库表:不需要。宠物没有需要查询、筛选或跨设备同步的历史数据,一个
|
||
JSON 文件足够,符合仓库 KISS/YAGNI 约束。
|
||
|
||
## 8. 位置与多显示器校验
|
||
|
||
```ts
|
||
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`
|
||
共享同一个“主窗口是否可见并聚焦”判定:
|
||
|
||
```ts
|
||
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 节的互斥规则。
|
||
- 多显示器插拔场景下确认位置回退逻辑(断开记忆显示器后宠物仍可见)。
|
||
- 打包后的应用在六个标准目标上完成启用、拖动、快速起草、退出流程的一次冒烟验证。
|
||
|
||
设计文档完成不代表功能实施或上述验证通过。
|