前阵子沉迷 Claude Code,天天跟 AI 搭伴写代码。某天用 /buddy 指令想跟它闲聊几句,突然冒出个念头:既然 AI 能陪我写代码,那我能不能也搞个东西陪着我?
不用太正经——就一个宠物,我在疯狂输出的时候它也跑起来,我摸鱼的时候它也瘫着。
然后就真的动手做了。这就是 AI 步步(AIbubu)—— 一个会监测你 AI 编码活跃度的桌面宠物。你写得多它跑得欢,你偷懒它就瘫着不动。
这篇文章主要聊聊技术实现,踩了哪些坑。
先看功能
功能还是挺完备的,兼顾了自己使用把玩,以及打开「局域网发现」后,可以和同事一起玩。就和上面的 gif 效果一样,可以看到落后你的同事, 以及赶超你的同事。
技术选型
整个项目是 pnpm monorepo:
packages/
├── app/ # Tauri 桌面应用
│ ├── src/ # Vue 3 前端
│ ├── src-tauri/ # Rust 后端
│ └── providers/ # AI 工具监测配置(TOML)
└── site/ # Astro 官网
| 层 | 选择 | 理由 |
|---|---|---|
| 桌面框架 | Tauri 2 | 包体 ~15MB,Electron 空包就 100MB+,没法忍 |
| 前端 | Vue 3 + Pinia | 用着顺手,Composition API 拆 composable 很舒服 |
| 官网 | Astro | 静态站点,构建飞快 |
| 测试 | Vitest | 跟 Vite 同生态,配置基本为零 |
选 Tauri 还有一个原因:我需要在后端做进程监测、读 SQLite 数据库这种系统级操作,Rust 比 Node.js 靠谱得多。
AI 工具监测:TOML 配置驱动
这是整个项目最核心的部分。问题很简单:怎么知道你现在到底在不在用 AI 写代码?
我的方案是每个 AI 工具一个 TOML 配置文件,放在 providers/ 目录下,Rust 后端按规则轮询。以 Cursor 为例:
[meta]
id = "cursor"
name = "Cursor AI"
category = "ide"
priority = 10
[detect]
adapter = "sqlite"
[detect.paths]
macos = "${APP_SUPPORT}/Cursor/User/globalStorage/state.vscdb"
linux = "${HOME}/.config/Cursor/User/globalStorage/state.vscdb"
windows = "${APPDATA}/Cursor/User/globalStorage/state.vscdb"
[activity]
adapter = "sqlite"
[activity.sqlite]
latest_query = """
SELECT
json_extract(value, '$.lastUpdatedAt') as ts,
json_extract(value, '$.status') as status,
json_extract(value, '$.totalLinesAdded') as lines_added,
json_extract(value, '$.filesChangedCount') as files_changed
FROM cursorDiskKV
WHERE key >= 'composerData:' AND key < 'composerDataa'
ORDER BY json_extract(value, '$.lastUpdatedAt') DESC
LIMIT 1
"""
[activity.status_map]
generating = "active_high"
streaming = "active_high"
[process_fallback]
enabled = true
names = ["cursor", "cursor helper"]
cpu_active_threshold = 50.0
Cursor 的检测逻辑是直接读它本地的 SQLite 数据库 state.vscdb,查 Composer 的状态——如果是 generating 或 streaming 就算高活跃度。查询还顺带取了 linesAdded 和 filesChanged 用来做数据洞察。
Claude Code 的实现不一样,它是解析 ~/.claude/projects/ 下的 JSONL 会话日志:
[meta]
id = "claude-code"
name = "Claude Code"
category = "cli"
priority = 9
[detect]
adapter = "jsonl"
[detect.paths]
macos = "${HOME}/.claude/projects/"
linux = "${HOME}/.claude/projects/"
[activity]
adapter = "jsonl"
[activity.jsonl]
file_pattern = "*.jsonl"
timestamp_field = "timestamp"
[process_fallback]
enabled = true
names = ["claude"]
parent_exclude = ["cursor", "code", "trae"]
注意最后那个 parent_exclude——Claude Code 经常被 Cursor 内部调用,得排掉父进程是 IDE 的情况,不然会重复计数。
这套配置化的好处很明显:加新工具不用改代码,写个 TOML 文件就行。目前支持 Cursor、Claude Code、Codex CLI、Trae,其他的还在验证中,也欢迎社区贡献。
透明窗口
桌面宠物的窗口得是透明的、始终置顶、不占任务栏。Tauri 2 里配 tauri.conf.json 就能搞定:
{
"label": "pet",
"title": "AI 步步",
"width": 80,
"height": 120,
"transparent": true,
"decorations": false,
"alwaysOnTop": true,
"skipTaskbar": true,
"resizable": false,
"shadow": false
}
看起来简单,但有个坑:macOS 全屏模式下,普通的 alwaysOnTop 窗口会被盖住。最后用了 NSPanel(macOSPrivateApi: true)才解决,宠物能浮在全屏应用上面。
像素动画:SpriteRenderer
精灵图渲染没有用 CSS background-position(一开始试过,高刷屏上速度不一致),最终用 Canvas + requestAnimationFrame + 时间戳差值来做帧率控制:
// SpriteRenderer.vue 核心帧循环
function tick(timestamp: number) {
const interval = 1000 / props.fps
if (timestamp - lastFrameTime >= interval) {
lastFrameTime = timestamp - ((timestamp - lastFrameTime) % interval)
if (currentFrame.value >= props.frameCount - 1) {
if (props.loop) {
currentFrame.value = 0
} else {
rafId = null
return
}
} else {
currentFrame.value++
}
drawFrame()
}
rafId = requestAnimationFrame(tick)
}
关键在 timestamp - lastFrameTime >= interval 这行——不是简单地每次 RAF 回调都切帧,而是根据实际经过的时间来决定该不该切。这样不管你是 60Hz 还是 144Hz 的显示器,动画速度都一样。
每一帧的绘制就是从精灵图上切一块画到 Canvas:
function drawFrame() {
const absFrame = props.startFrame + currentFrame.value
const col = absFrame % props.columns
const row = Math.floor(absFrame / props.columns)
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.drawImage(
img,
col * props.frameWidth,
row * props.frameHeight,
props.frameWidth,
props.frameHeight,
0, 0, canvas.width, canvas.height,
)
}
还加了个 visibilitychange 监听——窗口不可见的时候停掉 RAF,可见的时候再拉起来,省点资源。
皮肤系统
每个皮肤就是一个目录,里面放 skin.json 和精灵图。skin.json 定义了四个动画状态对应的帧参数:
{
"name": "Vita",
"format": "sprite",
"size": { "width": 48, "height": 48 },
"animations": {
"idle": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 24, "frameHeight": 24,
"frameCount": 4, "columns": 24,
"fps": 6, "startFrame": 0
}
},
"walk": {
"file": "skin.png",
"loop": true,
"sprite": {
"frameWidth": 24, "frameHeight": 24,
"frameCount": 6, "columns": 24,
"fps": 6, "startFrame": 5
}
},
"run": { "..." },
"sprint": { "..." }
}
}
应用启动自动扫描 public/skins/,发现新皮肤直接可用。除了精灵图还支持 Lottie、GIF、APNG——前端 PetRenderer 组件根据 format 字段决定用哪个渲染器:
<SpriteRenderer v-if="skinStore.currentManifest.format === 'sprite'" ... />
<LottieRenderer v-else-if="skinStore.currentManifest.format === 'lottie'" ... />
<ImageRenderer v-else :src="currentAnimation.src" />
内置 8 款皮肤,也支持用户从本地导入自己做的(文件夹或 ZIP 都行)。
步数量化:Rust 状态机
怎么把"编码活跃度"转化成宠物跑多快?后端用 Rust 写了一个状态机:
const RUN_THRESHOLD_S: u64 = 60;
const SPRINT_THRESHOLD_S: u64 = 180;
const ACTIVITY_COOLDOWN: Duration = Duration::from_secs(45);
pub fn update(&mut self, results: &[ProbeResult]) -> ScoredOutput {
let active_tool_count = count_unique_active_tools(results);
let has_activity = active_tool_count > 0;
// 45秒冷却桥接:Agent 工具调用间隙不会让宠物回到 idle
let in_cooldown = !has_activity
&& presence
&& self.last_real_activity
.map(|t| t.elapsed() < ACTIVITY_COOLDOWN)
.unwrap_or(false);
if !has_activity && !in_cooldown {
self.active_since = None;
return ScoredOutput { score: 0.0, movement: Movement::Idle, .. };
}
let raw_duration_s = now.duration_since(since).as_secs();
// 多工具加速:2工具 ×1.8, 3+ ×2.5
let speed_multiplier: f64 = if effective_tool_count >= 3 {
2.5
} else if effective_tool_count >= 2 {
1.8
} else {
1.0
};
let effective_s = (raw_duration_s as f64 * speed_multiplier) as u64;
// 根据持续活跃时长决定运动状态
let (movement, score) = if effective_s >= SPRINT_THRESHOLD_S {
(Movement::Sprint, 75.0 + ...)
} else if effective_s >= RUN_THRESHOLD_S {
(Movement::Run, 50.0 + ...)
} else {
(Movement::Walk, 25.0 + ...)
};
ScoredOutput { score: score.min(100.0), movement, .. }
}
简单说就是:持续活跃不到 60 秒是 Walk,超过 60 秒升 Run,超过 180 秒升 Sprint。有个 45 秒的冷却机制——Claude Code 思考的那几秒不会让宠物突然停下来,体验好很多。
同时开多个工具的话会有加速,比如 Cursor + Claude Code 同时活跃,时间按 1.8 倍算,所以升到 Sprint 会更快。
宠物互动:150ms 判定
交互逻辑单独提成了一个 composable(usePetInteraction.ts),里面有个细节值得说:怎么区分单击、双击和拖拽。
function onWindowMouseDown(e: MouseEvent) {
if (e.button !== 0) return
isClick = true
pressing.value = true
// 150ms 后判定为拖拽
dragTimer = setTimeout(async () => {
isClick = false
petStore.isDragging = true
await currentWindow.startDragging()
petStore.isDragging = false
}, 150)
}
function onPetClick() {
clickCount++
if (clickTimer) clearTimeout(clickTimer)
if (clickCount >= 2) {
clickCount = 0
petStore.playInteraction('poke') // 双击 → 戳一下
} else {
clickTimer = setTimeout(() => {
clickCount = 0
petStore.playInteraction('pat') // 单击 → 摸头
}, 250)
}
}
按下 150ms 内松手算点击,超过 150ms 开始拖拽。点击之后再等 250ms 看有没有第二次点击,有就是双击。时间阈值试了好几组才找到手感合适的。
单击飘 ❤️ 粒子,双击冒 ❗ 感叹号,右键打开社交面板。
局域网社交:UDP 广播
排行榜功能纠结过要不要搞个服务器,后来想了想:注册登录、隐私问题、运维成本...算了,用 UDP 广播。
Rust 后端绑端口 23456,每 5 秒往 255.255.255.255 广播一个心跳包:
pub const BROADCAST_PORT: u16 = 23456;
pub const PEER_TIMEOUT_MS: u64 = 15000;
#[derive(Serialize, Deserialize)]
pub struct Heartbeat {
pub peer_id: String,
pub nickname: String,
pub daily_steps: u64,
pub activity_score: u8,
pub movement_state: String,
pub pet_skin: String,
pub version: String,
}
接收端也很直白——收到心跳包反序列化,过滤掉自己发的,存到 HashMap 里,通过 Tauri 的 emit 推给前端:
match socket.recv_from(&mut buf) {
Ok((size, _addr)) => {
if let Ok(heartbeat) = serde_json::from_slice::<Heartbeat>(&buf[..size]) {
if heartbeat.peer_id == peer_id { continue; } // 过滤自己
if heartbeat.version != PROTOCOL_VERSION { continue; } // 版本校验
let update = PeerUpdate { ... };
peers_lock.insert(heartbeat.peer_id, (update.clone(), Instant::now()));
let _ = app_handle.emit("social-peer-update", &update);
}
}
// ...
}
超过 15 秒没收到某人的心跳就标记离线。有限流(每秒最多 100 个包)和数量上限(50 个 peer),防止被恶意刷。
所有数据不过服务器,拔掉网线就没了。同 WiFi 下自动发现,开箱即用。
踩过的坑
1. macOS 权限
读 Cursor 的 SQLite 要访问 ~/Library/Application Support/,读 Claude Code 的日志要访问 ~/.claude/。权限尽量最小化,但第一次启动还是会弹系统提示。
2. 多平台路径
同一个 AI 工具在 macOS、Windows、Linux 上的安装路径完全不一样。这也是用 TOML 配置的原因之一——[detect.paths] 里按平台分别配,Rust 侧读取时用 cfg!(target_os) 选。
3. setInterval vs requestAnimationFrame
最开始精灵图动画用 setInterval(fn, 1000/fps) 做帧切换,60Hz 屏幕看着挺好,换到 144Hz 就明显慢了——因为 setInterval 最小精度只有 ~4ms,跟渲染帧率完全没关系。改成 RAF + 时间戳差值之后才对上。
4. 点击和拖拽的冲突
桌面宠物上 mousedown 既要支持拖拽又要支持单击/双击。一开始用 click 事件,但 Tauri 的 startDragging() 会吃掉后续的鼠标事件,导致 click 永远触发不了。最终用 mousedown 计时 150ms 判定,手动管理点击状态才解决。
最后
项目 MIT 开源:
- GitHub: github.com/funAgent/ai…
- 官网: aibubu.app
欢迎贡献新的 AI 工具 Provider(写个 TOML 文件就行)、做皮肤、或者提 issue 吐槽。
你们用 AI 编码的时候,宠物要是能跑起来,会不会有点被监视的感觉?评论区聊聊 😂