## 拆解 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 姿态:思考/等待/出错
· 真实点击效果仍未验证(当前环境无可注入的可见窗口)
9.7 KiB
桌宠动作系统: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 会持续到下个动作 |
值得注意的三个设计
-
pet_ask是异步的。agent 提完问不阻塞,用户答了再通过事件唤醒。 避免 agent 干等,也允许用户先不答。 实用细节:新问题会替换还没回答的旧问题;关掉不答也会回一个事件, agent 不会永远等下去。 -
pet_walk_to支持cursor—— 走到鼠标当前横向位置。 这是个很自然的"到我这儿来"表达,不需要坐标。 -
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() 每次注入后归零,于是永远处于"用户正在操作"状态,
所有命令都被暂缓。
它的做法(我们已照此实现)是三重判定:
ownTick—— 自己最后一次注入的时刻,之后 40ms 内的输入忽略ownCursor—— 自己放下的光标位置,偏离 >2px 说明是用户动的- 无符号差值 ——
GetTickCount每 2^32 ms 回绕,必须按无符号比较
这个坑我们实测踩到了(deferMs:0 仍被暂缓),教训已写进 agent-inject.js 注释。
八、其他独立印证
Coopanion 独立印证了我们上一轮的两个判断:
-
坐标就是物理像素,不要换算 —— 它注释明写:
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,已改回并在判据中钉住。 -
必须显式声明 DPI 感知 —— 它显式调用
SetProcessDpiAwarenessContext(-4), 并注明 "before anything else"。我们是依赖 Electron 默认值,未显式设。 虽然 Electron 默认已是 per-monitor aware,但显式声明更稳。建议补上(见待办)。
九、待办(由本文档派生)
main.js显式调用SetProcessDpiAwarenessContext(-4),不依赖 Electron 默认值- 修
deferMs配置读取不生效的问题(实测deferMs:0仍走 3000ms 默认) - mascot 增加姿态:思考 / 等待 / 出错(参考第三节词表)
- 动作解析容错:未知动作丢弃而非整条失败
- (可选)mascot 待机自主行为:闲置时轻微眨眼/呼吸