Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions assets/desktop_pet/classic_pet.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@
// Extra hit margin (canvas px) so moving silhouettes stay clickable without per-frame region updates.
// Docked Whale Girl peeks in from the screen edge; PEEK_HIDDEN of the art stays past the edge
// so she can lean in without revealing where the drawing is cut.
const PEEK_HEIGHT = 170, PEEK_HIDDEN = 16;
// The peek art is drawn about 1.8 times larger than the sprite frames (eye height and headband-to-
// mouth both measure 1.8x), so it is shown at the frame scale divided by that ratio: her head
// keeps the size it has in every other pose instead of nearly doubling at the edge.
const PEEK_ART_RATIO = 1.8, PEEK_HIDDEN = 9;
const MOTION_MARGIN = { float: [0, 4], breathe: [0, 3], shake: [3, 0], wiggle: [8, 0], tilt: [16, 2], peek: [PEEK_HIDDEN + 4, 8] };
const timing = { fidgetMin: 7000, fidgetMax: 16000, sleepAfter: 5 * 60 * 1000, greeting: 6000 };
// The original agents hold a pose through self-looping branches (Clippy's idle stares ~6s).
Expand Down Expand Up @@ -101,14 +104,15 @@
const peeking = () => Boolean(docked && peek);
const activeMotion = () => (peeking() ? 'peek' : motionKind || (profile?.breathe ? 'breathe' : ''));
function geometry() {
const [w, h] = data.framesize;
const frameScale = Math.min(184 / w, 140 / h);
if (peeking()) {
const height = PEEK_HEIGHT, width = height * peek.fw / peek.fh;
return { peek: true, w: peek.fw, h: peek.fh, scale: height / peek.fh, width, height,
const scale = frameScale / PEEK_ART_RATIO, width = peek.fw * scale, height = peek.fh * scale;
return { peek: true, w: peek.fw, h: peek.fh, scale, width, height,
x: docked === 'right' ? cv.width - width + PEEK_HIDDEN : -PEEK_HIDDEN, y: cv.height - height - 2 };
}
const [w, h] = data.framesize;
const scale = Math.min(184 / w, 140 / h), width = w * scale, height = h * scale;
return { w, h, scale, width, height, x: (cv.width - width) / 2, y: cv.height - height - 8 };
const width = w * frameScale, height = h * frameScale;
return { w, h, scale: frameScale, width, height, x: (cv.width - width) / 2, y: cv.height - height - 8 };
}
function offsetAt(now, g) {
const kindOfMotion = activeMotion();
Expand Down
2 changes: 1 addition & 1 deletion assets/desktop_pet/whale/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@

## 趴边偷看(peek.png)

鲸鱼娘拖到屏幕左/右边缘吸附后改用 `peek.png`:两帧并排(左睁眼、右闭眼),每帧 178×300,透明背景,画面右缘就是她躲着的那条边(贴左边时页面水平镜像)。不来自上游项目,2026-10-16 用 Codex 的内置图像生成(codex-cli 0.162,`image_generation` 功能)绘制,以本目录 idle / welcome / joy 的首帧作为风格参考,要求同一角色设计与画风:从画面右缘探出半个头、双手扒着边、好奇又害羞地偷看。闭眼帧由同一张图做「只闭眼、其余不变」的编辑,再只把眼睛区域贴回睁眼图,眨眼时其余线条不抖动。纯绿背景抠成透明并去绿边,在她扒着的那条边线处裁掉,两帧同裁同缩放。生成用的是 ZipZipPipe 设计的角色形象,沿用上游许可说明。
鲸鱼娘拖到屏幕左/右边缘吸附后改用 `peek.png`:两帧并排(左睁眼、右闭眼),每帧 178×300,透明背景,画面右缘就是她躲着的那条边(贴左边时页面水平镜像)。不来自上游项目,2026-10-16 用 Codex 的内置图像生成(codex-cli 0.162,`image_generation` 功能)绘制,以本目录 idle / welcome / joy 的首帧作为风格参考,要求同一角色设计与画风:从画面右缘探出半个头、双手扒着边、好奇又害羞地偷看。闭眼帧由同一张图做「只闭眼、其余不变」的编辑,再只把眼睛区域贴回睁眼图,眨眼时其余线条不抖动。纯绿背景抠成透明并去绿边,在她扒着的那条边线处裁掉,两帧同裁同缩放。这张原画比精灵帧大约 1.8 倍,页面按常规帧缩放 ÷ 1.8 绘制(`classic_pet.js` 的 `PEEK_ART_RATIO`),换图时要重新量这个比例。生成用的是 ZipZipPipe 设计的角色形象,沿用上游许可说明。
5 changes: 3 additions & 2 deletions docs/desktop-agent-office.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,16 @@ Windows 与 macOS Desktop 可开启透明桌面宠物,默认关闭。设置
- **悬停**:鼠标移到宠物身上跳一下(Codex jumping,3 秒内不重复),悬停期间整个宠物框可点,避免跳动改变轮廓导致悬停闪烁。
- **单击**:做一个回应动作(回形针挥手、rover 被点、鲸鱼娘挥手);双击的第二下不会重复回应。
- **双击**:陪伴宠物双击把 ACECode 主窗口叫到前台(Codex Mini 也是直接打开主窗口),具体会话从卡片打开。虚拟办公室双击选中的人物打开其会话,双击空地打开当前会话,没有会话时也把主窗口叫到前台。Windows 由宠物窗口短暂挂接前台线程输入队列后激活主窗口(点击落在 WebView2 浏览器进程的子窗口里,本进程不一定有前台权限),macOS 走与通知点击相同的 `mac_bring_window_foreground`。
- **趴边偷看(只有鲸鱼娘)**:拖到屏幕左 / 右边缘附近松手(窗口中心离边缘不超过窗口宽度的 1/4,约等于身体碰到屏幕边),窗口贴到那条边,鲸鱼娘换成从屏幕边探出半个头、双手扒着边偷看的样子(贴左边时镜像):缓慢地探进探出,每 2–5 秒眨一次眼、偶尔连眨两下;悬停时探得更靠里并开心地眯眼,有会话等你处理时也会多探出来一点,新完成的会话让她开心眯眼、失败时抖一下;双击照常打开 ACECode,再拖起来就恢复全身。缩放和恢复默认大小时保持贴边,「回到右下角」取消吸附。画面是用 Codex 按鲸鱼娘原有帧的画风画的两帧(睁眼 / 闭眼),来源与处理见 `assets/desktop_pet/whale/README.md`。吸附判定与贴边位置是 `desktop_pet_layout` 里的纯函数(`desktop_pet_dock_side` / `dock_desktop_pet`),原生在拖动结束时判定并发 `{type:'pet-dock', side:'left'|'right'|''}`。
- **拖动**:原生拖动循环按窗口横向位移向页面发 `{type:'pet-drag', phase:'start'|'move'|'end', direction}`(`DesktopPetDragTracker` 累计 4 像素才报方向)。鲸鱼娘整个拖动过程都是被一只手拎起来的姿势(drag 帧)并以手为支点轻轻摆动;回形针朝拖动方向看;rover 先吃惊再朝拖动方向看,停住 0.45 秒转回正面。松开后继续原来的状态。
- **趴边偷看(只有鲸鱼娘)**:拖到屏幕左 / 右边缘附近松手(窗口中心离边缘不超过窗口宽度的 1/4,约等于身体碰到屏幕边),窗口贴到那条边,鲸鱼娘换成从屏幕边探出半个头、双手扒着边偷看的样子(贴左边时镜像):缓慢地探进探出,每 2–5 秒眨一次眼、偶尔连眨两下;悬停时探得更靠里并开心地眯眼,有会话等你处理时也会多探出来一点,新完成的会话让她开心眯眼、失败时抖一下;双击照常打开 ACECode,再拖起来就恢复全身。缩放和恢复默认大小时保持贴边,「回到右下角」取消吸附。画面是用 Codex 按鲸鱼娘原有帧的画风画的两帧(睁眼 / 闭眼),来源与处理见 `assets/desktop_pet/whale/README.md`。偷看原画比精灵帧大约 1.8 倍(眼睛高度、发箍到嘴的距离都量过),按常规帧缩放 ÷ 1.8 绘制,头和站着时一样大;曾经固定画成 170 像素高,头大了将近一倍。吸附判定与贴边位置是 `desktop_pet_layout` 里的纯函数(`desktop_pet_dock_side` / `dock_desktop_pet`),原生在拖动结束时判定并发 `{type:'pet-dock', side:'left'|'right'|''}`。
- **拖动**:原生拖动循环按窗口横向位移向页面发 `{type:'pet-drag', phase:'start'|'move'|'end', direction}`(`DesktopPetDragTracker` 累计 4 像素才报方向)。鲸鱼娘整个拖动过程都是被一只手拎起来的姿势(drag 帧)并以手为支点轻轻摆动;回形针朝拖动方向看;rover 先吃惊再朝拖动方向看,停住 0.45 秒转回正面。松开后继续原来的状态。松手时没有吸附,窗口又有一部分在屏幕外,就整窗收回工作区(`keep_desktop_pet_in_work_area`,所有宠物都适用)。吸附要窗口伸出屏幕约 1/4 宽,只是蹭到边上松手,正好会停在半出屏的位置,头顶的会话卡片会被屏幕边截掉。

虚拟办公室继续显示主/子 Agent 的完整协作场景。单会话有一个主角色 Maestro,多 agent 任务按真实子会话入座;无需启动额外模型,也没有演示任务循环。美术与拖动缩放来自原有 Claude 桌宠,旧的 `design-prototypes/agent-office` 事务所原型已移除。

## 操作

- 左下角齿轮菜单的“显示桌面宠物 / 隐藏桌面宠物”位于“设置”和“外观”之间。设置里的“桌面宠物”位于“个性化”和“使用情况”之间,提供同一个开启开关。
- 从 0.9.37 稳定版起,没有保存过选择的用户会收到一次开启邀请。关闭或选择“暂不开启”保持关闭;以后仍可从菜单或设置开启。预览复用嵌入的办公室 HTML,在独立沙箱 iframe 中播放演示,关闭弹框后释放;支持暂停和系统的“减少动态效果”,不访问真实会话或额外网络。
- 「更多宠物」宣传:已经答复过上面邀请的老用户,启动后在首屏弹一次:主标题「新特性」,副标题「增加更多宠物,丰富您快乐工作的每一天」,只展示大眼回形针、rover、鲸鱼娘的图和名字,不加说明(用户要求)。点一只就切成它,宠物没开就同时开启;「知道了」关闭。`office.json` 的 `pets_promo_seen` 记录是否看过,由 `aceDesktop_claimPetsPromo` 原子认领。新用户认领开启邀请时,或者没看过邀请就在设置里开关宠物时,这个标记一并置位,不会连弹两个窗。弹窗在 `components/DesktopPetsPromo.jsx`,与开启邀请共用遮挡与引导让位规则。
- 顶部菜单在鼠标移入办公室时显示,移出后保留 1 秒再隐藏;重新移入会取消隐藏,键盘操作时保持可见。隐藏菜单的透明区域可穿透点击。
- 菜单下方显示当前会话标题,切换或改名后同步更新;长标题单行省略,悬停可查看全文。断线或尚无选中会话时保留对应状态提示。
- 菜单右侧 pin 控制办公室是否始终置顶:默认置顶,取消后可被其他普通窗口覆盖,拖动、缩放和停靠均保持当前选择;重新启动桌宠恢复默认置顶。叉号只关闭办公室并同步菜单/设置的关闭状态,不退出 ACECode,也不停止 agent 任务;可在同一次运行中重新开启。
Expand Down
34 changes: 28 additions & 6 deletions src/apps/desktop/desktop_office_service.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class DesktopOfficeService : public std::enable_shared_from_this<DesktopOfficeSe
if (data.is_object()) {
enabled_ = data.value("enabled", nlohmann::json()) == true;
welcome_seen_ = data.value("welcome_seen", nlohmann::json()) == true;
pets_promo_seen_ = data.value("pets_promo_seen", nlohmann::json()) == true;
const auto kind = data.value("pet_kind", nlohmann::json());
if (kind.is_string() && valid_pet_kind(kind.get<std::string>())) pet_kind_ = kind.get<std::string>();
}
Expand Down Expand Up @@ -63,8 +64,21 @@ class DesktopOfficeService : public std::enable_shared_from_this<DesktopOfficeSe
const auto self = weak.lock();
if (!self) return std::string("{\"ok\":false}");
const bool show = self->state()["welcomePending"];
if (show && !self->save(self->enabled_, true)) return self->failure("save_failed").dump();
if (show) self->welcome_seen_ = true;
// A first-time user meets every pet through this invitation, so the later
// "more pets" announcement is consumed with it rather than shown on the next launch.
if (show && !self->save(self->enabled_, true, true)) return self->failure("save_failed").dump();
if (show) self->welcome_seen_ = self->pets_promo_seen_ = true;
auto result = self->state();
result["show"] = show;
return result.dump();
});
// One-time announcement of the companion pets for users who already answered the invitation.
host_.bind("aceDesktop_claimPetsPromo", [weak](const std::string&) {
const auto self = weak.lock();
if (!self) return std::string("{\"ok\":false}");
const bool show = self->state()["petsPromoPending"];
if (show && !self->save(self->enabled_, self->welcome_seen_, true)) return self->failure("save_failed").dump();
if (show) self->pets_promo_seen_ = true;
auto result = self->state();
result["show"] = show;
return result.dump();
Expand Down Expand Up @@ -98,7 +112,8 @@ class DesktopOfficeService : public std::enable_shared_from_this<DesktopOfficeSe

nlohmann::json state() const {
return {{"ok", true}, {"available", available_}, {"enabled", enabled_},
{"welcomePending", available_ && eligible_ && !welcome_seen_}, {"petKind", pet_kind_}};
{"welcomePending", available_ && eligible_ && !welcome_seen_},
{"petsPromoPending", available_ && welcome_seen_ && !pets_promo_seen_}, {"petKind", pet_kind_}};
}

nlohmann::json set_pet_kind(const std::string& kind) {
Expand All @@ -118,8 +133,12 @@ class DesktopOfficeService : public std::enable_shared_from_this<DesktopOfficeSe

nlohmann::json set_enabled(bool enabled) {
if (!available_) return failure("unavailable");
if (!save(enabled, true)) return failure("save_failed");
// Turning pets on or off before ever seeing the invitation means the user already
// found every pet in Settings; the "more pets" announcement would be news to no one.
const bool promo_seen = pets_promo_seen_ || !welcome_seen_;
if (!save(enabled, true, promo_seen)) return failure("save_failed");
welcome_seen_ = true;
pets_promo_seen_ = promo_seen;
enabled_ = enabled;
if (enabled && (!window_ || window_closed_)) {
destroy_window();
Expand All @@ -140,9 +159,11 @@ class DesktopOfficeService : public std::enable_shared_from_this<DesktopOfficeSe
return kind == "office" || kind == "clippy" || kind == "rover" || kind == "whale";
}

bool save(bool enabled, bool welcome_seen) const {
bool save(bool enabled, bool welcome_seen) const { return save(enabled, welcome_seen, pets_promo_seen_); }
bool save(bool enabled, bool welcome_seen, bool pets_promo_seen) const {
return atomic_write_file(path_to_utf8(path_),
nlohmann::json{{"enabled", enabled}, {"welcome_seen", welcome_seen}, {"pet_kind", pet_kind_}}.dump());
nlohmann::json{{"enabled", enabled}, {"welcome_seen", welcome_seen},
{"pets_promo_seen", pets_promo_seen}, {"pet_kind", pet_kind_}}.dump());
}

nlohmann::json failure(const std::string& error) const {
Expand Down Expand Up @@ -198,6 +219,7 @@ class DesktopOfficeService : public std::enable_shared_from_this<DesktopOfficeSe
bool eligible_ = false;
bool enabled_ = false;
bool welcome_seen_ = false;
bool pets_promo_seen_ = false;
bool window_closed_ = false;
std::string pet_kind_ = "office";
std::function<std::string()> preview_;
Expand Down
10 changes: 7 additions & 3 deletions src/apps/desktop/desktop_pet.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -283,11 +283,15 @@ struct PetController : std::enable_shared_from_this<PetController> {
webview->PostWebMessageAsJson(utf8_to_wide(event.dump()).c_str());
}

// 拖动松手后:鲸鱼娘离左/右边足够近就贴到那条边,页面换成趴边偷看。
// 拖动松手后:鲸鱼娘离左/右边足够近就贴到那条边,页面换成趴边偷看;
// 其它情况把窗口收回工作区,头顶的会话卡片不会被屏幕边截掉。
void settle_dock() {
const DesktopPetRect work = window_work_area();
docked_side = pet_kind == "whale" ? desktop_pet_dock_side(window_rect(), work) : 0;
if (docked_side) apply_placement({dock_desktop_pet(window_rect(), work, docked_side), placement.scale});
const DesktopPetRect current = window_rect();
docked_side = pet_kind == "whale" ? desktop_pet_dock_side(current, work) : 0;
const DesktopPetRect next = docked_side ? dock_desktop_pet(current, work, docked_side)
: keep_desktop_pet_in_work_area(current, work);
if (next.x != current.x || next.y != current.y) apply_placement({next, placement.scale});
publish_dock();
}

Expand Down
8 changes: 8 additions & 0 deletions src/apps/desktop/desktop_pet_layout.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,14 @@ DesktopPetRect dock_desktop_pet(const DesktopPetRect& window, const DesktopPetRe
return next;
}

DesktopPetRect keep_desktop_pet_in_work_area(const DesktopPetRect& window, const DesktopPetRect& work_area) {
if (work_area.width <= 0 || work_area.height <= 0) return window;
DesktopPetRect next = window;
next.x = clamp_origin(window.x, window.width, work_area.x, work_area.width);
next.y = clamp_origin(window.y, window.height, work_area.y, work_area.height);
return next;
}

int DesktopPetDragTracker::move(double x) {
if (!std::isfinite(x)) return 0;
const double dx = x - anchor_;
Expand Down
5 changes: 5 additions & 0 deletions src/apps/desktop/desktop_pet_layout.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ int desktop_pet_dock_side(const DesktopPetRect& window, const DesktopPetRect& wo
// 把窗口贴到工作区左(-1)/右(+1)边,竖直方向夹回工作区;side 为 0 时原样返回。
DesktopPetRect dock_desktop_pet(const DesktopPetRect& window, const DesktopPetRect& work_area, int side);

// 拖动松手后没有吸附时把整个窗口收回工作区:窗口一半露在屏幕外时,宠物头顶的会话卡片
// 会被屏幕边截掉(吸附要求窗口伸出屏幕约 1/4 宽,只蹭到边上松手正好落在这个区间)。
// 窗口比工作区还大时贴左 / 上边。
DesktopPetRect keep_desktop_pet_in_work_area(const DesktopPetRect& window, const DesktopPetRect& work_area);

// 右键菜单「切换宠物」里的四种宠物,顺序与设置页一致;id 与 office.json 的 pet_kind 相同。
struct DesktopPetKind {
const char* id;
Expand Down
10 changes: 7 additions & 3 deletions src/apps/desktop/desktop_pet_mac.mm
Original file line number Diff line number Diff line change
Expand Up @@ -476,11 +476,15 @@ - (void)choose:(NSMenuItem*)item {
deliver({{"type", "pet-dock"}, {"side", docked_side_ < 0 ? "left" : docked_side_ > 0 ? "right" : ""}});
}

// 拖动松手后:鲸鱼娘离左/右边足够近就贴到那条边,页面换成趴边偷看。
// 拖动松手后:鲸鱼娘离左/右边足够近就贴到那条边,页面换成趴边偷看;
// 其它情况把窗口收回可见区域,头顶的会话卡片不会被屏幕边截掉。
void PetController::settle_dock() {
const DesktopPetRect work = work_area();
docked_side_ = pet_kind_ == "whale" ? desktop_pet_dock_side(window_rect(), work) : 0;
if (docked_side_) apply_placement({dock_desktop_pet(window_rect(), work, docked_side_), current_scale()});
const DesktopPetRect current = window_rect();
docked_side_ = pet_kind_ == "whale" ? desktop_pet_dock_side(current, work) : 0;
const DesktopPetRect next = docked_side_ ? dock_desktop_pet(current, work, docked_side_)
: keep_desktop_pet_in_work_area(current, work);
if (next.x != current.x || next.y != current.y) apply_placement({next, current_scale()});
publish_dock();
}

Expand Down
Loading
Loading