3023 字
15 分钟
Copilot 有桌宠了,真的假的

Copilot 有桌宠了,真的假的?!#

事情起因#

事情是这样的,今中午左右,某个白色大猫给我发了一个桌宠图片

聊天记录

对,克莱拿了GPT戳了一个桌宠,但是吧……

SHUWU

我特么不用Codex啊

随后真心想试试这个白猫啊不这个桌宠,本人试了试co pet……额,copet的Agent对桌面版的Copilot用不了 不仅如此,就连支持的反重力也启用不了

然后术无和void全无语了……

更何况,Copilot CLI是支持的,但是是支持到隔壁Mac的

SHUWU

我特么是Windows啊,Mac的Copilot CLI我能用吗?!

嗯对,术无就萌生了一个想法,能否把桌宠弄出来呢?于是术无就开始了他的研究之路

研究过程#

温馨提醒:术无的研究过程是基于Windows系统的,Linux和Mac系统的Copilot桌宠可能不一样

另外:术无这次把五小时限额干爆了↓

额度

但还是写出来个桌宠,虽然不完美,但是吧……总算是有了 没有这博客就发不出来了

实机演示↓

桌宠

实现原理#

先说结论:这玩意儿一共就三个文件,各管一摊。

文件角色
extension.mjs扩展主进程(Node):注册 agent 工具 + 画布面板 + 本地 HTTP 服务 + 跨会话心跳
pet-window.ps1桌面悬浮窗本体(WPF 透明置顶窗):动画状态机 + 行为决策 + 配置热重载
pet.html画布面板:预览动画 + 召唤 / 收回控制栏
pet.json全部配置:贴图、动画、行为、台词

一、为什么是”三件套”#

Copilot App 的扩展跑在一个 Node 进程里。它能调 SDK、能开 HTTP 服务、能 spawn 子进程,但它没有 UI 能力——它画不了 Windows 桌面窗口。

所以思路非常直白:

  • Node 扩展当”大脑 + 服务端”:接 agent 的工具调用、维护桌宠状态、开一个本地 HTTP 服务;
  • 桌面窗口交给 PowerShell 起一个 WPF 窗口(-STA 是 WPF 的硬性要求);
  • 两者之间不搞 RPC、不搞 WebSocket,就一个 http://127.0.0.1:<随机端口>,窗口每 900ms 拉一次状态。
agent ──tools──▶ extension.mjs ──spawn──▶ powershell -STA pet-window.ps1
│ │
│◀───── GET /api/state ───────┘ (900ms 轮询)
│
├──▶ pet.html(画布面板,800ms 轮询)
└──▶ %TEMP%\copilot-desktop-pet\inst-<pid>.json(心跳)

为什么不让面板 pet.html 直接管窗口?因为画布面板是给用户看的,关掉面板窗口就没了——桌宠得活得比面板久。

二、桌面窗:pet-window.ps1#

1. 一个”什么都没有”的窗口#

XAML 里窗口的关键就四个属性:

<Window WindowStyle="None" AllowsTransparency="True" Background="Transparent"
Topmost="True" ShowInTaskbar="False" ...>

WindowStyle="None" 去掉标题栏和边框,AllowsTransparency + Background="Transparent" 让窗口背景透明,Topmost 置顶,ShowInTaskbar="False" 不在任务栏留痕。这样剩下的就只有你画上去的东西,看起来就是”一张贴图浮在桌面上”。

窗口尺寸不写死,而是根据配置算出来的:

窗口宽 = 帧宽 × scale + 24
窗口高 = 帧高 × scale + 64(气泡区)+ 16

初始位置贴着工作区右下角(WorkArea.Right - 窗口宽 - 60),所以第一次召唤它就从屏幕右下角冒出来。

窗口里的元素就四个,全在一个 Canvas 上:

  • Sprite:Image,贴图本体,RenderOptions.BitmapScalingMode="NearestNeighbor"——像素风放大不会被糊成一片;
  • Bubble:白底圆角 Border + TextBlock,默认 Collapsed,说话时才显示;
  • Shadow:一个 18% 透明度的黑色 Ellipse,加了 BlurEffect,就是脚下那团影子;
  • Badge:一个 🛠 的 TextBlock,agent 干活时显示在头顶。

2. 贴图怎么切帧#

贴图是一张等宽帧网格:一行一个动画,帧从左到右排。

"idle": { "row": 0, "frames": 4 },
"walk": { "row": 1, "frames": 4, "leftRow": 2 }

切帧就是算一个矩形再 CroppedBitmap:

Terminal window
$rect = New-Object System.Windows.Int32Rect(($frame * $fw), ($row * $fh), $fw, $fh)
$sprite.Source = New-Object System.Windows.Media.Imaging.CroppedBitmap($script:bitmap, $rect)

$frame 每 tick 自增再对 frames 取模,动画就跑起来了。

左右方向有两种处理:默认向左走时用 ScaleTransform(-1, 1) 镜像翻转;如果配置里给了 leftRow,说明作者把向左的帧单独画了一行(方向烘焙进贴图里了),这时直接用 leftRow 那一行、不翻转。

3. 动画状态机#

播哪个动画由 Resolve-AnimName 按优先级决定:

  1. overrideAnim(agent 通过工具 / 画布指定的)——最高优先级;
  2. 睡觉中且有 sleep 动画 → sleep;
  3. activity = working 且有 work 动画 → work(坐下敲电脑);
  4. 走动中且有 walk 动画 → walk;
  5. defaultAnimation;
  6. 兜底 idle。

注意每一级都带”有该动画才用”的判断,所以一个只有 idle / walk / sleep 三行的最小贴图也能跑。

4. 行为决策 Decide#

每过一段随机时间(wanderIntervalSec,默认 3~8 秒)跑一次 Decide:

  • autoWander 关了 → 老实待机;
  • agent 正在干活且有 work 动画 → 待机(不乱跑,坐着敲电脑);
  • 距上次交互超过 sleepAfterIdleSec(默认 45 秒)→ 睡觉;
  • 否则 55% 概率走动(随机方向),45% 概率待机。

走动时每 tick 更新 Left += dir × speed × dt,碰到工作区左右边界就反向。这样它在屏幕上是有”巡逻”感的,而不是在原地抽搐。

5. 两个定时器#

  • 动画定时器:间隔 max(30, 1000 / fps) 毫秒。每 tick 推进一帧、更新 sprite;同时算 dt 做走动位移和跳跃物理(hopV / hopY,重力 900),dt 上限 0.1 秒防止卡顿后瞬移。
  • 轮询定时器:固定 900ms。每 4 次(约 3.6 秒)检查一次 pet.json 的 LastWriteTime,变了就重读配置;每次都会 GET {StateUrl}api/state 拉桌宠状态。

6. 交互#

  • 左键按下:DragMove() 拖动窗口,同时把状态从 sleep 唤醒;
  • 左键抬起:如果按下和抬起的距离小于 6px,算”戳一戳”→ 从 speech.phrases 里随机抽一句冒泡 1.8 秒;顺便把窗口夹回工作区内,防止拖出屏幕;
  • 右键:一个 ContextMenu,三件事——自动走动开 / 关、睡觉 / 醒来、退出。

7. 配置热重载#

不用重启,pet.json 改完几秒内自动生效。重载时会比对帧宽高 / scale / fps 有没有变,变了就调 Update-Geometry 把窗口、贴图、气泡、影子、徽章的尺寸和位置全部重算一遍;sprite 换了就重新加载位图。

8. 保活#

轮询失败会累加 failCount,连续 10 次(约 9 秒)失败就 $window.Close()。

这是故意的:扩展进程死了,桌宠自己退场。否则你关掉 Copilot,桌面上会留一只没人管的幽灵。

三、扩展进程:extension.mjs#

1. 注册工具和画布#

通过 @github/copilot-sdk/extension 的 joinSession 注册了 5 个 agent 工具:

工具作用
desktop_pet_show召唤桌面桌宠
desktop_pet_hide收回桌宠
desktop_pet_say让桌宠说一句话(text、durationSec 默认 4)
desktop_pet_set_animation切换动画,传 auto 恢复自动行为
desktop_pet_reload重读 pet.json

以及一个画布 desktop-pet(显示名”桌宠”),动作和上面基本一一对应,open 时直接把本地服务的 URL 交给面板。

所以你可以直接跟 Copilot 说”召唤桌宠”、“让桌宠说:写完了”。

2. 本地 HTTP 服务#

createServer 监听 127.0.0.1:0(端口随机,避免撞车),所有响应带 Cache-Control: no-store:

路由说明
/、/index.html返回 pet.html
/pet.json返回当前配置;解析失败会带一个 _error 字段
/api/state桌宠状态:animation、message、activity、running、sessions
/api/sessions各扩展实例的心跳:pid、self、activity、petPid、ageSec
/api/pet/show、/api/pet/hide、/api/pet/toggle召唤 / 收回 / 切换
*.png、*.gif、*.webp按文件名从扩展目录读贴图(拒绝带 .. 的路径)

桌面窗和画布面板都是靠轮询 /api/state 和 /pet.json 工作的,服务端本身几乎是无状态的。

3. 会话事件 → 桌宠动作#

for (const ev of ["user.message", "assistant.turn_start", "assistant.message",
"assistant.reasoning", "tool.execution_start", "tool.execution_complete"]) {
session.on(ev, markWorking);
}

这些事件任意一个发生,activity 就变 working:桌面窗显示 🛠、切 work 动画、停止乱跑;面板状态栏也会显示”agent 工作中”。

session.on("session.idle", (event) => { ... });

会话空闲时切回 idle,并且如果刚才确实在干活、桌宠在跑、而且不是被中断的,就随机挑一句完成台词(“做完了。”、“任务完成,去看看吧。”…)冒泡 4 秒,同时播 6 秒 review 动画,之后恢复自动行为。

这就是 README 里说的”你干完活,它复盘并冒泡汇报”。

4. 单实例 / 多会话联动#

startDesktopPet() 里有两道闸:

  • 自己进程已经起过桌宠 → 直接返回;
  • 读心跳注册表,发现别的会话已经有一只活着的桌宠(petPid 还活着)→ 不再重复召唤,而是联动那一只。

否则你开三个 Copilot 窗口,就会有三只桌宠叠在一起打架。

四、跨会话感知#

这是最有意思的一块:任意一个 Copilot 会话在干活,你的桌宠都知道——哪怕那个会话根本没加载这个扩展(比如开在工作树里、别的仓库里)。

两层检测,命中任一就算”有会话在工作”:

第一层:心跳注册表

加载了扩展的会话,每 3 秒往 %TEMP%\copilot-desktop-pet\inst-<pid>.json 写一条:

{ "pid": 12345, "activity": "working", "petPid": 67890, "ts": 1759650000000 }

读取时,超过 12 秒没更新(REG_STALE_MS)或者进程已经不存在(process.kill(pid, 0) 探活,EPERM 也算活着)的条目直接删掉。GET /api/sessions 能看到每个会话的实时状态。

第二层:事件日志兜底

没加载扩展的会话怎么办?直接看它的会话事件日志:

~/.copilot/session-state/<会话>/events.jsonl

只要这个文件的 mtime 在最近 6 秒内被写过,就认为它在干活。这个结果缓存 1.5 秒,避免每 900ms 轮询时把整个目录 stat 一遍。

/api/state 的 activity 就是三者取或:自己的 activity、注册表里任一实例的 activity、兜底的 anySessionActive()。

NOTE

两个时间窗(12 秒、6 秒)意味着状态有秒级延迟——不会出现”你一停它就立刻停”,但也基本够用。

五、配置驱动换装#

pet.json 就是桌宠的全部定义,改完不用重启:

{
"name": "Claye",
"autoStart": true,
"sprite": "claye-spritesheet.png",
"frameWidth": 192,
"frameHeight": 208,
"scale": 1,
"fps": 8,
"defaultAnimation": "idle",
"animations": { "...": "一行一个动画,row + frames(可选 fps、leftRow)" },
"behavior": {
"autoWander": true,
"walkSpeedPxPerSec": 60,
"wanderIntervalSec": [4, 9],
"sleepAfterIdleSec": 40
},
"speech": { "phrases": ["……什么事?"], "fontSize": 13 }
}

想换一只桌宠?画一张等宽帧网格的 spritesheet,配一份 pet.json,齐活。scale 对小像素图建议 3~4,大图设 1。

六、踩过的坑 / 目前的局限#

  • 只有 Windows:WPF 是 Windows 独有,-STA 也是。Mac / Linux 得换一套方案(Electron 或者原生 Cocoa),所以开头才说”Linux 和 Mac 的 Copilot 桌宠可能不一样”。
  • 逻辑写了两遍:桌面窗(PowerShell)和画布面板(JS)各自维护了一套动画状态机和行为决策,改行为得改两处。这是当时为了”面板不依赖桌面窗也能预览”做的取舍。
  • 秒级延迟:轮询 + 心跳的机制决定了状态不是实时的。
  • 贴图必须等宽网格:不是自由帧动画,切帧是按固定宽高算的。
  • 依赖 PowerShell:扩展是用 spawn("powershell.exe", ...) 起窗口的,系统里没有 PowerShell 就跑不起来(Windows 一般都有就是了)。
  • 保活问题:桌宠启动一段时间后,扩展进程挂了,桌宠就会自己退场。这个到时候修复吧,先保证桌宠能跑起来。
  • Powershell启动不会感知对话:桌宠启动后(通过Powershell),Powershell 窗口不会感知对话的开始和结束,只能靠轮询状态来判断是否在工作中。甚至都感知不到

以上就是这只桌宠的全部家当了。说穿了没什么黑魔法:一个透明置顶窗 + 一张贴图 + 一个本地 HTTP 服务 + 两层跨会话探测,剩下的都是细节上的打磨。

后记#

这个桌宠的诞生,离不开克莱的鬼脑和术无力大大大大砖飞(砖头的确是飞了,带着他五小时限额一起),但是吧Copilot组内只有我在用,所以没啥人会改

如果你想要用这个桌宠,欢迎来找我,我可以把这个桌宠的代码发给你,或者你也可以自己动手改造,毕竟这个桌宠是开源的

如果你有更好的方案,欢迎提交issue或者PR,我会尽量采纳的

Ni-ShuWu
/
copilot-desktop-pet
Waiting for api.github.com...
00K
0K
0K
Waiting...