Files
goodbuddy/docs/features/desktop-pet/technical-design.md
T
mesalogo 374968d50b docs: add desktop pet feature design
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.
2026-09-03 23:22:33 +08:00

19 KiB
Raw Blame History

桌面宠物技术设计

文档信息

项目 内容
状态 设计中
版本 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 使用 Electron screen.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 节的互斥规则。
  • 多显示器插拔场景下确认位置回退逻辑(断开记忆显示器后宠物仍可见)。
  • 打包后的应用在六个标准目标上完成启用、拖动、快速起草、退出流程的一次冒烟验证。

设计文档完成不代表功能实施或上述验证通过。