diff --git a/assets/desktop_pet/classic_pet.js b/assets/desktop_pet/classic_pet.js index b60ec077..1bc058e3 100644 --- a/assets/desktop_pet/classic_pet.js +++ b/assets/desktop_pet/classic_pet.js @@ -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). @@ -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(); diff --git a/assets/desktop_pet/whale/README.md b/assets/desktop_pet/whale/README.md index 07389b8a..aad5caeb 100644 --- a/assets/desktop_pet/whale/README.md +++ b/assets/desktop_pet/whale/README.md @@ -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 设计的角色形象,沿用上游许可说明。 diff --git a/docs/desktop-agent-office.md b/docs/desktop-agent-office.md index 73eaa52d..59cc4935 100644 --- a/docs/desktop-agent-office.md +++ b/docs/desktop-agent-office.md @@ -16,8 +16,8 @@ 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` 事务所原型已移除。 @@ -25,6 +25,7 @@ Windows 与 macOS Desktop 可开启透明桌面宠物,默认关闭。设置 - 左下角齿轮菜单的“显示桌面宠物 / 隐藏桌面宠物”位于“设置”和“外观”之间。设置里的“桌面宠物”位于“个性化”和“使用情况”之间,提供同一个开启开关。 - 从 0.9.37 稳定版起,没有保存过选择的用户会收到一次开启邀请。关闭或选择“暂不开启”保持关闭;以后仍可从菜单或设置开启。预览复用嵌入的办公室 HTML,在独立沙箱 iframe 中播放演示,关闭弹框后释放;支持暂停和系统的“减少动态效果”,不访问真实会话或额外网络。 +- 「更多宠物」宣传:已经答复过上面邀请的老用户,启动后在首屏弹一次:主标题「新特性」,副标题「增加更多宠物,丰富您快乐工作的每一天」,只展示大眼回形针、rover、鲸鱼娘的图和名字,不加说明(用户要求)。点一只就切成它,宠物没开就同时开启;「知道了」关闭。`office.json` 的 `pets_promo_seen` 记录是否看过,由 `aceDesktop_claimPetsPromo` 原子认领。新用户认领开启邀请时,或者没看过邀请就在设置里开关宠物时,这个标记一并置位,不会连弹两个窗。弹窗在 `components/DesktopPetsPromo.jsx`,与开启邀请共用遮挡与引导让位规则。 - 顶部菜单在鼠标移入办公室时显示,移出后保留 1 秒再隐藏;重新移入会取消隐藏,键盘操作时保持可见。隐藏菜单的透明区域可穿透点击。 - 菜单下方显示当前会话标题,切换或改名后同步更新;长标题单行省略,悬停可查看全文。断线或尚无选中会话时保留对应状态提示。 - 菜单右侧 pin 控制办公室是否始终置顶:默认置顶,取消后可被其他普通窗口覆盖,拖动、缩放和停靠均保持当前选择;重新启动桌宠恢复默认置顶。叉号只关闭办公室并同步菜单/设置的关闭状态,不退出 ACECode,也不停止 agent 任务;可在同一次运行中重新开启。 diff --git a/src/apps/desktop/desktop_office_service.hpp b/src/apps/desktop/desktop_office_service.hpp index 44938f26..18516848 100644 --- a/src/apps/desktop/desktop_office_service.hpp +++ b/src/apps/desktop/desktop_office_service.hpp @@ -30,6 +30,7 @@ class DesktopOfficeService : public std::enable_shared_from_this())) pet_kind_ = kind.get(); } @@ -63,8 +64,21 @@ class DesktopOfficeService : public std::enable_shared_from_thisstate()["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(); @@ -98,7 +112,8 @@ class DesktopOfficeService : public std::enable_shared_from_this preview_; diff --git a/src/apps/desktop/desktop_pet.cpp b/src/apps/desktop/desktop_pet.cpp index 7bc43959..eb64682e 100644 --- a/src/apps/desktop/desktop_pet.cpp +++ b/src/apps/desktop/desktop_pet.cpp @@ -283,11 +283,15 @@ struct PetController : std::enable_shared_from_this { 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(); } diff --git a/src/apps/desktop/desktop_pet_layout.cpp b/src/apps/desktop/desktop_pet_layout.cpp index d6e03933..04e83100 100644 --- a/src/apps/desktop/desktop_pet_layout.cpp +++ b/src/apps/desktop/desktop_pet_layout.cpp @@ -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_; diff --git a/src/apps/desktop/desktop_pet_layout.hpp b/src/apps/desktop/desktop_pet_layout.hpp index a022b6d5..e34d2e45 100644 --- a/src/apps/desktop/desktop_pet_layout.hpp +++ b/src/apps/desktop/desktop_pet_layout.hpp @@ -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; diff --git a/src/apps/desktop/desktop_pet_mac.mm b/src/apps/desktop/desktop_pet_mac.mm index e5d227a6..e48f65de 100644 --- a/src/apps/desktop/desktop_pet_mac.mm +++ b/src/apps/desktop/desktop_pet_mac.mm @@ -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(); } diff --git a/tests/desktop/desktop_office_service_test.cpp b/tests/desktop/desktop_office_service_test.cpp index 02798927..10f3f417 100644 --- a/tests/desktop/desktop_office_service_test.cpp +++ b/tests/desktop/desktop_office_service_test.cpp @@ -80,6 +80,51 @@ TEST_F(DesktopOfficeServiceTest, WelcomeIsClaimedOnceAcrossRestartsWithoutEnabli EXPECT_FALSE(service->state()["enabled"].get()); } +// 场景:升级上来的老用户(office.json 已记过 welcome_seen)第一次启动带「更多宠物」宣传的版本。 +// 期望:宣传待展示,认领一次就不再出现(重启后也不出现),认领不改变已开启的宠物与当前宠物。 +TEST_F(DesktopOfficeServiceTest, PetsPromoIsClaimedOnceByUsersWhoAnsweredTheInvitation) { + std::filesystem::create_directories(dir); + { std::ofstream file(dir / "office.json"); file << R"({"enabled":true,"welcome_seen":true,"pet_kind":"office"})"; } + open("1.0.8"); + EXPECT_FALSE(service->state()["welcomePending"].get()); + EXPECT_TRUE(service->state()["petsPromoPending"].get()); + const Json first = host.call("aceDesktop_claimPetsPromo"); + EXPECT_TRUE(first["show"].get()); + EXPECT_FALSE(first["petsPromoPending"].get()); + EXPECT_FALSE(host.call("aceDesktop_claimPetsPromo")["show"].get()); + open("1.0.8"); + EXPECT_FALSE(host.call("aceDesktop_claimPetsPromo")["show"].get()); + EXPECT_TRUE(service->state()["enabled"].get()); + EXPECT_EQ(service->state()["petKind"], "office"); +} + +// 场景:全新用户。期望:只弹虚拟办公室的开启邀请;认领邀请时一并消耗「更多宠物」宣传, +// 下次启动不会再弹第二个窗。没看过邀请就在设置里开 / 关宠物,同样视为已经见过所有宠物; +// 老用户在设置里开关宠物则不影响宣传。 +TEST_F(DesktopOfficeServiceTest, FirstTimeUsersOnlyGetTheInvitation) { + open("1.0.8"); + EXPECT_TRUE(service->state()["welcomePending"].get()); + EXPECT_FALSE(service->state()["petsPromoPending"].get()); + EXPECT_TRUE(host.call("aceDesktop_claimOfficeWelcome")["show"].get()); + EXPECT_FALSE(service->state()["petsPromoPending"].get()); + open("1.0.8"); + EXPECT_FALSE(host.call("aceDesktop_claimPetsPromo")["show"].get()); + + std::filesystem::remove_all(dir); + open("1.0.8"); + ASSERT_TRUE(service->set_enabled(true)["ok"].get()); + EXPECT_FALSE(service->state()["petsPromoPending"].get()); + + std::filesystem::remove_all(dir); + std::filesystem::create_directories(dir); + { std::ofstream file(dir / "office.json"); file << R"({"enabled":true,"welcome_seen":true})"; } + open("1.0.8"); + ASSERT_TRUE(service->set_enabled(false)["ok"].get()); + EXPECT_TRUE(service->state()["petsPromoPending"].get()); + open("0.9.37", false); + EXPECT_FALSE(service->state()["petsPromoPending"].get()); +} + TEST_F(DesktopOfficeServiceTest, EnablingPersistsAndClosingReopensWithLatestSnapshot) { open(); ASSERT_TRUE(service->set_enabled(true)["ok"].get()); diff --git a/tests/desktop/desktop_pet_layout_test.cpp b/tests/desktop/desktop_pet_layout_test.cpp index 99029e0d..4c84e2d5 100644 --- a/tests/desktop/desktop_pet_layout_test.cpp +++ b/tests/desktop/desktop_pet_layout_test.cpp @@ -362,6 +362,33 @@ TEST(DesktopPetLayout, DockingAlignsTheWindowEdgeAndKeepsItOnScreen) { EXPECT_EQ(kept.y, 200); } +// 场景:拖到屏幕边松手但没进入吸附范围(吸附要伸出约 1/4 宽),窗口停在半出屏的位置。 +// bug 表现:宠物头顶的会话卡片被屏幕右边截掉一截(2026-10-16 用户截图,窗口 1229,496 299x219、 +// 工作区只有 1440 宽)。期望:整个窗口收回工作区,尺寸不变;本来就在里面的不动; +// 比工作区还大时贴左 / 上边;工作区无效时原样返回。 +TEST(DesktopPetLayout, UndockedWindowIsPulledBackInsideTheWorkArea) { + const DesktopPetRect work{0, 0, 1440, 852}; + const auto right = keep_desktop_pet_in_work_area({1229, 496, 299, 219}, work); + EXPECT_EQ(right.x + right.width, 1440); + EXPECT_EQ(right.y, 496); + EXPECT_EQ(right.width, 299); + EXPECT_EQ(right.height, 219); + const auto corner = keep_desktop_pet_in_work_area({-40, 800, 299, 219}, work); + EXPECT_EQ(corner.x, 0); + EXPECT_EQ(corner.y, 852 - 219); + const auto top = keep_desktop_pet_in_work_area({300, -60, 299, 219}, {0, 40, 1440, 812}); + EXPECT_EQ(top.y, 40); + const auto inside = keep_desktop_pet_in_work_area({600, 300, 299, 219}, work); + EXPECT_EQ(inside.x, 600); + EXPECT_EQ(inside.y, 300); + const auto huge = keep_desktop_pet_in_work_area({100, 100, 2000, 1000}, work); + EXPECT_EQ(huge.x, 0); + EXPECT_EQ(huge.y, 0); + const DesktopPetRect left_monitor{-1920, 0, 1920, 1040}; + EXPECT_EQ(keep_desktop_pet_in_work_area({-100, 200, 299, 219}, left_monitor).x, -299); + EXPECT_EQ(keep_desktop_pet_in_work_area({50, 60, 299, 219}, {0, 0, 0, 0}).x, 50); +} + TEST(DesktopPetLayout, MacResizeGripRejectsInvalidOrOutsideCoordinates) { const double nan = std::numeric_limits::quiet_NaN(); const double inf = std::numeric_limits::infinity(); diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index 9e79bd02..994f3bbb 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -2137,6 +2137,8 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '桌面宠物': 'Desktop pets', '开启桌面宠物': 'Enable desktop pet', '开启桌面宠物?': 'Enable a desktop pet?', + '新特性': 'New feature', + '增加更多宠物,丰富您快乐工作的每一天': 'More pets to brighten every working day', '显示桌面宠物': 'Show desktop pet', '隐藏桌面宠物': 'Hide desktop pet', '选择桌面宠物': 'Choose a desktop pet', diff --git a/web/scripts/test-classic-desktop-pets.mjs b/web/scripts/test-classic-desktop-pets.mjs index bf11f051..8c686941 100644 --- a/web/scripts/test-classic-desktop-pets.mjs +++ b/web/scripts/test-classic-desktop-pets.mjs @@ -248,6 +248,15 @@ try { assert.equal(await page.evaluate(() => ClassicPet.docked), 'right'); assert.equal((await pet()).animation, 'peek'); assert.ok(await alphaAt(300, 344) > 500 && await alphaAt(0, 120) === 0, 'peeks in from the right edge'); + // 场景:贴右边换成偷看图。bug 表现:曾固定画成 170 像素高,头比站着时大将近一倍。 + // 期望:按常规帧缩放 ÷ 1.8(偷看原画比精灵帧大 1.8 倍)画,300 像素高的原画约 91 像素。 + const peekHeight = await page.evaluate(() => { + const d = document.getElementById('cv').getContext('2d').getImageData(0, 0, 344, 252).data; + let top = 252, bottom = -1; + for (let y = 0; y < 252; y++) for (let x = 0; x < 344; x++) if (d[(y * 344 + x) * 4 + 3] > 32) { top = Math.min(top, y); bottom = y; break; } + return bottom - top + 1; + }); + assert.ok(peekHeight >= 80 && peekHeight <= 100, `peek head matches the standing pose (${peekHeight}px tall)`); await page.waitForFunction(() => ClassicPet.peekEyes === 1, null, { timeout: 12000 }); await page.waitForFunction(() => ClassicPet.peekEyes === 0, null, { timeout: 2000 }); assert.ok(await longestStill(1500) < 1500, 'docked whale keeps swaying'); diff --git a/web/scripts/test-virtual-office-preferences.mjs b/web/scripts/test-virtual-office-preferences.mjs index 5429475e..1d54477a 100644 --- a/web/scripts/test-virtual-office-preferences.mjs +++ b/web/scripts/test-virtual-office-preferences.mjs @@ -18,16 +18,18 @@ import {createRoot} from 'react-dom/client'; import {SidebarQuickMenu} from '/src/components/SidebarQuickMenu.jsx'; import {VirtualOfficeSettings} from '/src/components/VirtualOfficeSettings.jsx'; import {VirtualOfficeWelcome} from '/src/components/VirtualOfficeWelcome.jsx'; +import {DesktopPetsPromo} from '/src/components/DesktopPetsPromo.jsx'; import {useDesktopOffice} from '/src/lib/useDesktopOffice.js'; import {api} from '/src/lib/api.js'; import {connection} from '/src/lib/connection.js'; import '/src/styles/globals.css'; -let prefs={ok:true,available:true,enabled:false,welcomePending:true}; +let prefs={ok:true,available:true,enabled:false,welcomePending:true,petsPromoPending:true}; window.snapshotRequests=0;window.published=[];window.previewRequests=0;window.retained=new Set(); window.aceDesktop_getOfficePreferences=async()=>prefs; window.aceDesktop_setOfficeEnabled=async enabled=>(prefs={...prefs,enabled,welcomePending:false}); window.aceDesktop_setPetKind=async petKind=>(prefs={...prefs,petKind}); window.aceDesktop_claimOfficeWelcome=async()=>{const show=prefs.welcomePending;prefs={...prefs,welcomePending:false};return {...prefs,show};}; +window.aceDesktop_claimPetsPromo=async()=>{const show=prefs.petsPromoPending;prefs={...prefs,petsPromoPending:false};return {...prefs,show};}; window.aceDesktop_getOfficeState=async()=>({follow:true}); window.aceDesktop_updateOffice=async value=>{window.published.push(JSON.parse(value));return true;}; window.aceDesktop_getOfficePreview=async()=>{window.previewRequests++;return ${JSON.stringify(html)};}; @@ -37,12 +39,15 @@ Object.assign(api,{getDesktopOffice:async()=>{window.snapshotRequests++;const s= function Fixture(){ const office=useDesktopOffice({sessionId:'live',workspaceHash:'w'}); const [welcome,setWelcome]=useState(false); + const [promo,setPromo]=useState(false); window.office=office; return
-
+ +
{welcome&&setWelcome(false)}/>} + {promo&&setPromo(false)}/>}
; } createRoot(document.getElementById('root')).render(); @@ -150,7 +155,35 @@ try { await page.locator('iframe').waitFor({ state: 'detached' }); assert.equal(await page.getByRole('dialog').count(), 0); assert.equal(await page.locator('iframe').count(), 0); + // 场景:老用户第一次看到「更多宠物」宣传,此时桌面宠物是关着的。 + // 期望:主标题「新特性」+ 一行副标题,再加三只新宠物(图 + 名字),不加说明;点一只就切成它并打开宠物, + // 弹窗关闭;宣传只认领一次。浅色 / 深色、窄屏都不出横向滚动。 + await page.evaluate(() => nativeClose()); + await page.waitForFunction(() => !office.enabled); + await page.locator('#pets-promo').click(); + const promo = page.getByRole('dialog'); + await promo.waitFor(); + assert.equal(await promo.locator('h2').textContent(), '新特性'); + assert.deepEqual(await promo.locator('p').allTextContents(), ['增加更多宠物,丰富您快乐工作的每一天'], + 'the subtitle is the only text besides the title'); + assert.ok(await promo.locator('h2').evaluate(h => parseFloat(getComputedStyle(h).fontSize)) + > await promo.locator('p').evaluate(p => parseFloat(getComputedStyle(p).fontSize)), 'title outranks the subtitle'); + assert.deepEqual(await promo.locator('button[aria-pressed]').allTextContents(), ['大眼回形针', 'rover', '鲸鱼娘']); + assert.equal(await promo.locator('img').count(), 3); + await page.waitForFunction(() => [...document.querySelectorAll('[role=dialog] img')].every(img => img.complete && img.naturalWidth > 0)); + for (const theme of ['light', 'dark']) for (const width of [1100, 390]) { + await page.setViewportSize({ width, height: 800 }); + await page.evaluate(t => { document.documentElement.dataset.theme = t; }, theme); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)); + await page.screenshot({ path: path.join(output, `pets-promo-${theme}-${width}.png`) }); + } + await page.setViewportSize({ width: 1100, height: 800 }); + await promo.getByRole('button', { name: '鲸鱼娘' }).click(); + await page.waitForFunction(() => office.enabled && office.petKind === 'whale'); + await promo.waitFor({ state: 'detached' }); + await page.locator('#pets-promo').click(); + assert.equal(await page.getByRole('dialog').count(), 0, 'the pets announcement is claimed once'); assert.deepEqual(errors, []); assert.deepEqual(external, []); - console.log(JSON.stringify({ ok: true, coverage: 'opt-in, menu ordering, close/reopen, polling teardown, sandbox preview, light/dark, narrow viewport, pause, reduced motion, once-only claim', output })); + console.log(JSON.stringify({ ok: true, coverage: 'opt-in, menu ordering, close/reopen, polling teardown, sandbox preview, light/dark, narrow viewport, pause, reduced motion, once-only claim, pets announcement', output })); } finally { await browser?.close(); await server.close(); } diff --git a/web/src/App.jsx b/web/src/App.jsx index c456a602..e2436d79 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,6 +1,7 @@ import { useDesktopOffice } from './lib/useDesktopOffice.js'; import { isOfficeWelcomeVisible } from './lib/desktopOfficePreferences.js'; import { VirtualOfficeWelcome } from './components/VirtualOfficeWelcome.jsx'; +import { DesktopPetsPromo } from './components/DesktopPetsPromo.jsx'; // 顶层 App:鉴权 gate(401 → TokenPrompt)+ 主壳。 // // 视觉对齐设计稿方向 C:顶部 41px TopBar + 270px Sidebar + 主区(单会话/4宫格/9宫格) @@ -260,6 +261,9 @@ export function App() { const office = useDesktopOffice(activeRef); const [officeWelcomeOpen, setOfficeWelcomeOpen] = useState(false); const officeWelcomeAttempted = useRef(false); + const [petsPromoOpen, setPetsPromoOpen] = useState(false); + const petsPromoAttempted = useRef(false); + const desktopAnnouncementOpen = officeWelcomeOpen || petsPromoOpen; const workbenchOwner = sessionWorkbench.ownerFor(activeRef); const [sessionTitleTarget, setSessionTitleTarget] = useState(null); const [sessionActionsTarget, setSessionActionsTarget] = useState(null); @@ -520,7 +524,9 @@ export function App() { // Blocking it with the queued invitation would make both surfaces wait forever. const officeWelcomeVisible = isOfficeWelcomeVisible({ requested: officeWelcomeOpen, blocked: officeInvitationBlocked, tourPreparing: guidedTourPreparing, tourRunning: guidedTourRun }); - const guidedTourBlocked = officeInvitationBlocked || officeWelcomeVisible; + const petsPromoVisible = isOfficeWelcomeVisible({ requested: petsPromoOpen, + blocked: officeInvitationBlocked, tourPreparing: guidedTourPreparing, tourRunning: guidedTourRun }); + const guidedTourBlocked = officeInvitationBlocked || officeWelcomeVisible || petsPromoVisible; useEffect(() => { if (officeWelcomeAttempted.current || !office.welcomePending || authState !== 'ok' || !health @@ -530,6 +536,16 @@ export function App() { if (result.ok && result.show) setOfficeWelcomeOpen(true); }); }, [office.welcomePending, office.claimWelcome, authState, health, officeInvitationBlocked, guidedTourPreparing, guidedTourRun]); + // "More pets" is announced once to users who already answered the invitation above. + useEffect(() => { + if (petsPromoAttempted.current || office.welcomePending || !office.petsPromoPending || officeWelcomeOpen + || authState !== 'ok' || !health || officeInvitationBlocked || guidedTourPreparing || guidedTourRun) return; + petsPromoAttempted.current = true; + void office.claimPetsPromo().then(result => { + if (result.ok && result.show) setPetsPromoOpen(true); + }); + }, [office.welcomePending, office.petsPromoPending, office.claimPetsPromo, officeWelcomeOpen, authState, health, + officeInvitationBlocked, guidedTourPreparing, guidedTourRun]); useEffect(() => { if (office.error) toast({ kind: 'err', text: office.error }); }, [office.error]); useEffect(() => initInactiveSelection(), []); @@ -2193,7 +2209,7 @@ export function App() { || (projectSidebarCollapsed && !guidedTourPreparing && !guidedTourRun); const nativeSurfacesVisible = !showSettings - && !officeWelcomeOpen + && !desktopAnnouncementOpen && !showFeedback && !searchOpen && !updateDialogOpen @@ -2212,7 +2228,7 @@ export function App() { desktopMode: desktopModeRef.current, chatVisible: view === 'single' && !activeRef?.loop && !activeRef?.expertComponents, blockingSurfaceOpen: showSettings || showFeedback || searchOpen || updateDialogOpen - || officeWelcomeOpen + || desktopAnnouncementOpen || desktopCloseDialogOpen || configRecoveryBlocking || !!visibleQuestionReq || guidedTourPreparing || guidedTourRun, }); @@ -2222,7 +2238,7 @@ export function App() { loop: !!activeRef?.loop, showSettings: showSettings || showFeedback, searchOpen, - updateDialogOpen: updateDialogOpen || desktopCloseDialogOpen || configRecoveryBlocking || officeWelcomeOpen, + updateDialogOpen: updateDialogOpen || desktopCloseDialogOpen || configRecoveryBlocking || desktopAnnouncementOpen, permissionOpen: false, questionOpen: !!visibleQuestionReq, guidedTourPreparing, @@ -2500,6 +2516,9 @@ export function App() { {officeWelcomeVisible && ( setOfficeWelcomeOpen(false)} /> )} + {petsPromoVisible && ( + setPetsPromoOpen(false)} /> + )} PREVIEWS[pet.id]); + +// One-time announcement of the companion pets: a "新特性" title, one subtitle line and the pets, +// no explanations (user decision). +// Picking one switches to it, turning desktop pets on if they were off. +export function DesktopPetsPromo({ office, onClose }) { + const close = () => { if (!office.busy) onClose(); }; + const choose = async kind => { + if (office.busy) return; + if (office.petKind !== kind && !(await office.setPetKind(kind)).ok) return; + if (!office.enabled && !(await office.setEnabled(true)).ok) return; + onClose(); + }; + return ( + +
+
+

新特性

+ +
+

增加更多宠物,丰富您快乐工作的每一天

+
+ {NEW_PETS.map(pet => { + const current = office.enabled && office.petKind === pet.id; + return ; + })} +
+ {office.error &&

{office.error}

} +
+ +
+
+
+ ); +} diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index d183858b..eb4a48cc 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -806,6 +806,7 @@ export const sourceCatalogs = { "s_4ad8aa6461e64127": "上一题 (Shift+Tab)", "s_4af8f4c6dd2e233e": "大脑", "s_4afad488b461b58f": "侧边聊天记录", + "s_4b103ec0cea1c74b": "新特性", "s_4b19c2f91cd3f418": "消息通道只能在运行 ACECode 的这台电脑上设置", "s_4b427656a253ca1e": "请完全退出并重新启动 ACECode", "s_4b464b42a992847e": "无法更新桌面宠物,请重试。", @@ -2166,6 +2167,7 @@ export const sourceCatalogs = { "s_cadd75ef8275de01": "已查看 {{p0}} 张图像", "s_caecb5d342cc889a": "恢复目标", "s_cafc8a4f6acda2cb": "Tag 可多选;同一专家会出现在每个已选 Tag 下", + "s_cb16eec3c3bd3c51": "增加更多宠物,丰富您快乐工作的每一天", "s_cb2fcb9099668d50": "暂无回答", "s_cb31eb7a0c2aa250": "编辑模型 · {{p0}}", "s_cb51a062db592988": "这个机器人正由另一个 ACECode 连接,它退出后这里会自动接管。", @@ -3561,6 +3563,7 @@ export const sourceCatalogs = { "s_4ad8aa6461e64127": "Previous (Shift+Tab)", "s_4af8f4c6dd2e233e": "Brain", "s_4afad488b461b58f": "Side chat history", + "s_4b103ec0cea1c74b": "New feature", "s_4b19c2f91cd3f418": "Messaging channels can only be configured on the computer running ACECode", "s_4b427656a253ca1e": "Fully quit and restart ACECode", "s_4b464b42a992847e": "Could not update the desktop pet. Please try again.", @@ -4921,6 +4924,7 @@ export const sourceCatalogs = { "s_cadd75ef8275de01": "Viewed {{p0}} images", "s_caecb5d342cc889a": "Resume goal", "s_cafc8a4f6acda2cb": "Tags can be selected multiple times; the same expert will appear under each selected Tag", + "s_cb16eec3c3bd3c51": "More pets to brighten every working day", "s_cb2fcb9099668d50": "No answer yet", "s_cb31eb7a0c2aa250": "Edit model · {{p0}}", "s_cb51a062db592988": "Another ACECode is connected to this bot. This one takes over automatically after it exits.", diff --git a/web/src/lib/desktopOfficePreferences.js b/web/src/lib/desktopOfficePreferences.js index 53305f83..0622497b 100644 --- a/web/src/lib/desktopOfficePreferences.js +++ b/web/src/lib/desktopOfficePreferences.js @@ -16,7 +16,8 @@ export function parseOfficePreferences(value) { // One owner for the menu, settings, invitation and native close notifications. export function createDesktopOfficePreferences(win) { - let state = { ready: false, available: false, enabled: false, petKind: 'office', welcomePending: false, busy: false, error: '' }; + let state = { ready: false, available: false, enabled: false, petKind: 'office', welcomePending: false, petsPromoPending: false, + busy: false, error: '' }; let revision = 0; let disposed = false; const listeners = new Set(); @@ -74,5 +75,8 @@ export function createDesktopOfficePreferences(win) { return result; }, claimWelcome: () => request('aceDesktop_claimOfficeWelcome'), + // An older desktop shell has no pets announcement; treat it as already seen. + claimPetsPromo: () => (typeof win?.aceDesktop_claimPetsPromo === 'function' + ? request('aceDesktop_claimPetsPromo') : Promise.resolve({ ok: true, show: false })), }; } diff --git a/web/src/lib/desktopOfficePreferences.test.js b/web/src/lib/desktopOfficePreferences.test.js index 78bd233b..d6e49f9c 100644 --- a/web/src/lib/desktopOfficePreferences.test.js +++ b/web/src/lib/desktopOfficePreferences.test.js @@ -52,6 +52,30 @@ function fixture(overrides = {}) { assert.equal(store.getSnapshot().enabled, true, 'closed office can be reenabled'); store.dispose(); } +// 场景:老用户启动带「更多宠物」宣传的版本,原生状态带 petsPromoPending。 +// 期望:状态透传给界面,认领一次后不再显示;旧版桌面壳没有这个 bridge 时按已看过处理,不报错。 +{ + let promo = true; + const { store } = fixture({ + aceDesktop_getOfficePreferences: async () => JSON.stringify({ ...initial, welcomePending: false, petsPromoPending: promo }), + aceDesktop_claimPetsPromo: async () => { + const show = promo; + promo = false; + return { ...initial, welcomePending: false, petsPromoPending: false, show }; + }, + }); + store.start(); await flush(); + assert.equal(store.getSnapshot().petsPromoPending, true); + assert.equal((await store.claimPetsPromo()).show, true); + assert.equal(store.getSnapshot().petsPromoPending, false); + assert.equal((await store.claimPetsPromo()).show, false); + store.dispose(); + const older = fixture(); + older.store.start(); await flush(); + assert.equal(older.store.getSnapshot().petsPromoPending, false); + assert.deepEqual(await older.store.claimPetsPromo(), { ok: true, show: false }); + older.store.dispose(); +} { let resolve; const { store, emit } = fixture({ aceDesktop_setOfficeEnabled: () => new Promise(r => { resolve = r; }) }); diff --git a/web/src/lib/useDesktopOffice.js b/web/src/lib/useDesktopOffice.js index 5e5f64ee..291b92ec 100644 --- a/web/src/lib/useDesktopOffice.js +++ b/web/src/lib/useDesktopOffice.js @@ -74,5 +74,6 @@ export function useDesktopOffice(activeRef) { }; }, [state.enabled, state.available]); useEffect(() => {controllerRef.current?.setActive(activeRef);}, [activeRef]); - return { ...state, setEnabled: preferences.setEnabled, setPetKind: preferences.setPetKind, claimWelcome: preferences.claimWelcome }; + return { ...state, setEnabled: preferences.setEnabled, setPetKind: preferences.setPetKind, claimWelcome: preferences.claimWelcome, + claimPetsPromo: preferences.claimPetsPromo }; }