diff --git a/assets/xiaoqi-pet/icon.png b/assets/xiaoqi-pet/icon.png new file mode 100644 index 0000000..4eddb97 Binary files /dev/null and b/assets/xiaoqi-pet/icon.png differ diff --git a/external_plugins/xiaoqi-pet/.zcode-plugin/plugin.json b/external_plugins/xiaoqi-pet/.zcode-plugin/plugin.json new file mode 100644 index 0000000..e80bbe3 --- /dev/null +++ b/external_plugins/xiaoqi-pet/.zcode-plugin/plugin.json @@ -0,0 +1,15 @@ +{ + "name": "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.", + "author": { + "name": "fengxiaohui" + }, + "hooks": "./hooks/hooks.json", + "skills": "./skills", + "commands": "./commands", + "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 会话状态(思考、执行工具、等待确认、完成)实时投到桌面宠物上的伴侣,带多会话任务看板、彩色抽屉卡、桌面通知与今日用量 / 套餐余量胶囊。" + } +} diff --git a/external_plugins/xiaoqi-pet/LICENSE b/external_plugins/xiaoqi-pet/LICENSE new file mode 100644 index 0000000..69ae141 --- /dev/null +++ b/external_plugins/xiaoqi-pet/LICENSE @@ -0,0 +1,26 @@ +MIT License + +Copyright (c) 2026 Xiaoqi Desk Pet contributors + +This project is a ZCode port of dsh-xiaoqi-pet +(https://github.com/white-source/dsh-xiaoqi-pet), which is also released +under the MIT License, Copyright (c) 2026 Xiaoqi Desk Pet contributors. +Pet artwork and UI structure originate from that project. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/external_plugins/xiaoqi-pet/README.md b/external_plugins/xiaoqi-pet/README.md new file mode 100644 index 0000000..88d0885 --- /dev/null +++ b/external_plugins/xiaoqi-pet/README.md @@ -0,0 +1,137 @@ +# Xiaoqi Desk Pet (小栖桌宠) + +A ZCode plugin that puts a small elephant on your desktop. It floats above every window and mirrors what your ZCode sessions are doing — thinking, running tools, waiting for your approval, or finished — with a multi-session task board, color-coded drawer cards, single-task bubbles, desktop notices, and a usage/balance capsule on the pet's belly. + +Ported from [dsh-xiaoqi-pet](https://github.com/white-source/dsh-xiaoqi-pet) 1.5.2 (a DeepSeek Harness companion). The HTTP bridge and web overlay were replaced with ZCode plugin hooks feeding local event files — no port, no token. The pet itself comes in two flavors: a **native macOS Swift app** and a **cross-platform Electron app** for Windows / Linux / macOS; both read the same event files. + +## Components + +| Part | Role | +| --- | --- | +| **Hooks** (7 events) | `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PermissionRequest`, `PostToolUse`, `PostToolUseFailure`, `Stop` — each wraps the hook's stdin JSON into an atomic event file. `feed-event.sh` on macOS/Linux; Windows without bash falls back to `feed-event.ps1` automatically. The plugin ships hooks, so ZCode enables the hook runner on install. | +| **Skill** `xiaoqi-pet` | Lets the agent make the pet speak by writing an emote event file. | +| **Command** `/pet-say ` | 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 0000000..4eddb97 Binary files /dev/null and b/external_plugins/xiaoqi-pet/desktop/electron/assets/icon.png differ diff --git a/external_plugins/xiaoqi-pet/desktop/electron/assets/pet.svg b/external_plugins/xiaoqi-pet/desktop/electron/assets/pet.svg new file mode 100644 index 0000000..93739ba --- /dev/null +++ b/external_plugins/xiaoqi-pet/desktop/electron/assets/pet.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 +}