Remove Ask/Execute modes and general product tool approvals so requests use the selected runtime, enabled capabilities, and execution account permissions. Migrate retired application metadata while preserving user content. Retire the Results page while retaining conversation images and stored artifacts. Add local and remote workspace file imports, timestamps and sorting, improve image discovery and saving, and refine workspace navigation and supervision responsiveness. Include the accompanying web-search changes, tests, probes, and product documentation. Remote imports require an updated Agent. Existing artifacts are not automatically copied into project directories. Validation evidence and outstanding failures remain documented in feature progress files; no full test suite or real-model calls were rerun for this commit. Release note: Run requests without switching work modes, import files into a selected local or remote workspace folder, sort workspace files by date, and keep using conversation images after removal of the Results page.
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. 与既有模块集成
|
||
|
||
- 快速起草和拖放最终只调用主窗口渲染进程已有的“设置输入框文本”“追加附件”“激活
|
||
会话”“创建新会话”状态更新函数,不新建与模型选择、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 节的互斥规则。
|
||
- 多显示器插拔场景下确认位置回退逻辑(断开记忆显示器后宠物仍可见)。
|
||
- 打包后的应用在六个标准目标上完成启用、拖动、快速起草、退出流程的一次冒烟验证。
|
||
|
||
设计文档完成不代表功能实施或上述验证通过。
|