Files
HomeAgent/DESKTOP-PET-NOTES.md
JianFeeeee 57bf942474 feat(gui): 注入改 SendInput(默认)+ 用户操作时暂缓;拆解 Coopanion 桌宠动作系统
## 拆解 Coopanion(用户指出的参考项目)
新增 DESKTOP-PET-NOTES.md,记录 Pal-AI-Lab/Coopanion 的桌宠动作系统
(packages/cortico-world-desktop-pet):

  · 4 个工具:pet_say(气泡说话,可夹带动作标记) / pet_ask(提问,异步回答) /
    pet_walk_to(走到 0-1 比例或 left/center/right/cursor,含走到鼠标位置) /
    pet_act(只做动作; sit/sleep 是持续态)
  · 动作词表 VOCAB:10 个表情 + 13 个动作,每个词多个中文别名,
    英文 id 与中文别名进同一张 Map;未知词丢弃而非报错
  · 脚本标记:【词,词】= 先做动作再换新气泡;<词> = 打字到那里时做、不换气泡。
    让文字与动作在时间轴上对齐 —— 实现成本低但表现力强
  · 设计要点:身体是 agent 唯一可见输出通道(用户看不见消息正文);
    互动事件不单独唤醒模型,跟着下批事件到

可借鉴(已写入文档待办):姿态表达状态、动作与文字对齐、持续态、
动作解析容错、待机自主行为。不建议照搬:桌宠承载全部对话输出、ASR、戳摸甩。

## 注入层改 SendInput(参考它的实现)
原 PostMessage 路径只能操作原生 Win32 程序 —— Chromium/Electron 自绘界面
与游戏会忽略合成消息。这不是「平台硬限制」,是所选 API 的限制。
Coopanion 用 SendInput 注入系统输入队列,可靠性高得多。

新增三档模式 gui-prefs.deviceBridge.agentCursor:
  sendinput(默认) SendInput,可靠;会动真光标,故先等用户停手
  overlay           PostMessage,不动用户鼠标;只能操作原生程序
  real              保留旧代码路径
另有 deferMs:用户活动时最多等多久(默认 3000,上限 10000)

★ SendInput 绝对坐标是 0–65535 归一化值,不是像素。按虚拟桌面归一化
  并带 MOUSEEVENTV_VIRTUALDESK,多屏下也正确(Coopanion 只按主屏归一化)。
★ 文本用 KEYEVENTF.UNICODE 逐字符发,不受键盘布局与输入法影响(比 WM_CHAR 可靠)。

## 修「用户活动感知」把自己也算进去的 bug
GetLastInputInfo 是**全局**的 —— agent 自己的 SendInput 也被算作「用户输入」。
实测:deferMs 设 0 仍被暂缓,回执写「开始时空闲仅 0ms」,即上一条命令的
注入刚把 idleMs 清零。若不排除自身注入,会永远处于「用户正在操作」状态。

按 Coopanion 的三重判定修正:
  1. ownTick   自己最后一次注入时刻,之后 40ms 内的输入忽略
  2. ownCursor 自己放下的光标位置,偏离 >2px 说明是用户动的
  3. 无符号差值  GetTickCount 每 2^32ms 回绕,必须按无符号比较

## 修同一条命令被执行两遍
sendinput 分支把逻辑包进 runAction 箭头函数,里面的 return 只退出它自己,
于是执行完继续 fall through 到旧 real 路径。实测 8 条命令回了 16 个
cmd_result,且真光标被额外抢一次。补上外层 return。

## 判据
computeruse.test.mjs 47 条(npm test 共 131 条全绿),新增 4 条钉住本轮
踩过的坑,均做变异验证:
  · 三档模式存在(overlay/real 只在条件里,不能要求赋值形式)
  · 用户活动排除自身注入;且盯 send() 里的**调用点** —— 只查函数名时
    删掉调用仍会误报绿(已实测并改正)
  · SendInput 坐标归一化 + VIRTUALDESK
  · sendinput 分支必须 return(防双执行)

## 待办(已写入 GUI-PLAN.md)
  · loadGuiPrefs 不透传 agentCursor,导致 deferMs 配置读取不生效(实测)
  · 显式调 SetProcessDpiAwarenessContext(-4),不依赖 Electron 默认值
  · mascot 姿态:思考/等待/出错
  · 真实点击效果仍未验证(当前环境无可注入的可见窗口)
2026-10-02 15:27:33 +08:00

229 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 桌宠动作系统:Coopanion 拆解与可借鉴点
> 来源:`Pal-AI-Lab/Coopanion`(本机 clone 于 `/tmp/coop`)
> 相关包:`packages/cortico-world-desktop-pet/`
>
> 本文只记录**桌宠的动作/表情/互动系统**,以及本仓(HomeAgent GUI)可借鉴什么。
> 输入注入层(SendInput)的对比见 `GUI-PLAN.md` 与下文第七节。
---
## 一、它是什么
一个 C 形小生物,站在屏幕底边(任务栏上沿)。**用户看得见它的身体,看不见 agent 的消息正文** ——
想让用户看到内容,只能通过它冒气泡。
这句话是整个设计的关键:
> 桌宠身体是 agent 的**唯一可见输出通道**,而不是装饰。
这和我们 GUI 的定位不同:我们的 mascot 只是「agent 光标」,不承载信息。这是一个可以借的点。
---
## 二、核心工具(4 个)
| 工具 | 作用 | 关键设计 |
|---|---|---|
| `pet_say` | 头顶冒气泡说话 | 脚本里可夹带**动作标记**,边说边做 |
| `pet_ask` | 冒提问气泡 + 最多 3 个选项 | **立即返回**,回答通过 `[回答]` 事件异步送达 |
| `pet_walk_to` | 沿屏幕底边走到某处 | 目标是 0–1 比例或 `left/center/right/cursor` |
| `pet_act` | 不说话,只做一串动作 | `sit`/`sleep` 会**持续**到下个动作 |
### 值得注意的三个设计
1. **`pet_ask` 是异步的**。agent 提完问不阻塞,用户答了再通过事件唤醒。
避免 agent 干等,也允许用户先不答。
实用细节:新问题会**替换**还没回答的旧问题;关掉不答也会回一个事件,
agent 不会永远等下去。
2. **`pet_walk_to` 支持 `cursor`** —— 走到鼠标当前横向位置。
这是个很自然的"到我这儿来"表达,不需要坐标。
3. **`sit`/`sleep` 是持续态**,其他动作是一次性。
这让 agent 能表达"待机"与"我在休息",而不只是瞬间动画。
---
## 三、动作词表(`src/script.ts` 的 `VOCAB`)
它把动作分成两类:**表情**(持续几秒后回到平常脸)与**动作**。
### 表情(expression,10 个)
| id | 中文 | 表现 |
|---|---|---|
| `neutral` | 平静 | 默认的脸 |
| `happy` | 开心 / 高兴 | 眼睛弯成 ^ ^ |
| `wink` | 眨眼 | 一只眼 ^ |
| `love` | 喜欢 / 爱心 | 眼睛变心形,冒小心心 |
| `shy` | 害羞 | 脸红,眼神躲开 |
| `surprised` | 惊讶 / 吃惊 | 眼睛放大,头顶感叹号 |
| `angry` | 生气 | 皱眉,头顶怒气符号,身体发抖 |
| `sad` | 难过 / 伤心 | 八字眉,掉眼泪 |
| `sleepy` | 犯困 / 困 | 眯眼打哈欠 |
| `thinking` | 思考 / 想想 | 眼睛往上看,头顶冒圈 |
### 动作(motion,13 个)
| id | 中文 | 表现 |
|---|---|---|
| `stand` | 站起 / 站 | 站起来(坐着、睡着时) |
| `jump` | 跳 / 跳起来 | 原地起跳 |
| `hop` | 小跳 / 蹦 | 小小蹦一下 |
| `look` | 张望 / 看看 | 左右张望 |
| `turn` | 转身 | 转向另一边 |
| `nod` | 点头 | 点两下头 |
| `shake` | 摇头 | 摇头 |
| `spin` | 转圈 | 原地转一圈 |
| `sit` | 坐下 / 坐 | **坐下,持续到下个动作** |
| `sleep` | 睡觉 / 睡 | **躺下睡觉,持续到下个动作** |
| `dizzy` | 晕 / 转晕 | 头晕眼花几秒 |
| `walk` | 走走 / 散步 | 随便走一段 |
| `run` | 跑 / 跑起来 | 跑到屏幕另一头 |
### 词表设计的可借鉴处
- **每个词有多个中文别名**(`开心`/`高兴` 同一个 id)。LLM 输出自然语言时容错率更高。
- **英文 id 与中文别名都进同一张 Map**(`BY_WORD`),不区分来源。
- **未知词被丢弃而非报错**(`dropped` 数组收集但不中断)。
对 LLM 输出的容错策略,值得学。
- 词表通过 `pet.vocab` 变量**注入到 prompt**,让模型知道能做什么 ——
而不是把 30 个动作硬编码进 tool schema 的 enum。
---
## 四、脚本标记(说明与动作混排)
`pet_say` 的 `script` 参数里可以夹带两种标记:
| 标记 | 作用 |
|---|---|
| `【词, 词】` | 先做这些动作,然后**换一个新气泡**继续说 |
| `<词>` | 打字打到这里时做,**不换气泡** |
示例(源码里的原例):
```
【开心】今天也辛苦啦!<眨眼>要不要歇一会儿?
```
这个设计解决了一个真实问题:**让文字与动作在时间轴上对齐**。
`<眨眼>` 落在句子中间,让"眨眼"发生在念到那个字的时候,而不是整句之前。
比"先做动作再说话"生动得多,实现成本却很低(解析时切分即可)。
相关实现:`parseScript` / `parseActions` / `estimateSeconds`(`src/script.ts`),
`estimateSeconds` 用于让回执报告"大约显示多久"。
---
## 五、自主行为与互动
### 待机(没有指令时)
> 没收到指令时身体会自己眨眼、张望、散步、坐下打盹;你的指令会先执行。
注意最后半句:**agent 指令优先于自主行为**。自主行为是填充,不是竞争。
### 用户互动(事件形式回给 agent)
| 事件 | 含义 |
|---|---|
| `[语音] 用户:…` | 麦克风听到并转写的话(可能同音错字) |
| `[打字] 用户:…` | 在桌宠上双击或点悬停按钮打的字 |
| `[回答] …` | 对 `pet_ask` 的回答 |
| `[互动] …` | 戳、摸、拎起来甩出去,以及落地摔晕 |
细节值得注意:
- 互动事件**不单独唤醒 agent**,"跟着下一批事件到,可能已经过去一会儿"
—— 避免用户随手一戳就把模型叫起来烧钱。
- `asleep` 状态下被摸,文案不同(`摸了摸睡着的你`)。
状态影响事件语义,而不只是影响动画。
---
## 六、对本仓(HomeAgent GUI)的可借鉴点
### 6.1 可以借鉴(成本低、收益明确)
| 点 | 现状 | 建议 |
|---|---|---|
| **动作与文字时间轴对齐**(`【】`/`<>`) | 我们的 mascot 只有移动/点击两种状态 | 若要表达"思考/等待/出错",可引入轻量标记 |
| **姿态表达状态** | 「忙碌呼吸」一种 | 可区分:思考中(转圈)、等待用户(张望)、出错(晕) |
| **持续态 vs 一次性**(`sit`/`sleep`) | 无持续态 | agent 空闲/被禁用时用持续态表达,而非静止 |
| **LLM 输出容错**(未知词丢弃不报错) | 未处理 | 动作解析应容错,不能因一个错词整条失败 |
| **待机自主行为** | 无 | 光标闲置时可轻微眨眼/呼吸,让"活着"可感知 |
### 6.2 不建议照搬
| 点 | 原因 |
|---|---|
| **桌宠承载全部对话输出** | 我们是桌面客户端,有完整聊天界面,不该退化到只能冒气泡 |
| **语音输入(ASR)** | 与本期目标无关,且引入麦克风权限问题 |
| **戳/摸/甩出去** | 需要模型 + 物理引擎,投入产出比低 |
| **`pet.vocab` 注入 prompt** | 我们没把 mascot 交给 LLM 控,动作由 computeruse 隐式驱动 |
### 6.3 一个更重要的判断
**Coopanion 的桌宠和我们的"独立光标"解决的是不同问题:**
- 它的桌宠 = **输出通道**(让用户看见 agent 想说什么)
- 我们的 mascot 光标 = **过程指示**(让用户看见 agent 在操作哪里)
两者不冲突,甚至可以合并 —— 让我们的 mascot 在 agent 思考时表现"思考"姿态,
在执行 computeruse 时表现"移动/点击"。这正是 6.1 表格里那几条的价值所在。
---
## 七、接口层对比(顺带记录,详见 GUI-PLAN.md)
它和我们在**输入注入**上的关键分歧:
| | Coopanion | 本仓(HomeAgent GUI) |
|---|---|---|
| 注入 API | `SendInput` | `SendInput`(默认)/ `PostMessage`(可选) |
| 能否操作 Chromium/游戏 | ✅ 能 | ✅ 能(sendinput 模式) |
| 是否移动真实光标 | ✅ 会移动 | ✅ 会移动(sendinput)/ ❌ 不动(overlay) |
| 用户操作时 | 等待空闲再注入 | 等待空闲再注入(**借鉴自它**) |
| 自绘光标 | ❌ 没有 | ✅ 有(mascot + 点击脉冲) |
### 从它学到的关键一条
`GetLastInputInfo` 是**全局**的 —— agent 自己的 `SendInput` 也会被算作"用户输入"。
若不排除自己的注入,`idleMs()` 每次注入后归零,于是**永远处于"用户正在操作"状态**,
所有命令都被暂缓。
它的做法(我们已照此实现)是三重判定:
1. `ownTick` —— 自己最后一次注入的时刻,之后 40ms 内的输入忽略
2. `ownCursor` —— 自己放下的光标位置,偏离 >2px 说明是用户动的
3. 无符号差值 —— `GetTickCount` 每 2^32 ms 回绕,必须按无符号比较
这个坑我们实测踩到了(`deferMs:0` 仍被暂缓),教训已写进 `agent-inject.js` 注释。
---
## 八、其他独立印证
Coopanion 独立印证了我们上一轮的两个判断:
1. **坐标就是物理像素,不要换算** —— 它注释明写:
> All coordinates are physical pixels of the primary display. The process declares
> itself per-monitor DPI aware (v2) before anything else, so no API here returns scaled values.
我们曾错误地加了 `/scaleFactor`,已改回并在判据中钉住。
2. **必须显式声明 DPI 感知** —— 它显式调用 `SetProcessDpiAwarenessContext(-4)`,
并注明 "before anything else"。我们是**依赖 Electron 默认值**,未显式设。
虽然 Electron 默认已是 per-monitor aware,但显式声明更稳。**建议补上**(见待办)。
---
## 九、待办(由本文档派生)
- [ ] `main.js` 显式调用 `SetProcessDpiAwarenessContext(-4)`,不依赖 Electron 默认值
- [ ] 修 `deferMs` 配置读取不生效的问题(实测 `deferMs:0` 仍走 3000ms 默认)
- [ ] mascot 增加姿态:思考 / 等待 / 出错(参考第三节词表)
- [ ] 动作解析容错:未知动作丢弃而非整条失败
- [ ] (可选)mascot 待机自主行为:闲置时轻微眨眼/呼吸