AI 步步(一):用 Tauri 2 + Rust 做了个桌面宠物,它能监测你有没有在认真用 AI 写代码

0 阅读8分钟

前阵子沉迷 Claude Code,天天跟 AI 搭伴写代码。某天用 /buddy 指令想跟它闲聊几句,突然冒出个念头:既然 AI 能陪我写代码,那我能不能也搞个东西陪着我?

不用太正经——就一个宠物,我在疯狂输出的时候它也跑起来,我摸鱼的时候它也瘫着。

然后就真的动手做了。这就是 AI 步步(AIbubu)—— 一个会监测你 AI 编码活跃度的桌面宠物。你写得多它跑得欢,你偷懒它就瘫着不动。

demo.gif

这篇文章主要聊聊技术实现,踩了哪些坑。

先看功能

功能还是挺完备的,兼顾了自己使用把玩,以及打开「局域网发现」后,可以和同事一起玩。就和上面的 gif 效果一样,可以看到落后你的同事, 以及赶超你的同事。

today.jpg rank.jpg pet.jpg setting.jpg about.jpg

技术选型

整个项目是 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 的状态——如果是 generatingstreaming 就算高活跃度。查询还顺带取了 linesAddedfilesChanged 用来做数据洞察。

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 窗口会被盖住。最后用了 NSPanelmacOSPrivateApi: 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,可见的时候再拉起来,省点资源。

皮肤系统

Xnip2026-04-04_21-22-41.jpg

每个皮肤就是一个目录,里面放 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 思考的那几秒不会让宠物突然停下来,体验好很多。

微信图片_20260404210810_501_6.png

同时开多个工具的话会有加速,比如 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 广播

rank.jpg

排行榜功能纠结过要不要搞个服务器,后来想了想:注册登录、隐私问题、运维成本...算了,用 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 开源:

欢迎贡献新的 AI 工具 Provider(写个 TOML 文件就行)、做皮肤、或者提 issue 吐槽。

你们用 AI 编码的时候,宠物要是能跑起来,会不会有点被监视的感觉?评论区聊聊 😂