From 782033d458350c953cf8f7adf5cca5f9d84306b1 Mon Sep 17 00:00:00 2001 From: fengxiaohui Date: Wed, 28 Oct 2026 15:00:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(xiaoqi-pet):=20=E6=96=B0=E5=A2=9E=E5=B0=8F?= =?UTF-8?q?=E6=A0=96=E6=A1=8C=E5=AE=A0=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 桌面宠物:通过 7 个 plugin hooks 把 ZCode 会话状态(阶段、工具、等待确认、完成) 投递为本地事件文件,由 macOS 原生 / Windows·Linux Electron 桌宠渲染,提供多会话 看板、彩色抽屉卡、桌面通知与今日用量/套餐余量胶囊,并提供让小栖说话的 skill 与 /pet-say 命令。桌宠本体不随插件分发,从项目 GitHub Releases 安装。 --- assets/xiaoqi-pet/icon.png | Bin 0 -> 85882 bytes .../xiaoqi-pet/.zcode-plugin/plugin.json | 15 + external_plugins/xiaoqi-pet/LICENSE | 26 + external_plugins/xiaoqi-pet/README.md | 137 ++ external_plugins/xiaoqi-pet/README_CN.md | 137 ++ .../xiaoqi-pet/commands/pet-say.md | 9 + .../xiaoqi-pet/desktop/Info.plist | 32 + .../xiaoqi-pet/desktop/Resources/pet.svg | 46 + .../desktop/Sources/AppDelegate.swift | 172 +++ .../xiaoqi-pet/desktop/Sources/Model.swift | 1268 +++++++++++++++++ .../xiaoqi-pet/desktop/Sources/Views.swift | 919 ++++++++++++ .../xiaoqi-pet/desktop/Sources/Windows.swift | 525 +++++++ .../xiaoqi-pet/desktop/Sources/main.swift | 13 + external_plugins/xiaoqi-pet/desktop/build.sh | 39 + .../xiaoqi-pet/desktop/electron/README.md | 45 + .../desktop/electron/assets/icon.png | Bin 0 -> 85882 bytes .../desktop/electron/assets/pet.svg | 46 + .../xiaoqi-pet/desktop/electron/feed.js | 408 ++++++ .../xiaoqi-pet/desktop/electron/main.js | 551 +++++++ .../xiaoqi-pet/desktop/electron/package.json | 74 + .../xiaoqi-pet/desktop/electron/preload.js | 29 + .../desktop/electron/renderer/common.js | 228 +++ .../desktop/electron/renderer/panel.html | 29 + .../desktop/electron/renderer/panel.js | 109 ++ .../desktop/electron/renderer/pet.html | 29 + .../desktop/electron/renderer/pet.js | 194 +++ .../desktop/electron/renderer/styles.css | 618 ++++++++ .../xiaoqi-pet/desktop/electron/usage.js | 199 +++ .../xiaoqi-pet/hooks/feed-event.ps1 | 48 + .../xiaoqi-pet/hooks/feed-event.sh | 58 + external_plugins/xiaoqi-pet/hooks/hooks.json | 88 ++ .../xiaoqi-pet/skills/xiaoqi-pet/SKILL.md | 39 + marketplace.json | 86 +- 33 files changed, 6203 insertions(+), 13 deletions(-) create mode 100644 assets/xiaoqi-pet/icon.png create mode 100644 external_plugins/xiaoqi-pet/.zcode-plugin/plugin.json create mode 100644 external_plugins/xiaoqi-pet/LICENSE create mode 100644 external_plugins/xiaoqi-pet/README.md create mode 100644 external_plugins/xiaoqi-pet/README_CN.md create mode 100644 external_plugins/xiaoqi-pet/commands/pet-say.md create mode 100644 external_plugins/xiaoqi-pet/desktop/Info.plist create mode 100644 external_plugins/xiaoqi-pet/desktop/Resources/pet.svg create mode 100644 external_plugins/xiaoqi-pet/desktop/Sources/AppDelegate.swift create mode 100644 external_plugins/xiaoqi-pet/desktop/Sources/Model.swift create mode 100644 external_plugins/xiaoqi-pet/desktop/Sources/Views.swift create mode 100644 external_plugins/xiaoqi-pet/desktop/Sources/Windows.swift create mode 100644 external_plugins/xiaoqi-pet/desktop/Sources/main.swift create mode 100755 external_plugins/xiaoqi-pet/desktop/build.sh create mode 100644 external_plugins/xiaoqi-pet/desktop/electron/README.md create mode 100644 external_plugins/xiaoqi-pet/desktop/electron/assets/icon.png create mode 100644 external_plugins/xiaoqi-pet/desktop/electron/assets/pet.svg create mode 100644 external_plugins/xiaoqi-pet/desktop/electron/feed.js create mode 100644 external_plugins/xiaoqi-pet/desktop/electron/main.js create mode 100644 external_plugins/xiaoqi-pet/desktop/electron/package.json create mode 100644 external_plugins/xiaoqi-pet/desktop/electron/preload.js create mode 100644 external_plugins/xiaoqi-pet/desktop/electron/renderer/common.js create mode 100644 external_plugins/xiaoqi-pet/desktop/electron/renderer/panel.html create mode 100644 external_plugins/xiaoqi-pet/desktop/electron/renderer/panel.js create mode 100644 external_plugins/xiaoqi-pet/desktop/electron/renderer/pet.html create mode 100644 external_plugins/xiaoqi-pet/desktop/electron/renderer/pet.js create mode 100644 external_plugins/xiaoqi-pet/desktop/electron/renderer/styles.css create mode 100644 external_plugins/xiaoqi-pet/desktop/electron/usage.js create mode 100644 external_plugins/xiaoqi-pet/hooks/feed-event.ps1 create mode 100755 external_plugins/xiaoqi-pet/hooks/feed-event.sh create mode 100644 external_plugins/xiaoqi-pet/hooks/hooks.json create mode 100644 external_plugins/xiaoqi-pet/skills/xiaoqi-pet/SKILL.md diff --git a/assets/xiaoqi-pet/icon.png b/assets/xiaoqi-pet/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4eddb9744e149f8e4f9c4409de28d67ddafa4e69 GIT binary patch literal 85882 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 | Quick way to have the pet say something. | +| **Native macOS app** `desktop/` | Menu-bar accessory (`LSUIElement`), SwiftUI rendering. Recommended on macOS. | +| **Electron app** `desktop/electron/` | The pet for Windows / Linux (also runs on macOS, but exits when the native app is running so two pets never appear). | + +## What the pet shows + +- One floating, draggable pet with mood-based breathing, a task-count badge and a caption that follows the most active session. +- **Belly capsule**: by default **today's token usage** from ZCode's local usage database (zero config); with `balance.json` configured it shows your **plan balance** instead. Hover for the breakdown (input / output / reasoning / turns / tool calls / last 7 days). Refreshes every minute; the tray/menu has a manual refresh. +- **One active session** → a compact bubble with the task, current action and elapsed time. +- **Two or more sessions** → color-coded drawer cards (stable per-session colors) with the current checklist item or action, elapsed time and the last few steps; up to 16 sessions. +- **Task board** (click the pet) → *Needs you / In progress / Just finished* groups with bordered, color-coded cards. +- **Notices** → a non-blocking bubble when a session starts waiting for approval or finishes in the background (system notifications in the Electron app). +- Clicking a task card brings ZCode to the front. Drag anywhere; the pin freezes the position (native app). English and Chinese, light and dark. + +## Install (two steps: plugin + pet app) + +### 1. Install the plugin in ZCode (all platforms) + +**Plugin Marketplace → Add → Add Plugin Marketplace**, enter `white-source/zcode-xiaoqi-pet`, then install **Xiaoqi Desk Pet (小栖桌宠)**. This activates the hooks, skill and the `/pet-say` command. + +Without the pet app the plugin still works silently — it only writes event files (the hook sweeps leftovers older than a day). + +### 2a. macOS: download the native pet + +Grab `ZCode-Xiaoqi-Pet-*-macos-arm64.zip` from [Releases](https://github.com/white-source/zcode-xiaoqi-pet/releases/latest), unzip and drag `ZCode Xiaoqi Pet.app` into Applications. The build is unsigned, so on first launch right-click → Open, or: + +```sh +xattr -dr com.apple.quarantine "/Applications/ZCode Xiaoqi Pet.app" +open -a "ZCode Xiaoqi Pet.app" +``` + +Requires macOS 13+. Menu-bar paw icon: show/hide, board, pin, rescan, refresh usage, quit. + +To build from source instead: + +```sh +cd xiaoqi-pet/desktop +./build.sh --install +``` + +Requires Xcode command-line tools. + +### 2b. Windows / Linux: download the Electron pet + +Grab `*-win-x64.zip` (or `-win-arm64.zip`) from [Releases](https://github.com/white-source/zcode-xiaoqi-pet/releases/latest) and run the exe, or download the `*.AppImage` and `chmod +x` it. Linux needs a compositing window manager for transparency. + +Running / packaging from source requires Node.js 22+: + +```sh +cd xiaoqi-pet/desktop/electron +npm install +npm start # run directly +npm run dist:win # Windows zip (x64 + arm64) +npm run dist:linux # Linux AppImage (x64) +``` + +See [desktop/electron/README.md](desktop/electron/README.md). + +### 3. Try it + +Run any ZCode session — the pet wakes up within a second. `/pet-say hello` makes it speak. + +## Plan balance: how to configure + +ZCode's plan-quota API needs login credentials that ZCode stores encrypted; the plugin does not touch them, so the capsule defaults to local usage. If you have any JSON endpoint that reports a balance (a BigModel billing endpoint, a new-api / one-api relay, or a tiny proxy of your own), drop a `balance.json` into the state directory: + +```json +{ + "url": "https://example.com/api/quota", + "headers": { "Authorization": "Bearer xxx" }, + "valuePath": "data.remaining", + "label": "Plan balance", + "prefix": "¥", + "suffix": "" +} +``` + +`valuePath` is a dot path into the response (array indexes work, e.g. `data.balances.0.balance`). On fetch failure the last good value is kept and marked stale in the tooltip. State directories: + +| Platform | Directory | +| --- | --- | +| macOS | `~/Library/Application Support/XiaoqiPet/` | +| Windows | `%APPDATA%\XiaoqiPet\` | +| Linux | `$XDG_CONFIG_HOME/xiaoqi-pet/` (default `~/.config/xiaoqi-pet/`) | + +It contains `feed/` (hook events), `emote/` (pet speech) and optionally `balance.json`. + +## How the feed works + +`hooks/feed-event.sh` (or `.ps1`) appends one JSON file per hook fire into `feed/`: + +```json +{"v":1,"event":"PreToolUse","at":1769600000,"cwd":"/path/to/project","payload":{ …raw hook stdin… }} +``` + +The pet scans `feed/` once per second, folds the events into an in-memory per-session state machine (the port of the original host-side progress logic, implemented twice — Swift and JS — with identical semantics), and deletes each consumed file. Stale sessions prune themselves: running after 60 min, waiting after 12 h, done after 10 min without events. + +**Privacy:** session titles are just the project folder name — no prompt or response content is ever displayed. Hook payloads (which can include prompt text in `UserPromptSubmit` and a response preview in `Stop`) are written to user-only local files and deleted the moment the pet consumes them; an hourly sweep in the hook removes leftovers older than a day. Usage statistics are read-only from `~/.zcode/cli/db/db.sqlite`; the pet never writes it and never talks to the network (except the optional `balance.json` endpoint you configure). + +## Troubleshoot + +```sh +~/Applications/ZCode\ Xiaoqi\ Pet.app/Contents/MacOS/XiaoqiPet --diagnose +``` + +prints the feed directory, pending file count, the sessions the pet would render, today/7-day usage and whether a remote balance is configured. `pendingFeedFiles > 0` means the pet is not running or not consuming. On Windows / Linux check the Electron console output. + +## Side effects, permissions and third-party material + +- **Hooks run a short-lived process on seven events.** ZCode executes `bash "/hooks/feed-event.sh" ` (`powershell -File …feed-event.ps1` when bash is unavailable). Each run writes one JSON file of at most 64 KB under the state directory, exits 0, and is capped at a 5 s timeout. It never blocks or edits the session and leaves the event file for the pet to consume. +- **Files written:** `/feed/*.json` and `/emote/*.json`. State directory: macOS `~/Library/Application Support/XiaoqiPet/`, Linux `${XDG_CONFIG_HOME:-~/.config}/xiaoqi-pet/`, Windows `%APPDATA%\XiaoqiPet\`. The desktop pet deletes each event file after reading it; the hook sweeps leftovers older than a day. +- **Network:** the plugin itself makes no network requests. The desktop pet reads ZCode's local usage database (`~/.zcode/cli/db/db.sqlite`) read-only to show today's token count, and only talks to the network if the user creates an optional `balance.json` pointing at their own quota endpoint. +- **Credentials:** none. The plugin never reads, stores or transmits ZCode credentials or API keys. +- **Desktop pet is a separate download.** The pet app is not bundled and is not downloaded or launched by the plugin; users install it themselves from the project's GitHub Releases. The plugin remains harmless (no visible behaviour, no errors) if the app is absent. +- **Third-party material:** pet artwork and UI structure come from [dsh-xiaoqi-pet](https://github.com/white-source/dsh-xiaoqi-pet) (MIT, © 2026 Xiaoqi Desk Pet contributors). + +## Compatibility and verification status + +- ZCode Desktop with the 7 standard hook events; macOS 13+ (native); Windows 10+ / common Linux distros (Electron 45). +- Verified on this Mac: Swift build, state-machine output against simulated hook events, usage reads, Electron JS syntax. **Windows and Linux were not run on real machines** — the packaging and the PowerShell fallback are written but transparency, tray behavior and hook delivery still need verification on each target OS. +- No runtime dependencies (the Electron app has none beyond Electron itself), no install scripts. + +## License + +MIT. The Xiaoqi artwork and UI structure are taken from dsh-xiaoqi-pet (MIT, © white-source). diff --git a/external_plugins/xiaoqi-pet/README_CN.md b/external_plugins/xiaoqi-pet/README_CN.md new file mode 100644 index 0000000..6ad61e6 --- /dev/null +++ b/external_plugins/xiaoqi-pet/README_CN.md @@ -0,0 +1,137 @@ +# 小栖桌宠(Xiaoqi Desk Pet) + +一个 ZCode 插件:把一只小栖放到桌面上。它漂浮在所有窗口之上,实时展示 ZCode 会话正在做什么——思考、执行工具、等你确认、还是已完成——配有多会话任务看板、彩色抽屉卡、单任务气泡、桌面通知,肚子上还挂着今日用量 / 套餐余量胶囊。 + +移植自 [dsh-xiaoqi-pet](https://github.com/white-source/dsh-xiaoqi-pet) 1.5.2(DeepSeek Harness 桌面伙伴)。HTTP bridge 与页内 overlay 被替换为「ZCode 插件 hooks → 本地事件文件」,不再需要端口、token;桌宠本体有两种:**macOS 原生 Swift 应用**和 **Windows / Linux / macOS 通用的 Electron 应用**,读同一套事件文件。 + +## 组成 + +| 部件 | 作用 | +| --- | --- | +| **Hooks**(7 个事件) | `SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PermissionRequest`、`PostToolUse`、`PostToolUseFailure`、`Stop`——每次触发把 stdin JSON 包装成一个原子事件文件。macOS/Linux 走 `feed-event.sh`,没有 bash 的 Windows 自动落到 `feed-event.ps1`。插件自带 hooks,ZCode 会自动启用 hook runner。 | +| **Skill** `xiaoqi-pet` | agent 写互动事件文件,让小栖开口说话。 | +| **命令** `/pet-say <文本>` | 让小栖说一句话的快捷方式。 | +| **macOS 原生应用** `desktop/` | 菜单栏小程序(`LSUIElement`),SwiftUI 渲染,推荐 macOS 使用。 | +| **Electron 应用** `desktop/electron/` | Windows / Linux 的桌宠(macOS 也能跑,但原生在运行时会自动退出避免出现两只)。 | + +## 小栖会展示什么 + +- 一只可拖动的漂浮小栖,按状态呼吸(进行中 / 等待 / 完成),带任务数角标;说明文字跟随最活跃的会话。 +- **肚子胶囊**:默认显示 **今日 token 用量**(来自 ZCode 本地用量库,零配置);配置了 `balance.json` 后改显示 **套餐余量**。悬停看明细(输入 / 输出 / 推理 / 轮次 / 工具调用 / 近 7 天)。每分钟刷新,菜单可手动刷新。 +- **单会话** → 宠物下方一个紧凑气泡:任务名、当前动作、耗时。 +- **多会话** → 气泡让位给彩色抽屉卡(每个会话固定专属颜色),显示当前清单项 / 动作、耗时和最近几步;最多 16 个会话。 +- **任务看板**(点击小栖)→ 「需要你 / 进行中 / 刚完成」分组,每个任务一张带边框的彩色卡片。 +- **通知** → 会话开始等你确认、或在后台完成时,弹出非阻塞桌面气泡(Electron 版用系统通知)。 +- 点击任务卡把 ZCode 调到前台。拖到任意位置、图钉固定(原生版)。中英文、深浅色自适应。 + +## 安装(两步:插件 + 桌宠本体) + +### 1. 在 ZCode 里装插件(所有平台) + +**插件市场 → 添加 → 添加插件市场**,填入 `white-source/zcode-xiaoqi-pet`,然后安装 **小栖桌宠 / Xiaoqi Desk Pet**。这一步激活 hooks、skill 和 `/pet-say` 命令。 + +不装桌宠本体时插件也不会报错,只是事件文件会一直堆积(hook 每小时清掉超过一天的残留)。 + +### 2a. macOS:下载原生桌宠 + +到 [Releases](https://github.com/white-source/zcode-xiaoqi-pet/releases/latest) 下载 `ZCode-Xiaoqi-Pet-*-macos-arm64.zip`,解压后把 `ZCode Xiaoqi Pet.app` 拖进「应用程序」。未签名版本首次打开请右键 → 打开,或: + +```sh +xattr -dr com.apple.quarantine "/Applications/ZCode Xiaoqi Pet.app" +open -a "ZCode Xiaoqi Pet.app" +``` + +需要 macOS 13+。菜单栏爪印图标:显示 / 隐藏、看板、固定、重扫、刷新用量、退出。 + +想自己从源码构建: + +```sh +cd xiaoqi-pet/desktop +./build.sh --install +``` + +需要 Xcode 命令行工具。 + +### 2b. Windows / Linux:下载 Electron 桌宠 + +到 [Releases](https://github.com/white-source/zcode-xiaoqi-pet/releases/latest) 下载 `*-win-x64.zip`(或 `-win-arm64.zip`)解压运行,或下载 `*.AppImage` 后 `chmod +x` 运行。Linux 需要支持合成的窗口管理器才能透明。 + +从源码运行 / 自行打包需要 Node.js 22+: + +```sh +cd xiaoqi-pet/desktop/electron +npm install +npm start # 直接运行 +npm run dist:win # Windows zip(x64 + arm64) +npm run dist:linux # Linux AppImage(x64) +``` + +详见 [desktop/electron/README.md](desktop/electron/README.md)。 + +### 3. 试用 + +随便跑一个 ZCode 会话,一秒内小栖就会醒来。`/pet-say 你好` 让它说话。 + +## 套餐余量:怎么配 + +ZCode 的套餐额度接口需要 ZCode 内部加密保存的登录凭据,插件不去碰它;所以胶囊默认显示本地用量。如果你有任何能返回 JSON 的额度接口(BigModel 开放平台的账单接口、new-api / one-api 之类的中转、或自己写的小代理),在状态目录放一个 `balance.json`: + +```json +{ + "url": "https://example.com/api/quota", + "headers": { "Authorization": "Bearer xxx" }, + "valuePath": "data.remaining", + "label": "套餐余量", + "prefix": "¥", + "suffix": "" +} +``` + +`valuePath` 用点号路径取值(支持数组下标,如 `data.balances.0.balance`)。拉取失败会沿用上一次的值并在明细里标「过期」。状态目录: + +| 平台 | 目录 | +| --- | --- | +| macOS | `~/Library/Application Support/XiaoqiPet/` | +| Windows | `%APPDATA%\XiaoqiPet\` | +| Linux | `$XDG_CONFIG_HOME/xiaoqi-pet/`(默认 `~/.config/xiaoqi-pet/`) | + +下面有 `feed/`(hook 事件)、`emote/`(互动)、`balance.json`(可选)。 + +## 事件文件约定 + +`hooks/feed-event.sh`(或 `.ps1`)每次 hook 触发落一个 JSON 文件到 `feed/`: + +```json +{"v":1,"event":"PreToolUse","at":1769600000,"cwd":"/path/to/project","payload":{ …hook stdin 原文… }} +``` + +桌宠每秒扫描 `feed/`,把事件折叠进内存中的每会话状态机(原 dsh Host 侧进度逻辑的移植,Swift 与 JS 各一份、语义一致),消费后立即删除文件。超时会话自动清理:running 60 分钟、waiting 12 小时、done 10 分钟无事件即移除。 + +**隐私**:会话标题就是项目文件夹名——不展示任何 prompt 或回复内容。hook 载荷(`UserPromptSubmit` 含 prompt 文本、`Stop` 含回复预览)只写入用户专属的本地文件,桌宠消费后立即删除;桌宠不在线时,hook 内的每小时清扫会删掉超过一天的残留。用量统计只读 `~/.zcode/cli/db/db.sqlite`,不写、不联网。 + +## 排查 + +```sh +~/Applications/ZCode\ Xiaoqi\ Pet.app/Contents/MacOS/XiaoqiPet --diagnose +``` + +输出 feed 目录、待处理文件数、应用当前渲染的会话状态、今日 / 近 7 天用量和是否配置了远端余量。`pendingFeedFiles > 0` 说明桌宠没在运行或没在消费。Windows / Linux 看 Electron 控制台输出。 + +## 副作用、权限与第三方素材 + +- **hooks 在 7 个事件上起一次短暂进程。** ZCode 执行 `bash "<插件>/hooks/feed-event.sh" <事件>`(没有 bash 时用 `powershell -File …feed-event.ps1`)。每次运行只在状态目录写一个不超过 64 KB 的 JSON 文件、退出码 0、超时上限 5 秒;不阻塞、不改动会话,事件文件留给桌宠消费。 +- **写入的文件:** `<状态目录>/feed/*.json` 与 `<状态目录>/emote/*.json`。状态目录:macOS `~/Library/Application Support/XiaoqiPet/`,Linux `${XDG_CONFIG_HOME:-~/.config}/xiaoqi-pet/`,Windows `%APPDATA%\XiaoqiPet\`。桌宠读取后删除对应事件文件;hook 会清掉超过一天的残留。 +- **网络:** 插件本身不发任何网络请求。桌宠只读本地 ZCode 用量库(`~/.zcode/cli/db/db.sqlite`)显示今日 token;只有用户自己创建可选 `balance.json` 指向其额度接口时才会联网。 +- **凭据:** 不涉及。插件不读取、不保存、不传输 ZCode 的凭据或 API Key。 +- **桌宠本体需单独下载。** 桌宠程序不随插件分发,插件也不会下载或启动它;用户从项目的 GitHub Releases 自行安装。没装桌宠时插件不产生可见行为,也不会报错。 +- **第三方素材:** 小栖形象与界面结构来自 [dsh-xiaoqi-pet](https://github.com/white-source/dsh-xiaoqi-pet)(MIT,© 2026 Xiaoqi Desk Pet contributors)。 + +## 兼容性与验证状态 + +- ZCode Desktop(支持 7 个标准 hook 事件);macOS 13+(原生);Windows 10+ / 主流 Linux 发行版(Electron 45)。 +- 在本机(macOS)已验证:Swift 编译、模拟 hook 事件的状态机输出、用量读取、Electron 全部 JS 语法。**Windows 与 Linux 未在真机运行**——Electron 打包与 PowerShell 兜底脚本已写好但需要在目标系统上实际验证透明窗口、托盘和 hook 落盘。 +- 无运行时依赖(Electron 版除 Electron 本体外无 npm 依赖)、无安装脚本。 + +## 许可 + +MIT。小栖形象与界面结构均来自 dsh-xiaoqi-pet(MIT,© white-source)。 diff --git a/external_plugins/xiaoqi-pet/commands/pet-say.md b/external_plugins/xiaoqi-pet/commands/pet-say.md new file mode 100644 index 0000000..f8123ed --- /dev/null +++ b/external_plugins/xiaoqi-pet/commands/pet-say.md @@ -0,0 +1,9 @@ +--- +description: 让桌宠小栖说一句话 +--- + +让小栖桌宠把下面这句话显示为桌面气泡: + +$ARGUMENTS + +按 xiaoqi-pet 技能中的互动事件约定执行:向 `~/Library/Application Support/XiaoqiPet/emote/` 写入一个 `{"v":1,"type":"emote","speech":"…","seconds":6}` 事件文件(先写临时文件再原子改名)。把用户的话整理成一句不超过 60 字的短句,不要改动原意;若参数为空,让小栖打一声招呼。 diff --git a/external_plugins/xiaoqi-pet/desktop/Info.plist b/external_plugins/xiaoqi-pet/desktop/Info.plist new file mode 100644 index 0000000..82d4603 --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ZCode Xiaoqi Pet + CFBundleExecutable + XiaoqiPet + CFBundleIdentifier + com.xiaoqi.zcode-deskpet + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ZCode Xiaoqi Pet + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.3.1 + CFBundleVersion + 1 + LSMinimumSystemVersion + 13.0 + LSUIElement + + NSHighResolutionCapable + + NSHumanReadableCopyright + Copyright © 2026 Xiaoqi Desk Pet contributors. MIT License. + + diff --git a/external_plugins/xiaoqi-pet/desktop/Resources/pet.svg b/external_plugins/xiaoqi-pet/desktop/Resources/pet.svg new file mode 100644 index 0000000..93739ba --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/Resources/pet.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/external_plugins/xiaoqi-pet/desktop/Sources/AppDelegate.swift b/external_plugins/xiaoqi-pet/desktop/Sources/AppDelegate.swift new file mode 100644 index 0000000..7ed1323 --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/Sources/AppDelegate.swift @@ -0,0 +1,172 @@ +import AppKit +import Combine +import Foundation + +final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { + private let model = PetModel() + private let watcher = FeedWatcher() + private var petWindow: PetWindowController! + private var boardWindow: BoardWindowController! + private var drawerWindow: DrawerWindowController! + private var bubbleWindow: BubbleWindowController! + private var noticeWindow: NoticeWindowController! + private var statusItem: NSStatusItem! + private var tickTimer: Timer? + + private var showHideItem: NSMenuItem! + private var boardItem: NSMenuItem! + private var pinItem: NSMenuItem! + + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.accessory) + if CommandLine.arguments.contains("--light") { + NSApp.appearance = NSAppearance(named: .aqua) + } + NSWindow.allowsAutomaticWindowTabbing = false + UserDefaults.standard.register(defaults: ["xiaoqi.pet.locked": false]) + + petWindow = PetWindowController(model: model) + boardWindow = BoardWindowController(model: model, petController: petWindow) + drawerWindow = DrawerWindowController(model: model, petController: petWindow) + bubbleWindow = BubbleWindowController(model: model, petController: petWindow) + noticeWindow = NoticeWindowController(petController: petWindow) + + model.onNotice = { [weak self] notice in + guard let self else { return } + self.noticeWindow.show(notice, near: self.petWindow.panel.frame) + } + petWindow.onFrameChanged = { [weak self] frame in + guard let self else { return } + self.boardWindow.reposition(near: frame) + self.drawerWindow.reposition(near: frame) + self.bubbleWindow.reposition(near: frame) + self.noticeWindow.reposition(near: frame) + } + + buildStatusItem() + petWindow.show() + + watcher.onBatch = { [weak self] events, emotes in + self?.model.applyBatch(events, emotes) + } + watcher.start() + model.refreshBalance(force: true) + + let tick = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in + self?.model.tick() + } + RunLoop.main.add(tick, forMode: .common) + tickTimer = tick + + if CommandLine.arguments.contains("--board") { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in + self?.model.boardOpen = true + } + } + print("Xiaoqi Pet started") + } + + func applicationWillTerminate(_ notification: Notification) { + watcher.stop() + tickTimer?.invalidate() + if let statusItem { + NSStatusBar.system.removeStatusItem(statusItem) + } + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + false + } + + private func buildStatusItem() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + if let button = statusItem.button { + button.image = NSImage(systemSymbolName: "pawprint.fill", accessibilityDescription: "Xiaoqi") + button.image?.isTemplate = true + button.toolTip = L10n.text("Xiaoqi desktop companion", "小栖桌宠") + } + let menu = NSMenu() + menu.autoenablesItems = false + menu.delegate = self + + showHideItem = NSMenuItem(title: "", action: #selector(togglePetVisibility), keyEquivalent: "") + showHideItem.target = self + menu.addItem(showHideItem) + + boardItem = NSMenuItem(title: "", action: #selector(toggleBoardFromMenu), keyEquivalent: "") + boardItem.target = self + menu.addItem(boardItem) + + pinItem = NSMenuItem(title: "", action: #selector(togglePinFromMenu), keyEquivalent: "") + pinItem.target = self + menu.addItem(pinItem) + + menu.addItem(.separator()) + + let rescan = NSMenuItem(title: L10n.text("Rescan events", "重新扫描事件"), action: #selector(rescan), keyEquivalent: "r") + rescan.target = self + menu.addItem(rescan) + + let refreshBalance = NSMenuItem(title: L10n.text("Refresh usage / balance", "刷新用量 / 余量"), action: #selector(refreshBalanceFromMenu), keyEquivalent: "b") + refreshBalance.target = self + menu.addItem(refreshBalance) + + let quit = NSMenuItem(title: L10n.text("Quit Xiaoqi", "退出小栖"), action: #selector(quit), keyEquivalent: "q") + quit.target = self + menu.addItem(quit) + + statusItem.menu = menu + menuWillOpen(menu) + } + + func menuWillOpen(_ menu: NSMenu) { + showHideItem.title = petWindow.isVisible + ? L10n.text("Hide Xiaoqi", "隐藏小栖") + : L10n.text("Show Xiaoqi", "显示小栖") + boardItem.title = model.boardOpen + ? L10n.text("Close task board", "收起任务看板") + : L10n.text("Open task board", "打开任务看板") + pinItem.title = model.locked + ? L10n.text("Unpin position", "解除固定") + : L10n.text("Pin position", "固定位置") + pinItem.state = model.locked ? .on : .off + } + + @objc private func togglePetVisibility() { + if petWindow.isVisible { + model.boardOpen = false + noticeWindow.hide() + petWindow.hide() + drawerWindow.petVisibilityChanged() + bubbleWindow.petVisibilityChanged() + } else { + petWindow.show() + drawerWindow.petVisibilityChanged() + bubbleWindow.petVisibilityChanged() + } + } + + @objc private func toggleBoardFromMenu() { + if !petWindow.isVisible { petWindow.show() } + model.boardOpen.toggle() + if model.boardOpen { + boardWindow.reposition(near: petWindow.panel.frame) + } + } + + @objc private func togglePinFromMenu() { + model.toggleLock() + } + + @objc private func rescan() { + watcher.scan() + } + + @objc private func refreshBalanceFromMenu() { + model.refreshBalance(force: true) + } + + @objc private func quit() { + NSApp.terminate(nil) + } +} diff --git a/external_plugins/xiaoqi-pet/desktop/Sources/Model.swift b/external_plugins/xiaoqi-pet/desktop/Sources/Model.swift new file mode 100644 index 0000000..bcb8890 --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/Sources/Model.swift @@ -0,0 +1,1268 @@ +import AppKit +import Combine +import Foundation +import SQLite3 + +enum L10n { + static let isChinese = Locale.preferredLanguages.first?.lowercased().hasPrefix("zh") ?? false + + static func text(_ en: String, _ zh: String) -> String { + isChinese ? zh : en + } + + static func status(_ task: RemoteTask) -> String { + switch task.kind { + case "waiting": return text("Needs you", "需要你") + case "running": return text("Running", "进行中") + case "done": return text("Done", "已完成") + default: return "" + } + } + + static func pending(kind: String?, tool: String?) -> String { + switch kind ?? "" { + case "approval": + if let tool, !tool.isEmpty { + return text("Waiting for approval · \(tool)", "等待确认 · \(tool)") + } + return text("Waiting for tool approval", "等待确认工具调用") + case "question": + return text("Waiting for your answer", "等待你回答问题") + default: + return text("Waiting for you", "等待你处理") + } + } + + static func relative(_ milliseconds: Double) -> String { + guard milliseconds > 0 else { return "" } + let date = Date(timeIntervalSince1970: milliseconds / 1000) + let delta = max(0, Date().timeIntervalSince(date)) + if delta < 60 { return text("just now", "刚刚") } + if delta < 3600 { + let minutes = max(1, Int(delta / 60)) + return text("\(minutes) min ago", "\(minutes) 分钟前") + } + if delta < 86_400 { + let hours = max(1, Int(delta / 3600)) + return text("\(hours) h ago", "\(hours) 小时前") + } + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .none + return formatter.string(from: date) + } + + static func taskMeta(_ task: RemoteTask) -> String { + var parts: [String] = [] + if !task.project.isEmpty { parts.append(task.project) } + switch task.kind { + case "waiting": + parts.append(pending(kind: task.pendingKind, tool: task.pendingTool)) + case "running": + parts.append(text("Working", "正在处理")) + case "done": + parts.append(text("Completed", "已完成")) + default: + break + } + if task.kind != "running" || task.progress == nil { + let age = relative(task.updatedAt) + if !age.isEmpty { parts.append(age) } + } + return parts.joined(separator: " · ") + } + + static func phase(_ phase: String) -> String { + switch phase { + case "thinking": return text("Thinking", "思考中") + case "tool": return text("Running a tool", "执行工具中") + case "responding": return text("Writing the answer", "生成回复中") + case "done": return text("Finished", "已完成") + default: return text("Preparing", "准备中") + } + } + + static func phasePrimary(_ progress: RemoteProgress) -> String { + switch progress.phase { + case "tool": + let tool = progress.tool ?? "" + return tool.isEmpty + ? text("Running a tool", "执行工具中") + : text("Running \(tool)", "正在执行 \(tool)") + case "thinking": + if progress.step > 0 { + return text("Thinking · step \(progress.step)", "思考中 · 第 \(progress.step) 步") + } + return text("Thinking", "思考中") + case "responding": return text("Writing the answer", "生成回复中") + case "done": return text("Finished", "已完成") + default: return text("Preparing", "准备中") + } + } + + static func phaseShort(_ progress: RemoteProgress) -> String { + let todo = todoLine(progress) + if !todo.isEmpty { + return todo.count > 24 ? String(todo.prefix(23)) + "…" : todo + } + if let action = progress.action { + let label = actionLabel(action) + return label.count > 28 ? String(label.prefix(27)) + "…" : label + } + switch progress.phase { + case "tool": + let tool = progress.tool ?? "" + if tool.isEmpty { return text("Running a tool", "执行中") } + let short = tool.count > 12 ? String(tool.prefix(11)) + "…" : tool + return text("Running \(short)", "执行 \(short)") + case "thinking": + return progress.step > 0 + ? text("Thinking · step \(progress.step)", "思考 · 第\(progress.step)步") + : text("Thinking", "思考中") + case "responding": return text("Writing", "生成回复") + case "done": return text("Done", "已完成") + default: return text("Preparing", "准备中") + } + } + + static func elapsed(_ milliseconds: Double) -> String { + guard milliseconds > 0 else { return "" } + let total = Int(milliseconds / 1000) + let minutes = total / 60 + let seconds = total % 60 + if minutes >= 60 { + let hours = minutes / 60 + let remainder = minutes % 60 + return text("\(hours)h \(remainder)m", "\(hours) 小时 \(remainder) 分") + } + if minutes > 0 { return text("\(minutes)m \(seconds)s", "\(minutes) 分 \(seconds) 秒") } + return text("\(seconds)s", "\(seconds) 秒") + } + + static func progressDetails(_ progress: RemoteProgress) -> String { + var parts: [String] = [] + if progress.turn > 0 { parts.append(text("turn \(progress.turn)", "第 \(progress.turn) 轮")) } + if progress.step > 0 { parts.append(text("step \(progress.step)", "第 \(progress.step) 步")) } + if progress.toolCount > 0 { parts.append(text("\(progress.toolCount) tools", "\(progress.toolCount) 个工具")) } + let time = elapsed(progress.elapsedMs) + if !time.isEmpty { parts.append(time) } + return parts.joined(separator: " · ") + } + + static func actionLabel(_ action: RemoteAction) -> String { + let target = action.target + let label: String + switch action.kind { + case "read": label = text("Read \(target)", "读取 \(target)") + case "write": label = text("Write \(target)", "写入 \(target)") + case "edit": label = text("Edit \(target)", "修改 \(target)") + case "bash": label = text("Run: \(target)", "运行:\(target)") + case "glob": label = text("Find \(target)", "查找 \(target)") + case "grep": label = text("Search \(target)", "搜索 \(target)") + case "image": label = text("View \(target)", "查看 \(target)") + case "search": label = text("Search web: \(target)", "检索:\(target)") + case "fetch": label = text("Fetch: \(target)", "读取网页:\(target)") + case "subagent": label = text("Delegate: \(target)", "调度子任务:\(target)") + case "workflow": label = text("Workflow: \(target)", "运行工作流:\(target)") + case "skill": label = text("Skill: \(target)", "加载技能:\(target)") + case "todo": label = text("Update checklist", "更新任务清单") + case "schedule": label = text("Schedule: \(target)", "定时任务:\(target)") + case "question": label = text("Ask: \(target)", "提问:\(target)") + case "plan": label = text("Planning", "整理计划") + case "mcp": label = target.isEmpty ? text("MCP tool", "调用 MCP 工具") : text("MCP: \(target)", "MCP:\(target)") + default: label = target.isEmpty ? text("Use a tool", "调用工具") : text("Use \(target)", "调用 \(target)") + } + if action.done == true { + return action.failed == true ? "\(label) ✗" : "\(label) ✓" + } + return label + } + + static func actionLine(_ progress: RemoteProgress) -> String { + if let action = progress.action { return actionLabel(action) } + return phase(progress.phase) + } + + static func todoLine(_ progress: RemoteProgress) -> String { + guard let todos = progress.todos, todos.total > 0 else { return "" } + if !todos.current.isEmpty { return todos.current } + if todos.completed >= todos.total { return text("Checklist complete", "任务清单已完成") } + return "" + } + + static func todoCount(_ progress: RemoteProgress) -> String { + guard let todos = progress.todos, todos.total > 0 else { return "" } + return text("\(todos.completed)/\(todos.total) done", "\(todos.completed)/\(todos.total) 项") + } + + static func trail(_ progress: RemoteProgress) -> String { + progress.trail.suffix(3).map { item in + if item.kind == "action" { + let label = actionLabel(RemoteAction( + kind: item.actionKind ?? "tool", + target: item.label, + detail: nil, + done: item.done, + failed: item.failed + )) + return label.count > 42 ? String(label.prefix(41)) + "…" : label + } + switch item.kind { + case "tool": return item.label.isEmpty ? text("tool", "工具") : item.label + case "toolDone": return text("\(item.label) ✓", "\(item.label) ✓") + case "thinking": return text("think", "思考") + case "reply": return text("answer", "回复") + case "turn": return text("start", "开始") + default: return item.label + } + }.joined(separator: " → ") + } +} + +enum PetMood: String { + case idle + case running + case waiting + case done +} + +// MARK: - 桥接状态结构(看板与抽屉共用的展示模型) + +struct RemoteTrailItem: Codable, Equatable { + let kind: String + let label: String + let at: Double + let actionKind: String? + let done: Bool? + let failed: Bool? +} + +struct RemoteAction: Codable, Equatable { + let kind: String + let target: String + let detail: String? + let done: Bool? + let failed: Bool? +} + +struct RemoteTodos: Codable, Equatable { + let total: Int + let completed: Int + let current: String +} + +struct RemoteProgress: Codable, Equatable { + let phase: String + let turn: Int + let step: Int + let tool: String? + let toolCount: Int + let elapsedMs: Double + let updatedAt: Double + let action: RemoteAction? + let todos: RemoteTodos? + let trail: [RemoteTrailItem] +} + +struct RemoteTask: Codable, Identifiable, Equatable { + let id: String + let title: String + let project: String + let kind: String + let pendingKind: String? + let pendingTool: String? + let current: Bool + let updatedAt: Double + let progress: RemoteProgress? +} + +struct PetNotice: Identifiable { + let id = UUID() + let kind: String + let title: String + let body: String +} + +// MARK: - Hook 事件载荷解析 + +enum FeedError: LocalizedError { + case noEvents + + var errorDescription: String? { + switch self { + case .noEvents: + return L10n.text( + "No hook events yet. Install the xiaoqi-pet plugin in ZCode and run a session.", + "还没有收到 hook 事件。请在 ZCode 安装 xiaoqi-pet 插件并运行一次会话。" + ) + } + } +} + +/// 从 hook 的 stdin JSON(由 feed-event.sh 包装)里安全取值的工具。 +private func payloadString(_ payload: [String: Any], _ keys: [String]) -> String { + for key in keys { + if let value = payload[key] as? String { return value } + } + return "" +} + +private func payloadDouble(_ payload: [String: Any], _ keys: [String]) -> Double { + for key in keys { + if let number = payload[key] as? NSNumber { return number.doubleValue } + if let value = payload[key] as? String, let parsed = Double(value) { return parsed } + } + return 0 +} + +private func isoToMs(_ value: String) -> Double { + guard !value.isEmpty else { return 0 } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: value) { + return date.timeIntervalSince1970 * 1000 + } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + if let date = plain.date(from: value) { + return date.timeIntervalSince1970 * 1000 + } + return 0 +} + +func parseToolArguments(_ value: Any?) -> [String: Any] { + if let dict = value as? [String: Any] { return dict } + if let text = value as? String, text.count <= 262_144, + let data = text.data(using: .utf8), + let parsed = try? JSONSerialization.jsonObject(with: data), + let dict = parsed as? [String: Any] { + return dict + } + return [:] +} + +func shortText(_ value: Any?, _ max: Int) -> String { + guard let text = value as? String else { return "" } + let trimmed = text.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { return "" } + return trimmed.count > max ? String(trimmed.prefix(max - 1)) + "…" : trimmed +} + +func firstQuery(_ value: Any?) -> String { + if let array = value as? [Any], let first = array.first { + return shortText(first, 80) + } + return shortText(value, 80) +} + +private func lastSeparatorIndex(_ value: String) -> String.Index? { + let slash = value.lastIndex(of: "/") + let backslash = value.lastIndex(of: "\\") + switch (slash, backslash) { + case let (left?, right?): return max(left, right) + case let (left?, nil): return left + case let (nil, right?): return right + default: return nil + } +} + +func fileBase(_ value: Any?) -> String { + guard let text = value as? String, !text.trimmingCharacters(in: .whitespaces).isEmpty else { return "" } + let normalized = text.replacingOccurrences(of: "[/\\\\]+$", with: "", options: .regularExpression) + let base = lastSeparatorIndex(normalized).map { String(normalized[normalized.index(after: $0)...]) } ?? normalized + return shortText(base, 80) +} + +func projectName(_ cwd: String) -> String { + let trimmed = cwd.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return "" } + let normalized = trimmed.replacingOccurrences(of: "[/\\\\]+$", with: "", options: .regularExpression) + let base = lastSeparatorIndex(normalized).map { String(normalized[normalized.index(after: $0)...]) } ?? normalized + return base.isEmpty ? normalized : base +} + +/// 把 (工具名, 参数) 变成看板上的动作。与 dsh 版 actionForTool 对齐,改用 ZCode 工具名。 +func actionForTool(_ rawName: String, _ args: [String: Any]) -> RemoteAction { + let name = rawName.isEmpty ? "tool" : rawName + switch name { + case "Read", "ReadImage": + return RemoteAction(kind: "read", target: fileBase(args["file_path"] ?? args["path"]), detail: nil, done: false, failed: nil) + case "Write", "ApplyPatch", "NotebookEdit": + return RemoteAction(kind: "write", target: fileBase(args["file_path"] ?? args["path"]), detail: nil, done: false, failed: nil) + case "Edit", "MultiEdit": + return RemoteAction(kind: "edit", target: fileBase(args["file_path"] ?? args["path"]), detail: nil, done: false, failed: nil) + case "Bash", "PowerShell": + let target = shortText(args["description"], 100).isEmpty + ? shortText(args["command"], 100) + : shortText(args["description"], 100) + return RemoteAction(kind: "bash", target: target.isEmpty ? "shell" : target, detail: nil, done: false, failed: nil) + case "Glob": + return RemoteAction(kind: "glob", target: shortText(args["pattern"], 80).isEmpty ? shortText(args["path"], 80) : shortText(args["pattern"], 80), detail: nil, done: false, failed: nil) + case "Grep": + let target = shortText(args["pattern"], 80).isEmpty ? shortText(args["path"], 80) : shortText(args["pattern"], 80) + return RemoteAction(kind: "grep", target: target.isEmpty ? "content" : target, detail: nil, done: false, failed: nil) + case "WebSearch": + let query = firstQuery(args["queries"] ?? args["query"]) + return RemoteAction(kind: "search", target: query.isEmpty ? "web" : query, detail: nil, done: false, failed: nil) + case "WebFetch": + return RemoteAction(kind: "fetch", target: shortText(args["url"], 100).isEmpty ? "web" : shortText(args["url"], 100), detail: nil, done: false, failed: nil) + case "Agent", "Task", "AgentFork": + let description = shortText(args["description"], 80) + return RemoteAction(kind: "subagent", target: description.isEmpty ? L10n.text("delegate", "委派") : description, detail: nil, done: false, failed: nil) + case "Workflow": + return RemoteAction(kind: "workflow", target: shortText(args["name"], 80).isEmpty ? shortText(args["description"], 80) : shortText(args["name"], 80), detail: nil, done: false, failed: nil) + case "Skill": + return RemoteAction(kind: "skill", target: shortText(args["skill"] ?? args["name"], 60).isEmpty ? "skill" : shortText(args["skill"] ?? args["name"], 60), detail: nil, done: false, failed: nil) + case "TodoWrite": + return RemoteAction(kind: "todo", target: "", detail: nil, done: false, failed: nil) + case "CronCreate", "CronUpdate", "CronDelete", "OffPeakCreate": + return RemoteAction(kind: "schedule", target: shortText(args["title"], 80).isEmpty ? name : shortText(args["title"], 80), detail: nil, done: false, failed: nil) + case "AskUserQuestion": + var target = "" + if let questions = args["questions"] as? [[String: Any]], let first = questions.first { + target = shortText(first["question"], 80) + } + return RemoteAction(kind: "question", target: target, detail: nil, done: false, failed: nil) + case "EnterPlanMode", "ExitPlanMode": + return RemoteAction(kind: "plan", target: "", detail: nil, done: false, failed: nil) + default: + if name.hasPrefix("mcp__") { + let parts = name.components(separatedBy: "__").filter { !$0.isEmpty } + let target = parts.count > 2 ? "\(parts[1]) · \(parts[2])" : parts.dropFirst().joined(separator: " · ") + return RemoteAction(kind: "mcp", target: shortText(target, 80), detail: nil, done: false, failed: nil) + } + return RemoteAction(kind: "tool", target: shortText(name, 60), detail: nil, done: false, failed: nil) + } +} + +func todosForArgs(_ args: [String: Any]) -> RemoteTodos? { + guard let rawTodos = args["todos"] as? [[String: Any]] else { return nil } + let items = rawTodos.prefix(24).compactMap { item -> (String, String)? in + let content = shortText(item["content"], 160) + guard !content.isEmpty else { return nil } + let status = (item["status"] as? String) ?? "" + return (content, status == "completed" || status == "in_progress" ? status : "pending") + } + guard !items.isEmpty else { return nil } + let completed = items.filter { $0.1 == "completed" }.count + let current = items.first { $0.1 == "in_progress" }?.0 ?? "" + return RemoteTodos(total: items.count, completed: completed, current: current) +} + +// MARK: - 会话状态机(原 dsh Host 侧 progress 逻辑,改为消费 hook 事件) + +final class SessionState { + let id: String + var project: String + var kind = "running" + var phase = "starting" + var turn = 0 + var step = 0 + var tool = "" + var toolCount = 0 + var turnStartedAt: Double = 0 + var lastAt: Double = 0 + var action: RemoteAction? + var todos: RemoteTodos? + var trail: [RemoteTrailItem] = [] + var pendingKind = "" + var pendingTool = "" + + init(id: String, project: String, at: Double) { + self.id = id + self.project = project + lastAt = at + turnStartedAt = at + } + + func pushTrail(kind: String, label: String, at: Double, actionKind: String? = nil, done: Bool? = nil, failed: Bool? = nil) { + trail.append(RemoteTrailItem(kind: kind, label: label, at: at, actionKind: actionKind, done: done, failed: failed)) + if trail.count > 6 { trail = Array(trail.suffix(6)) } + } + + func apply(event: String, atMs: Double, payload: [String: Any], cwd: String) { + let project = projectName(cwd) + if !project.isEmpty { self.project = project } + lastAt = atMs + + switch event { + case "SessionStart": + kind = "running" + phase = "starting" + pendingKind = "" + pendingTool = "" + if turnStartedAt == 0 { turnStartedAt = atMs } + + case "UserPromptSubmit": + turn += 1 + step = 0 + tool = "" + toolCount = 0 + phase = "thinking" + action = nil + trail = [] + turnStartedAt = atMs + kind = "running" + pendingKind = "" + pendingTool = "" + + case "PreToolUse": + let name = payloadString(payload, ["tool_name", "toolName"]) + let args = parseToolArguments(payload["tool_input"] ?? payload["toolInput"]) + let action = actionForTool(name, args) + step += 1 + tool = name + toolCount += 1 + phase = "tool" + kind = "running" + pendingKind = "" + pendingTool = "" + self.action = action + if name == "TodoWrite", let todos = todosForArgs(args) { + self.todos = todos + } + pushTrail(kind: "action", label: action.target, at: atMs, actionKind: action.kind, done: false) + + case "PermissionRequest": + kind = "waiting" + pendingKind = "approval" + let name = payloadString(payload, ["tool_name", "toolName"]) + pendingTool = name.isEmpty ? shortText(payloadString(payload, ["reason"]), 60) : name + + case "PostToolUse": + markCurrentActionDone(failed: false) + phase = "thinking" + tool = "" + + case "PostToolUseFailure": + markCurrentActionDone(failed: true) + phase = "thinking" + tool = "" + kind = "running" + + case "Stop": + phase = "done" + kind = "done" + tool = "" + pendingKind = "" + pendingTool = "" + + default: + break + } + } + + private func markCurrentActionDone(failed: Bool) { + if let current = action { + action = RemoteAction(kind: current.kind, target: current.target, detail: current.detail, done: true, failed: failed) + if let last = trail.last, last.kind == "action", last.actionKind == current.kind { + trail[trail.count - 1] = RemoteTrailItem( + kind: last.kind, label: last.label, at: last.at, + actionKind: last.actionKind, done: true, failed: failed + ) + } + } + } + + func task(nowMs: Double) -> RemoteTask { + let elapsed: Double + if turnStartedAt > 0 { + elapsed = phase == "done" + ? max(0, lastAt - turnStartedAt) + : max(0, nowMs - turnStartedAt) + } else { + elapsed = 0 + } + let progress = RemoteProgress( + phase: phase, + turn: turn, + step: step, + tool: tool, + toolCount: toolCount, + elapsedMs: elapsed, + updatedAt: lastAt, + action: action, + todos: todos, + trail: Array(trail.suffix(4)) + ) + return RemoteTask( + id: id, + title: project.isEmpty ? "ZCode" : project, + project: project, + kind: kind, + pendingKind: pendingKind.isEmpty ? nil : pendingKind, + pendingTool: pendingTool.isEmpty ? nil : pendingTool, + current: false, + updatedAt: lastAt, + progress: progress + ) + } +} + +final class SessionStore { + private(set) var sessions: [String: SessionState] = [:] + + static let maxTasks = 16 + + func apply(event: String, atMs: Double, payload: [String: Any], cwd: String) { + let id = payloadString(payload, ["session_id", "sessionId"]) + let key = id.isEmpty ? "(unknown)" : id + let state = sessions[key] ?? SessionState(id: key, project: projectName(cwd), at: atMs) + sessions[key] = state + state.apply(event: event, atMs: atMs, payload: payload, cwd: cwd) + } + + /// 超时清理:running 60 分钟、waiting 12 小时、done 10 分钟无事件即移除。 + func prune(nowMs: Double) { + let runningStale: Double = 60 * 60 * 1000 + let waitingStale: Double = 12 * 60 * 60 * 1000 + let doneStale: Double = 10 * 60 * 1000 + sessions = sessions.filter { _, state in + let age = nowMs - state.lastAt + switch state.kind { + case "waiting": return age < waitingStale + case "done": return age < doneStale + default: return age < runningStale + } + } + } + + func snapshot(nowMs: Double) -> [RemoteTask] { + prune(nowMs: nowMs) + let tasks = sessions.values.map { $0.task(nowMs: nowMs) } + .sorted { left, right in + if left.updatedAt != right.updatedAt { return left.updatedAt > right.updatedAt } + return left.id < right.id + } + return Array(tasks.prefix(Self.maxTasks)) + } +} + +// MARK: - 事件文件监听 + +struct HookEvent { + let event: String + let atMs: Double + let cwd: String + let payload: [String: Any] +} + +final class FeedWatcher { + static let stateRoot: URL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/XiaoqiPet", isDirectory: true) + static let feedDir: URL = stateRoot.appendingPathComponent("feed", isDirectory: true) + static let emoteDir: URL = stateRoot.appendingPathComponent("emote", isDirectory: true) + + /// 每轮扫描收到的事件按时间排序后整批回调(主线程)。 + var onBatch: ((_ events: [HookEvent], _ emotes: [[String: Any]]) -> Void)? + + private var timer: Timer? + private let readOnly: Bool + + init(readOnly: Bool = false) { + self.readOnly = readOnly + } + + func start() { + if !readOnly { + try? FileManager.default.createDirectory(at: Self.feedDir, withIntermediateDirectories: true) + try? FileManager.default.createDirectory(at: Self.emoteDir, withIntermediateDirectories: true) + } + scan() + let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in + self?.scan() + } + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + } + + func stop() { + timer?.invalidate() + timer = nil + } + + func scan() { + var events: [HookEvent] = [] + var emotes: [[String: Any]] = [] + + scanDirectory(Self.feedDir) { envelope in + guard let event = envelope["event"] as? String else { return } + let atSeconds = (envelope["at"] as? NSNumber)?.doubleValue ?? 0 + let payload = envelope["payload"] as? [String: Any] ?? [:] + // 事件自身的 ISO 时间戳最精确;缺失时才退回 hook 落盘时刻(秒级)。 + let payloadMs = isoToMs(payloadString(payload, ["timestamp"])) + let atMs = payloadMs > 0 ? payloadMs : atSeconds * 1000 + let cwd = (envelope["cwd"] as? String) ?? "" + events.append(HookEvent(event: event, atMs: atMs, cwd: cwd, payload: payload)) + } + scanDirectory(Self.emoteDir) { envelope in + emotes.append(envelope) + } + + events.sort { left, right in + if left.atMs != right.atMs { return left.atMs < right.atMs } + return false + } + if !events.isEmpty || !emotes.isEmpty { + onBatch?(events, emotes) + } + } + + private func scanDirectory(_ directory: URL, handler: ([String: Any]) -> Void) { + let fileManager = FileManager.default + guard let entries = try? fileManager.contentsOfDirectory( + at: directory, includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey] + ) else { return } + for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { + guard entry.lastPathComponent.hasSuffix(".json"), + !entry.lastPathComponent.hasPrefix(".") else { continue } + let values = try? entry.resourceValues(forKeys: [.contentModificationDateKey, .isRegularFileKey]) + guard values?.isRegularFile == true else { continue } + guard let data = try? Data(contentsOf: entry), + let object = try? JSONSerialization.jsonObject(with: data), + let envelope = object as? [String: Any] else { + discard(entry: entry) + continue + } + handler(envelope) + discard(entry: entry) + } + } + + /// 诊断模式只读不删;正常运行消费完立即删除(事件文件都是先写临时文件再原子改名,不会读到半截)。 + private func discard(entry: URL) { + guard !readOnly else { return } + try? FileManager.default.removeItem(at: entry) + } +} + +// MARK: - 用量与余额(肚子上的胶囊) + +/// 来自 ZCode 本地用量库(~/.zcode/cli/db/db.sqlite,只读)的今日/本周 token 统计。 +struct UsageSnapshot: Equatable { + let todayTotal: Int + let todayInput: Int + let todayOutput: Int + let todayReasoning: Int + let todayTurns: Int + let todayTools: Int + let weekTotal: Int +} + +/// 用户在 balance.json 里配置的远端额度接口返回的一条余量。 +struct RemoteBalance: Equatable { + let label: String + let display: String + let fetchedAt: Date +} + +struct BalanceInfo: Equatable { + let usage: UsageSnapshot? + let remote: RemoteBalance? + let remoteError: String? + let updatedAt: Date +} + +func formatTokens(_ value: Int) -> String { + func trimmed(_ number: Double, _ unit: String) -> String { + let text = String(format: "%.1f", number) + return (text.hasSuffix(".0") ? String(text.dropLast(2)) : text) + unit + } + if value >= 1_000_000 { return trimmed(Double(value) / 1_000_000, "M") } + if value >= 1_000 { return trimmed(Double(value) / 1_000, "k") } + return "\(value)" +} + +final class BalanceProvider { + static let ttl: TimeInterval = 60 + static let configURL = FeedWatcher.stateRoot.appendingPathComponent("balance.json") + + struct RemoteConfig { + let url: URL + let headers: [String: String] + let valuePath: String + let label: String + let prefix: String + let suffix: String + } + + private(set) var current: BalanceInfo? + private var lastRefresh = Date.distantPast + private var inFlight = false + + var isStale: Bool { Date().timeIntervalSince(lastRefresh) >= Self.ttl } + + static func databasePath() -> String { + let base = ProcessInfo.processInfo.environment["ZCODE_DATA_BASE_DIR"] ?? NSHomeDirectory() + return (base as NSString).appendingPathComponent(".zcode/cli/db/db.sqlite") + } + + func refresh(force: Bool, completion: @escaping (BalanceInfo) -> Void) { + guard force || isStale else { + if let current { completion(current) } + return + } + guard !inFlight else { return } + inFlight = true + lastRefresh = Date() + let usage = Self.readUsage() + guard let config = Self.readRemoteConfig() else { + let info = BalanceInfo(usage: usage, remote: nil, remoteError: nil, updatedAt: Date()) + current = info + inFlight = false + completion(info) + return + } + Self.fetchRemote(config) { [weak self] result in + DispatchQueue.main.async { + guard let self else { return } + let info: BalanceInfo + switch result { + case .success(let remote): + info = BalanceInfo(usage: usage, remote: remote, remoteError: nil, updatedAt: Date()) + case .failure(let error): + info = BalanceInfo(usage: usage, remote: self.current?.remote, remoteError: error.localizedDescription, updatedAt: Date()) + } + self.current = info + self.inFlight = false + completion(info) + } + } + } + + // MARK: 本地用量 + + static func readUsage() -> UsageSnapshot? { + let path = databasePath() + guard FileManager.default.fileExists(atPath: path) else { return nil } + var db: OpaquePointer? + guard sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, let db else { + if db != nil { sqlite3_close(db) } + return nil + } + defer { sqlite3_close(db) } + + let now = Date() + let startOfToday = Calendar.current.startOfDay(for: now).timeIntervalSince1970 * 1000 + let weekAgo = now.addingTimeInterval(-7 * 86_400).timeIntervalSince1970 * 1000 + + guard let today = sumUsage(db, sinceMs: startOfToday), + let week = sumUsage(db, sinceMs: weekAgo) else { return nil } + return UsageSnapshot( + todayTotal: today.total, + todayInput: today.input, + todayOutput: today.output, + todayReasoning: today.reasoning, + todayTurns: today.turns, + todayTools: today.tools, + weekTotal: week.total + ) + } + + private struct UsageRow { + let total: Int + let input: Int + let output: Int + let reasoning: Int + let turns: Int + let tools: Int + } + + private static func sumUsage(_ db: OpaquePointer, sinceMs: Double) -> UsageRow? { + let sql = """ + SELECT COALESCE(SUM(computed_total_tokens), 0), COALESCE(SUM(input_tokens), 0), + COALESCE(SUM(output_tokens), 0), COALESCE(SUM(reasoning_tokens), 0), + COUNT(*), COALESCE(SUM(tool_call_count), 0) + FROM turn_usage WHERE started_at >= ? + """ + var statement: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return nil } + defer { sqlite3_finalize(statement) } + sqlite3_bind_int64(statement, 1, sqlite3_int64(sinceMs)) + guard sqlite3_step(statement) == SQLITE_ROW else { return nil } + return UsageRow( + total: Int(sqlite3_column_int64(statement, 0)), + input: Int(sqlite3_column_int64(statement, 1)), + output: Int(sqlite3_column_int64(statement, 2)), + reasoning: Int(sqlite3_column_int64(statement, 3)), + turns: Int(sqlite3_column_int64(statement, 4)), + tools: Int(sqlite3_column_int64(statement, 5)) + ) + } + + // MARK: 远端余量(可选) + + static func readRemoteConfig() -> RemoteConfig? { + guard let data = try? Data(contentsOf: configURL), + let object = try? JSONSerialization.jsonObject(with: data), + let dict = object as? [String: Any], + let urlText = dict["url"] as? String, + let url = URL(string: urlText) else { return nil } + var headers: [String: String] = [:] + if let raw = dict["headers"] as? [String: Any] { + for (key, value) in raw { + if let text = value as? String { headers[key] = text } + } + } + return RemoteConfig( + url: url, + headers: headers, + valuePath: (dict["valuePath"] as? String) ?? "", + label: (dict["label"] as? String) ?? L10n.text("Plan balance", "套餐余量"), + prefix: (dict["prefix"] as? String) ?? "", + suffix: (dict["suffix"] as? String) ?? "" + ) + } + + static func extractValue(_ json: Any, path: String) -> Any? { + guard !path.isEmpty else { return json } + var cursor: Any = json + for component in path.split(separator: ".") { + let key = String(component) + if let dict = cursor as? [String: Any], let next = dict[key] { + cursor = next + } else if let array = cursor as? [Any], let index = Int(key), array.indices.contains(index) { + cursor = array[index] + } else { + return nil + } + } + return cursor + } + + static func displayValue(_ value: Any?) -> String? { + if let number = value as? NSNumber { + let double = number.doubleValue + if double == double.rounded() && abs(double) < 1e15 { + return NumberFormatter.localizedString(from: NSNumber(value: Int(double)), number: .decimal) + } + return String(format: "%.2f", double) + } + if let text = value as? String, !text.isEmpty { return text } + return nil + } + + private static func fetchRemote(_ config: RemoteConfig, completion: @escaping (Result) -> Void) { + var request = URLRequest(url: config.url) + request.timeoutInterval = 6 + request.setValue("application/json", forHTTPHeaderField: "Accept") + for (key, value) in config.headers { + request.setValue(value, forHTTPHeaderField: key) + } + let session = URLSession(configuration: .ephemeral) + session.dataTask(with: request) { data, response, error in + if let error { + completion(.failure(error)) + return + } + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode), let data else { + let code = (response as? HTTPURLResponse)?.statusCode ?? 0 + completion(.failure(NSError(domain: "XiaoqiBalance", code: code, userInfo: [NSLocalizedDescriptionKey: "HTTP \(code)"]))) + return + } + guard let json = try? JSONSerialization.jsonObject(with: data), + let display = displayValue(extractValue(json, path: config.valuePath)) else { + completion(.failure(NSError(domain: "XiaoqiBalance", code: 1, userInfo: [NSLocalizedDescriptionKey: L10n.text("valuePath not found in response", "响应里找不到 valuePath")]))) + return + } + completion(.success(RemoteBalance(label: config.label, display: config.prefix + display + config.suffix, fetchedAt: Date()))) + }.resume() + } +} + +// MARK: - 宠物模型 + +final class PetModel: ObservableObject { + @Published private(set) var tasks: [RemoteTask] = [] + @Published private(set) var connected = false + @Published private(set) var lastUpdated: Date? + @Published private(set) var emoteSpeech = "" + @Published private(set) var emoteUntil: Date? + @Published private(set) var balance: BalanceInfo? + @Published var boardOpen = false + @Published var locked: Bool { + didSet { UserDefaults.standard.set(locked, forKey: "xiaoqi.pet.locked") } + } + + var onNotice: ((PetNotice) -> Void)? + var onStateChange: (() -> Void)? + + private let store = SessionStore() + private let balanceProvider = BalanceProvider() + private var previousKinds: [String: String] = [:] + private var previousWaiting: Set = [] + private var didSeed = false + private var lastEventAt: Double = 0 + + init() { + locked = UserDefaults.standard.bool(forKey: "xiaoqi.pet.locked") + } + + // MARK: 用量 / 余额 + + func refreshBalance(force: Bool) { + balanceProvider.refresh(force: force) { [weak self] info in + guard let self else { return } + self.balance = info + self.onStateChange?() + } + } + + /// 肚子胶囊:配置了远端额度就显示余量,否则显示今日 token 用量。 + var balanceText: String? { + guard let balance else { return nil } + if let remote = balance.remote { return remote.display } + if let usage = balance.usage { + return L10n.text("Today \(formatTokens(usage.todayTotal))", "今日 \(formatTokens(usage.todayTotal))") + } + return nil + } + + var balanceDetail: String? { + guard let balance else { return nil } + var lines: [String] = [] + if let remote = balance.remote { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + lines.append(L10n.text( + "\(remote.label) \(remote.display) · updated \(formatter.string(from: remote.fetchedAt))", + "\(remote.label) \(remote.display) · \(formatter.string(from: remote.fetchedAt)) 更新" + )) + } else if let error = balance.remoteError { + lines.append(L10n.text("Plan balance unavailable: \(error)", "套餐余量获取失败:\(error)")) + } + if let usage = balance.usage { + lines.append(L10n.text( + "Today \(formatTokens(usage.todayTotal)) tokens · in \(formatTokens(usage.todayInput)) · out \(formatTokens(usage.todayOutput)) · reasoning \(formatTokens(usage.todayReasoning)) · \(usage.todayTurns) turns · \(usage.todayTools) tools · 7d \(formatTokens(usage.weekTotal))", + "今日 \(formatTokens(usage.todayTotal)) tokens · 输入 \(formatTokens(usage.todayInput)) · 输出 \(formatTokens(usage.todayOutput)) · 推理 \(formatTokens(usage.todayReasoning)) · \(usage.todayTurns) 轮 · \(usage.todayTools) 次工具 · 近 7 天 \(formatTokens(usage.weekTotal))" + )) + } + return lines.isEmpty ? nil : lines.joined(separator: "\n") + } + + // MARK: 事件入口 + + func applyBatch(_ events: [HookEvent], _ emotes: [[String: Any]]) { + let nowMs = Date().timeIntervalSince1970 * 1000 + for item in events { + store.apply(event: item.event, atMs: item.atMs, payload: item.payload, cwd: item.cwd) + lastEventAt = max(lastEventAt, item.atMs) + } + refresh(noticeCheck: true, nowMs: nowMs) + + // 多条互动同批到达时只展示最后一条,避免气泡连发。 + for envelope in emotes { + applyEmote(envelope) + } + } + + func applyEmote(_ envelope: [String: Any]) { + let speech = shortText(envelope["speech"], 200) + guard !speech.isEmpty else { return } + var seconds = (envelope["seconds"] as? NSNumber)?.doubleValue ?? 6 + if seconds <= 0 { seconds = 6 } + seconds = min(30, max(3, seconds)) + emoteSpeech = speech + emoteUntil = Date().addingTimeInterval(seconds) + onNotice?(PetNotice(kind: "emote", title: L10n.text("Xiaoqi", "小栖"), body: speech)) + onStateChange?() + } + + /// 每秒刷新:让“进行中”的耗时与互动气泡随时间走动;用量每分钟重读一次。 + func tick() { + if let until = emoteUntil, until < Date() { + emoteSpeech = "" + emoteUntil = nil + } + refresh(noticeCheck: false, nowMs: Date().timeIntervalSince1970 * 1000) + if balanceProvider.isStale { + refreshBalance(force: false) + } + } + + // MARK: 派生状态 + + var waiting: [RemoteTask] { tasks.filter { $0.kind == "waiting" } } + var running: [RemoteTask] { tasks.filter { $0.kind == "running" } } + var done: [RemoteTask] { tasks.filter { $0.kind == "done" } } + + var mood: PetMood { + if !waiting.isEmpty { return .waiting } + if !running.isEmpty { return .running } + if !done.isEmpty { return .done } + return .idle + } + + var badgeCount: Int { + if !waiting.isEmpty { return waiting.count } + if !done.isEmpty { return done.count } + return running.count + } + + var badgeKind: String { + if !waiting.isEmpty { return "waiting" } + if !done.isEmpty { return "done" } + return "running" + } + + var caption: String { + if !waiting.isEmpty { + return L10n.text("\(waiting.count) need you", "\(waiting.count) 个待处理") + } + if !running.isEmpty { + if let focus = running.first, let progress = focus.progress { + let phase = L10n.phaseShort(progress) + return running.count > 1 + ? L10n.text("\(running.count) running · \(phase)", "\(running.count) 进行中 · \(phase)") + : phase + } + return L10n.text("\(running.count) running", "\(running.count) 进行中") + } + if !done.isEmpty { + return L10n.text("\(done.count) finished", "\(done.count) 个已完成") + } + return connected ? L10n.text("Quietly here", "安静陪伴") : L10n.text("Waiting for ZCode…", "等待 ZCode…") + } + + var summary: String { + var parts: [String] = [] + if !waiting.isEmpty { + parts.append(L10n.text("\(waiting.count) need you", "\(waiting.count) 个任务需要你")) + } + if !running.isEmpty { + parts.append(L10n.text("\(running.count) running", "\(running.count) 个任务进行中")) + } + if !done.isEmpty { + parts.append(L10n.text("\(done.count) just finished", "\(done.count) 个任务刚完成")) + } + if parts.isEmpty { + return connected + ? L10n.text("All sessions are quiet", "所有会话都在休息") + : L10n.text("Waiting for ZCode hooks", "等待 ZCode 连接") + } + return parts.joined(separator: " · ") + } + + // MARK: 内部 + + private func refresh(noticeCheck: Bool, nowMs: Double) { + let incoming = store.snapshot(nowMs: nowMs) + tasks = incoming + lastUpdated = Date() + connected = !incoming.isEmpty || (lastEventAt > 0 && nowMs - lastEventAt < 10 * 60 * 1000) + + if noticeCheck { + let oldKinds = previousKinds + let oldWaiting = previousWaiting + var fresh: [PetNotice] = [] + for task in incoming { + if task.kind == "waiting", !oldWaiting.contains(task.id) { + fresh.append(PetNotice( + kind: "waiting", + title: task.title, + body: L10n.pending(kind: task.pendingKind, tool: task.pendingTool) + )) + } else if task.kind == "done", oldKinds[task.id] == "running" { + fresh.append(PetNotice( + kind: "done", + title: task.title, + body: L10n.text("Task complete", "任务完成") + )) + } + } + if didSeed, let notice = fresh.first { + onNotice?(notice) + } + previousKinds = Dictionary(uniqueKeysWithValues: incoming.map { ($0.id, $0.kind) }) + previousWaiting = Set(incoming.filter { $0.kind == "waiting" }.map(\.id)) + didSeed = true + } + onStateChange?() + } + + func toggleLock() { + locked.toggle() + } + + func activateZCode() { + if let app = NSRunningApplication.runningApplications(withBundleIdentifier: "dev.zcode.app").first { + app.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + return + } + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "dev.zcode.app") else { return } + NSWorkspace.shared.openApplication(at: url, configuration: NSWorkspace.OpenConfiguration()) { _, _ in } + } +} + +// MARK: - 诊断输出(--diagnose) + +enum XiaoqiDiagnose { + static func run() -> String { + let watcher = FeedWatcher(readOnly: true) + let store = SessionStore() + var emoteCount = 0 + + watcher.onBatch = { events, emotes in + for item in events { + store.apply(event: item.event, atMs: item.atMs, payload: item.payload, cwd: item.cwd) + } + emoteCount += emotes.count + } + + let feedFiles = (try? FileManager.default.contentsOfDirectory( + at: FeedWatcher.feedDir, includingPropertiesForKeys: nil + ))?.filter { $0.lastPathComponent.hasSuffix(".json") } ?? [] + let feedCount = feedFiles.count + watcher.scan() + + let nowMs = Date().timeIntervalSince1970 * 1000 + let tasks = store.snapshot(nowMs: nowMs) + var report: [String: Any] = [ + "feedDir": FeedWatcher.feedDir.path, + "pendingFeedFiles": feedCount, + "parsedEmotes": emoteCount, + "tasks": tasks.map { task in + [ + "id": task.id, + "title": task.title, + "kind": task.kind, + "phase": task.progress?.phase ?? "", + "action": task.progress.flatMap { progress in + progress.action.map { L10n.actionLabel($0) } + } ?? "", + "elapsedMs": task.progress?.elapsedMs ?? 0, + ] as [String: Any] + }, + ] + if feedCount > 0 && tasks.isEmpty { + report["unparsedFeedFiles"] = feedCount + } + if tasks.isEmpty && feedCount == 0 { + report["hint"] = FeedError.noEvents.errorDescription ?? "" + } + if let usage = BalanceProvider.readUsage() { + report["usage"] = [ + "database": BalanceProvider.databasePath(), + "todayTotalTokens": usage.todayTotal, + "todayInput": usage.todayInput, + "todayOutput": usage.todayOutput, + "todayReasoning": usage.todayReasoning, + "todayTurns": usage.todayTurns, + "todayToolCalls": usage.todayTools, + "weekTotalTokens": usage.weekTotal, + "belly": L10n.text("Today \(formatTokens(usage.todayTotal))", "今日 \(formatTokens(usage.todayTotal))"), + ] as [String: Any] + } else { + report["usage"] = "unavailable: \(BalanceProvider.databasePath())" + } + report["remoteBalanceConfig"] = FileManager.default.fileExists(atPath: BalanceProvider.configURL.path) + ? BalanceProvider.configURL.path + : "not configured" + + guard let data = try? JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys]), + let text = String(data: data, encoding: .utf8) else { + return "diagnose-encode-failed" + } + return text + } +} diff --git a/external_plugins/xiaoqi-pet/desktop/Sources/Views.swift b/external_plugins/xiaoqi-pet/desktop/Sources/Views.swift new file mode 100644 index 0000000..4cfc1e0 --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/Sources/Views.swift @@ -0,0 +1,919 @@ +import AppKit +import SwiftUI + +enum PetAssets { + static let pet: NSImage? = { + guard let url = Bundle.main.url(forResource: "pet", withExtension: "svg") else { return nil } + return NSImage(contentsOf: url) + }() +} + +extension PetMood { + var color: Color { + switch self { + case .waiting: return Color(red: 0.88, green: 0.57, blue: 0.18) + case .running: return Color(red: 0.16, green: 0.55, blue: 0.50) + case .done: return Color(red: 0.12, green: 0.56, blue: 0.39) + case .idle: return .clear + } + } +} + +struct PetStageView: View { + @ObservedObject var model: PetModel + let onToggleBoard: () -> Void + let onToggleLock: () -> Void + let onBeginDrag: () -> Void + let onDrag: () -> Void + let onEndDrag: () -> Void + + @State private var hovering = false + @State private var dragMoved = false + @State private var breathe = false + + private var mood: PetMood { model.mood } + + var body: some View { + VStack(spacing: 0) { + ZStack(alignment: .topTrailing) { + ZStack { + if mood != .idle { + Circle() + .fill(mood.color.opacity(0.12)) + .frame(width: 118, height: 118) + .blur(radius: 12) + .offset(y: 2) + } + if let image = PetAssets.pet { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .scaledToFit() + .frame(width: 128, height: 128) + .scaleEffect(breathe ? (mood == .waiting ? 1.035 : 1.02) : 1) + .offset(y: breathe ? (mood == .running ? -4 : -2) : 0) + .rotationEffect(.degrees(mood == .waiting ? (breathe ? 2.4 : -2.4) : 0)) + .shadow(color: .black.opacity(0.22), radius: 10, y: 6) + } else { + Image(systemName: "pawprint.fill") + .font(.system(size: 54, weight: .semibold)) + .foregroundStyle(mood == .idle ? Color.secondary : mood.color) + .frame(width: 128, height: 128) + } + if let text = model.balanceText { + Text(text) + .font(.system(size: 10.2, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + .lineLimit(1) + .padding(.horizontal, 6) + .padding(.vertical, 2.5) + .background(Capsule().fill(Color.black.opacity(0.34))) + .overlay(Capsule().strokeBorder(Color.white.opacity(0.18), lineWidth: 0.5)) + .offset(y: 30) + .allowsHitTesting(false) + .help(model.balanceDetail ?? "") + .accessibilityLabel(model.balanceDetail ?? text) + } + } + .frame(width: 140, height: 134) + .contentShape(Rectangle()) + .onTapGesture { + if !dragMoved { onToggleBoard() } + } + .gesture( + DragGesture(minimumDistance: 3) + .onChanged { value in + guard !model.locked else { return } + if !dragMoved { + let distance = hypot(value.translation.width, value.translation.height) + if distance < 3 { return } + dragMoved = true + onBeginDrag() + } + onDrag() + } + .onEnded { _ in + if dragMoved { onEndDrag() } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + dragMoved = false + } + } + ) + + if model.badgeCount > 0 { + badge + .offset(x: -4, y: 6) + } + } + .overlay(alignment: .topLeading) { + Button(action: onToggleLock) { + Image(systemName: model.locked ? "pin.fill" : "pin") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(model.locked ? Color.white : Color.secondary) + .frame(width: 25, height: 25) + .background( + Circle().fill(model.locked ? moodColor.opacity(0.9) : Color.primary.opacity(0.06)) + ) + .overlay(Circle().strokeBorder(Color.primary.opacity(0.09), lineWidth: 0.5)) + } + .buttonStyle(.plain) + .opacity(hovering || model.locked ? 1 : 0) + .animation(.easeOut(duration: 0.12), value: hovering) + .padding(.leading, 8) + .padding(.top, 10) + .help(model.locked + ? L10n.text("Unpin position", "解除固定") + : L10n.text("Pin position", "固定位置")) + } + + if model.tasks.isEmpty { + Text(model.caption) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)) + .padding(.top, -4) + .allowsHitTesting(false) + } + } + .frame(width: 164, height: 186, alignment: .bottom) + .onHover { value in + hovering = value + } + .onAppear { startBreathing() } + .onChange(of: model.mood) { _ in startBreathing() } + .contextMenu { + Button(L10n.text("Open task board", "打开任务看板")) { onToggleBoard() } + Button(model.locked + ? L10n.text("Unpin position", "解除固定") + : L10n.text("Pin position", "固定位置")) { onToggleLock() } + Divider() + Button(L10n.text("Quit Xiaoqi", "退出小栖")) { NSApp.terminate(nil) } + } + .accessibilityElement(children: .contain) + .accessibilityLabel(L10n.text("Xiaoqi desktop companion", "小栖桌宠")) + } + + private var moodColor: Color { + mood == .idle ? Color.accentColor : mood.color + } + + private var badge: some View { + Text(model.badgeCount > 9 ? "9+" : "\(model.badgeCount)") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.white) + .frame(minWidth: 22, minHeight: 22) + .padding(.horizontal, 2) + .background(Capsule().fill(model.badgeKind == "waiting" ? PetMood.waiting.color : model.badgeKind == "done" ? PetMood.done.color : PetMood.running.color)) + .overlay(Capsule().strokeBorder(Color(nsColor: .windowBackgroundColor), lineWidth: 2)) + .shadow(color: .black.opacity(0.2), radius: 4, y: 2) + .accessibilityLabel(L10n.text("\(model.badgeCount) notices", "\(model.badgeCount) 条提醒")) + } + + private func startBreathing() { + breathe = false + let duration: Double + switch mood { + case .running: duration = 0.9 + case .waiting: duration = 1.55 + case .done: duration = 2.1 + case .idle: duration = 4.2 + } + let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + guard !reduceMotion else { return } + DispatchQueue.main.async { + withAnimation(.easeInOut(duration: duration).repeatForever(autoreverses: true)) { + breathe = true + } + } + } +} + +struct BoardView: View { + @ObservedObject var model: PetModel + let onClose: () -> Void + let onToggleLock: () -> Void + let onOpenTask: (RemoteTask) -> Void + + var body: some View { + VStack(spacing: 0) { + header + if !model.connected { + connectionBanner + } + Divider().opacity(0.35) + content + Divider().opacity(0.35) + footer + } + .frame(width: 344) + .frame(maxHeight: .infinity) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(Color.primary.opacity(0.1), lineWidth: 0.6) + ) + .shadow(color: .black.opacity(0.28), radius: 22, y: 12) + .padding(12) + } + + private var header: some View { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 7) { + Text(L10n.text("Xiaoqi", "小栖")) + .font(.system(size: 15, weight: .bold)) + HStack(spacing: 4) { + Circle() + .fill(model.connected ? PetMood.done.color : Color.secondary.opacity(0.5)) + .frame(width: 5, height: 5) + Text(L10n.text("LIVE", "实时")) + .font(.system(size: 8, weight: .bold)) + .tracking(0.6) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(Color.primary.opacity(0.055))) + .overlay(Capsule().strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5)) + } + Text(model.summary) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 0) + HStack(spacing: 4) { + BoardIconButton(systemName: model.locked ? "pin.fill" : "pin", + active: model.locked, + help: model.locked + ? L10n.text("Unpin position", "解除固定") + : L10n.text("Pin position", "固定位置"), + action: onToggleLock) + BoardIconButton(systemName: "xmark", action: onClose) + } + } + .padding(.horizontal, 14) + .padding(.top, 13) + .padding(.bottom, 10) + } + + private var connectionBanner: some View { + HStack(spacing: 7) { + ProgressView() + .controlSize(.small) + Text(L10n.text("Waiting for ZCode hooks…", "等待 ZCode hook 事件…")) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(PetMood.waiting.color.opacity(0.09)) + } + + private var content: some View { + let palette = TaskPalette.assignments(for: model.tasks) + return ScrollView(.vertical, showsIndicators: false) { + if model.tasks.isEmpty { + emptyState + } else { + VStack(alignment: .leading, spacing: 4) { + if !model.waiting.isEmpty { + section(L10n.text("Needs you", "需要你"), tasks: model.waiting, palette: palette) + } + if !model.running.isEmpty { + section(L10n.text("In progress", "进行中"), tasks: model.running, palette: palette) + } + if !model.done.isEmpty { + section(L10n.text("Just finished", "刚完成"), tasks: model.done, palette: palette) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var emptyState: some View { + VStack(spacing: 8) { + Image(systemName: model.connected ? "sparkles" : "bolt.horizontal.circle") + .font(.system(size: 28, weight: .light)) + .foregroundStyle(model.connected ? PetMood.running.color : Color.secondary) + Text(model.connected + ? L10n.text("All sessions are quiet", "所有会话都安静了") + : L10n.text("Waiting for ZCode", "等待 ZCode 连接")) + .font(.system(size: 12.5, weight: .semibold)) + Text(model.connected + ? L10n.text("Xiaoqi will speak up when a task starts or needs you.", "任务开始运行或需要你处理时,小栖会提醒你。") + : L10n.text("Install the xiaoqi-pet plugin in ZCode, then run any session.", "在 ZCode 安装 xiaoqi-pet 插件后运行一次会话即可。")) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .lineSpacing(2) + .frame(maxWidth: 236) + } + .frame(maxWidth: .infinity, minHeight: 290) + .padding(20) + } + + private func section(_ label: String, tasks: [RemoteTask], palette: [String: Color]) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(label.uppercased()) + .font(.system(size: 9.5, weight: .semibold)) + .tracking(0.7) + .foregroundStyle(.secondary) + Spacer() + Text("\(tasks.count)") + .font(.system(size: 9.5, weight: .semibold)) + .foregroundStyle(.tertiary) + } + .padding(.horizontal, 7) + .padding(.top, 7) + .padding(.bottom, 3) + ForEach(tasks) { task in + TaskRowView( + task: task, + accent: palette[task.id] ?? PetMood.running.color, + onOpen: onOpenTask + ) + } + } + } + + private var footer: some View { + HStack(spacing: 6) { + Image(systemName: model.locked ? "pin.fill" : "hand.draw") + .font(.system(size: 9.5)) + Text(model.locked + ? L10n.text("Pinned in place · click the pin to unlock", "位置已固定 · 点击图钉可解锁") + : L10n.text("Drag Xiaoqi anywhere · click a task to focus ZCode", "拖动小栖到任意位置 · 点击任务切到 ZCode")) + .lineLimit(1) + Spacer(minLength: 0) + } + .font(.system(size: 9.5)) + .foregroundStyle(.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 9) + } +} + +struct BoardIconButton: View { + let systemName: String + var active = false + var help: String = "" + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(active ? Color.accentColor : Color.secondary) + .frame(width: 26, height: 26) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(active ? Color.accentColor.opacity(0.12) : Color.clear) + ) + } + .buttonStyle(.plain) + .help(help) + } +} + +struct TaskRowView: View { + let task: RemoteTask + let accent: Color + let onOpen: (RemoteTask) -> Void + @State private var hovering = false + + private func progressColor(_ phase: String) -> Color { + switch phase { + case "tool": return PetMood.waiting.color + case "responding": return PetMood.done.color + case "thinking": return PetMood.running.color + default: return .secondary + } + } + + private func progressSymbol(_ phase: String) -> String { + switch phase { + case "tool": return "wrench.and.screwdriver.fill" + case "responding": return "text.bubble.fill" + case "thinking": return "brain.head.profile" + default: return "hourglass" + } + } + + private func actionSymbol(_ progress: RemoteProgress) -> String { + guard let action = progress.action else { return progressSymbol(progress.phase) } + if action.failed == true { return "xmark.circle.fill" } + switch action.kind { + case "read", "write", "edit": return "doc.text.fill" + case "bash": return "terminal.fill" + case "glob", "grep": return "magnifyingglass" + case "image": return "photo.fill" + case "search", "fetch": return "globe" + case "subagent": return "person.2.fill" + case "workflow": return "point.3.connected.trianglepath.dotted" + case "todo": return "checklist" + case "schedule": return "clock.badge.checkmark" + case "question": return "questionmark.bubble.fill" + case "plan": return "map.fill" + case "mcp": return "antenna.radiowaves.left.and.right" + case "skill": return "sparkles" + default: return progressSymbol(progress.phase) + } + } + + private func progressHeadline(_ progress: RemoteProgress) -> String { + if progress.phase == "tool", let tool = progress.tool, !tool.isEmpty { + return L10n.text("Running \(tool)", "正在执行 \(tool)") + } + return L10n.phase(progress.phase) + } + + var body: some View { + Button { + onOpen(task) + } label: { + HStack(spacing: 9) { + Circle() + .fill(accent) + .frame(width: 7, height: 7) + .overlay(Circle().strokeBorder(accent.opacity(0.2), lineWidth: 4)) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 5) { + Text(task.title) + .font(.system(size: 12.2, weight: .semibold)) + .lineLimit(1) + .truncationMode(.tail) + if task.current { + Text(L10n.text("CURRENT", "当前")) + .font(.system(size: 7, weight: .bold)) + .tracking(0.4) + .foregroundStyle(Color.accentColor) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background(Capsule().fill(Color.accentColor.opacity(0.12))) + } + } + Text(L10n.taskMeta(task)) + .font(.system(size: 10.2)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + if let progress = task.progress, task.kind == "running" || task.kind == "waiting" { + let todo = L10n.todoLine(progress) + if !todo.isEmpty { + HStack(spacing: 6) { + Image(systemName: "checklist") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(PetMood.running.color) + Text(todo) + .font(.system(size: 11.6, weight: .semibold)) + .foregroundStyle(.primary.opacity(0.9)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + Text(L10n.todoCount(progress)) + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.top, 2) + } + HStack(alignment: .top, spacing: 6) { + Image(systemName: actionSymbol(progress)) + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(progress.action?.failed == true ? Color.red : progressColor(progress.phase)) + .padding(.top, 2) + Text(L10n.actionLine(progress)) + .font(.system(size: 12.2, weight: .semibold)) + .foregroundStyle(progressColor(progress.phase)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.top, 1) + HStack(spacing: 8) { + if !progress.trail.isEmpty { + Text(L10n.trail(progress)) + .font(.system(size: 10.8, weight: .medium)) + .foregroundStyle(.primary.opacity(0.7)) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 4) + Text(L10n.elapsed(progress.elapsedMs)) + .font(.system(size: 10.8, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize() + } + } + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 8.5, weight: .semibold)) + .foregroundStyle(.tertiary) + .opacity(hovering ? 1 : 0.15) + } + .padding(.leading, 12) + .padding(.trailing, 8) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(hovering ? accent.opacity(0.12) : accent.opacity(0.045)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(accent.opacity(hovering ? 0.52 : 0.32), lineWidth: 0.8) + ) + .overlay(alignment: .leading) { + RoundedRectangle(cornerRadius: 1.5, style: .continuous) + .fill(accent) + .frame(width: 3) + .padding(.vertical, 9) + .offset(x: 1) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .help(L10n.text("Bring ZCode to the front", "把 ZCode 调到前台")) + } +} + +enum TaskPalette { + static let colors: [Color] = [ + Color(red: 0.16, green: 0.56, blue: 0.50), + Color(red: 0.88, green: 0.56, blue: 0.18), + Color(red: 0.31, green: 0.50, blue: 0.94), + Color(red: 0.62, green: 0.45, blue: 0.90), + Color(red: 0.87, green: 0.39, blue: 0.56), + Color(red: 0.42, green: 0.65, blue: 0.27), + Color(red: 0.90, green: 0.44, blue: 0.30), + ] + + static func assignments(for tasks: [RemoteTask]) -> [String: Color] { + var used = Set() + var result: [String: Color] = [:] + for task in tasks { + var index = stableIndex(task.id) + while used.contains(index) { + index = (index + 1) % colors.count + } + used.insert(index) + result[task.id] = colors[index] + } + return result + } + + private static func stableIndex(_ value: String) -> Int { + var hash: UInt64 = 1_469_598_103_934_665_603 + for byte in value.utf8 { + hash ^= UInt64(byte) + hash = hash &* 1_099_511_628_211 + } + return Int(hash % UInt64(colors.count)) + } +} + +struct TaskDrawerView: View { + @ObservedObject var model: PetModel + let onOpen: (RemoteTask) -> Void + + private var visible: [RemoteTask] { Array(model.tasks.prefix(4)) } + private var extra: Int { max(0, model.tasks.count - 4) } + + var body: some View { + let palette = TaskPalette.assignments(for: visible) + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(visible.enumerated()), id: \.element.id) { index, task in + DrawerTaskCard( + task: task, + index: index, + accent: palette[task.id] ?? PetMood.running.color, + onOpen: onOpen + ) + .transition(.move(edge: .leading).combined(with: .opacity)) + } + if extra > 0 { + Text(L10n.text("+\(extra) more in the board", "看板里还有 \(extra) 个任务")) + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(.secondary) + .padding(.leading, 10) + .padding(.top, 2) + } + } + .frame(width: 308, alignment: .leading) + .padding(12) + .animation(.spring(response: 0.36, dampingFraction: 0.84), value: visible.map(\.id)) + } +} + +struct DrawerTaskCard: View { + let task: RemoteTask + let index: Int + let accent: Color + let onOpen: (RemoteTask) -> Void + @State private var hovering = false + + private var progress: RemoteProgress? { task.progress } + private var todo: String { progress.map(L10n.todoLine) ?? "" } + private var todoCount: String { progress.map(L10n.todoCount) ?? "" } + private var actionText: String { + if task.kind == "waiting" { + return L10n.pending(kind: task.pendingKind, tool: task.pendingTool) + } + if let progress { return L10n.actionLine(progress) } + return L10n.status(task) + } + private var elapsed: String { + if let progress { return L10n.elapsed(progress.elapsedMs) } + return L10n.relative(task.updatedAt) + } + private var actionSymbol: String { + if task.kind == "waiting" { return "exclamationmark.circle.fill" } + guard let progress, let action = progress.action else { return "circle.dotted" } + if action.failed == true { return "xmark.circle.fill" } + switch action.kind { + case "read", "write", "edit": return "doc.text.fill" + case "bash": return "terminal.fill" + case "glob", "grep": return "magnifyingglass" + case "image": return "photo.fill" + case "search", "fetch": return "globe" + case "subagent": return "person.2.fill" + case "workflow": return "point.3.connected.trianglepath.dotted" + case "todo": return "checklist" + case "schedule": return "clock.badge.checkmark" + case "question": return "questionmark.bubble.fill" + case "plan": return "map.fill" + case "mcp": return "antenna.radiowaves.left.and.right" + case "skill": return "sparkles" + default: return "circle.dotted" + } + } + + var body: some View { + Button { + onOpen(task) + } label: { + HStack(alignment: .top, spacing: 9) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(accent) + .frame(width: 3) + .padding(.vertical, 3) + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Circle() + .fill(accent) + .frame(width: 5, height: 5) + Text(task.title) + .font(.system(size: 12.2, weight: .semibold)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + Text(L10n.status(task)) + .font(.system(size: 9.4, weight: .medium)) + .foregroundStyle(accent) + .lineLimit(1) + } + if !todo.isEmpty { + HStack(spacing: 5) { + Image(systemName: "checklist") + .font(.system(size: 9.5, weight: .semibold)) + .foregroundStyle(accent) + Text(todo) + .font(.system(size: 10.7, weight: .medium)) + .foregroundStyle(.primary.opacity(0.82)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + Text(todoCount) + .font(.system(size: 9.4, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + HStack(spacing: 5) { + Image(systemName: actionSymbol) + .font(.system(size: 9.5, weight: .semibold)) + .foregroundStyle(progress?.action?.failed == true ? Color.red : accent) + Text(actionText) + .font(.system(size: 11.4, weight: .medium)) + .foregroundStyle(.primary.opacity(0.9)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + Text(elapsed) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize() + } + if let progress, !progress.trail.isEmpty { + Text(L10n.trail(progress)) + .font(.system(size: 9.5, weight: .medium)) + .foregroundStyle(.primary.opacity(0.58)) + .lineLimit(1) + .truncationMode(.tail) + } + } + Image(systemName: "chevron.right") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(.tertiary) + .opacity(hovering ? 1 : 0.12) + .padding(.top, 5) + } + .padding(.horizontal, 11) + .padding(.vertical, 9) + .frame(width: 285, height: 96, alignment: .topLeading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.regularMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(accent.opacity(hovering ? 0.34 : 0.18), lineWidth: 0.6) + ) + .shadow(color: .black.opacity(hovering ? 0.26 : 0.16), radius: hovering ? 16 : 10, y: 6) + .offset(x: CGFloat(index) * 6 + (hovering ? 5 : 0)) + .animation(.easeOut(duration: 0.14), value: hovering) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .help(L10n.text("Bring ZCode to the front", "把 ZCode 调到前台")) + } +} + +struct TaskBubbleFanView: View { + @ObservedObject var model: PetModel + + private var visible: [RemoteTask] { Array(model.tasks.prefix(3)) } + + var body: some View { + HStack(alignment: .top, spacing: 8) { + ForEach(Array(visible.enumerated()), id: \.element.id) { index, task in + TaskBubbleView(task: task) + .offset(y: [0, 10, 4][index % 3]) + } + } + .frame(width: CGFloat(visible.count) * 224 + CGFloat(max(0, visible.count - 1)) * 8, alignment: .leading) + .padding(.horizontal, 10) + .padding(.top, 2) + .animation(.spring(response: 0.35, dampingFraction: 0.84), value: visible.map(\.id)) + } +} + +struct TaskBubbleView: View { + let task: RemoteTask + + private var accent: Color { + switch task.kind { + case "waiting": return PetMood.waiting.color + case "running": return PetMood.running.color + case "done": return PetMood.done.color + default: return .secondary + } + } + + private var actionText: String { + if task.kind == "waiting" { + return L10n.pending(kind: task.pendingKind, tool: task.pendingTool) + } + if let progress = task.progress { return L10n.actionLine(progress) } + return L10n.status(task) + } + + private var elapsed: String { + if let progress = task.progress { return L10n.elapsed(progress.elapsedMs) } + return L10n.relative(task.updatedAt) + } + + private var actionSymbol: String { + if task.kind == "waiting" { return "exclamationmark.circle.fill" } + guard let action = task.progress?.action else { return "circle.dotted" } + if action.failed == true { return "xmark.circle.fill" } + switch action.kind { + case "read", "write", "edit": return "doc.text.fill" + case "bash": return "terminal.fill" + case "glob", "grep": return "magnifyingglass" + case "image": return "photo.fill" + case "search", "fetch": return "globe" + case "subagent": return "person.2.fill" + case "workflow": return "point.3.connected.trianglepath.dotted" + case "todo": return "checklist" + case "question": return "questionmark.bubble.fill" + case "mcp": return "antenna.radiowaves.left.and.right" + default: return "circle.dotted" + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(task.title) + .font(.system(size: 11.2, weight: .semibold)) + .foregroundStyle(.primary.opacity(0.92)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + Circle() + .fill(accent) + .frame(width: 5, height: 5) + Text(L10n.status(task)) + .font(.system(size: 8.8, weight: .medium)) + .foregroundStyle(accent) + .lineLimit(1) + } + HStack(spacing: 5) { + Image(systemName: actionSymbol) + .font(.system(size: 8.5, weight: .semibold)) + .foregroundStyle(accent) + Text(actionText) + .font(.system(size: 9.8, weight: .medium)) + .foregroundStyle(.primary.opacity(0.84)) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 4) + Text(elapsed) + .font(.system(size: 8.8, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize() + } + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .frame(width: 224, height: 52, alignment: .topLeading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 15, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 15, style: .continuous) + .strokeBorder(Color.primary.opacity(0.1), lineWidth: 0.5) + ) + .shadow(color: .black.opacity(0.2), radius: 10, y: 5) + .allowsHitTesting(false) + } +} + +struct NoticeView: View { + let notice: PetNotice + + private var accent: Color { + switch notice.kind { + case "waiting": return PetMood.waiting.color + case "done": return PetMood.done.color + default: return Color(red: 0.31, green: 0.50, blue: 0.94) + } + } + + private var symbol: String { + switch notice.kind { + case "waiting": return "exclamationmark" + case "done": return "checkmark" + default: return "quote.bubble.fill" + } + } + + var body: some View { + HStack(alignment: .top, spacing: 9) { + ZStack { + Circle() + .fill(accent) + .frame(width: 21, height: 21) + Image(systemName: symbol) + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(.white) + } + VStack(alignment: .leading, spacing: 2) { + Text(notice.title) + .font(.system(size: 12, weight: .semibold)) + .lineLimit(1) + Text(notice.body) + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + .lineLimit(3) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 13) + .padding(.vertical, 11) + .frame(width: 320, alignment: .leading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(Color.primary.opacity(0.1), lineWidth: 0.6) + ) + .shadow(color: .black.opacity(0.24), radius: 16, y: 8) + .padding(10) + } +} diff --git a/external_plugins/xiaoqi-pet/desktop/Sources/Windows.swift b/external_plugins/xiaoqi-pet/desktop/Sources/Windows.swift new file mode 100644 index 0000000..61e4ce4 --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/Sources/Windows.swift @@ -0,0 +1,525 @@ +import AppKit +import Combine +import SwiftUI + +final class XiaoqiPanel: NSPanel { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} + +private func visibleFrame(containing point: NSPoint) -> NSRect { + NSScreen.screens.first(where: { $0.frame.contains(point) })?.visibleFrame + ?? NSScreen.main?.visibleFrame + ?? NSRect(x: 0, y: 0, width: 1440, height: 900) +} + +private func clampOrigin(_ origin: NSPoint, size: NSSize, within frame: NSRect) -> NSPoint { + NSPoint( + x: min(max(origin.x, frame.minX + 8), max(frame.minX + 8, frame.maxX - size.width - 8)), + y: min(max(origin.y, frame.minY + 8), max(frame.minY + 8, frame.maxY - size.height - 8)) + ) +} + +final class PetWindowController { + let model: PetModel + let panel: XiaoqiPanel + var onFrameChanged: ((NSRect) -> Void)? + + private var dragStartOrigin = NSPoint.zero + private var dragStartMouse = NSPoint.zero + private var dragging = false + + private static let xKey = "xiaoqi.pet.x" + private static let yKey = "xiaoqi.pet.y" + + init(model: PetModel) { + self.model = model + panel = XiaoqiPanel( + contentRect: NSRect(x: 0, y: 0, width: 164, height: 186), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isFloatingPanel = true + panel.level = .floating + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.hidesOnDeactivate = false + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.isMovableByWindowBackground = false + panel.isReleasedWhenClosed = false + panel.animationBehavior = .none + panel.isRestorable = false + panel.acceptsMouseMovedEvents = true + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + + let view = PetStageView( + model: model, + onToggleBoard: { [weak model] in model?.boardOpen.toggle() }, + onToggleLock: { [weak model] in model?.toggleLock() }, + onBeginDrag: { [weak self] in self?.beginDrag() }, + onDrag: { [weak self] in self?.drag() }, + onEndDrag: { [weak self] in self?.endDrag() } + ) + panel.contentView = NSHostingView(rootView: view) + + let defaults = UserDefaults.standard + let size = panel.frame.size + var origin: NSPoint + if defaults.object(forKey: Self.xKey) != nil, defaults.object(forKey: Self.yKey) != nil { + origin = NSPoint( + x: defaults.double(forKey: Self.xKey), + y: defaults.double(forKey: Self.yKey) + ) + } else { + let frame = NSScreen.main?.visibleFrame ?? visibleFrame(containing: .zero) + origin = NSPoint(x: frame.minX + 38, y: frame.minY + 74) + } + let screen = visibleFrame(containing: NSPoint(x: origin.x + size.width / 2, y: origin.y + size.height / 2)) + panel.setFrameOrigin(clampOrigin(origin, size: size, within: screen)) + } + + var isVisible: Bool { panel.isVisible } + + func show() { + panel.orderFrontRegardless() + } + + func hide() { + panel.orderOut(nil) + } + + func toggle() { + panel.isVisible ? hide() : show() + } + + func beginDrag() { + guard !model.locked else { return } + dragging = true + dragStartOrigin = panel.frame.origin + dragStartMouse = NSEvent.mouseLocation + } + + func drag() { + guard dragging, !model.locked else { return } + let mouse = NSEvent.mouseLocation + let next = NSPoint( + x: dragStartOrigin.x + mouse.x - dragStartMouse.x, + y: dragStartOrigin.y + mouse.y - dragStartMouse.y + ) + let screen = visibleFrame(containing: NSPoint(x: mouse.x, y: mouse.y)) + panel.setFrameOrigin(clampOrigin(next, size: panel.frame.size, within: screen)) + onFrameChanged?(panel.frame) + } + + func endDrag() { + guard dragging else { return } + dragging = false + let origin = panel.frame.origin + UserDefaults.standard.set(origin.x, forKey: Self.xKey) + UserDefaults.standard.set(origin.y, forKey: Self.yKey) + onFrameChanged?(panel.frame) + } +} + +final class BoardWindowController { + let model: PetModel + let panel: XiaoqiPanel + private var cancellable: AnyCancellable? + private var tasksCancellable: AnyCancellable? + private weak var petController: PetWindowController? + + init(model: PetModel, petController: PetWindowController) { + self.model = model + self.petController = petController + panel = XiaoqiPanel( + contentRect: NSRect(x: 0, y: 0, width: 368, height: 530), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isFloatingPanel = true + panel.level = .floating + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.hidesOnDeactivate = false + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.isReleasedWhenClosed = false + panel.animationBehavior = .none + panel.isRestorable = false + + let view = BoardView( + model: model, + onClose: { [weak model] in model?.boardOpen = false }, + onToggleLock: { [weak model] in model?.toggleLock() }, + onOpenTask: { [weak model] _ in + model?.activateZCode() + model?.boardOpen = false + } + ) + panel.contentView = NSHostingView(rootView: view) + + cancellable = model.$boardOpen + .removeDuplicates() + .sink { [weak self] open in + guard let self else { return } + if open { + self.resizeForContent() + self.reposition(near: self.petController?.panel.frame) + self.panel.orderFrontRegardless() + } else { + self.panel.orderOut(nil) + } + } + + tasksCancellable = model.$tasks + .removeDuplicates() + .sink { [weak self] _ in + guard let self, self.model.boardOpen else { return } + self.resizeForContent() + self.reposition(near: self.petController?.panel.frame) + } + } + + private func resizeForContent() { + let groups = [model.waiting, model.running, model.done].filter { !$0.isEmpty } + let desired: CGFloat + if groups.isEmpty { + desired = 378 + } else { + var height: CGFloat = 112 + for group in groups { + height += 26 + for task in group { + let hasProgress = task.progress != nil && (task.kind == "running" || task.kind == "waiting") + height += hasProgress ? 136 : 54 + } + } + height += CGFloat(max(0, groups.count - 1)) * 4 + desired = min(530, max(300, height)) + } + panel.setContentSize(NSSize(width: 368, height: desired)) + } + + func reposition(near petFrame: NSRect?) { + guard let pet = petFrame ?? petController?.panel.frame else { return } + let screen = visibleFrame(containing: NSPoint(x: pet.midX, y: pet.midY)) + let size = panel.frame.size + var x = pet.maxX + 12 + if x + size.width > screen.maxX - 8 { + x = pet.minX - size.width - 12 + } + if x < screen.minX + 8 { + x = min(max(screen.minX + 8, pet.midX - size.width / 2), screen.maxX - size.width - 8) + } + var y = pet.midY - size.height * 0.55 + y = min(max(y, screen.minY + 8), screen.maxY - size.height - 8) + panel.setFrameOrigin(NSPoint(x: x, y: y)) + } +} + +final class DrawerWindowController { + let model: PetModel + let panel: XiaoqiPanel + private weak var petController: PetWindowController? + private var tasksCancellable: AnyCancellable? + private var boardCancellable: AnyCancellable? + private var fadingOut = false + + init(model: PetModel, petController: PetWindowController) { + self.model = model + self.petController = petController + panel = XiaoqiPanel( + contentRect: NSRect(x: 0, y: 0, width: 332, height: 220), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isFloatingPanel = true + panel.level = .floating + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.hidesOnDeactivate = false + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.isReleasedWhenClosed = false + panel.animationBehavior = .none + panel.isRestorable = false + + panel.contentView = NSHostingView(rootView: TaskDrawerView( + model: model, + onOpen: { [weak model] _ in + model?.activateZCode() + } + )) + + tasksCancellable = model.$tasks + .removeDuplicates() + .sink { [weak self] _ in self?.refresh() } + boardCancellable = model.$boardOpen + .removeDuplicates() + .sink { [weak self] _ in self?.refresh() } + refresh(animated: false) + } + + func petVisibilityChanged() { + refresh(animated: false) + } + + func reposition(near petFrame: NSRect?) { + guard panel.isVisible else { return } + panel.setFrameOrigin(targetOrigin(for: panel.frame.size, petFrame: petFrame)) + } + + private func desiredHeight() -> CGFloat { + let count = min(model.tasks.count, 4) + var height: CGFloat = 24 + CGFloat(count) * 96 + CGFloat(max(0, count - 1)) * 6 + if model.tasks.count > 4 { height += 22 } + return height + } + + private func refresh(animated: Bool = true) { + guard !model.boardOpen, model.tasks.count >= 2, petController?.isVisible == true else { + hide() + return + } + let size = NSSize(width: 332, height: desiredHeight()) + panel.setContentSize(size) + let target = targetOrigin(for: size, petFrame: petController?.panel.frame) + if panel.isVisible { + panel.alphaValue = 1 + panel.setFrameOrigin(target) + return + } + let start = NSPoint(x: target.x - 16, y: target.y) + panel.setFrameOrigin(start) + panel.alphaValue = 0 + panel.orderFrontRegardless() + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.22 + panel.animator().alphaValue = 1 + } + } else { + panel.alphaValue = 1 + } + panel.setFrameOrigin(target) + } + + private func targetOrigin(for size: NSSize, petFrame: NSRect?) -> NSPoint { + guard let pet = petFrame ?? petController?.panel.frame else { return NSPoint(x: 0, y: 0) } + let screen = visibleFrame(containing: NSPoint(x: pet.midX, y: pet.midY)) + var x = pet.maxX + 12 + if x + size.width > screen.maxX - 8 { + x = pet.minX - size.width - 12 + } + x = min(max(x, screen.minX + 8), screen.maxX - size.width - 8) + var y = pet.midY - size.height / 2 + y = min(max(y, screen.minY + 8), screen.maxY - size.height - 8) + return NSPoint(x: x, y: y) + } + + private func hide() { + guard panel.isVisible, !fadingOut else { return } + fadingOut = true + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.15 + panel.animator().alphaValue = 0 + } completionHandler: { [weak self] in + guard let self else { return } + self.panel.orderOut(nil) + self.panel.alphaValue = 1 + self.fadingOut = false + } + } +} + +final class BubbleWindowController { + let model: PetModel + let panel: XiaoqiPanel + private weak var petController: PetWindowController? + private var tasksCancellable: AnyCancellable? + private var fadingOut = false + + init(model: PetModel, petController: PetWindowController) { + self.model = model + self.petController = petController + panel = XiaoqiPanel( + contentRect: NSRect(x: 0, y: 0, width: 244, height: 66), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isFloatingPanel = true + panel.level = .floating + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.hidesOnDeactivate = false + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.isReleasedWhenClosed = false + panel.animationBehavior = .none + panel.isRestorable = false + panel.ignoresMouseEvents = true + panel.contentView = NSHostingView(rootView: TaskBubbleFanView(model: model)) + tasksCancellable = model.$tasks + .removeDuplicates() + .sink { [weak self] _ in self?.refresh() } + refresh(animated: false) + } + + func petVisibilityChanged() { + refresh(animated: false) + } + + func reposition(near petFrame: NSRect?) { + guard panel.isVisible else { return } + panel.setFrameOrigin(targetOrigin(for: panel.frame.size, petFrame: petFrame)) + } + + private func desiredWidth() -> CGFloat { + let count = min(model.tasks.count, 3) + guard count > 0 else { return 244 } + return 20 + CGFloat(count) * 224 + CGFloat(count - 1) * 8 + } + + private func refresh(animated: Bool = true) { + guard model.tasks.count == 1, petController?.isVisible == true else { + hide() + return + } + let size = NSSize(width: desiredWidth(), height: 66) + panel.setContentSize(size) + let target = targetOrigin(for: size, petFrame: petController?.panel.frame) + if panel.isVisible { + panel.alphaValue = 1 + panel.setFrameOrigin(target) + return + } + panel.setFrameOrigin(NSPoint(x: target.x, y: target.y - 6)) + panel.alphaValue = 0 + panel.orderFrontRegardless() + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.2 + panel.animator().alphaValue = 1 + } + } else { + panel.alphaValue = 1 + } + panel.setFrameOrigin(target) + } + + private func targetOrigin(for size: NSSize, petFrame: NSRect?) -> NSPoint { + guard let pet = petFrame ?? petController?.panel.frame else { return .zero } + let screen = visibleFrame(containing: NSPoint(x: pet.midX, y: pet.midY)) + var x = pet.minX + if x + size.width > screen.maxX - 8 { + x = screen.maxX - size.width - 8 + } + x = min(max(x, screen.minX + 8), max(screen.minX + 8, screen.maxX - size.width - 8)) + var y = pet.minY - size.height - 4 + if y < screen.minY + 4 { + y = pet.maxY + 4 + } + y = min(max(y, screen.minY + 8), max(screen.minY + 8, screen.maxY - size.height - 8)) + return NSPoint(x: x, y: y) + } + + private func hide() { + guard panel.isVisible, !fadingOut else { return } + fadingOut = true + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.14 + panel.animator().alphaValue = 0 + } completionHandler: { [weak self] in + guard let self else { return } + self.panel.orderOut(nil) + self.panel.alphaValue = 1 + self.fadingOut = false + } + } +} + +final class NoticeWindowController { + private let panel: XiaoqiPanel + private var hideTimer: Timer? + private weak var petController: PetWindowController? + + init(petController: PetWindowController) { + self.petController = petController + panel = XiaoqiPanel( + contentRect: NSRect(x: 0, y: 0, width: 340, height: 108), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isFloatingPanel = true + panel.level = .floating + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + panel.hidesOnDeactivate = false + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.isReleasedWhenClosed = false + panel.animationBehavior = .none + panel.isRestorable = false + panel.ignoresMouseEvents = true + panel.alphaValue = 0 + } + + func show(_ notice: PetNotice, near petFrame: NSRect?) { + hideTimer?.invalidate() + panel.contentView = NSHostingView(rootView: NoticeView(notice: notice)) + if let pet = petFrame { + let screen = visibleFrame(containing: NSPoint(x: pet.midX, y: pet.midY)) + let size = panel.frame.size + var x = pet.midX - size.width / 2 + x = min(max(x, screen.minX + 8), screen.maxX - size.width - 8) + var y = pet.maxY + 8 + if y + size.height > screen.maxY - 8 { + y = pet.minY - size.height - 8 + } + y = min(max(y, screen.minY + 8), screen.maxY - size.height - 8) + panel.setFrameOrigin(NSPoint(x: x, y: y)) + } + panel.alphaValue = 0 + panel.orderFrontRegardless() + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + panel.animator().alphaValue = 1 + } + let lifetime: TimeInterval = notice.kind == "emote" ? 6 : 7 + hideTimer = Timer.scheduledTimer(withTimeInterval: lifetime, repeats: false) { [weak self] _ in + self?.hide() + } + } + + func reposition(near petFrame: NSRect?) { + guard panel.isVisible, let pet = petFrame else { return } + let screen = visibleFrame(containing: NSPoint(x: pet.midX, y: pet.midY)) + let size = panel.frame.size + var x = pet.midX - size.width / 2 + x = min(max(x, screen.minX + 8), screen.maxX - size.width - 8) + var y = pet.maxY + 8 + if y + size.height > screen.maxY - 8 { + y = pet.minY - size.height - 8 + } + y = min(max(y, screen.minY + 8), screen.maxY - size.height - 8) + panel.setFrameOrigin(NSPoint(x: x, y: y)) + } + + func hide() { + hideTimer?.invalidate() + hideTimer = nil + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.16 + panel.animator().alphaValue = 0 + } completionHandler: { + self.panel.orderOut(nil) + } + } +} diff --git a/external_plugins/xiaoqi-pet/desktop/Sources/main.swift b/external_plugins/xiaoqi-pet/desktop/Sources/main.swift new file mode 100644 index 0000000..dd29a09 --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/Sources/main.swift @@ -0,0 +1,13 @@ +import AppKit +import Foundation + +if CommandLine.arguments.contains("--diagnose") { + print(XiaoqiDiagnose.run()) + exit(0) +} + +let app = NSApplication.shared +let delegate = AppDelegate() +app.delegate = delegate +app.setActivationPolicy(.accessory) +app.run() diff --git a/external_plugins/xiaoqi-pet/desktop/build.sh b/external_plugins/xiaoqi-pet/desktop/build.sh new file mode 100755 index 0000000..a27d2ef --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/build.sh @@ -0,0 +1,39 @@ +#!/bin/zsh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +DIST="$ROOT/dist" +APP="$DIST/ZCode Xiaoqi Pet.app" +ARCH="$(uname -m)" +TARGET="${ARCH}-apple-macos13.0" + +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" + +echo "Compiling XiaoqiPet for $TARGET…" +xcrun swiftc \ + -O \ + -swift-version 5 \ + -target "$TARGET" \ + -o "$APP/Contents/MacOS/XiaoqiPet" \ + "$ROOT/Sources/"*.swift \ + -framework SwiftUI \ + -framework AppKit \ + -framework Foundation + +cp "$ROOT/Info.plist" "$APP/Contents/Info.plist" +cp "$ROOT/Resources/pet.svg" "$APP/Contents/Resources/pet.svg" + +if [[ -f "$ROOT/Resources/AppIcon.icns" ]]; then + cp "$ROOT/Resources/AppIcon.icns" "$APP/Contents/Resources/AppIcon.icns" +fi + +codesign --force --deep --sign - "$APP" >/dev/null +echo "Built: $APP" + +if [[ "${1:-}" == "--install" ]]; then + mkdir -p "$HOME/Applications" + rm -rf "$HOME/Applications/ZCode Xiaoqi Pet.app" + ditto "$APP" "$HOME/Applications/ZCode Xiaoqi Pet.app" + echo "Installed: $HOME/Applications/ZCode Xiaoqi Pet.app" +fi diff --git a/external_plugins/xiaoqi-pet/desktop/electron/README.md b/external_plugins/xiaoqi-pet/desktop/electron/README.md new file mode 100644 index 0000000..ef6f284 --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/electron/README.md @@ -0,0 +1,45 @@ +# Xiaoqi Pet — Windows / Linux(跨平台 Electron 版) + +xiaoqi-pet 插件的跨平台桌宠,用 Electron 实现。读的是插件 hooks 落盘的本地事件文件(不是 HTTP 桥),和 macOS 原生版共享同一套目录契约与状态语义。 + +macOS 优先用 `desktop/` 里的原生 Swift 版;本 Electron 版主要面向 Windows 和 Linux。在 macOS 上如果检测到原生版正在运行,会弹窗后自动退出,避免桌面上同时出现两只小栖。 + +## 从源码运行 + +需要 Node.js 22+(用到内置的 `node:sqlite` 读 ZCode 用量库)。 + +```sh +cd desktop/electron +npm install +npm start +``` + +## 打包 + +```sh +npm run dist:win # Windows zip(x64 + arm64) +npm run dist:linux # Linux AppImage(x64 + arm64) +``` + +产物在 `desktop/electron/dist/`,未签名。Windows SmartScreen 首次运行会提示;Linux 需要支持合成的窗口管理器才能透明。 + +## 行为 + +- 透明、无边框、置顶的宠物窗口;拖动移动,位置记忆。 +- 单会话:宠物下方任务气泡。多会话:彩色抽屉任务卡面板(每会话固定颜色)。 +- 点击任务卡尝试通过 `zcode://workspace` 把 ZCode 调到前台。 +- 托盘菜单:显示 / 隐藏、任务面板、刷新用量 / 余量、退出。 +- 肚子胶囊:默认今日 token 用量(只读 `~/.zcode/cli/db/db.sqlite`,Windows/Linux 路径相同,可用 `ZCODE_DATA_BASE_DIR` 覆盖根目录);配置 `balance.json` 后显示远端套餐余量,60 秒刷新。 +- 等待确认、后台完成、agent 说话(emote)都会冒泡或走系统通知。 +- 事件目录: + - macOS:`~/Library/Application Support/XiaoqiPet/{feed,emote}/` + - Windows:`%APPDATA%\XiaoqiPet\{feed,emote}\` + - Linux:`$XDG_CONFIG_HOME/xiaoqi-pet/{feed,emote}/`(默认 `~/.config/xiaoqi-pet/`) + +## Windows hook 兜底 + +插件安装后,没有 bash 的 Windows 机器上 hooks 会自动落到 `hooks/feed-event.ps1`(Windows PowerShell 5.1 兼容),把事件写到 `%APPDATA%\XiaoqiPet\feed\`。装了 Git-Bash 的机器直接走 sh 脚本,同样落到这个目录。 + +## 验证状态 + +本仓库在 macOS 上开发:全部 JS 通过 `node --check`,状态机与 macOS Swift 版同语义并有模拟事件冒烟。**Windows 与 Linux 未在真机运行过**——请在目标系统上构建并验证透明窗口、托盘、通知、hook 落盘和 DPI 缩放后再宣布可用。 diff --git a/external_plugins/xiaoqi-pet/desktop/electron/assets/icon.png b/external_plugins/xiaoqi-pet/desktop/electron/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4eddb9744e149f8e4f9c4409de28d67ddafa4e69 GIT binary patch literal 85882 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/external_plugins/xiaoqi-pet/desktop/electron/feed.js b/external_plugins/xiaoqi-pet/desktop/electron/feed.js new file mode 100644 index 0000000..48d7b2a --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/electron/feed.js @@ -0,0 +1,408 @@ +'use strict'; + +// 事件文件监听与每会话状态机:与 macOS Swift 版(desktop/Sources/Model.swift)同一套语义。 +// hooks 把 stdin JSON 包成 feed/*.json,这里扫描、折叠成看板任务快照,消费后删除。 + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const MAX_TASKS = 16; +const RUNNING_STALE_MS = 60 * 60 * 1000; +const WAITING_STALE_MS = 12 * 60 * 60 * 1000; +const DONE_STALE_MS = 10 * 60 * 1000; + +function stateRoot() { + if (process.platform === 'darwin') { + return path.join(os.homedir(), 'Library', 'Application Support', 'XiaoqiPet'); + } + if (process.platform === 'win32') { + const appdata = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); + return path.join(appdata, 'XiaoqiPet'); + } + const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + return path.join(xdg, 'xiaoqi-pet'); +} + +function feedDir() { + return path.join(stateRoot(), 'feed'); +} + +function emoteDir() { + return path.join(stateRoot(), 'emote'); +} + +function shortText(value, max) { + if (typeof value !== 'string') return ''; + const text = value.replace(/\s+/g, ' ').trim(); + if (text === '') return ''; + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} + +function lastSeparatorIndex(value) { + const slash = value.lastIndexOf('/'); + const backslash = value.lastIndexOf('\\'); + return Math.max(slash, backslash); +} + +function fileBase(value) { + if (typeof value !== 'string' || value.trim() === '') return ''; + const normalized = value.replace(/[\\/]+$/, ''); + const index = lastSeparatorIndex(normalized); + const base = index >= 0 ? normalized.slice(index + 1) : normalized; + return shortText(base, 80); +} + +function projectName(cwd) { + if (typeof cwd !== 'string' || cwd.trim() === '') return ''; + const normalized = cwd.replace(/[\\/]+$/, ''); + const index = lastSeparatorIndex(normalized); + const base = index >= 0 ? normalized.slice(index + 1) : normalized; + return base === '' ? normalized : base; +} + +function firstQuery(value) { + if (Array.isArray(value)) return shortText(String(value[0] ?? ''), 80); + return shortText(String(value ?? ''), 80); +} + +function parseToolArguments(value) { + if (value && typeof value === 'object' && !Array.isArray(value)) return value; + if (typeof value === 'string' && value.length <= 262_144) { + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } + } + return {}; +} + +function isoToMs(value) { + if (typeof value !== 'string' || value === '') return 0; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function actionForTool(rawName, args) { + const name = rawName === '' ? 'tool' : rawName; + switch (name) { + case 'Read': + case 'ReadImage': + return { kind: 'read', target: fileBase(args.file_path ?? args.path) }; + case 'Write': + case 'ApplyPatch': + case 'NotebookEdit': + return { kind: 'write', target: fileBase(args.file_path ?? args.path) }; + case 'Edit': + case 'MultiEdit': + return { kind: 'edit', target: fileBase(args.file_path ?? args.path) }; + case 'Bash': + case 'PowerShell': { + const target = shortText(args.description, 100) || shortText(args.command, 100) || 'shell'; + return { kind: 'bash', target }; + } + case 'Glob': + return { kind: 'glob', target: shortText(args.pattern, 80) || shortText(args.path, 80) }; + case 'Grep': + return { kind: 'grep', target: shortText(args.pattern, 80) || shortText(args.path, 80) || 'content' }; + case 'WebSearch': + return { kind: 'search', target: firstQuery(args.queries ?? args.query) || 'web' }; + case 'WebFetch': + return { kind: 'fetch', target: shortText(args.url, 100) || 'web' }; + case 'Agent': + case 'Task': + case 'AgentFork': + return { kind: 'subagent', target: shortText(args.description, 80) || 'delegate' }; + case 'Workflow': + return { kind: 'workflow', target: shortText(args.name, 80) || shortText(args.description, 80) }; + case 'Skill': + return { kind: 'skill', target: shortText(args.skill ?? args.name, 60) || 'skill' }; + case 'TodoWrite': + return { kind: 'todo', target: '' }; + case 'CronCreate': + case 'CronUpdate': + case 'CronDelete': + case 'OffPeakCreate': + return { kind: 'schedule', target: shortText(args.title, 80) || name }; + case 'AskUserQuestion': { + let target = ''; + if (Array.isArray(args.questions) && typeof args.questions[0] === 'object' && args.questions[0]) { + target = shortText(args.questions[0].question, 80); + } + return { kind: 'question', target }; + } + case 'EnterPlanMode': + case 'ExitPlanMode': + return { kind: 'plan', target: '' }; + default: { + if (name.startsWith('mcp__')) { + const parts = name.split('__').filter(Boolean); + const target = parts.length > 2 ? `${parts[1]} · ${parts[2]}` : parts.slice(1).join(' · '); + return { kind: 'mcp', target: shortText(target, 80) }; + } + return { kind: 'tool', target: shortText(name, 60) }; + } + } +} + +function todosForArgs(args) { + if (!Array.isArray(args.todos)) return null; + const items = args.todos + .slice(0, 24) + .map((item) => { + if (!item || typeof item !== 'object') return null; + const content = shortText(item.content, 160); + if (content === '') return null; + const status = item.status === 'completed' || item.status === 'in_progress' ? item.status : 'pending'; + return { content, status }; + }) + .filter(Boolean); + if (items.length === 0) return null; + const completed = items.filter((item) => item.status === 'completed').length; + const current = (items.find((item) => item.status === 'in_progress') || {}).content || ''; + return { total: items.length, completed, current }; +} + +class SessionState { + constructor(id, project, atMs) { + this.id = id; + this.project = project; + this.kind = 'running'; + this.phase = 'starting'; + this.turn = 0; + this.step = 0; + this.tool = ''; + this.toolCount = 0; + this.turnStartedAt = atMs; + this.lastAt = atMs; + this.action = null; + this.todos = null; + this.trail = []; + this.pendingKind = ''; + this.pendingTool = ''; + } + + pushTrail(item) { + this.trail.push(item); + if (this.trail.length > 6) this.trail = this.trail.slice(-6); + } + + markCurrentActionDone(failed) { + if (this.action) { + this.action = { ...this.action, done: true, failed: failed || false }; + const last = this.trail[this.trail.length - 1]; + if (last && last.kind === 'action' && last.actionKind === this.action.kind) { + this.trail[this.trail.length - 1] = { ...last, done: true, failed: failed || false }; + } + } + } + + apply(event, atMs, payload, cwd) { + const project = projectName(cwd); + if (project !== '') this.project = project; + this.lastAt = atMs; + + switch (event) { + case 'SessionStart': + this.kind = 'running'; + this.phase = 'starting'; + this.pendingKind = ''; + this.pendingTool = ''; + if (this.turnStartedAt === 0) this.turnStartedAt = atMs; + break; + case 'UserPromptSubmit': + this.turn += 1; + this.step = 0; + this.tool = ''; + this.toolCount = 0; + this.phase = 'thinking'; + this.action = null; + this.trail = []; + this.turnStartedAt = atMs; + this.kind = 'running'; + this.pendingKind = ''; + this.pendingTool = ''; + break; + case 'PreToolUse': { + const name = typeof payload.tool_name === 'string' ? payload.tool_name : (payload.toolName ?? ''); + const args = parseToolArguments(payload.tool_input ?? payload.toolInput); + const action = actionForTool(String(name), args); + this.step += 1; + this.tool = String(name); + this.toolCount += 1; + this.phase = 'tool'; + this.kind = 'running'; + this.pendingKind = ''; + this.pendingTool = ''; + this.action = { ...action, done: false, failed: false }; + if (String(name) === 'TodoWrite') { + const todos = todosForArgs(args); + if (todos) this.todos = todos; + } + this.pushTrail({ kind: 'action', label: action.target, at: atMs, actionKind: action.kind, done: false, failed: false }); + break; + } + case 'PermissionRequest': { + this.kind = 'waiting'; + this.pendingKind = 'approval'; + const name = typeof payload.tool_name === 'string' ? payload.tool_name : (payload.toolName ?? ''); + this.pendingTool = String(name) !== '' + ? String(name) + : shortText(typeof payload.reason === 'string' ? payload.reason : '', 60); + break; + } + case 'PostToolUse': + this.markCurrentActionDone(false); + this.phase = 'thinking'; + this.tool = ''; + break; + case 'PostToolUseFailure': + this.markCurrentActionDone(true); + this.phase = 'thinking'; + this.tool = ''; + this.kind = 'running'; + break; + case 'Stop': + this.phase = 'done'; + this.kind = 'done'; + this.tool = ''; + this.pendingKind = ''; + this.pendingTool = ''; + break; + default: + break; + } + } + + task(nowMs) { + let elapsed = 0; + if (this.turnStartedAt > 0) { + elapsed = this.phase === 'done' + ? Math.max(0, this.lastAt - this.turnStartedAt) + : Math.max(0, nowMs - this.turnStartedAt); + } + return { + id: this.id, + title: this.project === '' ? 'ZCode' : this.project, + project: this.project, + kind: this.kind, + pendingKind: this.pendingKind === '' ? null : this.pendingKind, + pendingTool: this.pendingTool === '' ? null : this.pendingTool, + current: false, + updatedAt: this.lastAt, + progress: { + phase: this.phase, + turn: this.turn, + step: this.step, + tool: this.tool, + toolCount: this.toolCount, + elapsedMs: elapsed, + updatedAt: this.lastAt, + action: this.action, + todos: this.todos, + trail: this.trail.slice(-4), + }, + }; + } +} + +class SessionStore { + constructor() { + this.sessions = new Map(); + this.lastEventAt = 0; + } + + apply(event, atMs, payload, cwd) { + const id = typeof payload.session_id === 'string' && payload.session_id !== '' + ? payload.session_id + : (typeof payload.sessionId === 'string' && payload.sessionId !== '' ? payload.sessionId : '(unknown)'); + let state = this.sessions.get(id); + if (!state) { + state = new SessionState(id, projectName(cwd), atMs); + this.sessions.set(id, state); + } + state.apply(event, atMs, payload, cwd); + this.lastEventAt = Math.max(this.lastEventAt, atMs); + } + + prune(nowMs) { + for (const [id, state] of this.sessions) { + const age = nowMs - state.lastAt; + const stale = state.kind === 'waiting' + ? age >= WAITING_STALE_MS + : (state.kind === 'done' ? age >= DONE_STALE_MS : age >= RUNNING_STALE_MS); + if (stale) this.sessions.delete(id); + } + } + + snapshot(nowMs) { + this.prune(nowMs); + const tasks = [...this.sessions.values()] + .map((state) => state.task(nowMs)) + .sort((left, right) => (right.updatedAt - left.updatedAt) || left.id.localeCompare(right.id)); + return tasks.slice(0, MAX_TASKS); + } +} + +function scanDirectory(directory, onEnvelope, remove) { + let entries; + try { + entries = fs.readdirSync(directory); + } catch { + return; + } + for (const name of entries.sort()) { + if (!name.endsWith('.json') || name.startsWith('.')) continue; + const file = path.join(directory, name); + let envelope; + try { + envelope = JSON.parse(fs.readFileSync(file, 'utf8')); + } catch { + if (remove) { + try { fs.unlinkSync(file); } catch { /* leave it; a later sweep can retry */ } + } + continue; + } + if (envelope && typeof envelope === 'object') onEnvelope(envelope); + if (remove) { + try { fs.unlinkSync(file); } catch { /* ignore */ } + } + } +} + +function collectEvents(remove) { + const events = []; + const emotes = []; + scanDirectory(feedDir(), (envelope) => { + if (typeof envelope.event !== 'string') return; + const payload = envelope.payload && typeof envelope.payload === 'object' ? envelope.payload : {}; + const atSeconds = Number(envelope.at) || 0; + // 事件自身的 ISO 时间戳最精确;缺失时才退回 hook 落盘时刻(秒级)。 + const payloadMs = isoToMs(payload.timestamp ?? payload.time); + const atMs = payloadMs > 0 ? payloadMs : atSeconds * 1000; + events.push({ + event: envelope.event, + atMs, + cwd: typeof envelope.cwd === 'string' ? envelope.cwd : '', + payload, + }); + }, remove); + scanDirectory(emoteDir(), (envelope) => emotes.push(envelope), remove); + events.sort((left, right) => left.atMs - right.atMs); + return { events, emotes }; +} + +module.exports = { + MAX_TASKS, + stateRoot, + feedDir, + emoteDir, + collectEvents, + SessionStore, + actionForTool, + projectName, + shortText, + isoToMs, +}; diff --git a/external_plugins/xiaoqi-pet/desktop/electron/main.js b/external_plugins/xiaoqi-pet/desktop/electron/main.js new file mode 100644 index 0000000..476dc59 --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/electron/main.js @@ -0,0 +1,551 @@ +'use strict'; + +// 小栖桌宠跨平台伴侣(Windows / Linux / macOS)。 +// 状态来源:xiaoqi-pet 插件 hooks 落盘的事件文件(feed.js),不再轮询任何 HTTP 桥。 + +const { app, BrowserWindow, dialog, ipcMain, screen, Tray, Menu, nativeImage, shell, Notification } = require('electron'); +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const feed = require('./feed'); +const usage = require('./usage'); + +const PET_W = 196; +const PANEL_W = 372; +const POLL_MS = 1000; + +const isMac = process.platform === 'darwin'; +const isWindows = process.platform === 'win32'; +const isLinux = process.platform === 'linux'; + +if (isLinux) { + app.commandLine.appendSwitch('enable-transparent-visuals'); +} +if (isWindows) { + app.setAppUserModelId('com.xiaoqi.zcode-deskpet'); +} + +let petWindow = null; +let panelWindow = null; +let tray = null; +let pollTimer = null; +let polling = false; +let store = new feed.SessionStore(); +let state = { connected: false, tasks: [], source: 'hooks', balance: null, emote: null }; +let emptyScans = 0; +let panelAuto = false; +let panelManuallyClosed = false; +let dragState = null; +let quitting = false; +let petPosition = null; + +const gotLock = app.requestSingleInstanceLock(); +if (!gotLock) { + app.quit(); +} else { + app.on('second-instance', () => showPet()); + app.whenReady().then(start).catch((error) => { + console.error('[xiaoqi] startup failed', error); + app.quit(); + }); +} + +app.on('window-all-closed', () => { + // 隐藏宠物时仍驻留托盘。 +}); + +app.on('before-quit', () => { + quitting = true; + savePosition(); + if (pollTimer) clearInterval(pollTimer); +}); + +function zh() { + return String(app.getLocale() || '').toLowerCase().startsWith('zh'); +} + +function prefsPath() { + return path.join(app.getPath('userData'), 'window-state.json'); +} + +function readJson(file) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch { + return null; + } +} + +function loadPosition() { + const saved = readJson(prefsPath()); + if (saved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) { + petPosition = { x: Math.round(saved.x), y: Math.round(saved.y) }; + } +} + +function savePosition() { + if (!petWindow || petWindow.isDestroyed()) return; + const bounds = petWindow.getBounds(); + petPosition = { x: bounds.x, y: bounds.y }; + try { + fs.mkdirSync(path.dirname(prefsPath()), { recursive: true }); + fs.writeFileSync(prefsPath(), JSON.stringify(petPosition), { mode: 0o600 }); + } catch { + // 位置持久化失败可忽略。 + } +} + +function clampPosition(x, y, width, height) { + const display = screen.getDisplayNearestPoint({ + x: Math.round(x + width / 2), + y: Math.round(y + height / 2), + }); + const area = display.workArea; + return { + x: Math.min(Math.max(Math.round(x), area.x + 4), area.x + area.width - width - 4), + y: Math.min(Math.max(Math.round(y), area.y + 4), area.y + area.height - height - 4), + }; +} + +function petHeight() { + return state.tasks.length === 1 ? 248 : 188; +} + +function defaultPosition() { + const area = screen.getPrimaryDisplay().workArea; + return { + x: area.x + 24, + y: area.y + area.height - petHeight() - 24, + }; +} + +function nativePetRunning() { + if (!isMac) return false; + try { + const result = spawnSync('pgrep', ['-f', 'ZCode Xiaoqi Pet.app/Contents/MacOS/XiaoqiPet'], { encoding: 'utf8' }); + return result.status === 0; + } catch { + return false; + } +} + +function start() { + if (nativePetRunning()) { + dialog.showMessageBoxSync({ + type: 'info', + title: 'Xiaoqi', + message: zh() ? '请使用 macOS 原生小栖' : 'Please use the native macOS Xiaoqi', + detail: zh() + ? 'macOS 原生小栖正在运行;跨平台版已退出,避免桌面上同时出现两只小栖。' + : 'A native Xiaoqi pet is already running. The cross-platform Electron version will exit so two pets do not appear on the desktop.', + }); + app.quit(); + return; + } + if (isMac && app.dock) app.dock.hide(); + try { + fs.mkdirSync(feed.feedDir(), { recursive: true, mode: 0o700 }); + fs.mkdirSync(feed.emoteDir(), { recursive: true, mode: 0o700 }); + } catch { + // 目录可能已存在。 + } + loadPosition(); + const initial = petPosition || defaultPosition(); + const safe = clampPosition(initial.x, initial.y, PET_W, petHeight()); + petPosition = safe; + + createPetWindow(safe); + createPanelWindow(); + createTray(); + startPolling(); +} + +function createPetWindow(position) { + petWindow = new BrowserWindow({ + x: position.x, + y: position.y, + width: PET_W, + height: petHeight(), + frame: false, + transparent: true, + backgroundColor: '#00000000', + hasShadow: false, + resizable: false, + maximizable: false, + minimizable: false, + movable: false, + alwaysOnTop: true, + skipTaskbar: true, + show: false, + title: 'Xiaoqi Pet', + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + backgroundThrottling: false, + }, + }); + try { + petWindow.setAlwaysOnTop(true, 'floating'); + petWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + } catch { + // 平台窗口细节;基础窗口不受影响。 + } + petWindow.loadFile(path.join(__dirname, 'renderer', 'pet.html')); + petWindow.webContents.on('did-finish-load', () => { + sendState(); + if (petWindow && !petWindow.isVisible()) petWindow.showInactive(); + }); + petWindow.once('ready-to-show', () => { + if (petWindow && !petWindow.isVisible()) petWindow.showInactive(); + }); + petWindow.on('closed', () => { + petWindow = null; + }); +} + +function createPanelWindow() { + panelWindow = new BrowserWindow({ + width: PANEL_W, + height: 420, + frame: false, + transparent: true, + backgroundColor: '#00000000', + hasShadow: false, + resizable: false, + maximizable: false, + minimizable: false, + movable: false, + alwaysOnTop: true, + skipTaskbar: true, + show: false, + title: 'Xiaoqi Tasks', + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + backgroundThrottling: false, + }, + }); + try { + panelWindow.setAlwaysOnTop(true, 'floating'); + panelWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + } catch { + // 可选的平台润色。 + } + panelWindow.loadFile(path.join(__dirname, 'renderer', 'panel.html')); + panelWindow.webContents.on('did-finish-load', sendState); + panelWindow.on('close', (event) => { + if (quitting) return; + event.preventDefault(); + hidePanel(true); + }); + panelWindow.on('closed', () => { + panelWindow = null; + }); +} + +function createTray() { + const iconPath = path.join(__dirname, 'assets', 'icon.png'); + let image = nativeImage.createFromPath(iconPath); + if (image.isEmpty()) image = nativeImage.createEmpty(); + image = image.resize({ width: 18, height: 18 }); + tray = new Tray(image); + tray.setToolTip(zh() ? '小栖桌宠' : 'Xiaoqi'); + updateTrayMenu(); + tray.on('click', () => { + if (!petWindow) return; + if (petWindow.isVisible()) hidePet(); + else showPet(); + }); +} + +function updateTrayMenu() { + if (!tray) return; + const labels = zh() + ? { show: '显示小栖', hide: '隐藏小栖', panel: '任务面板', refresh: '刷新用量 / 余量', quit: '退出小栖' } + : { show: 'Show Xiaoqi', hide: 'Hide Xiaoqi', panel: 'Task board', refresh: 'Refresh usage / balance', quit: 'Quit Xiaoqi' }; + const menu = Menu.buildFromTemplate([ + { + label: petWindow && petWindow.isVisible() ? labels.hide : labels.show, + click: () => (petWindow && petWindow.isVisible() ? hidePet() : showPet()), + }, + { label: labels.panel, click: () => togglePanel(true) }, + { label: labels.refresh, click: () => { void refreshBalance(true); } }, + { type: 'separator' }, + { label: labels.quit, click: () => quitApp() }, + ]); + tray.setContextMenu(menu); +} + +function showPet() { + if (!petWindow) return; + petWindow.showInactive(); + updateTrayMenu(); + if (state.tasks.length >= 2 && !panelManuallyClosed) showPanel(false); +} + +function hidePet() { + if (petWindow) petWindow.hide(); + hidePanel(false); + updateTrayMenu(); +} + +function quitApp() { + quitting = true; + savePosition(); + app.quit(); +} + +function taskPanelHeight() { + const n = Math.min(Math.max(state.tasks.length, 1), 4); + return Math.min(620, Math.max(430, 84 + n * 150)); +} + +function panelPosition() { + if (!petWindow || !panelWindow) return { x: 0, y: 0 }; + const pet = petWindow.getBounds(); + const height = taskPanelHeight(); + const area = screen.getDisplayMatching(pet).workArea; + let x = pet.x + pet.width + 12; + if (x + PANEL_W > area.x + area.width - 8) x = pet.x - PANEL_W - 12; + x = Math.min(Math.max(x, area.x + 8), area.x + area.width - PANEL_W - 8); + let y = pet.y + pet.height / 2 - height / 2; + y = Math.min(Math.max(y, area.y + 8), area.y + area.height - height - 8); + return { x: Math.round(x), y: Math.round(y) }; +} + +function updatePanelSize() { + if (!panelWindow) return; + const height = taskPanelHeight(); + panelWindow.setContentSize(PANEL_W, height); + const pos = panelPosition(); + panelWindow.setPosition(pos.x, pos.y); +} + +function showPanel(force) { + if (!panelWindow) return; + if (force === false && panelManuallyClosed) return; + if (force !== false) panelManuallyClosed = false; + updatePanelSize(); + if (!panelWindow.isVisible()) panelWindow.showInactive(); + sendState(); +} + +function hidePanel(manual) { + if (!panelWindow) return; + panelWindow.hide(); + if (manual) { + panelManuallyClosed = true; + panelAuto = false; + } + updateTrayMenu(); +} + +function togglePanel(user) { + if (!panelWindow) return; + if (panelWindow.isVisible()) { + hidePanel(Boolean(user)); + } else { + panelManuallyClosed = false; + panelAuto = false; + showPanel(true); + } +} + +function startPolling() { + void poll(); + pollTimer = setInterval(() => { void poll(); }, POLL_MS); +} + +async function poll() { + if (polling || quitting) return; + polling = true; + try { + const { events, emotes } = feed.collectEvents(true); + const nowMs = Date.now(); + let sawEvents = false; + for (const item of events) { + store.apply(item.event, item.atMs, item.payload, item.cwd); + sawEvents = true; + } + if (sawEvents) emptyScans = 0; + else emptyScans += 1; + + const tasks = store.snapshot(nowMs); + const lastEventAt = store.lastEventAt ?? 0; + const connected = tasks.length > 0 || (lastEventAt > 0 && nowMs - lastEventAt < 10 * 60 * 1000); + + let emote = state.emote; + if (emotes.length > 0) { + const envelope = emotes[emotes.length - 1]; + const speech = typeof envelope.speech === 'string' ? envelope.speech.trim().slice(0, 200) : ''; + if (speech !== '') { + let seconds = Number(envelope.seconds); + if (!Number.isFinite(seconds) || seconds <= 0) seconds = 6; + emote = { speech, until: nowMs + Math.min(30, Math.max(3, seconds)) * 1000 }; + } + } + if (emote && emote.until < nowMs) emote = null; + + const balance = await usage.getBalance(false); + applyState({ connected, tasks, source: 'hooks', balance, emote }); + } catch (error) { + console.error('[xiaoqi] poll failed', error); + } finally { + polling = false; + } +} + +function applyState(next) { + const previous = state; + const wasMulti = previous.tasks.length >= 2; + state = next; + const isMulti = state.tasks.length >= 2; + if (isMulti && !wasMulti) { + panelManuallyClosed = false; + panelAuto = true; + } + notifyTransitions(previous, state); + updateTaskWindows(); +} + +function updateTaskWindows() { + resizePetWindow(); + sendState(); + if (!panelWindow) return; + if (state.tasks.length >= 2 && !panelManuallyClosed && !panelWindow.isVisible()) { + panelAuto = true; + showPanel(true); + } else if (state.tasks.length < 2) { + if (state.tasks.length === 0) { + hidePanel(false); + panelAuto = false; + } else if (panelAuto && panelWindow.isVisible()) { + hidePanel(false); + panelAuto = false; + } + } + if (panelWindow.isVisible()) updatePanelSize(); + updateTrayMenu(); +} + +function resizePetWindow() { + if (!petWindow) return; + const target = petHeight(); + const bounds = petWindow.getBounds(); + if (bounds.height === target) return; + const pos = clampPosition(bounds.x, bounds.y, PET_W, target); + petWindow.setBounds({ x: pos.x, y: pos.y, width: PET_W, height: target }); + if (panelWindow && panelWindow.isVisible()) { + const panelPos = panelPosition(); + panelWindow.setPosition(panelPos.x, panelPos.y); + } +} + +async function refreshBalance(force) { + try { + const balance = await usage.getBalance(force); + state = { ...state, balance }; + sendState(); + } catch (error) { + console.error('[xiaoqi] balance refresh failed', error); + } +} + +function sendState() { + const payload = { + connected: state.connected, + tasks: state.tasks, + source: state.source, + balance: state.balance, + emote: state.emote, + }; + for (const win of [petWindow, panelWindow]) { + if (win && !win.isDestroyed() && !win.webContents.isLoading()) { + win.webContents.send('state', payload); + } + } +} + +function notifyTransitions(previous, next) { + if (!previous || !Array.isArray(previous.tasks)) return; + const old = new Map(previous.tasks.map((task) => [task.id, task])); + const waiting = next.tasks.filter((task) => task.kind === 'waiting' && old.get(task.id)?.kind !== 'waiting'); + const done = next.tasks.filter((task) => ( + task.kind === 'done' + && old.get(task.id)?.kind === 'running' + )); + if (!Notification.isSupported() || (waiting.length === 0 && done.length === 0)) return; + const icon = path.join(__dirname, 'assets', 'icon.png'); + for (const task of waiting) { + try { + new Notification({ + title: zh() ? '小栖 · 需要你处理' : 'Xiaoqi · Needs you', + body: task.pendingTool ? `${task.title} · ${task.pendingTool}` : task.title, + icon, + }).show(); + } catch { + // 通知是可选能力。 + } + } + for (const task of done) { + try { + new Notification({ + title: zh() ? '小栖 · 任务完成' : 'Xiaoqi · Task complete', + body: task.title, + icon, + }).show(); + } catch { + // 通知是可选能力。 + } + } +} + +function focusZCode() { + shell.openExternal('zcode://workspace').catch(() => { + if (isMac) { + try { + spawnSync('open', ['-a', 'ZCode'], { stdio: 'ignore' }); + } catch { + // 没有别的拉起手段了。 + } + } + }); +} + +ipcMain.on('pet:drag-start', () => { + if (!petWindow) return; + dragState = { + origin: petWindow.getPosition(), + cursor: screen.getCursorScreenPoint(), + }; +}); + +ipcMain.on('pet:drag-move', () => { + if (!petWindow || !dragState) return; + const cursor = screen.getCursorScreenPoint(); + const dx = cursor.x - dragState.cursor.x; + const dy = cursor.y - dragState.cursor.y; + const height = petWindow.getBounds().height; + const pos = clampPosition(dragState.origin[0] + dx, dragState.origin[1] + dy, PET_W, height); + petWindow.setPosition(pos.x, pos.y); + if (panelWindow && panelWindow.isVisible()) { + const panelPos = panelPosition(); + panelWindow.setPosition(panelPos.x, panelPos.y); + } +}); + +ipcMain.on('pet:drag-end', () => { + dragState = null; + savePosition(); +}); + +ipcMain.on('pet:toggle-panel', () => togglePanel(true)); +ipcMain.on('panel:close', () => hidePanel(true)); +ipcMain.on('task:open', () => { + focusZCode(); +}); diff --git a/external_plugins/xiaoqi-pet/desktop/electron/package.json b/external_plugins/xiaoqi-pet/desktop/electron/package.json new file mode 100644 index 0000000..46ef924 --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/electron/package.json @@ -0,0 +1,74 @@ +{ + "name": "xiaoqi-pet-zcode-desktop", + "version": "0.3.1", + "private": true, + "description": "Cross-platform Xiaoqi (小栖) desktop companion for Windows, Linux and macOS, fed by the xiaoqi-pet ZCode plugin hooks.", + "main": "main.js", + "license": "MIT", + "author": "fengxiaohui", + "scripts": { + "start": "electron .", + "dist:win": "electron-builder --win", + "dist:linux": "electron-builder --linux", + "dist:win:x64": "electron-builder --win zip --x64", + "dist:linux:x64": "electron-builder --linux AppImage --x64" + }, + "devDependencies": { + "electron": "45.1.0", + "electron-builder": "26.18.0" + }, + "build": { + "appId": "com.xiaoqi.zcode-deskpet", + "productName": "ZCode Xiaoqi Pet", + "artifactName": "ZCode-Xiaoqi-Pet-${version}-${os}-${arch}.${ext}", + "directories": { + "output": "dist" + }, + "files": [ + "main.js", + "feed.js", + "usage.js", + "preload.js", + "renderer/**/*", + "assets/**/*", + "package.json" + ], + "win": { + "target": [ + { + "target": "zip", + "arch": [ + "x64", + "arm64" + ] + } + ], + "icon": "assets/icon.png" + }, + "linux": { + "target": [ + { + "target": "AppImage", + "arch": [ + "x64", + "arm64" + ] + } + ], + "category": "Utility", + "icon": "assets/icon.png" + }, + "mac": { + "target": [ + { + "target": "dir", + "arch": [ + "arm64" + ] + } + ], + "category": "public.app-category.utilities", + "icon": "assets/icon.png" + } + } +} diff --git a/external_plugins/xiaoqi-pet/desktop/electron/preload.js b/external_plugins/xiaoqi-pet/desktop/electron/preload.js new file mode 100644 index 0000000..2c7fb88 --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/electron/preload.js @@ -0,0 +1,29 @@ +'use strict'; + +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('xiaoqi', { + onState(callback) { + const listener = (_event, value) => callback(value); + ipcRenderer.on('state', listener); + return () => ipcRenderer.removeListener('state', listener); + }, + dragStart() { + ipcRenderer.send('pet:drag-start'); + }, + dragMove() { + ipcRenderer.send('pet:drag-move'); + }, + dragEnd() { + ipcRenderer.send('pet:drag-end'); + }, + togglePanel() { + ipcRenderer.send('pet:toggle-panel'); + }, + closePanel() { + ipcRenderer.send('panel:close'); + }, + openTask(id) { + ipcRenderer.send('task:open', id); + }, +}); diff --git a/external_plugins/xiaoqi-pet/desktop/electron/renderer/common.js b/external_plugins/xiaoqi-pet/desktop/electron/renderer/common.js new file mode 100644 index 0000000..aee67d2 --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/electron/renderer/common.js @@ -0,0 +1,228 @@ +'use strict'; + +(() => { + const zh = String(navigator.language || '').toLowerCase().startsWith('zh'); + const text = (en, cn) => (zh ? cn : en); + const COLORS = ['#2A8A80', '#D9942F', '#4F8CFF', '#A78BFA', '#E879A6', '#7CB342', '#E76F51']; + + function escapeHtml(value) { + return String(value == null ? '' : value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + function stableIndex(id) { + let hash = 1469598103934665603n; + for (const byte of new TextEncoder().encode(String(id))) { + hash ^= BigInt(byte); + hash = BigInt.asUintN(64, hash * 1099511628211n); + } + return Number(hash % BigInt(COLORS.length)); + } + + function taskColors(tasks) { + const used = new Set(); + const result = {}; + for (const task of tasks) { + let index = stableIndex(task.id); + while (used.has(index)) index = (index + 1) % COLORS.length; + used.add(index); + result[task.id] = COLORS[index]; + } + return result; + } + + function statusText(task) { + switch (task.kind) { + case 'waiting': return text('Needs you', '待处理'); + case 'running': return text('In progress', '进行中'); + case 'done': return text('Done', '已完成'); + default: return ''; + } + } + + function pendingText(task) { + switch (task.pendingKind || '') { + case 'approval': + return task.pendingTool + ? text(`Waiting for approval · ${task.pendingTool}`, `等待确认 · ${task.pendingTool}`) + : text('Waiting for approval', '等待确认'); + case 'question': return text('Waiting for your answer', '等待回答'); + default: return text('Waiting for you', '待处理'); + } + } + + function actionLabel(action) { + if (!action || !action.kind) return ''; + const target = action.target || ''; + const labels = { + read: text(`Read ${target}`, `读取 ${target}`), + write: text(`Write ${target}`, `写入 ${target}`), + edit: text(`Edit ${target}`, `修改 ${target}`), + bash: text(`Run: ${target}`, `运行:${target}`), + glob: text(`Find ${target}`, `查找 ${target}`), + grep: text(`Search ${target}`, `搜索 ${target}`), + image: text(`View ${target}`, `查看 ${target}`), + search: text(`Search web: ${target}`, `检索:${target}`), + fetch: text(`Fetch: ${target}`, `读取网页:${target}`), + subagent: text(`Delegate: ${target}`, `调度子任务:${target}`), + workflow: text(`Workflow: ${target}`, `运行工作流:${target}`), + skill: text(`Skill: ${target}`, `加载技能:${target}`), + todo: text('Update checklist', '更新任务清单'), + schedule: text(`Schedule: ${target}`, `定时任务:${target}`), + question: text(`Ask: ${target}`, `提问:${target}`), + plan: text('Planning', '整理计划'), + mcp: target ? text(`MCP: ${target}`, `MCP:${target}`) : text('MCP tool', '调用 MCP 工具'), + tool: target ? text(`Use ${target}`, `调用 ${target}`) : text('Use a tool', '调用工具'), + }; + const label = labels[action.kind] || labels.tool; + if (action.done) return action.failed ? `${label} ✗` : `${label} ✓`; + return label; + } + + function phaseText(phase) { + switch (phase) { + case 'thinking': return text('Thinking', '思考中'); + case 'tool': return text('Running a tool', '执行工具中'); + case 'responding': return text('Writing the answer', '生成回复中'); + case 'done': return text('Finished', '已完成'); + default: return text('Preparing', '准备中'); + } + } + + function taskHeadline(task) { + if (task.kind === 'waiting') return pendingText(task); + if (task.progress && task.progress.action) return actionLabel(task.progress.action); + if (task.progress && task.progress.phase) return phaseText(task.progress.phase); + return statusText(task); + } + + function todoLine(task) { + const todos = task.progress && task.progress.todos; + if (!todos || !todos.total) return ''; + if (todos.current) return todos.current; + if (todos.completed >= todos.total) return text('Checklist complete', '任务清单已完成'); + return ''; + } + + function todoCount(task) { + const todos = task.progress && task.progress.todos; + if (!todos || !todos.total) return ''; + return text(`${todos.completed}/${todos.total} done`, `${todos.completed}/${todos.total} 项`); + } + + function formatTokens(value) { + const trim = (number, unit) => { + const formatted = number.toFixed(1); + return `${formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted}${unit}`; + }; + if (value >= 1_000_000) return trim(value / 1_000_000, 'M'); + if (value >= 1_000) return trim(value / 1_000, 'k'); + return String(value); + } + + function el(ms) { + const total = Math.max(0, Math.floor((Number(ms) || 0) / 1000)); + if (total >= 3600) { + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + return text(`${h}h ${m}m`, `${h} 小时 ${m} 分`); + } + if (total >= 60) { + return text(`${Math.floor(total / 60)}m ${total % 60}s`, `${Math.floor(total / 60)} 分 ${total % 60} 秒`); + } + return text(`${total}s`, `${total} 秒`); + } + + function relative(ms) { + if (!Number.isFinite(ms) || ms <= 0) return ''; + const delta = Math.max(0, Date.now() - ms); + if (delta < 60_000) return text('just now', '刚刚'); + if (delta < 3_600_000) return text(`${Math.floor(delta / 60_000)} min ago`, `${Math.floor(delta / 60_000)} 分钟前`); + if (delta < 86_400_000) return text(`${Math.floor(delta / 3_600_000)} h ago`, `${Math.floor(delta / 3_600_000)} 小时前`); + return new Date(ms).toLocaleDateString(); + } + + function elapsedFor(task) { + if (task.progress && Number.isFinite(task.progress.elapsedMs)) return el(task.progress.elapsedMs); + return relative(task.updatedAt); + } + + function trail(task) { + const items = task.progress && Array.isArray(task.progress.trail) ? task.progress.trail : []; + return items.slice(-3).map((item) => { + if (item && item.kind === 'action') { + const label = actionLabel({ kind: item.actionKind, target: item.label, done: item.done, failed: item.failed }); + return label.length > 42 ? `${label.slice(0, 41)}…` : label; + } + return ''; + }).filter(Boolean).join(' → '); + } + + function summary(tasks) { + const waiting = tasks.filter((task) => task.kind === 'waiting').length; + const running = tasks.filter((task) => task.kind === 'running').length; + const done = tasks.filter((task) => task.kind === 'done').length; + const parts = []; + if (waiting) parts.push(text(`${waiting} need you`, `${waiting} 个待处理`)); + if (running) parts.push(text(`${running} running`, `${running} 个进行中`)); + if (done) parts.push(text(`${done} just finished`, `${done} 个刚完成`)); + return parts.join(' · ') || text('All sessions are quiet', '所有会话都在休息'); + } + + function balanceText(balance) { + if (!balance) return ''; + if (balance.remote && balance.remote.display && !balance.remote.stale) return balance.remote.display; + if (balance.remote && balance.remote.display) return `${balance.remote.display}*`; + if (balance.usage) return text(`Today ${formatTokens(balance.usage.todayTotal)}`, `今日 ${formatTokens(balance.usage.todayTotal)}`); + return ''; + } + + function balanceDetail(balance) { + if (!balance) return ''; + const lines = []; + if (balance.remote) { + const time = new Date(balance.remote.fetchedAt).toLocaleTimeString(); + const staleSuffix = balance.remote.stale ? text(' (stale)', '(过期)') : ''; + lines.push(text( + `${balance.remote.label} ${balance.remote.display} · ${time}${staleSuffix}`, + `${balance.remote.label} ${balance.remote.display} · ${time}${staleSuffix}` + )); + } else if (balance.remoteError) { + lines.push(text(`Plan balance unavailable: ${balance.remoteError}`, `套餐余量获取失败:${balance.remoteError}`)); + } + if (balance.usage) { + lines.push(text( + `Today ${formatTokens(balance.usage.todayTotal)} tokens · in ${formatTokens(balance.usage.todayInput)} · out ${formatTokens(balance.usage.todayOutput)} · reasoning ${formatTokens(balance.usage.todayReasoning)} · ${balance.usage.todayTurns} turns · ${balance.usage.todayTools} tools · 7d ${formatTokens(balance.usage.weekTotal)}`, + `今日 ${formatTokens(balance.usage.todayTotal)} tokens · 输入 ${formatTokens(balance.usage.todayInput)} · 输出 ${formatTokens(balance.usage.todayOutput)} · 推理 ${formatTokens(balance.usage.todayReasoning)} · ${balance.usage.todayTurns} 轮 · ${balance.usage.todayTools} 次工具 · 近 7 天 ${formatTokens(balance.usage.weekTotal)}` + )); + } + return lines.join('\n'); + } + + const api = { + text, + escapeHtml, + taskColors, + statusText, + pendingText, + actionLabel, + phaseText, + taskHeadline, + todoLine, + todoCount, + formatTokens, + el, + relative, + elapsedFor, + trail, + summary, + balanceText, + balanceDetail, + isZh: zh, + }; + window.Xiaoqi = api; +})(); diff --git a/external_plugins/xiaoqi-pet/desktop/electron/renderer/panel.html b/external_plugins/xiaoqi-pet/desktop/electron/renderer/panel.html new file mode 100644 index 0000000..17da0c4 --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/electron/renderer/panel.html @@ -0,0 +1,29 @@ + + + + + + Xiaoqi Tasks + + + +

+
+
+
+ 小栖 + 实时 +
+
+
+
+ +
+
+
+
+
+ + + + diff --git a/external_plugins/xiaoqi-pet/desktop/electron/renderer/panel.js b/external_plugins/xiaoqi-pet/desktop/electron/renderer/panel.js new file mode 100644 index 0000000..a6c66ed --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/electron/renderer/panel.js @@ -0,0 +1,109 @@ +'use strict'; + +(() => { + const Xiaoqi = window.Xiaoqi; + const scroll = document.getElementById('scroll'); + const summary = document.getElementById('summary'); + const live = document.getElementById('live'); + const liveLabel = document.getElementById('live-label'); + const appName = document.getElementById('app-name'); + const foot = document.getElementById('foot'); + + let state = { connected: false, tasks: [] }; + + const symbols = { + read: '▤', write: '▤', edit: '▤', bash: '▣', + glob: '⌕', grep: '⌕', image: '▧', + search: '◎', fetch: '◎', subagent: '◫', + workflow: '⌁', skill: '✦', todo: '☷', + schedule: '◷', question: '?', plan: '◈', mcp: '⇄', tool: '•', + }; + + function section(label, tasks, colorMap) { + if (!tasks.length) return ''; + const cards = tasks.map((task) => card(task, colorMap[task.id] || '#2a8a80')).join(''); + return `
+ + ${cards} +
`; + } + + function card(task, color) { + const progress = task.progress || {}; + const action = progress.action || null; + const symbol = action && action.failed ? '✗' : (symbols[action && action.kind] || '•'); + const headline = Xiaoqi.taskHeadline(task); + const todo = Xiaoqi.todoLine(task); + const todoCount = Xiaoqi.todoCount(task); + const trail = Xiaoqi.trail(task); + const meta = [task.project, Xiaoqi.statusText(task)].filter(Boolean).map(Xiaoqi.escapeHtml).join(' · '); + const todoHtml = todo + ? `
${Xiaoqi.escapeHtml(todo)}${Xiaoqi.escapeHtml(todoCount)}
` + : ''; + const trailHtml = trail + ? `${Xiaoqi.escapeHtml(trail)}` + : ''; + return ``; + } + + function render() { + const tasks = Array.isArray(state.tasks) ? state.tasks : []; + appName.textContent = Xiaoqi.isZh ? '小栖' : 'Xiaoqi'; + liveLabel.textContent = Xiaoqi.isZh ? '实时' : 'LIVE'; + live.classList.toggle('active', state.connected); + summary.textContent = state.connected + ? Xiaoqi.summary(tasks) + : (tasks.length ? Xiaoqi.summary(tasks) : (Xiaoqi.isZh ? '等待 ZCode hook 事件…' : 'Waiting for ZCode hooks…')); + foot.textContent = tasks.length + ? (Xiaoqi.isZh ? '点击任务卡片把 ZCode 调到前台' : 'Click a task to bring ZCode to the front') + : (Xiaoqi.isZh ? '小栖会在这里显示任务进展' : 'Xiaoqi will show task progress here'); + + if (!tasks.length) { + scroll.innerHTML = `
+
✦
+
${Xiaoqi.isZh ? '所有会话都安静了' : 'All sessions are quiet'}
+
${Xiaoqi.isZh ? '任务开始运行或需要你处理时,小栖会提醒你。' : 'Xiaoqi will speak up when a task starts or needs you.'}
+
`; + return; + } + + const colors = Xiaoqi.taskColors(tasks); + const waiting = tasks.filter((task) => task.kind === 'waiting'); + const running = tasks.filter((task) => task.kind === 'running'); + const done = tasks.filter((task) => task.kind === 'done'); + scroll.innerHTML = [ + section(Xiaoqi.isZh ? '需要你' : 'Needs you', waiting, colors), + section(Xiaoqi.isZh ? '进行中' : 'In progress', running, colors), + section(Xiaoqi.isZh ? '刚完成' : 'Just finished', done, colors), + ].join(''); + + for (const node of scroll.querySelectorAll('.task-card')) { + node.addEventListener('click', () => { + const id = node.getAttribute('data-task-id'); + if (id) window.xiaoqi.openTask(id); + }); + } + } + + document.getElementById('close').addEventListener('click', () => window.xiaoqi.closePanel()); + window.xiaoqi.onState((next) => { + state = next || { connected: false, tasks: [] }; + render(); + }); + setInterval(render, 1000); + render(); +})(); diff --git a/external_plugins/xiaoqi-pet/desktop/electron/renderer/pet.html b/external_plugins/xiaoqi-pet/desktop/electron/renderer/pet.html new file mode 100644 index 0000000..bc8749d --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/electron/renderer/pet.html @@ -0,0 +1,29 @@ + + + + + + Xiaoqi + + + +
+ + + +
+ + +
+ + + + + diff --git a/external_plugins/xiaoqi-pet/desktop/electron/renderer/pet.js b/external_plugins/xiaoqi-pet/desktop/electron/renderer/pet.js new file mode 100644 index 0000000..b8c7188 --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/electron/renderer/pet.js @@ -0,0 +1,194 @@ +'use strict'; + +(() => { + const Xiaoqi = window.Xiaoqi; + const stage = document.getElementById('stage'); + const badge = document.getElementById('badge'); + const balance = document.getElementById('balance'); + const bubble = document.getElementById('bubble'); + const bubbleTitle = document.getElementById('bubble-title'); + const bubbleDot = document.getElementById('bubble-dot'); + const bubbleAction = document.getElementById('bubble-action'); + const bubbleTime = document.getElementById('bubble-time'); + const hint = document.getElementById('hint'); + + let state = { connected: false, tasks: [], balance: null, emote: null }; + let pointer = null; + let suppressClick = false; + + function moodFor(tasks) { + if (tasks.some((task) => task.kind === 'waiting')) return 'waiting'; + if (tasks.some((task) => task.kind === 'running')) return 'running'; + if (tasks.some((task) => task.kind === 'done')) return 'done'; + return 'idle'; + } + + function badgeFor(tasks) { + const waiting = tasks.filter((task) => task.kind === 'waiting').length; + const done = tasks.filter((task) => task.kind === 'done').length; + const running = tasks.filter((task) => task.kind === 'running').length; + if (waiting) return { count: waiting, kind: 'waiting' }; + if (done) return { count: done, kind: 'done' }; + return { count: running, kind: 'running' }; + } + + function taskLine(task) { + if (task.kind === 'waiting') return Xiaoqi.pendingText(task); + if (task.progress && task.progress.action) return Xiaoqi.actionLabel(task.progress.action); + if (task.progress && task.progress.phase) return Xiaoqi.phaseText(task.progress.phase); + return Xiaoqi.statusText(task); + } + + function activeEmote() { + const emote = state.emote; + if (!emote || typeof emote.speech !== 'string' || emote.speech === '') return null; + if (Number.isFinite(emote.until) && emote.until < Date.now()) return null; + return emote; + } + + function render() { + const tasks = Array.isArray(state.tasks) ? state.tasks : []; + document.body.dataset.mood = moodFor(tasks); + + const badgeInfo = badgeFor(tasks); + if (badgeInfo.count > 0) { + badge.className = `pet-badge visible ${badgeInfo.kind}`; + badge.textContent = badgeInfo.count > 9 ? '9+' : String(badgeInfo.count); + badge.title = Xiaoqi.text(`${badgeInfo.count} notices`, `${badgeInfo.count} 条提醒`); + } else { + badge.className = 'pet-badge'; + badge.textContent = ''; + } + + const balanceLabel = Xiaoqi.balanceText(state.balance); + if (balanceLabel) { + balance.hidden = false; + balance.textContent = balanceLabel; + balance.title = Xiaoqi.balanceDetail(state.balance); + } else { + balance.hidden = true; + balance.textContent = ''; + } + + const emote = activeEmote(); + if (emote) { + bubble.hidden = false; + bubble.classList.add('is-emote'); + bubbleTitle.textContent = Xiaoqi.text('Xiaoqi', '小栖'); + bubbleDot.style.background = '#4F8CFF'; + bubbleAction.textContent = emote.speech; + bubbleTime.textContent = ''; + hint.textContent = ''; + } else if (tasks.length === 1) { + const task = tasks[0]; + const colors = Xiaoqi.taskColors(tasks); + bubble.hidden = false; + bubble.classList.remove('is-emote'); + bubbleTitle.textContent = task.title; + bubbleDot.style.background = colors[task.id] || 'var(--accent)'; + bubbleAction.textContent = taskLine(task); + bubbleTime.textContent = Xiaoqi.elapsedFor(task); + hint.textContent = ''; + } else { + bubble.hidden = true; + bubble.classList.remove('is-emote'); + hint.textContent = tasks.length === 0 + ? (state.connected ? Xiaoqi.text('Quietly here', '安静陪伴') : Xiaoqi.text('Waiting for ZCode…', '等待 ZCode…')) + : ''; + } + + stage.setAttribute('aria-label', tasks.length + ? Xiaoqi.text('Xiaoqi · open task board', '小栖 · 打开任务面板') + : Xiaoqi.text('Xiaoqi', '小栖')); + stage.title = tasks.length + ? Xiaoqi.text('Click to open tasks · drag to move', '点击打开任务 · 拖动移动') + : Xiaoqi.text('Drag to move · click to open tasks', '拖动移动 · 点击打开任务'); + } + + window.xiaoqi.onState((next) => { + state = next || { connected: false, tasks: [], balance: null, emote: null }; + render(); + }); + + // 互动气泡会过期、耗时会走动:每秒重绘一次。 + setInterval(render, 1000); + + stage.addEventListener('pointerdown', (event) => { + if (event.button !== 0) return; + event.preventDefault(); + stage.focus({ preventScroll: true }); + pointer = { + id: event.pointerId, + startX: event.screenX, + startY: event.screenY, + moved: false, + }; + try { + stage.setPointerCapture(event.pointerId); + } catch { + // 指针捕获只是增强。 + } + }); + + stage.addEventListener('pointermove', (event) => { + if (!pointer || event.pointerId !== pointer.id) return; + const dx = event.screenX - pointer.startX; + const dy = event.screenY - pointer.startY; + if (!pointer.moved && Math.hypot(dx, dy) < 4) return; + if (!pointer.moved) { + pointer.moved = true; + stage.classList.add('is-dragging'); + window.xiaoqi.dragStart(); + } + window.xiaoqi.dragMove(); + }); + + const finishPointer = (event) => { + if (!pointer || event.pointerId !== pointer.id) return; + const moved = pointer.moved; + pointer = null; + stage.classList.remove('is-dragging'); + try { + stage.releasePointerCapture(event.pointerId); + } catch { + // 已释放。 + } + if (moved) { + suppressClick = true; + window.xiaoqi.dragEnd(); + setTimeout(() => { suppressClick = false; }, 0); + } else { + window.xiaoqi.togglePanel(); + } + }; + + stage.addEventListener('pointerup', finishPointer); + stage.addEventListener('pointercancel', (event) => { + if (!pointer) return; + const moved = pointer.moved; + pointer = null; + stage.classList.remove('is-dragging'); + if (moved) window.xiaoqi.dragEnd(); + try { + stage.releasePointerCapture(event.pointerId); + } catch { + // 已释放。 + } + }); + + stage.addEventListener('click', () => { + if (suppressClick) { + suppressClick = false; + } + }); + + stage.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + window.xiaoqi.togglePanel(); + } + }); + + window.addEventListener('contextmenu', (event) => event.preventDefault()); + render(); +})(); diff --git a/external_plugins/xiaoqi-pet/desktop/electron/renderer/styles.css b/external_plugins/xiaoqi-pet/desktop/electron/renderer/styles.css new file mode 100644 index 0000000..fdcfcb1 --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/electron/renderer/styles.css @@ -0,0 +1,618 @@ +:root { + --text: #f4f6f6; + --secondary: rgba(238, 243, 242, 0.62); + --tertiary: rgba(238, 243, 242, 0.44); + --panel-bg: rgba(30, 32, 34, 0.96); + --bubble-bg: rgba(34, 36, 39, 0.94); + --surface: rgba(255, 255, 255, 0.04); + --border: rgba(255, 255, 255, 0.12); + --shadow: rgba(0, 0, 0, 0.3); + --accent: #2a8a80; +} + +@media (prefers-color-scheme: light) { + :root { + --text: #202525; + --secondary: rgba(28, 38, 37, 0.64); + --tertiary: rgba(28, 38, 37, 0.46); + --panel-bg: rgba(250, 251, 250, 0.97); + --bubble-bg: rgba(250, 251, 250, 0.95); + --surface: rgba(20, 30, 28, 0.035); + --border: rgba(20, 30, 28, 0.12); + --shadow: rgba(20, 30, 28, 0.16); + } +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: transparent; +} + +body { + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", "PingFang SC", sans-serif; + -webkit-font-smoothing: antialiased; + user-select: none; +} + +/* ---------- Pet window ---------- */ + +.pet-body { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 8px; + width: 100%; + height: 100%; +} + +.pet-stage { + position: relative; + width: 158px; + height: 154px; + display: flex; + align-items: center; + justify-content: center; + flex: none; + cursor: grab; + touch-action: none; +} + +.pet-stage.is-dragging { + cursor: grabbing; +} + +.pet-stage.is-locked { + cursor: pointer; +} + +.pet-art { + width: 150px; + height: 150px; + object-fit: contain; + pointer-events: none; + filter: drop-shadow(0 9px 14px rgba(0, 0, 0, 0.22)); + animation: breathe 4.4s ease-in-out infinite; + transform-origin: 50% 82%; +} + +.pet-body[data-mood="running"] .pet-art { + animation-duration: 0.9s; + animation-name: work; +} + +.pet-body[data-mood="waiting"] .pet-art { + animation: wait 1.6s ease-in-out infinite; +} + +.pet-body[data-mood="done"] .pet-art { + animation: happy 2.4s ease-in-out infinite; +} + +.pet-stage.is-dragging .pet-art { + transform: scale(1.035); +} + +.pet-badge { + position: absolute; + top: 6px; + right: 0; + min-width: 22px; + height: 22px; + padding: 0 6px; + border: 2px solid var(--panel-bg); + border-radius: 999px; + display: none; + align-items: center; + justify-content: center; + color: #fff; + font-size: 10px; + font-weight: 700; + font-variant-numeric: tabular-nums; + box-shadow: 0 3px 10px var(--shadow); +} + +.pet-badge.visible { + display: flex; +} + +.pet-badge.waiting { background: #d9942f; animation: badge 1.8s ease-in-out infinite; } +.pet-badge.running { background: #2a8a80; } +.pet-badge.done { background: #1c8a60; } + +.pet-bubble { + width: 188px; + min-height: 48px; + margin-top: 2px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 15px; + background: var(--bubble-bg); + box-shadow: 0 9px 24px var(--shadow); + backdrop-filter: blur(18px); + pointer-events: none; +} + +.pet-bubble[hidden] { + display: none; +} + +.bubble-title { + overflow: hidden; + color: var(--text); + font-size: 11.4px; + font-weight: 650; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bubble-row { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; + margin-top: 2px; + color: var(--secondary); + font-size: 9.9px; + line-height: 15px; +} + +.bubble-dot { + width: 5px; + height: 5px; + border-radius: 50%; + flex: none; + background: var(--task-color, var(--accent)); +} + +.bubble-action { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bubble-time { + flex: none; + color: var(--tertiary); + font-variant-numeric: tabular-nums; +} + +.hint { + margin-top: 4px; + color: var(--tertiary); + font-size: 9px; + line-height: 14px; + text-align: center; +} + +/* ---------- Task panel ---------- */ + +.panel-body { + padding: 10px; +} + +.panel { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 18px; + background: var(--panel-bg); + box-shadow: 0 20px 55px var(--shadow); + backdrop-filter: blur(22px); +} + +.panel-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex: none; + padding: 13px 14px 9px 16px; +} + +.panel-heading { + min-width: 0; +} + +.panel-title-line { + display: flex; + align-items: center; + gap: 7px; +} + +.panel-title { + font-size: 15px; + font-weight: 700; + line-height: 21px; +} + +.live { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 6px; + border: 1px solid var(--border); + border-radius: 999px; + color: var(--secondary); + font-size: 8.5px; + font-weight: 600; + letter-spacing: 0.05em; + line-height: 15px; + text-transform: uppercase; +} + +.live-dot { + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--tertiary); +} + +.live.active .live-dot { + background: #1c8a60; + box-shadow: 0 0 0 3px rgba(28, 138, 96, 0.16); + animation: pulse 1.8s ease-in-out infinite; +} + +.panel-summary { + margin-top: 2px; + overflow: hidden; + color: var(--secondary); + font-size: 11px; + line-height: 17px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.panel-actions { + display: flex; + gap: 3px; + flex: none; +} + +.icon-button { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--secondary); + cursor: pointer; + font: inherit; + font-size: 15px; + line-height: 1; +} + +.icon-button:hover { + background: var(--surface); + color: var(--text); +} + +.icon-button:focus-visible, +.task-card:focus-visible { + outline: 2px solid #4f8cff; + outline-offset: 2px; +} + +.panel-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + padding: 2px 10px 10px; +} + +.panel-scroll::-webkit-scrollbar { + width: 8px; +} + +.panel-scroll::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 999px; + background-clip: padding-box; + background-color: var(--border); +} + +.task-section + .task-section { + margin-top: 7px; +} + +.task-section-label { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 6px 3px; + color: var(--secondary); + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.06em; + line-height: 14px; + text-transform: uppercase; +} + +.task-section-count { + color: var(--tertiary); + font-variant-numeric: tabular-nums; +} + +.task-card { + position: relative; + display: block; + width: 100%; + margin: 0 0 6px; + padding: 9px 10px 9px 13px; + border: 1px solid color-mix(in srgb, var(--task-color, var(--border)) 38%, transparent); + border-left: 3px solid var(--task-color, var(--border)); + border-radius: 12px; + background: color-mix(in srgb, var(--task-color, transparent) 5%, transparent); + color: inherit; + text-align: left; + font: inherit; + cursor: pointer; +} + +.task-card:hover { + background: color-mix(in srgb, var(--task-color, var(--surface)) 11%, transparent); +} + +.card-title-line { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.card-dot { + width: 5px; + height: 5px; + flex: none; + border-radius: 50%; + background: var(--task-color, var(--accent)); +} + +.card-title { + flex: 1; + min-width: 0; + overflow: hidden; + color: var(--text); + font-size: 12.1px; + font-weight: 650; + line-height: 17px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-state { + flex: none; + color: var(--task-color, var(--secondary)); + font-size: 9px; + font-weight: 600; + line-height: 15px; +} + +.card-current { + flex: none; + padding: 0 4px; + border-radius: 4px; + background: rgba(79, 140, 255, 0.13); + color: #4f8cff; + font-size: 7.5px; + font-weight: 700; + line-height: 14px; + letter-spacing: 0.03em; + text-transform: uppercase; +} + +.card-meta { + display: block; + margin-top: 1px; + overflow: hidden; + color: var(--secondary); + font-size: 9.8px; + line-height: 15px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-todo { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; + margin-top: 3px; + padding-top: 4px; + border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + color: color-mix(in srgb, var(--text) 84%, transparent); + font-size: 10.3px; + font-weight: 600; + line-height: 15px; +} + +.card-todo-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-todo-label::before { + content: '☷'; + margin-right: 5px; + color: var(--task-color, var(--accent)); +} + +.card-todo-count { + flex: none; + color: var(--secondary); + font-size: 9px; + font-weight: 500; +} + +.card-action { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; + margin-top: 3px; + color: var(--task-color, var(--secondary)); + font-size: 10.8px; + font-weight: 600; + line-height: 16px; +} + +.card-action-symbol { + flex: none; + width: 12px; + text-align: center; + font-size: 9px; +} + +.card-action-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-time { + flex: none; + color: var(--secondary); + font-size: 9.2px; + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.card-trail { + display: block; + min-width: 0; + margin-top: 3px; + overflow: hidden; + color: color-mix(in srgb, var(--text) 62%, transparent); + font-size: 9.3px; + font-weight: 500; + line-height: 14px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.empty-state { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + min-height: 170px; + padding: 20px; + color: var(--secondary); + text-align: center; +} + +.empty-mark { + font-size: 28px; + line-height: 34px; + color: var(--accent); +} + +.empty-title { + color: var(--text); + font-size: 12px; + font-weight: 650; +} + +.empty-copy { + max-width: 250px; + font-size: 10px; + line-height: 16px; +} + +.panel-foot { + flex: none; + padding: 7px 14px 9px; + border-top: 1px solid var(--border); + color: var(--tertiary); + font-size: 9.3px; + line-height: 14px; +} + +@keyframes breathe { + 0%, 100% { transform: translateY(0) scaleY(1); } + 50% { transform: translateY(-2.5px) scaleY(1.012); } +} + +@keyframes work { + 0%, 100% { transform: translateY(0) rotate(-1deg); } + 25% { transform: translateY(-3px) rotate(1.2deg); } + 60% { transform: translateY(-1px) rotate(-0.6deg); } +} + +@keyframes wait { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-1.5px); } +} + +@keyframes happy { + 0%, 100% { transform: translateY(0) scaleY(1); } + 35% { transform: translateY(-4px) scaleY(1.03); } + 55% { transform: translateY(0) scaleY(0.98); } +} + +@keyframes badge { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-2px); } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation: none !important; + transition: none !important; + } +} + +/* Account balance on the pet's belly/waist */ +.balance-pill { + position: absolute; + left: 50%; + top: 73%; + transform: translate(-50%, -50%); + padding: 2px 6px; + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: 999px; + background: rgba(8, 24, 23, 0.72); + color: #fff9ec; + font-size: 9.6px; + font-weight: 700; + font-variant-numeric: tabular-nums; + line-height: 14px; + letter-spacing: 0.01em; + white-space: nowrap; + pointer-events: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.24); +} +.balance-pill[hidden] { display: none; } diff --git a/external_plugins/xiaoqi-pet/desktop/electron/usage.js b/external_plugins/xiaoqi-pet/desktop/electron/usage.js new file mode 100644 index 0000000..2cd1204 --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/electron/usage.js @@ -0,0 +1,199 @@ +'use strict'; + +// 用量与余额:肚子上的胶囊数据源。 +// - 默认读 ZCode 本地用量库(~/.zcode/cli/db/db.sqlite,只读)统计今日/近 7 天 token。 +// - 可选:在 XiaoqiPet/balance.json 配置任意返回 JSON 的额度接口(url + headers + valuePath), +// 配置后胶囊改显示远端余量。60 秒缓存。 + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { stateRoot } = require('./feed'); + +const TTL_MS = 60_000; + +function databasePath() { + const base = process.env.ZCODE_DATA_BASE_DIR || os.homedir(); + return path.join(base, '.zcode', 'cli', 'db', 'db.sqlite'); +} + +function balanceConfigPath() { + return path.join(stateRoot(), 'balance.json'); +} + +function formatTokens(value) { + const trim = (number, unit) => { + const text = number.toFixed(1); + return `${text.endsWith('.0') ? text.slice(0, -2) : text}${unit}`; + }; + if (value >= 1_000_000) return trim(value / 1_000_000, 'M'); + if (value >= 1_000) return trim(value / 1_000, 'k'); + return String(value); +} + +function readUsage() { + const file = databasePath(); + if (!fs.existsSync(file)) return null; + let DatabaseSync; + try { + ({ DatabaseSync } = require('node:sqlite')); + } catch { + return null; + } + let db; + try { + db = new DatabaseSync(file, { readOnly: true }); + } catch { + // WAL 只读打开失败时退回临时副本。 + try { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'xiaoqi-db-')); + for (const suffix of ['', '-wal', '-shm']) { + try { fs.copyFileSync(file + suffix, path.join(tmp, `db.sqlite${suffix}`)); } catch { /* optional part */ } + } + db = new DatabaseSync(path.join(tmp, 'db.sqlite'), { readOnly: true }); + } catch { + return null; + } + } + try { + const startOfToday = new Date(new Date().setHours(0, 0, 0, 0)).getTime(); + const weekAgo = Date.now() - 7 * 86_400_000; + const row = (since) => db.prepare(` + SELECT COALESCE(SUM(computed_total_tokens), 0) AS total, + COALESCE(SUM(input_tokens), 0) AS input, + COALESCE(SUM(output_tokens), 0) AS output, + COALESCE(SUM(reasoning_tokens), 0) AS reasoning, + COUNT(*) AS turns, + COALESCE(SUM(tool_call_count), 0) AS tools + FROM turn_usage WHERE started_at >= ? + `).get(since); + const today = row(startOfToday); + const week = row(weekAgo); + if (!today) return null; + return { + todayTotal: Number(today.total), + todayInput: Number(today.input), + todayOutput: Number(today.output), + todayReasoning: Number(today.reasoning), + todayTurns: Number(today.turns), + todayTools: Number(today.tools), + weekTotal: Number(week ? week.total : 0), + }; + } catch { + return null; + } finally { + try { db.close(); } catch { /* ignore */ } + } +} + +function readRemoteConfig() { + try { + const dict = JSON.parse(fs.readFileSync(balanceConfigPath(), 'utf8')); + if (!dict || typeof dict.url !== 'string') return null; + const url = new URL(dict.url); + const headers = {}; + if (dict.headers && typeof dict.headers === 'object') { + for (const [key, value] of Object.entries(dict.headers)) { + if (typeof value === 'string') headers[key] = value; + } + } + return { + url, + headers, + valuePath: typeof dict.valuePath === 'string' ? dict.valuePath : '', + label: typeof dict.label === 'string' ? dict.label : '套餐余量', + prefix: typeof dict.prefix === 'string' ? dict.prefix : '', + suffix: typeof dict.suffix === 'string' ? dict.suffix : '', + }; + } catch { + return null; + } +} + +function extractValue(json, valuePath) { + if (valuePath === '') return json; + let cursor = json; + for (const key of valuePath.split('.')) { + if (cursor && typeof cursor === 'object' && !Array.isArray(cursor) && key in cursor) { + cursor = cursor[key]; + } else if (Array.isArray(cursor) && /^\d+$/.test(key) && Number(key) < cursor.length) { + cursor = cursor[Number(key)]; + } else { + return undefined; + } + } + return cursor; +} + +function displayValue(value) { + if (typeof value === 'number' && Number.isFinite(value)) { + return Number.isInteger(value) ? value.toLocaleString('en-US') : value.toFixed(2); + } + if (typeof value === 'string' && value !== '') return value; + return null; +} + +async function fetchRemote(config) { + let response; + try { + response = await fetch(config.url, { + method: 'GET', + headers: { accept: 'application/json', ...config.headers }, + signal: AbortSignal.timeout(6000), + }); + } catch (error) { + throw new Error(error && error.name === 'TimeoutError' ? 'timeout' : 'network error'); + } + if (!response.ok) throw new Error(`HTTP ${response.status}`); + let json; + try { + json = await response.json(); + } catch { + throw new Error('invalid json'); + } + const display = displayValue(extractValue(json, config.valuePath)); + if (display === null) throw new Error('valuePath not found in response'); + return { label: config.label, display: config.prefix + display + config.suffix, fetchedAt: Date.now() }; +} + +let cache = null; +let cachedAt = 0; +let inFlight = null; + +async function getBalance(force = false) { + const now = Date.now(); + if (!force && cache && now - cachedAt < TTL_MS) return cache; + if (inFlight) return inFlight; + cachedAt = now; + const usage = readUsage(); + const config = readRemoteConfig(); + if (!config) { + cache = { usage, remote: null, remoteError: null, updatedAt: now }; + return cache; + } + inFlight = (async () => { + let remote = null; + let remoteError = null; + try { + remote = await fetchRemote(config); + } catch (error) { + remoteError = error instanceof Error ? error.message : String(error); + // 拉取失败时沿用上一次的余量并标记过期,和 dsh 版一致。 + remote = cache && cache.remote ? { ...cache.remote, stale: true } : null; + } + return { usage, remote, remoteError, updatedAt: Date.now() }; + })().finally(() => { + inFlight = null; + }); + cache = await inFlight; + return cache; +} + +module.exports = { + databasePath, + balanceConfigPath, + formatTokens, + readUsage, + readRemoteConfig, + getBalance, +}; diff --git a/external_plugins/xiaoqi-pet/hooks/feed-event.ps1 b/external_plugins/xiaoqi-pet/hooks/feed-event.ps1 new file mode 100644 index 0000000..9764f26 --- /dev/null +++ b/external_plugins/xiaoqi-pet/hooks/feed-event.ps1 @@ -0,0 +1,48 @@ +# 小栖桌宠事件投递(Windows 兜底,兼容 Windows PowerShell 5.1)。 +# 当机器上没有 bash 时由 hooks.json 的 `||` 分支调用;行为与 feed-event.sh 一致: +# 把 stdin JSON 包装成一个事件文件写到 %APPDATA%\XiaoqiPet\feed\,先写临时文件再改名,永远退出 0。 + +param([string]$Event = "") + +$ErrorActionPreference = "SilentlyContinue" +$allowed = @("SessionStart", "UserPromptSubmit", "PreToolUse", "PermissionRequest", "PostToolUse", "PostToolUseFailure", "Stop") +if ($allowed -notcontains $Event) { exit 0 } + +$appdata = $env:APPDATA +if (-not $appdata) { $appdata = Join-Path $env:USERPROFILE "AppData\Roaming" } +$dir = Join-Path (Join-Path $appdata "XiaoqiPet") "feed" +try { + New-Item -ItemType Directory -Force -Path $dir | Out-Null +} catch { + exit 0 +} + +$payload = "" +try { $payload = [Console]::In.ReadToEnd() } catch { $payload = "" } +if ($payload.Length -gt 65536) { $payload = $payload.Substring(0, 65536) } + +$cwd = (Get-Location).Path.Replace("\", "\\").Replace('"', '\"') +$at = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() +$json = '{"v":1,"event":"' + $Event + '","at":' + $at + ',"cwd":"' + $cwd + '","payload":' + $payload + '}' + +$tmp = Join-Path $dir (".feed." + [guid]::NewGuid().ToString("N")) +$final = Join-Path $dir ("{0}-{1}-{2}.json" -f $at, $PID, (Get-Random -Maximum 32767)) +try { + [System.IO.File]::WriteAllText($tmp, $json, (New-Object System.Text.UTF8Encoding($false))) + Move-Item -Force -Path $tmp -Destination $final +} catch { + Remove-Item -Force -Path $tmp +} + +# 桌宠应用不在线时避免目录无限增长:每小时顺带清理超过一天的残留事件文件。 +$marker = Join-Path $dir ".cleaned" +$stale = $true +if (Test-Path $marker) { + $stale = ((Get-Date) - (Get-Item $marker).LastWriteTime).TotalSeconds -gt 3600 +} +if ($stale) { + Get-ChildItem -Path $dir -Filter *.json | Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-1) } | Remove-Item -Force + New-Item -ItemType File -Force -Path $marker | Out-Null +} + +exit 0 diff --git a/external_plugins/xiaoqi-pet/hooks/feed-event.sh b/external_plugins/xiaoqi-pet/hooks/feed-event.sh new file mode 100755 index 0000000..9613c48 --- /dev/null +++ b/external_plugins/xiaoqi-pet/hooks/feed-event.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# 小栖桌宠事件投递:把 ZCode hook 的 stdin JSON 原样包装成一个事件文件。 +# 目录:macOS ~/Library/Application Support/XiaoqiPet/feed/ +# Linux $XDG_CONFIG_HOME/xiaoqi-pet/feed/(默认 ~/.config/xiaoqi-pet/feed/) +# Windows(Git-Bash) %APPDATA%\XiaoqiPet\feed\ +# 每个事件一个文件,先写临时文件再原子改名;任何情况下都退出 0,不影响会话。 + +event="$1" +case "$event" in + SessionStart|UserPromptSubmit|PreToolUse|PermissionRequest|PostToolUse|PostToolUseFailure|Stop) ;; + *) exit 0 ;; +esac + +case "$(uname -s)" in + Darwin) + base="$HOME/Library/Application Support/XiaoqiPet" + ;; + MINGW*|MSYS*|CYGWIN*) + appdata="${APPDATA:-$HOME/AppData/Roaming}" + appdata="$(cygpath -u "$appdata" 2>/dev/null || printf '%s' "$appdata")" + base="$appdata/XiaoqiPet" + ;; + *) + base="${XDG_CONFIG_HOME:-$HOME/.config}/xiaoqi-pet" + ;; +esac + +dir="$base/feed" +mkdir -p "$dir" 2>/dev/null || exit 0 +chmod 700 "$base" "$dir" 2>/dev/null + +tmp=$(mktemp "$dir/.feed.XXXXXX") 2>/dev/null || exit 0 +cwd_escaped=$(pwd | sed 's/\\/\\\\/g; s/"/\\"/g') +{ + printf '{"v":1,"event":"%s","at":%s,"cwd":"%s","payload":' "$event" "$(date +%s)" "$cwd_escaped" + head -c 65536 + printf '}' +} > "$tmp" 2>/dev/null + +if [ -s "$tmp" ] && mv -f "$tmp" "$dir/$(date +%s)-$$-$RANDOM.json" 2>/dev/null; then + : +else + rm -f "$tmp" 2>/dev/null +fi + +# 桌宠应用不在线时避免目录无限增长:每小时顺带清理超过一天的残留事件文件。 +marker="$dir/.cleaned" +now=$(date +%s) +marker_mtime=0 +if [ -f "$marker" ]; then + marker_mtime=$(stat -f %m "$marker" 2>/dev/null || stat -c %Y "$marker" 2>/dev/null || echo 0) +fi +if [ $((now - marker_mtime)) -gt 3600 ]; then + find "$dir" -name '*.json' -mtime +1 -delete 2>/dev/null + touch "$marker" 2>/dev/null +fi + +exit 0 diff --git a/external_plugins/xiaoqi-pet/hooks/hooks.json b/external_plugins/xiaoqi-pet/hooks/hooks.json new file mode 100644 index 0000000..2faa99e --- /dev/null +++ b/external_plugins/xiaoqi-pet/hooks/hooks.json @@ -0,0 +1,88 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/feed-event.sh\" SessionStart || powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/feed-event.ps1\" SessionStart", + "timeoutMs": 5000, + "statusMessage": "小栖桌宠:同步会话状态" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/feed-event.sh\" UserPromptSubmit || powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/feed-event.ps1\" UserPromptSubmit", + "timeoutMs": 5000, + "statusMessage": "小栖桌宠:同步会话状态" + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/feed-event.sh\" PreToolUse || powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/feed-event.ps1\" PreToolUse", + "timeoutMs": 5000, + "statusMessage": "小栖桌宠:同步会话状态" + } + ] + } + ], + "PermissionRequest": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/feed-event.sh\" PermissionRequest || powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/feed-event.ps1\" PermissionRequest", + "timeoutMs": 5000, + "statusMessage": "小栖桌宠:同步会话状态" + } + ] + } + ], + "PostToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/feed-event.sh\" PostToolUse || powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/feed-event.ps1\" PostToolUse", + "timeoutMs": 5000, + "statusMessage": "小栖桌宠:同步会话状态" + } + ] + } + ], + "PostToolUseFailure": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/feed-event.sh\" PostToolUseFailure || powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/feed-event.ps1\" PostToolUseFailure", + "timeoutMs": 5000, + "statusMessage": "小栖桌宠:同步会话状态" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/feed-event.sh\" Stop || powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/hooks/feed-event.ps1\" Stop", + "timeoutMs": 5000, + "statusMessage": "小栖桌宠:同步会话状态" + } + ] + } + ] + } +} diff --git a/external_plugins/xiaoqi-pet/skills/xiaoqi-pet/SKILL.md b/external_plugins/xiaoqi-pet/skills/xiaoqi-pet/SKILL.md new file mode 100644 index 0000000..433e56d --- /dev/null +++ b/external_plugins/xiaoqi-pet/skills/xiaoqi-pet/SKILL.md @@ -0,0 +1,39 @@ +--- +name: xiaoqi-pet +description: 让 ZCode 桌宠「小栖」说话或互动时使用。当用户提到桌宠、小栖、桌面宠物(pet),或要求当前任务进度在桌宠上露出的场景;也包含 xiaoqi-pet 插件的事件文件约定与排查方法。 +--- + +# 小栖桌宠(xiaoqi-pet) + +小栖是一个桌面宠物(macOS 原生应用,或 Windows / Linux 的 Electron 版),漂浮在所有窗口之上,实时显示 ZCode 会话的进行状态(思考/执行工具/等待确认/完成)。会话状态由插件 hooks 自动投递,你不需要为状态展示做任何事。 + +## 让小栖说话 + +写一个互动事件文件,小栖桌面端会在 1 秒内弹出一个临时气泡,展示 `speech` 文本: + +```bash +# macOS +dir="$HOME/Library/Application Support/XiaoqiPet/emote" +# Linux:dir="${XDG_CONFIG_HOME:-$HOME/.config}/xiaoqi-pet/emote" +# Windows(Git-Bash):dir="${APPDATA}/XiaoqiPet/emote" +mkdir -p "$dir" +tmp=$(mktemp "$dir/.emote.XXXXXX") +printf '{"v":1,"type":"emote","speech":"%s","seconds":6}' "说的话" > "$tmp" +mv -f "$tmp" "$dir/$(date +%s)-$$.json" +``` + +字段说明: + +- `speech`:必填,气泡文本,UTF-8,建议不超过 60 个字符,最长 200(超长会被截断)。只放一句话,不放列表和代码。 +- `seconds`:可选,展示时长 3–30 秒,默认 6。 + +## 使用分寸 + +- 只在用户明确要求("让小栖说……")、阶段性里程碑(重大检查点通过)或等待用户输入需要提醒时使用;每个回合至多一次,不刷屏。 +- `speech` 不要包含敏感信息、长路径、代码片段;语气简短可爱即可。 +- 文件必须先写临时文件再 `mv` 原子改名,避免读到半截内容。 + +## 排查 + +- 用户说小栖没反应:先确认状态目录是否存在(macOS `~/Library/Application Support/XiaoqiPet/`,Linux `~/.config/xiaoqi-pet/`,Windows `%APPDATA%\XiaoqiPet\`;hook 已在投递则存在);再确认桌宠是否在运行(macOS 是「ZCode Xiaoqi Pet.app」,菜单栏爪印图标)。应用未运行时事件文件会安全留存,应用启动后会自动补上最近状态。 +- 状态长时间不动:运行 `~/Applications/ZCode Xiaoqi Pet.app/Contents/MacOS/XiaoqiPet --diagnose`(或 dist 路径)查看解析出的会话状态;`pendingFeedFiles` 大于 0 说明应用没在消费事件。 diff --git a/marketplace.json b/marketplace.json index 2fc615c..bd02f44 100644 --- a/marketplace.json +++ b/marketplace.json @@ -10,7 +10,10 @@ "url": "https://z.ai" }, "unlistedPlugins": [ - { "name": "openpencil", "source": "./external_plugins/openpencil" } + { + "name": "openpencil", + "source": "./external_plugins/openpencil" + } ], "plugins": [ { @@ -827,7 +830,9 @@ }, { "name": "excalidraw", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "Excalidraw", "displayName_i18n": { "en": "Excalidraw", @@ -850,7 +855,9 @@ "name": "photocraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/photocraft/icon.png", "source": "./external_plugins/photocraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "PhotoCraft", "displayName_i18n": { "en": "PhotoCraft", @@ -877,7 +884,9 @@ "name": "vectorcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/vectorcraft/icon.png", "source": "./external_plugins/vectorcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "VectorCraft", "displayName_i18n": { "en": "VectorCraft", @@ -904,7 +913,9 @@ "name": "pdfcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/pdfcraft/icon.png", "source": "./external_plugins/pdfcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "PdfCraft", "displayName_i18n": { "en": "PdfCraft", @@ -931,7 +942,9 @@ "name": "designcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/designcraft/icon.png", "source": "./external_plugins/designcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "DesignCraft", "displayName_i18n": { "en": "DesignCraft", @@ -958,7 +971,9 @@ "name": "wordcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/wordcraft/icon.png", "source": "./external_plugins/wordcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "WordCraft", "displayName_i18n": { "en": "WordCraft", @@ -985,7 +1000,9 @@ "name": "gridcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/gridcraft/icon.png", "source": "./external_plugins/gridcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "GridCraft", "displayName_i18n": { "en": "GridCraft", @@ -1012,7 +1029,9 @@ "name": "deckcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/deckcraft/icon.png", "source": "./external_plugins/deckcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "DeckCraft", "displayName_i18n": { "en": "DeckCraft", @@ -1039,7 +1058,9 @@ "name": "cadcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/cadcraft/icon.png", "source": "./external_plugins/cadcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "CADCraft", "displayName_i18n": { "en": "CADCraft", @@ -1066,7 +1087,9 @@ "name": "soundcraft", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/soundcraft/icon.png", "source": "./external_plugins/soundcraft", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "SoundCraft", "displayName_i18n": { "en": "SoundCraft", @@ -1149,7 +1172,9 @@ }, "category": "developer-tools", "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/kicad/icon.png", - "componentTypes": ["ui"], + "componentTypes": [ + "ui" + ], "displayName": "KiCad", "displayName_i18n": { "en": "KiCad", @@ -1443,6 +1468,41 @@ "mcp", "verification" ] + }, + { + "name": "xiaoqi-pet", + "source": "./external_plugins/xiaoqi-pet", + "version": "0.3.1", + "description": "Xiaoqi desk pet: a companion for ZCode that mirrors live session activity — phases, tools, approval waits and completions — on a desktop pet, with a multi-session task board, color-coded task drawers, desktop notices and a today's-usage / plan-balance capsule.", + "description_i18n": { + "en": "Xiaoqi desk pet: a companion for ZCode that mirrors live session activity — phases, tools, approval waits and completions — on a desktop pet, with a multi-session task board, color-coded task drawers, desktop notices and a today's-usage / plan-balance capsule.", + "zh-CN": "小栖桌宠:把 ZCode 会话状态(思考、执行工具、等待确认、完成)实时投到桌面宠物上的伴侣,带多会话任务看板、彩色抽屉卡、桌面通知与今日用量 / 套餐余量胶囊。" + }, + "category": "utilities", + "keywords": [ + "desktop-pet", + "session-monitor", + "notifications", + "hooks", + "macos", + "windows", + "linux" + ], + "author": { + "name": "white-source", + "url": "https://github.com/white-source" + }, + "icon": "https://cdn-zcode.z.ai/zcode/official-plugin/assets/xiaoqi-pet/icon.png", + "examplePrompts": [ + "Let the pet say the build finished and show how long the last session took", + "Which of my ZCode sessions is waiting on me right now?" + ], + "examplePrompts_i18n": { + "zh-CN": [ + "让小栖说一声构建完成,并显示上一轮会话用了多久", + "现在哪个 ZCode 会话在等我确认?" + ] + } } ] -} \ No newline at end of file +}