From e80bccba355fd0b20dda3f42b2b0107d177cada0 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Tue, 6 Oct 2026 04:47:37 +0800 Subject: [PATCH 1/5] feat: Connect desktop agent office to live sessions --- assets/desktop_pet/DESIGN.md | 123 ++ assets/desktop_pet/OFL.txt | 93 ++ assets/desktop_pet/agent_office_pet.html | 1250 +++++++++++++++++ assets/desktop_pet/surface.md | 40 + cmake/acecode_desktop.cmake | 17 +- cmake/acecode_layer_libraries.cmake | 2 +- cmake/acecode_source_paths.cmake | 2 + design-prototypes/agent-office/DESIGN.md | 9 - design-prototypes/agent-office/PRODUCT.md | 23 - design-prototypes/agent-office/README.md | 58 - design-prototypes/agent-office/app.js | 103 -- design-prototypes/agent-office/assets.js | 274 ---- design-prototypes/agent-office/index.html | 71 - design-prototypes/agent-office/office.css | 219 --- design-prototypes/agent-office/renderer.js | 147 -- design-prototypes/agent-office/simulation.js | 94 -- .../agent-office/simulation.test.cjs | 78 - design-prototypes/agent-office/surface.md | 5 - docs/daemon-api.md | 27 + docs/desktop-agent-office.md | 22 + .../agent-office/native-multi-complete.png | Bin 0 -> 19526 bytes .../agent-office/native-multi-working.png | Bin 0 -> 20193 bytes .../add-pixel-agent-office-demo/design.md | 25 - .../add-pixel-agent-office-demo/proposal.md | 23 - .../specs/pixel-agent-office-demo/spec.md | 33 - .../add-pixel-agent-office-demo/tasks.md | 14 - .../.openspec.yaml | 2 +- .../integrate-desktop-agent-office/design.md | 44 + .../proposal.md | 27 + .../specs/desktop-agent-office/spec.md | 72 + .../integrate-desktop-agent-office/tasks.md | 24 + .../verification.md | 46 + src/apps/desktop/desktop_pet.cpp | 781 ++++++++++ src/apps/desktop/desktop_pet.hpp | 36 + src/apps/desktop/desktop_pet_layout.cpp | 154 ++ src/apps/desktop/desktop_pet_layout.hpp | 65 + src/apps/desktop/main.cpp | 8 +- src/apps/web/routes/routes_desktop_office.cpp | 115 ++ src/apps/web/server.cpp | 1 + src/apps/web/server_helpers.cpp | 4 + src/apps/web/server_impl.hpp | 1 + src/domain/session/event_dispatcher.cpp | 6 + src/domain/session/event_dispatcher.hpp | 5 + .../session/global_session_catalog_meta.hpp | 2 + src/domain/session/global_session_search.cpp | 57 + src/domain/session/global_session_search.hpp | 2 + src/domain/session/session_activity_state.cpp | 99 ++ src/domain/session/session_activity_state.hpp | 32 + src/domain/session/session_client.hpp | 2 + src/domain/session/session_manager.cpp | 19 +- src/domain/session/session_manager.hpp | 7 + .../session/session_manager_history.cpp | 35 + .../session/session_recent_activity.cpp | 48 + .../session/session_recent_activity.hpp | 22 + src/domain/session/session_storage.cpp | 4 + src/domain/session/session_storage.hpp | 2 + src/engine/agent/turn/turn_finalizer.cpp | 5 +- src/host/session_host/session_registry.cpp | 2 + tests/desktop/desktop_pet_layout_test.cpp | 208 +++ tests/session/session_activity_state_test.cpp | 73 + .../session/session_recent_activity_test.cpp | 142 ++ tests/web/desktop_office_route_test.cpp | 155 ++ web/scripts/i18n-en-overrides.mjs | 6 + web/scripts/test-desktop-office.mjs | 83 ++ web/src/App.jsx | 2 + web/src/components/ActivityLine.jsx | 3 +- web/src/components/Icon.jsx | 4 +- web/src/components/SubagentPanel.jsx | 3 +- web/src/components/ToolBlock.jsx | 6 +- web/src/i18n/sourceCatalog.generated.js | 12 + web/src/lib/api.js | 4 + web/src/lib/desktopNotify.js | 4 +- web/src/lib/desktopNotify.test.js | 18 + web/src/lib/desktopOffice.test.js | 122 ++ web/src/lib/desktopOfficeController.js | 104 ++ web/src/lib/desktopOfficeState.js | 81 ++ web/src/lib/runTests.js | 1 + web/src/lib/toolIcons.js | 29 + web/src/lib/useDesktopOffice.js | 56 + 79 files changed, 4400 insertions(+), 1197 deletions(-) create mode 100644 assets/desktop_pet/DESIGN.md create mode 100644 assets/desktop_pet/OFL.txt create mode 100644 assets/desktop_pet/agent_office_pet.html create mode 100644 assets/desktop_pet/surface.md delete mode 100644 design-prototypes/agent-office/DESIGN.md delete mode 100644 design-prototypes/agent-office/PRODUCT.md delete mode 100644 design-prototypes/agent-office/README.md delete mode 100644 design-prototypes/agent-office/app.js delete mode 100644 design-prototypes/agent-office/assets.js delete mode 100644 design-prototypes/agent-office/index.html delete mode 100644 design-prototypes/agent-office/office.css delete mode 100644 design-prototypes/agent-office/renderer.js delete mode 100644 design-prototypes/agent-office/simulation.js delete mode 100644 design-prototypes/agent-office/simulation.test.cjs delete mode 100644 design-prototypes/agent-office/surface.md create mode 100644 docs/desktop-agent-office.md create mode 100644 docs/images/agent-office/native-multi-complete.png create mode 100644 docs/images/agent-office/native-multi-working.png delete mode 100644 openspec/changes/add-pixel-agent-office-demo/design.md delete mode 100644 openspec/changes/add-pixel-agent-office-demo/proposal.md delete mode 100644 openspec/changes/add-pixel-agent-office-demo/specs/pixel-agent-office-demo/spec.md delete mode 100644 openspec/changes/add-pixel-agent-office-demo/tasks.md rename openspec/changes/{add-pixel-agent-office-demo => integrate-desktop-agent-office}/.openspec.yaml (50%) create mode 100644 openspec/changes/integrate-desktop-agent-office/design.md create mode 100644 openspec/changes/integrate-desktop-agent-office/proposal.md create mode 100644 openspec/changes/integrate-desktop-agent-office/specs/desktop-agent-office/spec.md create mode 100644 openspec/changes/integrate-desktop-agent-office/tasks.md create mode 100644 openspec/changes/integrate-desktop-agent-office/verification.md create mode 100644 src/apps/desktop/desktop_pet.cpp create mode 100644 src/apps/desktop/desktop_pet.hpp create mode 100644 src/apps/desktop/desktop_pet_layout.cpp create mode 100644 src/apps/desktop/desktop_pet_layout.hpp create mode 100644 src/apps/web/routes/routes_desktop_office.cpp create mode 100644 src/domain/session/session_activity_state.cpp create mode 100644 src/domain/session/session_activity_state.hpp create mode 100644 src/domain/session/session_recent_activity.cpp create mode 100644 src/domain/session/session_recent_activity.hpp create mode 100644 tests/desktop/desktop_pet_layout_test.cpp create mode 100644 tests/session/session_activity_state_test.cpp create mode 100644 tests/session/session_recent_activity_test.cpp create mode 100644 tests/web/desktop_office_route_test.cpp create mode 100644 web/scripts/test-desktop-office.mjs create mode 100644 web/src/lib/desktopOffice.test.js create mode 100644 web/src/lib/desktopOfficeController.js create mode 100644 web/src/lib/desktopOfficeState.js create mode 100644 web/src/lib/toolIcons.js create mode 100644 web/src/lib/useDesktopOffice.js diff --git a/assets/desktop_pet/DESIGN.md b/assets/desktop_pet/DESIGN.md new file mode 100644 index 000000000..bc079636f --- /dev/null +++ b/assets/desktop_pet/DESIGN.md @@ -0,0 +1,123 @@ +--- +name: ACECode 像素办公室 +description: 桌面宠物资源的局部视觉规则 +colors: + office-paper: "#fff9e9" + office-ink: "#27324f" + office-line: "#a8a6a4" + office-selected: "#dce7f6" + scene-outline: "#2b2737" +typography: + body: + fontFamily: '"Pixelify Sans", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", system-ui, sans-serif' + fontSize: "12px" + lineHeight: 1.4 + label: + fontFamily: '-apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", sans-serif' + fontSize: "12px" + lineHeight: 1.3 + label-compact: + fontFamily: '-apple-system, BlinkMacSystemFont, "PingFang SC", "Segoe UI", sans-serif' + fontSize: "11px" + lineHeight: 1.3 +rounded: + control: "4px" + bubble: "6px" +spacing: + control-group: "4px" +components: + control: + backgroundColor: "{colors.office-paper}" + textColor: "{colors.office-ink}" + typography: "{typography.label}" + rounded: "{rounded.control}" + padding: "5px 7px" + control-selected: + backgroundColor: "{colors.office-selected}" + textColor: "{colors.office-ink}" + rounded: "{rounded.control}" +--- + +# Design System: ACECode 像素办公室 + +## Overview + +**Creative North Star: "保留原作的像素办公室"** + +本规范仅用于桌面宠物资源。视觉权威是已指定的原作;当前实现证据是 [agent_office_pet.html](agent_office_pet.html)。等距房间、家具、角色和透明外缘延续原作,新增会话控件保持紧凑。此处不定义 ACECode 全局品牌或通用页面风格。 + +**Key Characteristics:** + +- 房间是主体,顶部控制条承担会话切换与跟随。 +- 场景用像素轮廓和分面明暗,操作文字优先保证中文辨认。 +- 同一会话保持稳定布局和角色外观;动画表达真实状态变化。 + +## Colors + +房间保留原有多套地面、墙面和家具调色板;交互层使用独立的浅纸色与深墨色。 + +### Primary + +选中控件使用 `office-selected`,通过整面底色和四边边框共同区分状态。 + +### Neutral + +`office-paper` 用于控件、通知、工具图标底和成员列表;`office-ink` 用于操作文字与工具图标;`office-line` 用于控件及成员列表边界。`scene-outline` 保留给像素精灵轮廓。 + +**The 分层配色 Rule.** 房间材质色服务于像素物件,操作层的状态色服务于真实交互;两者不互相推断业务状态。 + +## Typography + +场景气泡继承 `body` 的内嵌 Pixelify Sans 与中文回退字体。顶部控件使用 `label`,窄窗口使用 `label-compact`;成员列表使用系统正文。这里没有展示标题字阶。 + +会话标题保持单行省略,完整工作区和会话名保留在提示与可访问名称中。气泡有宽度上限和省略处理;缩放房间时不应按比例无限缩小操作文字。 + +## Layout + +画布基准为 (344 × 252),房间整体向下为控制条留出空间。场景随窗口等比缩放,控制条固定在场景顶部,保持一行;窄于 (400px) 时收紧字号与间距,窄于 (250px) 时继续压缩控件内边距。 + +房间有经典、横排、纵排、L 形、工作岛五套模板,均保留主 agent 和七个子 agent 工位、家具区及机器人路线。布局和配色由会话种子决定。 + +**The 稳定房间 Rule.** 同一会话的状态刷新不重新随机布置家具或改换角色外观。 + +控制条的紧凑组间距仅适用于这一小型桌面窗口。展开成员列表限高并独立滚动,不压缩或重排房间。 + +## Elevation & Depth + +家具与墙体通过等距分面、统一轮廓和遮挡顺序形成深度;透明区域保留桌面背景。操作条主要依靠底色和细边框区分层级。 + +原作气泡带短距离像素投影,属于该场景的既有物件语言;不将此投影扩展为普通按钮、列表或 ACECode 其他页面的阴影规则。 + +## Shapes + +场景以离散像素、等距长方体和精灵轮廓构成。操作控件采用小圆角与完整细边框,气泡保留尾角。工具图标使用与聊天界面共源的 SVG,不用文字字符代替。 + +整数倍缩放保持清晰像素;非整数倍先做整数倍像素放大,再缩至窗口尺寸,维持像素宽窄的稳定。 + +## Components + +### 会话控件 + +浅纸色紧凑按钮,选中时整面变色。悬停改变底色,键盘焦点有独立轮廓;跟随开关用方形选中标记,并同步 `aria-checked`。 + +### 场景状态 + +角色动作、气泡和工具图标共同呈现状态。等待授权、等待回答、失败、停止均有明确文案;当前工具图标只在执行工具时显示。上下文文件堆属于相应工位,依据当前上下文使用率呈现。 + +### 溢出成员 + +超过可见工位时显示 `+N`,展开后列出成员名称和当前状态。列表随快照更新;已离开的成员移除。列表仍展开时,失效焦点回到溢出入口;溢出消失时关闭列表。 + +## Do's and Don'ts + +### Do: + +- **Do** 保留原作家具、透明房间、等距像素构形与稳定会话布局。 +- **Do** 让控制条、提示和成员列表保持可读,并把状态变化绑定到真实快照。 +- **Do** 为会话按钮和跟随开关保留焦点及可访问状态,减少动态效果偏好下抑制消息传递动效。 + +### Don't: + +- **Don't** 把局部宠物界面改造成卡片仪表盘或给透明场景铺满页面底色。 +- **Don't** 用装饰性忙碌动画冒充执行、完成、失败或上下文数据。 +- **Don't** 将这份局部规范中的小字号、紧凑间距和像素投影推广为全站默认样式。 diff --git a/assets/desktop_pet/OFL.txt b/assets/desktop_pet/OFL.txt new file mode 100644 index 000000000..cc97f4546 --- /dev/null +++ b/assets/desktop_pet/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Pixelify Sans Project Authors (https://github.com/eifetx/Pixelify-Sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/desktop_pet/agent_office_pet.html b/assets/desktop_pet/agent_office_pet.html new file mode 100644 index 000000000..9722fd446 --- /dev/null +++ b/assets/desktop_pet/agent_office_pet.html @@ -0,0 +1,1250 @@ + + + + + + +ACECode 像素办公室 + + + +
+
+
+ +
+
正在连接会话
+ + + + +
+
+ +
+ + + diff --git a/assets/desktop_pet/surface.md b/assets/desktop_pet/surface.md new file mode 100644 index 000000000..3ef3820df --- /dev/null +++ b/assets/desktop_pet/surface.md @@ -0,0 +1,40 @@ +# 像素办公室界面约束 + +模式:Operate。用途是在桌面像素场景中查看真实 agent 状态,并切换所观察的主会话。视觉规则见 [DESIGN.md](DESIGN.md),场景实现见 [agent_office_pet.html](agent_office_pet.html)。 + +## 视觉与首屏 + +透明等距房间占据主体,顶部仅保留最近会话和跟随开关。原作家具、五套布局模板及像素角色保持;场景不扩展为管理页面。首次连接前显示空房间,连接或读取提示位于控制条下方。 + +## 会话与成员 + +- 最近列表跨工作区汇总,按用户最后发送消息的时间降序取五个主会话,排除已归档会话和子会话。 +- 跟随默认开启,观察主窗口当前会话;手动选择会话后关闭跟随。重新开启后回到主窗口当前会话。 +- 房间种子来自会话 ID;会话切换清理旧角色与瞬态效果,同一会话刷新保持布局和角色外观。 +- 房间显示一个主 agent 与最多七个子 agent;更多成员通过 `+N` 列表查看。列表展示完整当前成员集合及状态,展开期间持续更新。 + +## 状态表达 + +| 真实状态 | 场景表达 | +| --- | --- | +| 尚未开始执行 | 待命;没有演示角色补位 | +| 推理、调用工具 | 思考或工作;工具执行时显示与聊天共源的图标 | +| 等待授权、等待回答、重试 | 保留各自的明确状态文案 | +| 主 agent 成功完成 | 留在主工位,显示 `zzz`;下一轮开始后恢复执行状态 | +| 子 agent 成功完成 | 离开工位;后续任务出现时再按真实成员恢复 | +| 执行失败或停止 | 本轮保留失败或停止状态,不能表现为成功休息 | +| 整理上下文 | 显示整理状态及机器人提示;文件堆随后跟随新上下文比例 | + +文件堆反映当前上下文使用量相对窗口容量的比例,不使用会话累计消耗量。容量未知时不绘制文件堆,不能把未知当作零用量。消息信封由真实成员变化或消息记录触发;减少动态效果偏好下抑制该动效。 + +## 桌面交互 + +- 拖动场景移动窗口;单击角色查看名称和状态,双击打开对应会话。 +- 滚轮或把手调整窗口大小,把手双击恢复默认大小;控件和成员列表中的滚动不触发场景缩放。 +- 右键交给原生菜单。桌面窗口保持透明、置顶及不抢焦点约束;主动打开会话属于独立操作。 +- 成员列表独立滚动,点击成员打开会话并关闭列表,Escape 关闭列表;列表区域与原生命中区域保持同步。 +- 连接中断时保留最后快照并显示重连提示,不能继续伪造实时进展。 + +## 依据与验证边界 + +规则来自当前 HTML、`desktopOfficeState.js`、`desktopOfficeController.js` 与原生宠物窗口实现。本文只记录已实现行为与视觉约束,不作为原生窗口实测通过的证明;验收状态以该变更的任务和验证记录为准。 diff --git a/cmake/acecode_desktop.cmake b/cmake/acecode_desktop.cmake index 3416eb372..18a26d9bd 100644 --- a/cmake/acecode_desktop.cmake +++ b/cmake/acecode_desktop.cmake @@ -78,6 +78,7 @@ set(ACECODE_DESKTOP_SOURCES ${ACECODE_DESKTOP_MAIN_SOURCE} ${ACECODE_DESKTOP_SPLASH_SOURCE} ${ACECODE_DESKTOP_WEB_HOST_SOURCE} + ${ACECODE_DESKTOP_PET_SOURCE} ) if(UNIX AND NOT APPLE) list(APPEND ACECODE_DESKTOP_SOURCES @@ -129,8 +130,22 @@ endif() if(WIN32) target_link_libraries(acecode-desktop PRIVATE - ole32 shell32 user32 gdi32 + ole32 shell32 user32 gdi32 dwmapi ) + set(ACECODE_DESKTOP_PET_PAGE "${CMAKE_SOURCE_DIR}/assets/desktop_pet/agent_office_pet.html") + set(ACECODE_DESKTOP_PET_PAGE_CPP "${CMAKE_BINARY_DIR}/generated/desktop_pet_page_embedded.cpp") + add_custom_command( + OUTPUT ${ACECODE_DESKTOP_PET_PAGE_CPP} + COMMAND ${CMAKE_COMMAND} + -DBIN2CPP_INPUT=${ACECODE_DESKTOP_PET_PAGE} + -DBIN2CPP_OUTPUT=${ACECODE_DESKTOP_PET_PAGE_CPP} + -DBIN2CPP_SYMBOL=desktop_pet_page + -P ${CMAKE_SOURCE_DIR}/cmake/acecode_bin2cpp.cmake + DEPENDS ${ACECODE_DESKTOP_PET_PAGE} ${CMAKE_SOURCE_DIR}/cmake/acecode_bin2cpp.cmake + COMMENT "Embedding the desktop agent office" + VERBATIM + ) + target_sources(acecode-desktop PRIVATE ${ACECODE_DESKTOP_PET_PAGE_CPP}) endif() if(UNIX AND NOT APPLE) diff --git a/cmake/acecode_layer_libraries.cmake b/cmake/acecode_layer_libraries.cmake index 5153f4bc9..edac43a2e 100644 --- a/cmake/acecode_layer_libraries.cmake +++ b/cmake/acecode_layer_libraries.cmake @@ -164,7 +164,7 @@ set(ACECODE_DESKTOP_BINARY_ONLY_SOURCES ${ACECODE_DEEPIN_WINDOW_EFFECTS_SOURCE} ${ACECODE_DESKTOP_LINUX_SOURCE} ${ACECODE_AGENT_BROWSER_HOST_SOURCE} ${ACECODE_AGENT_BROWSER_HOST_MAC_SOURCE} ${ACECODE_DESKTOP_MAIN_SOURCE} ${ACECODE_DESKTOP_SPLASH_SOURCE} - ${ACECODE_DESKTOP_WEB_HOST_SOURCE}) + ${ACECODE_DESKTOP_WEB_HOST_SOURCE} ${ACECODE_DESKTOP_PET_SOURCE}) acecode_collect_group_sources(_desktop apps/desktop) list(REMOVE_ITEM _desktop ${ACECODE_DESKTOP_BINARY_ONLY_SOURCES}) acecode_add_layer_library(acecode_desktop_support apps ${_desktop}) diff --git a/cmake/acecode_source_paths.cmake b/cmake/acecode_source_paths.cmake index cfb418163..30f340a89 100644 --- a/cmake/acecode_source_paths.cmake +++ b/cmake/acecode_source_paths.cmake @@ -7,6 +7,7 @@ set(ACECODE_DEEPIN_WINDOW_EFFECTS_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/d set(ACECODE_DESKTOP_MAIN_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/main.cpp") set(ACECODE_DESKTOP_SPLASH_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/splash_screen.cpp") set(ACECODE_DESKTOP_WEB_HOST_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/web_host.cpp") +set(ACECODE_DESKTOP_PET_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/desktop_pet.cpp") set(ACECODE_DESKTOP_LINUX_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/linux_desktop.cpp") set(ACECODE_AGENT_BROWSER_HOST_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/agent_browser_host.cpp") set(ACECODE_AGENT_BROWSER_HOST_MAC_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/agent_browser_host_mac.mm") @@ -85,6 +86,7 @@ acecode_require_sources("shared target source paths" ${ACECODE_DESKTOP_MAIN_SOURCE} ${ACECODE_DESKTOP_SPLASH_SOURCE} ${ACECODE_DESKTOP_WEB_HOST_SOURCE} + ${ACECODE_DESKTOP_PET_SOURCE} ${ACECODE_DESKTOP_LINUX_SOURCE} ${ACECODE_AGENT_BROWSER_HOST_SOURCE} ${ACECODE_AGENT_BROWSER_HOST_MAC_SOURCE} diff --git a/design-prototypes/agent-office/DESIGN.md b/design-prototypes/agent-office/DESIGN.md deleted file mode 100644 index 0904f1c84..000000000 --- a/design-prototypes/agent-office/DESIGN.md +++ /dev/null @@ -1,9 +0,0 @@ -# 像素 AI 办公室视觉约定 - -用户参考图是视觉权威:浅绿网格地砖、米白墙面、蓝色办公椅、彩色显示器、细深色像素轮廓。直接以代码构建此风格。 - -场景占据首屏主区域。外壳像一张暖白的办公室管理台:低饱和苔绿导航、纸面按钮、轻量成员名册;避免管理卡片挤占像素场景。标题“好好工作事务所”,强调小团队共同工作的生活感。 - -共享 2:1 等距投影;所有素材以整数像素绘制,最近邻缩放。人物使用同一模板及发型、肤色、衣服换色;桌椅、显示器、绿植、柜体共享资源定义。墙面、书架、咖啡角、会议桌、窗外树木组成完整房间。 - -主交互:点击小人或成员名册查看状态;派发演示任务;暂停/继续及 1x/2x/4x;缩放;查看共享资源图鉴。窄屏保持场景完整,名册下移。键盘焦点清楚,减少动态偏好默认暂停。 diff --git a/design-prototypes/agent-office/PRODUCT.md b/design-prototypes/agent-office/PRODUCT.md deleted file mode 100644 index dd3598669..000000000 --- a/design-prototypes/agent-office/PRODUCT.md +++ /dev/null @@ -1,23 +0,0 @@ -# 像素 AI 办公室 - - - -## Platform - -web - -## Users - -用户希望先体验 Agent Team 的可视化办公室,再决定正式集成。 - -## Product Purpose - -用有工作动作的小人呈现 AI 团队分工,验证像素办公室效果。 - -## Capabilities and Constraints - -用户确认独立演示页,先看效果;用代码绘制,最大化资源复用。所有任务与团队数据为本地模拟,不连接真实模型。实现选择为原生 HTML/CSS/Canvas,无外部资源依赖。 - -## Brand Commitments - -以用户提供的等距像素办公室图片为视觉依据。 diff --git a/design-prototypes/agent-office/README.md b/design-prototypes/agent-office/README.md deleted file mode 100644 index f193822ac..000000000 --- a/design-prototypes/agent-office/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# 好好工作事务所 - -等距像素风 AI 办公室 demo。参考用户提供的办公室画面,全部使用 Canvas 代码绘制;团队与任务为本地模拟,没有真实模型请求。 - -## 打开 - -双击本目录的 **index.html**,使用 Chrome 或 Edge 打开即可。不需要安装依赖、启动服务器或联网。请保留同目录的 CSS 和四个 JS 文件。 - -默认自动演示一个进行中的项目。开启系统“减少动态效果”时默认暂停,可手动点击“继续”。 - -## 可以体验什么 - -- 6 位伙伴分别负责规划、开发、设计、研究、审查和测试。 -- 点击小人或右侧成员按钮查看当前任务;成员按钮支持键盘操作。 -- 小人打字、眨眼,空闲时沿过道走去咖啡角,再回到工位。 -- 按依赖交接任务;整个项目完成后,“派发新任务”会启用。 -- 暂停/继续、1×/2×/4×、80%–140% 缩放、适应窗口。页面背景获得焦点时,空格也可暂停。 -- “素材图鉴”展示共享家具与人物;Escape 关闭图鉴。 - -## 资源复用 - -| 文件 | 唯一职责 | -| --- | --- | -| assets.js | 整数像素光栅、等距几何、家具工厂、人物模板、Atlas 缓存 | -| simulation.js | 角色数据、通道移动、咖啡角占用、显式时间驱动的任务依赖 | -| renderer.js | 缓存房间、家具实例布局、深度排序、人物与气泡合成、坐标命中 | -| app.js | DOM 交互、动画调度、名册、详情与素材图鉴 | -| office.css | 独立原型的响应式视觉系统 | - -- 地面重复使用 4 个配色图块;重复工位、椅子、隔板、绿植引用同一个缓存对象。 -- 人物共享同一套轮廓,通过发型、衣服、肤色和有限动作帧换色组合;头像与图鉴也取自同一 Atlas。 -- 墙壁、窗户、地板与光照只绘制一次到离屏 Canvas。家具实例只存位置与资源引用。 -- 最多 30 FPS 渲染,DOM 状态约每 220ms 更新一次;暂停、页面隐藏或打开图鉴时取消连续动画调度。 -- 缓存按资源类型、配色、动作和有限帧编号索引,不随运行时间无限增长。图鉴提供当前图块数和像素缓冲区估算,不包含浏览器自身开销。 -- **外部图片、字体、运行时依赖均为 0。** 源图不会作为背景贴图或资产被加载。 - -新增一种家具:在 assets.js 的家具工厂中定义它,再向 renderer.js 的 furniture 数组添加实例。重复实例不需要重新绘制素材。修改角色外观只需修改 profiles 的配色与样式字段。 - -将来接入真实 Agent Team 时,可保留 Atlas 和 Renderer,用真实事件驱动的状态适配器替换 Simulation;维持 actor 位置、action、任务进度和 status 的数据接口即可。本 demo 尚未接入 ACECode 正式界面。 - -## 验证 - -状态机使用 Node 内置测试,无额外测试依赖: - -~~~powershell -node --test design-prototypes/agent-office/simulation.test.cjs -~~~ - -已验证: - -- 7 项状态机测试:暂停、倍速、无效时间、任务依赖、工位约束、咖啡往返、重复派发与完整完成。 -- Chromium 的 file 页面和本地 HTTP 页面;没有外部网络请求和页面脚本错误。 -- 鼠标/键盘成员选择、场景命中、缩放、图鉴及完成后的任务派发。 -- 暂停时模拟时间不变且没有连续绘制;隐藏页面和图鉴打开时不推进。 -- 320 / 390 / 768 / 1024 / 1440px 布局无横向溢出;桌面、手机和 150% DPI 截图检查。 -- 仓库 web 的 pnpm test、pnpm build,以及本次 OpenSpec 严格校验通过。 - -设计记录位于 PRODUCT.md、DESIGN.md 和 surface.md;行为规范位于 openspec/changes/add-pixel-agent-office-demo。 diff --git a/design-prototypes/agent-office/app.js b/design-prototypes/agent-office/app.js deleted file mode 100644 index 90e2f15e0..000000000 --- a/design-prototypes/agent-office/app.js +++ /dev/null @@ -1,103 +0,0 @@ -/* DOM is an adapter: simulation and artwork do not depend on this UI. */ -(function (O) { - 'use strict'; - const $=id=>document.getElementById(id); - const atlas=new O.Atlas(),sim=new O.Simulation(),renderer=new O.Renderer($('office'),atlas,sim); - const media=window.matchMedia('(prefers-reduced-motion: reduce)'); sim.paused=media.matches; - const events=new AbortController(),listen=(el,event,handler)=>el.addEventListener(event,handler,{signal:events.signal}); - const rows=[]; let raf=null,lastTime=null,lastUi=0,eventSignature='',toastTimer=null; - const setText=(el,text)=>{text=String(text);if(el.textContent!==text)el.textContent=text;}; - for(const a of sim.agents) { - const row=document.createElement('button'); row.type='button';row.className='agent-row';row.dataset.agent=a.index; - row.innerHTML=''; - row.querySelector('.agent-name').textContent=a.name;row.querySelector('.agent-role').textContent=a.role; - atlas.portrait(row.querySelector('canvas'),a.index); - listen(row,'click',()=>select(a.index));$('agent-list').append(row);rows.push(row); - } - function select(index) { sim.selected=index;updateUI();renderer.render(); } - function updateUI() { - setText($('clock'),sim.clock);setText($('day'),String(1+Math.floor(sim.time/(3*1440))).padStart(2,'0')); - let working=0; - for(const a of sim.agents) { - const row=rows[a.index],status=sim.status(a.index),state=row.querySelector('.agent-state'); - row.setAttribute('aria-pressed',String(sim.selected===a.index)); - row.setAttribute('aria-label',a.name+','+a.role+','+status.label); - setText(state,status.label);state.dataset.state=status.state; - if(a.action==='work')working++; - } - setText($('working-count'),working);setText($('resting-count'),(6-working)+' 位待命 / 休息'); - const a=sim.agents[sim.selected],task=sim.tasks[sim.selected],status=sim.status(sim.selected),percent=Math.floor(task.elapsed/task.duration*100); - setText($('detail-name'),a.name);setText($('detail-role'),a.role);setText($('detail-id'),'AGENTS '+String(a.index+1).padStart(2,'0')); - setText($('detail-state'),status.label); - const description=task.elapsed>=task.duration?'已完成'+task.title+'。休息一下,准备迎接下一个好想法。':!sim.ready(a.index)?'等待'+task.deps.filter(i=>sim.tasks[i].elapsedsim.agents[i].name).join('、')+'交接,然后开始'+task.title+'。':task.text; - setText($('detail-task'),description);setText($('detail-percent'),percent+'%');$('detail-progress').style.width=percent+'%'; - setText($('project-title'),sim.title);setText($('project-status'),sim.finished?'作品已完成':sim.paused?'协作已暂停':'团队正在协作'); - setText($('task-count'),sim.doneCount+' / 6 项任务完成'); - const total=Math.floor(sim.progress*100);$('project-percent').firstChild.textContent=total;$('project-fill').style.width=total+'%'; - setText($('progress-caption'),sim.finished?'做得不错!可以派发下一个任务了。':'一步一步,把想法变成作品。'); - setText($('scene-heading'),sim.paused?'把灵感暂停一下':sim.finished?'今天的好想法已完成':'灵感正在发生'); - const dispatch=$('new-project');dispatch.disabled=sim.active; - dispatch.lastChild.textContent=sim.active?'任务进行中':'派发新任务';dispatch.title=sim.active?'当前项目完成后,可派发下一个演示任务':'开启下一个演示项目'; - $('toggle-pause').setAttribute('aria-pressed',String(sim.paused)); - $('toggle-pause').querySelector('use').setAttribute('href',sim.paused?'#i-play':'#i-pause'); - setText($('toggle-pause').querySelector('span'),sim.paused?'继续':'暂停');$('pause-overlay').hidden=!sim.paused; - for(const button of document.querySelectorAll('[data-speed]')) button.setAttribute('aria-pressed',String(Number(button.dataset.speed)===sim.speed)); - const signature=sim.events.map(e=>e.time+e.text).join('|'); - if(signature!==eventSignature) { - eventSignature=signature;$('activity-list').replaceChildren(); - for(const e of sim.events.slice(0,3)) { const li=document.createElement('li'),time=document.createElement('time'),text=document.createElement('span');time.textContent=e.time;text.textContent=e.text;li.append(time,text);$('activity-list').append(li); } - } - setText($('zoom-label'),Math.round(renderer.zoom*100)+'%'); - $('zoom-out').disabled=renderer.zoom<=.8;$('zoom-in').disabled=renderer.zoom>=1.4; - } - function stop() { if(raf!==null) cancelAnimationFrame(raf);raf=null;lastTime=null; } - function canAnimate() {return !sim.paused&&!document.hidden&&!$('library').open;} - function start() { if(raf===null&&canAnimate()) {lastTime=null;raf=requestAnimationFrame(frame);} } - function frame(now) { - raf=null;if(!canAnimate()) {lastTime=null;return;} - if(lastTime===null)lastTime=now; - const dt=(now-lastTime)/1000; - if(dt>=1/30) { - sim.tick(Math.min(dt,.25));lastTime=now;renderer.render(); - if(now-lastUi>220) {updateUI();lastUi=now;} - } - raf=requestAnimationFrame(frame); - } - function refresh() {updateUI();renderer.render();} - function togglePause() {sim.paused=!sim.paused;stop();refresh();start();} - function toast(message) {clearTimeout(toastTimer);setText($('toast'),message);$('toast').hidden=false;toastTimer=setTimeout(()=>{$('toast').hidden=true;},3200);} - function zoom(delta) {renderer.zoom=Math.round(Math.max(.8,Math.min(1.4,renderer.zoom+delta))*100)/100;refresh();} - listen($('toggle-pause'),'click',togglePause); - listen($('new-project'),'click',()=>{if(sim.startProject()) {refresh();toast('新任务已派发,看看大家怎么分工吧。');}}); - for(const button of document.querySelectorAll('[data-speed]')) listen(button,'click',()=>{sim.speed=Number(button.dataset.speed);updateUI();}); - listen($('zoom-in'),'click',()=>zoom(.1));listen($('zoom-out'),'click',()=>zoom(-.1)); - listen($('zoom-reset'),'click',()=>{renderer.zoom=1;refresh();}); - listen($('office'),'pointermove',e=>{const next=renderer.hit(e);$('office').style.cursor=next>=0?'pointer':'default';if(next!==renderer.hover){renderer.hover=next;renderer.render();}}); - listen($('office'),'pointerleave',()=>{renderer.hover=-1;renderer.render();}); - listen($('office'),'click',e=>{const hit=renderer.hit(e);if(hit>=0)select(hit);}); - listen(document,'keydown',e=>{if(e.code==='Space'&&e.target===document.body&&!$('library').open){e.preventDefault();togglePause();}}); - listen(document,'visibilitychange',()=>{stop();start();}); - listen(window,'pagehide',stop);listen(window,'pageshow',start); - listen(media,'change',e=>{if(e.matches){sim.paused=true;stop();refresh();}}); - const labels=[['tile','共享地砖'],['desk','标准工位'],['chair','办公椅'],['partition','工位隔板'],['bookcase','资料书架'],['cabinet','储物柜'],['plant','绿植'],['server','服务器'],['whiteboard','任务白板'],['sofa','休息沙发'],['table','咖啡小桌'],['coffee','咖啡台']]; - let libraryBuilt=false; - function buildLibrary() { - if(libraryBuilt)return;libraryBuilt=true; - for(const [type,label] of labels) { - const item=document.createElement('div');item.className='asset-tile'; - const preview=document.createElement('canvas');preview.width=110;preview.height=96;preview.setAttribute('aria-label',label); - const c=preview.getContext('2d');c.imageSmoothingEnabled=false; - if(type==='tile')atlas.draw(c,atlas.tile(0),55,27,1.8); - else {const s=atlas.get(type);c.drawImage(s.canvas,29,51,102,93,0,0,110,96);} - const caption=document.createElement('span');caption.textContent=label;item.append(preview,caption);$('asset-grid').append(item); - } - for(const a of sim.agents) {const item=document.createElement('div'),portrait=document.createElement('canvas');portrait.width=42;portrait.height=50;atlas.portrait(portrait,a.index);const label=document.createElement('span');label.textContent=a.name;item.append(portrait,label);$('portrait-grid').append(item);} - } - listen($('open-library'),'click',()=>{buildLibrary();const stats=atlas.stats();setText($('asset-stats'),stats.sprites+' 个缓存图块 · 约 '+stats.kib+' KiB 图块内存 · 0 张外部图片');$('library').showModal();stop();}); - listen($('close-library'),'click',()=>{$('library').close();}); - listen($('library'),'close',start); - listen($('library'),'click',e=>{if(e.target===$('library')){const r=$('library').getBoundingClientRect();if(e.clientXr.right||e.clientYr.bottom)$('library').close();}}); - // A small, explicit seam for embedding, debugging and deterministic verification. - O.demo={simulation:sim,renderer,atlas,refresh,destroy(){stop();events.abort();clearTimeout(toastTimer);}}; - refresh();start(); -})(globalThis.PixelOffice = globalThis.PixelOffice || {}); diff --git a/design-prototypes/agent-office/assets.js b/design-prototypes/agent-office/assets.js deleted file mode 100644 index ba6f73de0..000000000 --- a/design-prototypes/agent-office/assets.js +++ /dev/null @@ -1,274 +0,0 @@ -/* Shared, code-drawn pixel atlas. No image files, fonts, network or libraries. */ -(function (O) { - 'use strict'; - const C = { - ink: '#41483f', edge: '#69715e', cream: '#f6f1df', wall: '#e8e5d4', - blue: '#548cb4', blueLight: '#75aed1', blueDark: '#3e6688', - wood: '#b78a56', woodLight: '#d2ad72', woodDark: '#8e6b49', - green: '#658c48', greenLight: '#91b75e', greenDark: '#3d6339', - }; - function canvas(w, h) { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; } - function rect(c, x, y, w, h, color) { - c.fillStyle = color; c.fillRect(Math.round(x), Math.round(y), Math.round(w), Math.round(h)); - } - function line(c, x0, y0, x1, y1, color) { - x0 = Math.round(x0); y0 = Math.round(y0); x1 = Math.round(x1); y1 = Math.round(y1); - const dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1; - const dy = -Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1; - let err = dx + dy; - c.fillStyle = color; - for (;;) { - c.fillRect(x0, y0, 1, 1); - if (x0 === x1 && y0 === y1) break; - const e2 = 2 * err; - if (e2 >= dy) { err += dy; x0 += sx; } - if (e2 <= dx) { err += dx; y0 += sy; } - } - } - function poly(c, points, fill, stroke) { - const p = points.map(([x, y]) => [Math.round(x), Math.round(y)]); - const lo = Math.min(...p.map(v => v[1])), hi = Math.max(...p.map(v => v[1])); - c.fillStyle = fill; - for (let y = lo; y <= hi; y++) { - const crosses = []; - for (let i = 0; i < p.length; i++) { - const a = p[i], b = p[(i + 1) % p.length]; - if ((a[1] <= y && b[1] > y) || (b[1] <= y && a[1] > y)) { - crosses.push(a[0] + (y - a[1]) * (b[0] - a[0]) / (b[1] - a[1])); - } - } - crosses.sort((a, b) => a - b); - for (let i = 0; i + 1 < crosses.length; i += 2) { - c.fillRect(Math.ceil(crosses[i]), y, Math.floor(crosses[i + 1]) - Math.ceil(crosses[i]) + 1, 1); - } - } - if (stroke) p.forEach((v, i) => line(c, ...v, ...p[(i + 1) % p.length], stroke)); - } - const iso = (x, y, z = 0) => [Math.round((x - y) * 24), Math.round((x + y) * 12 - z)]; - function plane(c, x, y, w, d, z, color, stroke) { - poly(c, [iso(x, y, z), iso(x + w, y, z), iso(x + w, y + d, z), iso(x, y + d, z)], color, stroke); - } - function box(c, x, y, w, d, h, z, colors, stroke = C.ink) { - const a = iso(x, y, z), b = iso(x + w, y, z), e = iso(x + w, y + d, z), f = iso(x, y + d, z); - const at = iso(x, y, z + h), bt = iso(x + w, y, z + h), et = iso(x + w, y + d, z + h), ft = iso(x, y + d, z + h); - poly(c, [bt, et, e, b], colors[2], stroke); - poly(c, [ft, et, e, f], colors[1], stroke); - poly(c, [at, bt, et, ft], colors[0], stroke); - } - function panel(c, x, y, z, w, h, fill, stroke) { - poly(c, [iso(x, y, z), iso(x + w, y, z), iso(x + w, y, z + h), iso(x, y, z + h)], fill, stroke); - } - function shadow(c, w, d) { plane(c, -w / 2 + .13, -d / 2 + .16, w, d, 0, 'rgba(64,82,56,.16)'); } - const metal = ['#eff0e4', '#c1c5b7', '#d6d9cc']; - const wood = [C.woodLight, C.woodDark, C.wood]; - function mug(c, x, y, z, color = '#f4e9cd') { - box(c, x, y, .14, .14, 5, z, [color, '#c6b693', color]); - const p = iso(x + .14, y + .03, z + 3); - rect(c, p[0], p[1], 3, 3, C.ink); rect(c, p[0], p[1], 2, 1, color); - } - function desk(c, variant) { - shadow(c, 1.8, 1.3); - for (const [x, y] of [[-.74,-.42],[.58,-.42],[-.74,.37],[.58,.37]]) { - box(c, x, y, .13, .13, 23, 0, metal); - } - box(c, .38, -.37, .32, .74, 15, 6, metal); - for (const z of [10,15,20]) panel(c, .42, .38, z, .25, 1, '#8d9686'); - box(c, -.83, -.51, 1.66, 1.10, 4, 23, metal); - plane(c, -.74, -.44, 1.50, .94, 28, '#f9f8f0'); - box(c, -.16, -.32, .32, .25, 2, 28, ['#7a837a','#5c635c','#68716b']); - box(c, -.03, -.27, .09, .09, 6, 29, metal); - box(c, -.36, -.36, .79, .12, 19, 34, ['#546369','#353f44','#4a5559']); - panel(c, -.30, -.231, 36, .66, 15, variant === 'warm' ? '#bb976a' : '#83b8c7'); - panel(c, -.25, -.226, 38, .56, 10, variant === 'warm' ? '#e4be85' : '#b3d9d5'); - for (let i = 0; i < 4; i++) panel(c, -.20, -.22, 39 + i * 2, .15 + (i % 3) * .09, 1, i % 2 ? '#579393' : '#eef4d7'); - const p = iso(.30, -.22, 35); rect(c, p[0], p[1], 1, 1, '#b9d786'); - box(c, -.21, .13, .69, .24, 1, 28, ['#9ba8a2','#88958e','#88958e']); - for (let i = 0; i < 5; i++) plane(c, -.17 + i * .12, .16, .06, .13, 30, '#e1e7d9'); - plane(c, .60, .10, .18, .28, 28, '#b4c4b1'); - box(c, .64, .16, .08, .12, 2, 28, metal); - mug(c, -.65, .18, 28, variant === 'warm' ? '#b48256' : '#98ad7d'); - } - function chair(c) { - shadow(c, .70, .65); - const foot = iso(0, 0, 3); - for (const [x, y] of [[-10,4],[10,4],[0,-5]]) { - line(c, ...foot, x, y, '#535c53'); rect(c, x - 1, y, 3, 3, '#434a43'); - } - box(c, -.06, -.05, .12, .12, 11, 3, ['#b4bbae','#7d857a','#8d9589']); - box(c, -.27, -.27, .55, .54, 4, 13, [C.blueLight,C.blueDark,C.blue]); - box(c, -.30, .25, .60, .11, 16, 18, [C.blueLight,C.blueDark,C.blue]); - line(c, -11, -17, -11, -10, '#606a60'); line(c, -11, -17, -5, -14, '#414c46'); - } - function plant(c, small) { - shadow(c, .48, .48); - box(c, -.18, -.18, .36, .36, small ? 8 : 13, 0, ['#eae9d9','#b4b6a5','#d2d4c1']); - plane(c, -.13, -.13, .26, .26, small ? 9 : 14, '#6f6647'); - const s = small ? .6 : 1; - c.save(); c.scale(s, s); - line(c, 0, -12, 0, -47, '#5f743a'); line(c, 1, -17, 1, -43, '#8a9c49'); - for (const [x,y,flip] of [[-2,-25,1],[2,-34,-1],[-2,-40,1],[2,-46,-1],[-1,-33,1],[1,-25,-1]]) { - const p = [[x,y],[x+flip*12,y-12],[x+flip*9,y-16],[x+flip*3,y-12],[x,y-2]]; - poly(c,p,C.greenDark); poly(c,p.map(([a,b])=>[a+flip,b-1]),C.green); - line(c,x,y-2,x+flip*8,y-12,C.greenLight); - } - poly(c,[[0,-40],[-4,-55],[0,-59],[4,-53],[2,-40]],C.greenDark); - line(c,0,-43,0,-56,C.greenLight); c.restore(); - } - function bookcase(c) { - shadow(c, 1.18, .65); - box(c, -.58, -.28, 1.16, .57, 63, 0, ['#edeada','#b5b7a5','#c5c9b7']); - panel(c, -.50, .30, 5, 1.0, 52, '#888d7d', C.ink); - const books = ['#d0af63','#74969f','#a2654f','#718668','#e1d9b5','#667f9f']; - for (let row = 0; row < 3; row++) { - for (let i = 0; i < 6; i++) { - const z = 7 + row * 17, height = 11 + (i * 3 + row) % 5; - panel(c, -.45 + i * .152, .33, z, .12, height, books[(i + row * 2) % books.length], '#696c58'); - panel(c, -.43 + i * .152, .335, z + 3, .07, 1, '#e4dfc9'); - } - box(c, -.51, .18, 1.02, .20, 2, 5 + row * 17, metal); - } - } - function cabinet(c) { - shadow(c, 1.05, .64); box(c, -.52, -.32, 1.04, .64, 59, 0, metal); - panel(c, -.44, .33, 5, .87, 48, '#d0d3c4', '#858d7c'); - const a = iso(0,.34,6), b=iso(0,.34,52); line(c,...a,...b,'#868d7d'); - for(const x of [-.09,.09]) { const p=iso(x,.35,29); rect(c,p[0],p[1],1,7,'#6e786b'); } - } - function server(c) { - shadow(c, .9, .75); box(c,-.45,-.34,.9,.68,62,0,['#737f79','#45524e','#566560']); - for (let i=0;i<5;i++) { - panel(c,-.35,.35,5+i*11,.70,8,'#2f3f39','#879187'); - panel(c,-.27,.36,8+i*11,.30,1,'#64766b'); - const p=iso(.23,.36,10+i*11); rect(c,p[0],p[1],2,2,i===3?'#d6b468':'#a1bd74'); - } - } - function whiteboard(c) { - for(const x of [-.75,.75]) { box(c,x,-.10,.09,.13,50,0,metal); box(c,x-.17,-.12,.42,.27,2,0,metal); } - box(c,-.92,-.14,1.85,.15,37,18,metal); panel(c,-.83,.02,22,1.67,30,'#f8f8ec','#a7af9b'); - const colors=['#c49b63','#92aa76','#779eab','#c19b8b']; - for(let row=0;row<2;row++) for(let i=0;i<4;i++) panel(c,-.67+i*.36,.035,27+row*12,.23,7,colors[(i+row)%4]); - panel(c,-.65,.04,46,1.13,1,'#829172'); box(c,-.91,.02,1.87,.13,2,18,metal); - } - function sofa(c) { - shadow(c,1.90,.90); - for(const x of [-.8,.65]) box(c,x,-.20,.13,.13,7,0,wood); - box(c,-.94,-.32,1.88,.78,12,5,['#c99368','#966843','#ad7953']); - box(c,-.94,-.37,1.88,.20,25,12,['#dcaf80','#b4855e','#c39166']); - for(const x of [-.91,.68]) box(c,x,-.22,.24,.80,14,13,['#dca374','#ad7a52','#c59263']); - for(const x of [-.62,.06]) box(c,x,-.09,.61,.52,3,19,['#deb487','#bd946c','#c99c71']); - box(c,-.5,-.03,.31,.19,9,22,['#b2bf91','#88956e','#9eaa80']); - } - function table(c) { - shadow(c,1.15,.70); - for(const x of [-.43,.35]) for(const y of [-.19,.22]) box(c,x,y,.08,.08,13,0,wood); - box(c,-.60,-.34,1.2,.75,3,13,wood); - plane(c,-.34,-.20,.40,.34,17,'#e7e1c8','#87907a'); - plane(c,-.31,-.17,.32,.25,18,'#9caf93'); mug(c,.21,.0,17); - } - function coffee(c) { - shadow(c,1.90,.78); box(c,-.95,-.36,1.90,.72,27,0,wood); - for(const x of [-.85,-.26,.33]) { panel(c,x,.37,3,.51,20,'#cba276','#846747'); const p=iso(x+.18,.38,20); rect(c,p[0],p[1],4,1,'#6d6248'); } - box(c,-1,-.39,2,.82,3,27,['#eee9d7','#c1bca9','#d9d5bf']); - box(c,-.67,-.20,.55,.45,18,30,['#888c7d','#4b5850','#616a60']); - panel(c,-.59,.26,35,.38,10,'#293d36'); panel(c,-.54,.27,42,.28,2,'#b8c7a5'); - mug(c,-.46,.21,32); mug(c,.13,.10,30); mug(c,.43,.10,30,'#a4b087'); - box(c,.68,-.17,.16,.16,7,30,['#d1ab73','#b08452','#c69c64']); - } - function partition(c) { - box(c,-.85,-.09,1.7,.18,31,0,['#acbf82','#8ea565','#9eb578']); - for(const x of [-.85,.8]) box(c,x,-.12,.06,.24,34,0,metal); - box(c,-.90,-.12,1.8,.23,2,34,metal); - } - function planter(c) { - box(c,-1.13,-.24,2.26,.48,15,0,['#edeedd','#bbbdaa','#d1d4bf']); - plane(c,-1.05,-.17,2.1,.34,16,'#77794e'); - for(let i=0;i<9;i++) { - const p=iso(-.97+i*.23,0,19); rect(c,p[0]-3,p[1]-9,6,12,i%2?'#668349':'#7e9958'); - rect(c,p[0]-1,p[1]-12,3,8,'#a0b976'); - } - } - function rug(c) { plane(c,-1.2,-.9,2.4,1.8,.2,'#d2bea0','#a49679'); plane(c,-1.05,-.74,2.1,1.48,.4,'#c7b797','#b8a88a'); } - const furniture = { desk, chair, plant, bookcase, cabinet, server, whiteboard, sofa, table, coffee, partition, planter, rug }; - const profiles = [ - { id:'ce', name:'阿策', role:'团队协调', color:'#b28c57', dark:'#7e6244', hair:'#554936', lightHair:'#7a6748', skin:'#edc395', style:0 }, - { id:'ma', name:'小码', role:'全栈开发', color:'#628ea8', dark:'#436378', hair:'#343e3c', lightHair:'#535c50', skin:'#e8bd8f', style:1 }, - { id:'hui', name:'阿绘', role:'视觉设计', color:'#c48568', dark:'#945e48', hair:'#78543f', lightHair:'#9b7351', skin:'#f2cea2', style:2 }, - { id:'xun', name:'小寻', role:'资料研究', color:'#a497b7', dark:'#756c87', hair:'#504637', lightHair:'#70624a', skin:'#e7bb87', style:3 }, - { id:'shen', name:'阿审', role:'代码审查', color:'#84946b', dark:'#5a6d49', hair:'#4e493e', lightHair:'#77705c', skin:'#dba977', style:1 }, - { id:'ce2', name:'小测', role:'质量测试', color:'#c1a157', dark:'#8a733e', hair:'#654c36', lightHair:'#96744c', skin:'#f1c89b', style:0 }, - ]; - // All six people share this silhouette. Palette/style/frame are the only variants. - function person(c, p, action, frame) { - const seated = action === 'work', walking = action === 'walk'; - const bob = walking ? frame % 2 : 0; - const oy = seated ? -5 : -bob; - c.save(); c.translate(0,oy); - if (!seated) { rect(c,-8,0,18,3,'rgba(52,65,43,.16)'); } - const leg = walking ? [0,2,0,-2][frame%4] : 0; - rect(c,-6,-12,6,11+leg,'#374640'); rect(c,2,-12,6,11-leg,'#43534a'); - rect(c,-7,-3+leg,8,4,C.ink); rect(c,2,-3-leg,9,4,C.ink); - if(seated) { rect(c,-6,-5,6,6,'#b7b3a0'); rect(c,1,-5,6,6,'#c6c0a6'); } - poly(c,[[-8,-26],[5,-27],[10,-23],[9,-11],[-7,-10],[-10,-20]],C.ink); - rect(c,-7,-25,13,14,p.color); rect(c,-7,-22,4,10,p.dark); rect(c,-2,-25,6,3,'#eee9d7'); - rect(c,2,-23,3,10,p.color); rect(c,6,-21,3,7,p.dark); - if(action==='work') { - const k=frame%2; rect(c,6,-19-k,6,4,p.color); rect(c,10,-21-k,5,4,p.skin); - rect(c,-2,-16+k,9,3,p.color); rect(c,6,-18+k,5,3,p.skin); - } else if(action==='coffee') { - rect(c,6,-22,4,8,p.color); rect(c,8,-25,4,4,p.skin); - rect(c,10,-28,6,7,C.ink); rect(c,11,-27,5,5,'#e8e1c8'); rect(c,15,-26,3,3,'#cfc7ad'); - } else { - rect(c,-10,-22,3,10,p.dark); rect(c,-10,-13+(walking?leg:0),3,4,p.skin); - rect(c,8,-21,3,9,p.color); rect(c,8,-12-(walking?leg:0),3,4,p.skin); - } - // Large expressive head, three-quarter view toward the desk. - poly(c,[[-9,-40],[-6,-44],[5,-44],[10,-39],[10,-29],[6,-25],[-5,-25],[-10,-30]],C.ink); - rect(c,-7,-39,15,11,p.skin); rect(c,-5,-29,11,3,p.skin); - rect(c,-8,-35,3,6,p.skin); rect(c,8,-34,3,4,p.skin); - rect(c,-7,-41,13,4,p.hair); rect(c,-9,-38,6,7,p.hair); rect(c,-7,-43,12,3,p.hair); - rect(c,-5,-42,8,2,p.lightHair); rect(c,-8,-37,3,9,p.hair); - rect(c,-4,-39,4,2,p.hair); rect(c,3,-40,5,3,p.hair); - if(p.style===1) { rect(c,-9,-40,5,3,p.hair); rect(c,-5,-45,8,3,p.hair); } - if(p.style===2) { rect(c,-12,-36,4,14,p.hair); rect(c,-11,-36,2,9,p.lightHair); rect(c,7,-39,3,6,p.hair); rect(c,-10,-29,3,2,p.color); } - if(p.style===3) { rect(c,-9,-44,16,4,p.dark); rect(c,-11,-40,22,3,p.color); rect(c,-4,-45,9,1,p.color); } - const blink = action==='idle' && frame===1; - rect(c,1,-35,2,blink?1:3,'#3e4136'); rect(c,6,-34,2,blink?1:3,'#3e4136'); - rect(c,5,-28,3,1,'#b77959'); rect(c,8,-30,2,1,'#d8a278'); - if(p.id==='shen') { line(c,-1,-36,4,-34,'#424e40'); line(c,5,-34,9,-33,'#424e40'); rect(c,-1,-35,5,4,'#414a3e'); rect(c,0,-34,3,2,'#b9c8af'); rect(c,6,-33,3,2,'#b9c8af'); } - c.restore(); - } - class Atlas { - constructor() { this.cache = new Map(); } - get(type, variant='') { - const key=type+':'+variant; - if(this.cache.has(key)) return this.cache.get(key); - const bitmap=canvas(160,160), c=bitmap.getContext('2d'); c.imageSmoothingEnabled=false; - c.translate(80,132); - if(!furniture[type]) throw new Error('Unknown office asset: '+type); - furniture[type](c,variant); - const sprite={canvas:bitmap,ax:80,ay:132}; this.cache.set(key,sprite); return sprite; - } - person(index,action='idle',frame=0) { - const count=action==='walk'?4:2; frame=((frame%count)+count)%count; - const key='person:'+index+':'+action+':'+frame; - if(this.cache.has(key)) return this.cache.get(key); - const bitmap=canvas(48,64),c=bitmap.getContext('2d'); c.translate(22,54); - person(c,profiles[index],action,frame); - const sprite={canvas:bitmap,ax:22,ay:54}; this.cache.set(key,sprite); return sprite; - } - tile(index=0) { - const key='tile:'+index; if(this.cache.has(key)) return this.cache.get(key); - const bitmap=canvas(49,25),c=bitmap.getContext('2d'); - const colors=['#c1cdb3','#c4cfb8','#bfccb2','#c7d1bb']; - poly(c,[[24,0],[48,12],[24,24],[0,12]],colors[index%4]); - line(c,0,12,24,0,'#d6dfcc'); line(c,24,0,48,12,'#d3ddc7'); - line(c,0,12,24,24,'#aebfa2'); line(c,24,24,48,12,'#b0bfa4'); - rect(c,15+(index%2)*8,13,1,1,'#b7c7a8'); - const sprite={canvas:bitmap,ax:24,ay:0}; this.cache.set(key,sprite); return sprite; - } - draw(c,sprite,x,y,scale=1) { c.drawImage(sprite.canvas,Math.round(x-sprite.ax*scale),Math.round(y-sprite.ay*scale),sprite.canvas.width*scale,sprite.canvas.height*scale); } - portrait(target,index) { const c=target.getContext('2d'); c.imageSmoothingEnabled=false; c.clearRect(0,0,target.width,target.height); const s=this.person(index); c.drawImage(s.canvas,7,7,30,36,0,0,target.width,target.height); } - stats() { let bytes=0; for(const s of this.cache.values()) bytes+=s.canvas.width*s.canvas.height*4; return {sprites:this.cache.size,kib:Math.round(bytes/1024)}; } - } - O.Art={C,canvas,rect,line,poly,iso,plane,box,panel}; O.Atlas=Atlas; O.profiles=profiles; -})(globalThis.PixelOffice = globalThis.PixelOffice || {}); diff --git a/design-prototypes/agent-office/index.html b/design-prototypes/agent-office/index.html deleted file mode 100644 index 31629f9ba..000000000 --- a/design-prototypes/agent-office/index.html +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - 好好工作事务所 · 像素 AI 办公室 - - - - - - - - - -
-
- - -

好好工作事务所.

A LITTLE OFFICE, BIG IDEAS
-
-
团队在线DAY 01
-
-
-
-

办公室

/每个好想法,都有人在认真实现
-
本地模拟 DEMO
-
-
-
-
灵感正在发生WORKSPACE / 01
晴 · 适合一起做点什么
-
- 请使用支持 Canvas 的浏览器查看办公室。 -
小小的办公室,大大的可能。
- -
-
点击小人,看看 TA 在忙什么
100%
-
- -
-

做一个有趣的 AI 工作台

演示项目
团队正在协作·0 / 6 项任务完成
项目进度0%
一步一步,把想法变成作品。
-
-
像素很小,想法很大。6 位 AI 伙伴 · 本地模拟 · 无需联网
-
-

办公室素材图鉴

同一套积木,搭出整个办公室。

家具与房间

一个人物模板,六位伙伴

家具重复使用同一缓存图块;人物通过发型、衣服换色和动作帧组合。场景、头像、图鉴共享素材,不加载任何图片。

- - - - diff --git a/design-prototypes/agent-office/office.css b/design-prototypes/agent-office/office.css deleted file mode 100644 index 4580d302b..000000000 --- a/design-prototypes/agent-office/office.css +++ /dev/null @@ -1,219 +0,0 @@ -:root { - color-scheme: light; - --paper: #f8f7f0; --paper-deep: #eeeee4; --ink: #344b3e; --muted: #5e6d55; - --line: #d9dfd1; --green: #4c6b48; --green-deep: #354f35; --green-soft: #e3eadb; - --scene: #e3e9dc; --white: #fffef9; - font-family: "Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC", sans-serif; - font-size: 13px; color: var(--ink); background: #eeeee5; -} -* { box-sizing: border-box; } -body { margin: 0; } -button, a, input { -webkit-tap-highlight-color: transparent; } -button { font: inherit; color: inherit; cursor: pointer; } -button:disabled { cursor: default; opacity: .45; } -a { color: inherit; text-decoration: none; } -button, a { touch-action: manipulation; } -button:focus-visible, a:focus-visible { outline: 2px solid var(--green); outline-offset: 4px; } -::selection { color: var(--white); background: var(--green); } -[hidden] { display: none !important; } -svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: square; stroke-linejoin: miter; flex-shrink: 0; } -.icon-definitions { position: absolute; width: 0; height: 0; overflow: hidden; } -.app-shell { max-width: 1600px; margin: 0 auto; padding: 0 38px; } -.app-header { height: 100px; display: flex; justify-content: space-between; align-items: center; } -.brand { display: flex; gap: 13px; align-items: center; } -.brand-mark { background: var(--green); color: #f2efd6; width: 43px; height: 44px; display: grid; place-items: center; clip-path: polygon(4px 0,calc(100% - 4px) 0,calc(100% - 4px) 4px,100% 4px,100% calc(100% - 4px),calc(100% - 4px) calc(100% - 4px),calc(100% - 4px) 100%,4px 100%,4px calc(100% - 4px),0 calc(100% - 4px),0 4px,4px 4px); } -.brand-mark svg { width: 29px; height: 29px; stroke-width: 1.5; } -h1 { margin: 0 0 5px; font-size: 21px; font-weight: 700; letter-spacing: .035em; } -.brand-period { color: #56694d; margin-left: 3px; } -.brand-subtitle { font-size: 11px; letter-spacing: .19em; color: var(--muted); } -.header-right { display: flex; align-items: center; gap: 24px; font-size: 12px; } -.online { display: flex; align-items: center; gap: 7px; } -.online i, .status-dot { background: #678652; width: 6px; height: 6px; display: inline-block; } -.day { font-size: 11px; color: var(--muted); letter-spacing: .06em; } -.day b { color: var(--ink); margin-left: 5px; font-size: 13px; } -.clock { display: flex; gap: 7px; align-items: center; font-variant-numeric: tabular-nums; } -.clock svg { width: 15px; height: 15px; } -.button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 36px; padding: 7px 13px; border: 1px solid #cfd7c7; background: var(--paper); border-radius: 4px; transition: background 120ms; white-space: nowrap; } -.button:hover:not(:disabled) { background: var(--green-soft); } -.primary { background: var(--green); border-color: var(--green); color: var(--white); padding-inline: 15px; } -.primary:hover:not(:disabled) { background: var(--green-deep); } -main { background: var(--paper); border: 1px solid #cbd3c3; border-radius: 7px; overflow: hidden; } -.workspace-header { min-height: 68px; padding: 15px 24px; display: flex; align-items: center; justify-content: space-between; gap: 14px; border-bottom: 1px solid var(--line); } -.workspace-name { display: flex; align-items: center; gap: 12px; min-width: 0; } -.room-marker { width: 11px; height: 12px; border: 2px solid var(--green); border-top-width: 4px; } -h2 { font-size: 14px; margin: 0; font-weight: 700; } -.workspace-slash { color: #56694d; margin: 0 2px; } -.workspace-subtitle { font-size: 11px; color: var(--muted); } -.workspace-actions { display: flex; align-items: center; gap: 15px; } -.demo-label { color: #56694d; font-size: 11px; letter-spacing: .04em; } -.workspace { display: grid; grid-template-columns: minmax(0,1fr) 270px; } -.office-section { min-width: 0; background: var(--scene); display: flex; flex-direction: column; border-right: 1px solid var(--line); } -.scene-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 22px 25px 0; position: relative; z-index: 1; } -.scene-heading > div { display: flex; align-items: center; gap: 7px; } -.scene-heading strong { font-size: 12px; font-weight: 400; } -.live-square { width: 5px; height: 5px; background: #80946f; } -.scene-caption { font-size: 11px; color: #56694d; letter-spacing: .1em; margin-left: 6px; } -.scene-weather { display: flex; gap: 7px; align-items: center; font-size: 11px; color: #56694d; } -.pixel-sun { width: 9px; height: 9px; background: #bdad58; box-shadow: 0 -3px 0 -2px #a99643,0 3px 0 -2px #a99643,3px 0 0 -2px #a99643,-3px 0 0 -2px #a99643; } -.stage { flex: 1; min-height: 465px; height: min(43vw,610px); position: relative; display: grid; place-items: center; overflow: hidden; } -#office { position: absolute; inset: 0; min-width: 0; min-height: 0; display: block; width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated; } -.scene-note { position: absolute; bottom: 18px; left: 26px; font-size: 11px; color: #56694d; display: flex; align-items: center; gap: 8px; pointer-events: none; } -.note-dash { width: 14px; height: 1px; background: #839577; } -.pause-overlay { position: absolute; top: 18px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 6px; background: var(--paper); border: 1px solid #b9c5af; border-radius: 3px; padding: 8px 12px; color: var(--green); font-size: 11px; } -.pause-overlay svg { width: 13px; height: 13px; } -.scene-toolbar { height: 48px; border-top: 1px solid #cfd8c6; display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 0 19px 0 25px; background: rgba(245,247,235,.48); } -.scene-hint { color: #56694d; font-size: 11px; display: flex; align-items: center; gap: 7px; } -.scene-hint svg { width: 13px; height: 13px; } -.view-controls { display: flex; align-items: center; gap: 3px; } -.icon-button { display: inline-grid; place-items: center; border: 0; background: transparent; width: 31px; height: 31px; border-radius: 3px; } -.icon-button:hover:not(:disabled) { background: #d5dfca; } -.icon-button svg { width: 16px; height: 16px; } -#zoom-label { font-size: 11px; width: 37px; text-align: center; font-variant-numeric: tabular-nums; } -.control-divider { width: 1px; height: 13px; background: #c3cdb9; margin: 0 5px; } -.team-panel { padding: 23px 19px 0; display: flex; flex-direction: column; min-width: 0; } -.panel-heading { display: flex; justify-content: space-between; align-items: center; } -.count-label { font-size: 11px; color: var(--muted); } -.team-summary { display: flex; flex-wrap: wrap; gap: 12px; font-size: 11px; color: var(--muted); margin-top: 10px; padding-bottom: 18px; border-bottom: 1px solid var(--line); } -.team-summary > span:first-child { color: var(--green); } -.team-summary .status-dot { width: 5px; height: 5px; margin-right: 5px; } -.agent-list { padding: 11px 0 12px; display: grid; gap: 3px; } -.agent-row { display: grid; grid-template-columns: 38px 1fr auto; width: 100%; gap: 9px; align-items: center; background: transparent; border: 1px solid transparent; padding: 5px 7px; border-radius: 4px; text-align: left; min-height: 49px; } -.agent-row:hover { background: #eef0e4; } -.agent-row[aria-pressed="true"] { background: #e8eddc; border-color: #ced9bd; } -.avatar { width: 35px; height: 35px; background: #e3e7d8; border-radius: 4px; image-rendering: pixelated; } -.agent-row:nth-child(2) .avatar { background: #dde7e9; } -.agent-row:nth-child(3) .avatar { background: #f0e3d6; } -.agent-row:nth-child(4) .avatar { background: #e6e0ed; } -.agent-row:nth-child(5) .avatar { background: #e6e6dd; } -.agent-row:nth-child(6) .avatar { background: #e9e5d4; } -.agent-name { display: block; font-size: 12px; margin-bottom: 3px; } -.agent-role { color: #56694d; display: block; font-size: 11px; } -.agent-state { font-size: 11px; color: #56694d; display: flex; gap: 4px; align-items: center; } -.agent-state::before { content: ''; width: 4px; height: 4px; background: currentColor; } -.agent-state[data-state="waiting"],.agent-state[data-state="coffee"] { color: #56694d; } -.agent-detail { padding: 14px 0 17px; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } -.detail-heading { display: flex; justify-content: space-between; font-size: 11px; letter-spacing: .06em; color: var(--muted); } -.detail-label { font-size: 11px; letter-spacing: 0; } -.detail-name { display: flex; align-items: center; gap: 8px; margin-top: 11px; } -.detail-name strong { font-size: 14px; } -#detail-role { font-size: 11px; color: var(--muted); } -.state-tag { margin-left: auto; font-size: 11px; padding: 3px 5px; background: #e7eadc; color: #56694d; border-radius: 2px; } -#detail-task { font-size: 11px; line-height: 1.8; color: #56694d; min-height: 36px; margin: 9px 0 11px; } -.detail-meter { display: flex; justify-content: space-between; font-size: 11px; color: var(--muted); margin-bottom: 7px; font-variant-numeric: tabular-nums; } -.meter { height: 5px; background: #e2e6d9; overflow: hidden; } -.meter > span { display: block; height: 100%; background: #829b66; width: 0; } -.activity-section { padding-top: 17px; flex: 1; } -.activity-heading { display: flex; align-items: center; justify-content: space-between; } -h3 { font-size: 11px; margin: 0; font-weight: 400; } -.activity-live { font-size: 11px; color: #56694d; letter-spacing: .08em; } -.activity-list { padding: 0; margin: 11px 0; list-style: none; display: grid; gap: 10px; } -.activity-list li { display: grid; grid-template-columns: 31px 1fr; gap: 7px; font-size: 11px; line-height: 1.7; } -.activity-list time { color: #56694d; font-variant-numeric: tabular-nums; } -.activity-list span { color: #56694d; } -.team-footer { display: flex; align-items: center; justify-content: center; gap: 7px; color: #56694d; font-size: 11px; min-height: 45px; border-top: 1px solid var(--line); } -.team-footer svg { width: 14px; height: 14px; } -.project-bar { display: flex; align-items: center; justify-content: space-between; gap: 25px; padding: 22px 26px; border-top: 1px solid var(--line); background: #faf9f3; } -.project-info { min-width: 0; } -.project-heading { display: flex; align-items: center; gap: 9px; } -.project-heading h2 { font-size: 13px; } -.project-indicator { width: 10px; height: 10px; border: 2px solid #66854f; } -.project-kind { font-size: 11px; color: #56694d; background: #edf0e4; padding: 3px 6px; border-radius: 3px; white-space: nowrap; } -.project-meta { display: flex; gap: 8px; margin: 9px 0 0 19px; color: #56694d; font-size: 11px; } -.meta-dot { color: #56694d; } -.project-progress { width: clamp(140px,20vw,250px); } -.project-progress > div:first-child { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: #56694d; margin-bottom: 6px; } -.project-progress strong { color: var(--green); font-size: 18px; line-height: 20px; font-weight: 500; font-variant-numeric: tabular-nums; } -.project-progress strong span { font-size: 11px; margin-left: 2px; } -.project-meter { height: 7px; } -.progress-caption { display: block; font-size: 11px; margin-top: 7px; color: #56694d; } -.simulation-controls { display: flex; gap: 14px; align-items: center; } -.pause-button { min-width: 74px; font-size: 11px; } -.pause-button svg { width: 12px; height: 12px; } -.speed-control { display: flex; border: 1px solid #d5dcca; border-radius: 4px; padding: 3px; } -.speed-control button { background: transparent; border: 0; border-radius: 2px; width: 31px; height: 26px; font-size: 11px; } -.speed-control button[aria-pressed="true"] { background: #e1e8d7; color: #405b34; } -.speed-control button:hover { background: #ebeee3; } -.app-footer { display: flex; justify-content: space-between; align-items: center; padding: 18px 2px 22px; font-size: 11px; color: #56694d; } -.app-footer > span:first-child { display: flex; align-items: center; gap: 7px; } -.footer-pixel { width: 6px; height: 6px; background: #8ea179; } -dialog { padding: 0; width: min(660px,calc(100vw - 32px)); max-height: calc(100dvh - 48px); color: var(--ink); background: var(--paper); border: 1px solid #aab99b; border-radius: 7px; } -dialog::backdrop { background: rgba(42,56,35,.45); } -.library-heading { padding: 22px 24px; display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid var(--line); } -.library-heading h2 { font-size: 18px; } -.library-heading p { color: var(--muted); font-size: 11px; margin: 8px 0 0; } -.library-content { padding: 20px 24px 24px; } -.library-content h3 { font-weight: 700; margin-bottom: 12px; } -.asset-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); margin-bottom: 25px; } -.asset-tile { display: flex; flex-direction: column; align-items: center; padding: 4px 4px 10px; background: #f0f1e7; font-size: 11px; color: #56694d; } -.asset-tile canvas { width: 100%; max-width: 110px; height: 92px; image-rendering: pixelated; object-fit: contain; } -.portrait-grid { display: grid; grid-template-columns: repeat(6,minmax(0,1fr)); gap: 12px; text-align: center; font-size: 11px; } -.portrait-grid canvas { display: block; width: 100%; height: auto; background: #e7eadc; image-rendering: pixelated; margin-bottom: 7px; } -.library-footnote { font-size: 11px; color: var(--muted); line-height: 1.9; margin: 22px 0 12px; } -#asset-stats { font-size: 11px; color: #56694d; } -.toast { position: fixed; bottom: 25px; left: 50%; transform: translateX(-50%); background: #344c37; color: #56694d; padding: 12px 18px; border-radius: 4px; font-size: 12px; max-width: calc(100vw - 32px); z-index: 10; } -.noscript { padding: 20px; text-align: center; } -* { scrollbar-width: thin; scrollbar-color: #bbc7ad var(--paper); } -@media (min-width: 1550px) { .stage { height: 625px; } } -@media (max-width: 1150px) { - .app-shell { padding: 0 22px; } .header-right { gap: 15px; } .workspace { grid-template-columns: minmax(0,1fr) 242px; } - .team-panel { padding-inline: 14px; } .scene-caption,.scene-weather { display: none; } .stage { min-height: 460px; } - .project-bar { padding-inline: 20px; gap: 16px; } .simulation-controls { gap: 8px; } .project-kind { display: none; } -} -@media (max-width: 860px) { - .app-shell { padding: 0 16px; } .app-header { height: 84px; } h1 { font-size: 18px; } .brand-subtitle { font-size: 11px; } - .online,.day,.workspace-subtitle,.workspace-slash { display: none; } .workspace-header { padding: 13px 18px; min-height: 62px; } - .workspace { grid-template-columns: minmax(0,1fr); } .office-section { border-right: 0; } .stage { height: 65vw; min-height: 330px; } - .team-panel { padding: 20px; display: grid; grid-template-columns: 1fr 1fr; gap: 0 24px; border-top: 1px solid var(--line); } - .panel-heading,.team-summary { grid-column: 1; } .team-summary { padding-bottom: 10px; } .agent-list { grid-column: 1; grid-row: 3 / 5; } - .agent-detail { grid-column: 2; grid-row: 1 / 4; border-top: 0; padding-top: 0; align-self: start; } - .activity-section { grid-column: 2; grid-row: 4; padding-top: 4px; } .team-footer { grid-column: 1 / 3; } - .project-bar { flex-wrap: wrap; gap: 20px; } .project-info { flex: 1 1 50%; } .project-progress { flex: 1 1 30%; } - .simulation-controls { margin-left: auto; } .scene-weather { display: flex; } .app-footer > span:last-child { font-size: 11px; } -} -@media (max-width: 520px) { - .app-shell { padding: 0 10px; } .brand-mark { width: 35px; height: 36px; } .brand-mark svg { width: 24px; height: 24px; } - .brand { gap: 8px; } h1 { font-size: 16px; letter-spacing: 0; } .brand-subtitle { font-size: 11px; letter-spacing: .11em; } - .header-right { gap: 10px; } .clock { display: none; } .library-button { padding: 7px; min-height: 33px; font-size: 11px; } - .workspace-header { padding: 12px; } .workspace-actions { gap: 8px; } .demo-label { font-size: 11px; } .primary { font-size: 11px; padding-inline: 9px; min-height: 33px; } - .scene-heading { padding: 16px 14px 0; } .scene-heading strong { font-size: 11px; } .scene-weather { font-size: 11px; } - .stage { min-height: 280px; height: 76vw; } .scene-note { left: 15px; bottom: 12px; font-size: 11px; } - .scene-toolbar { padding: 0 8px 0 12px; } .scene-hint { font-size: 11px; gap: 4px; } .view-controls { gap: 0; } - .icon-button { width: 29px; height: 32px; } .control-divider { margin: 0 2px; } #zoom-label { width: 30px; font-size: 11px; } - .team-panel { padding: 18px 12px 0; gap: 0 13px; grid-template-columns: minmax(0,1.1fr) minmax(0,1fr); } - .team-summary { gap: 6px; font-size: 11px; } .agent-row { grid-template-columns: 29px 1fr auto; gap: 5px; padding: 5px 3px; } - .avatar { width: 28px; height: 32px; } .agent-name { font-size: 11px; } .agent-role { font-size: 11px; } .agent-state { font-size: 11px; } - .detail-name { flex-wrap: wrap; gap: 5px; } #detail-role { font-size: 11px; } .state-tag { margin-left: 0; } #detail-task { font-size: 11px; } - .detail-heading { font-size: 11px; } .detail-label { font-size: 11px; } .activity-list li { font-size: 11px; grid-template-columns: 27px 1fr; gap: 3px; } - .project-bar { padding: 18px 14px; gap: 16px; } .project-info { flex-basis: 100%; } .project-heading h2 { font-size: 12px; } - .project-progress { flex-basis: 40%; } .progress-caption { font-size: 11px; } .simulation-controls { gap: 6px; } - .pause-button { min-width: 58px; padding: 6px 8px; } .speed-control button { width: 26px; } - .app-footer { padding-block: 15px; font-size: 11px; } .app-footer > span:last-child { max-width: 155px; text-align: right; line-height: 1.6; } - .asset-grid { grid-template-columns: repeat(3,minmax(0,1fr)); } .portrait-grid { gap: 6px; } .library-content { padding: 16px; } -} -@media (prefers-reduced-motion: reduce) { *,*::before,*::after { transition: none !important; } } - -@media (max-width: 520px) { - .brand-subtitle { font-size: 10px; letter-spacing: 0; } - .demo-label { max-width: 65px; text-align: right; line-height: 1.4; } - .scene-weather { max-width: 110px; text-align: right; line-height: 1.5; } - .scene-hint { max-width: 170px; line-height: 1.5; } - .team-panel { display: block; padding: 18px 14px 0; } - .agent-list { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 5px 9px; padding: 12px 0 16px; } - .agent-row { min-height: 58px; grid-template-columns: 30px 1fr; gap: 2px 6px; padding: 6px; } - .agent-row .avatar { grid-row: 1 / 3; } - .agent-state { grid-column: 2; margin-top: 3px; } - .agent-role { line-height: 1.5; } - .agent-name { font-size: 12px; } - .agent-detail { border-top: 1px solid var(--line); padding-top: 15px; } - .detail-name { gap: 9px; } .state-tag { margin-left: auto; } - #detail-task { min-height: 0; } - .activity-section { padding-top: 16px; } - .activity-list li { grid-template-columns: 37px 1fr; gap: 8px; } - .project-progress { flex: 1 1 100%; } - .simulation-controls { width: 100%; justify-content: flex-end; } - .app-footer { align-items: flex-start; gap: 12px; line-height: 1.6; } - .library-heading { padding: 18px 16px; } - .portrait-grid { grid-template-columns: repeat(3,minmax(0,1fr)); gap: 15px; } -} - -@media (max-width: 370px) { .library-button span { display: none; } .brand-subtitle { font-size: 10px; } } diff --git a/design-prototypes/agent-office/renderer.js b/design-prototypes/agent-office/renderer.js deleted file mode 100644 index 2d9f98e47..000000000 --- a/design-prototypes/agent-office/renderer.js +++ /dev/null @@ -1,147 +0,0 @@ -/* Room caching and depth composition use the same atlas as all UI previews. */ -(function (O) { - 'use strict'; - const {C,canvas,rect,line,poly,iso,plane,box,panel}=O.Art; - const origin={x:340,y:155}, size={x:14,y:11}; - const project=(x,y,z=0)=>{ const p=iso(x,y,z); return {x:origin.x+p[0],y:origin.y+p[1]}; }; - function windowPanel(c,start,width,left=false) { - c.save(); if(left) c.scale(-1,1); - panel(c,start,-.015,36,width,47,'#aaa995','#808975'); - panel(c,start+.08,.0,39,width-.16,43,'#f8f4e4'); - panel(c,start+.16,.025,42,width-.32,37,'#a4ccda','#8bafba'); - panel(c,start+.18,.03,44,width-.36,13,'#b9dce3'); - for(let i=0;i<5;i++) { - const x=start+.19+i*(width-.4)/5,h=7+(i*7)%8; - panel(c,x,.035,43,(width-.4)/5+.01,h,i%2?'#91b9ad':'#a2c5b7'); - } - for(let i=1;i<3;i++) panel(c,start+width*i/3-.025,.045,42,.075,37,'#f6f3e2','#b1c3bc'); - panel(c,start+.16,.05,61,width-.32,2,'#d3e8e5'); - box(c,start-.04,.04,width+.08,.18,3,35,['#f6f3e7','#cac9b5','#e0dfcc'],'#a5ad97'); - c.restore(); - } - function tinyTree(c,x,y,scale=1) { - c.save(); c.translate(x,y); c.scale(scale,scale); - rect(c,-2,0,4,14,'#91856a'); - for(const [a,b,w,h,color] of [[-10,-13,20,17,'#9bab85'],[-14,-9,27,11,'#94a77b'],[-8,-17,17,11,'#b1bf96'],[-4,-20,10,8,'#b9c59e']]) rect(c,a,b,w,h,color); - rect(c,-10,-4,6,4,'#82986a'); c.restore(); - } - function buildRoom(atlas) { - const result=canvas(768,512),c=result.getContext('2d'); c.imageSmoothingEnabled=false; - // Garden fragments and the offset foundation ground the cutaway room. - tinyTree(c,77,303,1.2); tinyTree(c,703,333,.9); tinyTree(c,664,378,.65); - for(const [x,y] of [[87,332],[663,408],[492,479],[160,393],[58,285]]) { - rect(c,x,y,2,3,'#b2bfa3'); rect(c,x+4,y-1,1,3,'#a8b798'); - } - c.save(); c.translate(origin.x,origin.y); - poly(c,[iso(-.15,.0,-11),iso(14.5,.1,-11),iso(14.5,11.3,-11),iso(-.15,11.3,-11)],'rgba(91,107,73,.16)'); - box(c,0,0,size.x,size.y,9,-9,['#c7d1b9','#a3ac94','#b2b9a2'],'#858f76'); - for(let y=0;y=0;i--) { const h=this.hits[i]; if(p.x>=h.x-17*this.zoom&&p.x<=h.x+18*this.zoom&&p.y>=h.y-52*this.zoom&&p.y<=h.y+7*this.zoom) return h.index; } return -1; } - bubble(c,x,y,text,color='#526a43') { - c.font='10px "Microsoft YaHei", sans-serif'; const width=Math.ceil(c.measureText(text).width)+16; - rect(c,x-width/2,y-19,width,17,'#839375'); rect(c,x-width/2-1,y-17,width+2,13,'#839375'); - rect(c,x-width/2+1,y-18,width-2,15,'#fffdf1'); rect(c,x-width/2,y-16,width,11,'#fffdf1'); - poly(c,[[x-3,y-2],[x+3,y-2],[x,y+2]],'#839375'); poly(c,[[x-2,y-3],[x+2,y-3],[x,y]],'#fffdf1'); - c.fillStyle=color;c.textAlign='center';c.fillText(text,Math.round(x),Math.round(y-7)); - } - render() { - const c=this.ctx,sim=this.sim,atlas=this.atlas; - c.imageSmoothingEnabled=false; c.clearRect(0,0,768,512); c.save(); - c.translate(384,280);c.scale(this.zoom,this.zoom);c.translate(-384,-280); - c.drawImage(this.background,0,0); - const selected=sim.agents[sim.selected],sp=project(selected.x,selected.y); - c.save();c.translate(sp.x,sp.y+1);plane(c,-.47,-.43,.94,.86,0,'rgba(244,244,197,.4)','#829c61'); c.restore(); - const items=this.furniture.map(item=>({...item,depth:item.depth===undefined?item.x+item.y:item.depth})); - for(const a of sim.agents) items.push({type:'person',x:a.x,y:a.y,agent:a,depth:a.x+a.y+.05}); - items.sort((a,b)=>a.depth-b.depth); - this.hits=[]; - for(const item of items) { - const p=project(item.x,item.y); - if(item.type==='person') { - const a=item.agent,frame=a.action==='walk'?Math.floor(sim.time*7+a.index)%4:a.action==='idle'?(Math.floor(sim.time+a.index*1.7)%6===0?1:0):Math.floor(sim.time*4+a.index)%2; - atlas.draw(c,atlas.person(a.index,a.action,frame),p.x,p.y); - this.hits.push({...this.screen(a.x,a.y),index:a.index}); - } else atlas.draw(c,item.sprite,p.x,p.y); - } - // Name plates are a display layer; all hit tests target real actor positions. - for(const a of sim.agents) { - const p=project(a.x,a.y),active=a.index===sim.selected||a.index===this.hover; - c.font='8px "Microsoft YaHei", sans-serif'; c.textAlign='center'; - const name=a.name,width=24; - rect(c,p.x-width/2,p.y+5,width,12,active?'#506947':'rgba(249,249,232,.90)'); - c.fillStyle=active?'#fffce8':'#5c7050'; c.fillText(name,p.x,p.y+14); - } - for(const a of sim.agents) { - const selected=a.index===sim.selected; - const show=selected||(a.action==='work'&&Math.floor(sim.time/4+a.index)%3===0)||(a.action==='coffee'); - if(!show)continue; - const p=project(a.x,a.y),status=sim.status(a.index); - const text=a.action==='coffee'?'补充一点灵感':status.label+(a.action==='work'?'...':''); - this.bubble(c,p.x+4,p.y-53,text,selected?'#46653c':'#677a58'); - } - // Shared coffee corner: tiny steam frames animate without rebuilding furniture. - const coffee=project(10.55,.95,50),phase=Math.floor(sim.time*3)%3; - line(c,coffee.x,coffee.y-3-phase,coffee.x+1,coffee.y-7-phase,'#ecebd6'); - if(sim.finished) { const p=project(7,4,82); this.bubble(c,p.x,p.y,'好想法,完成了!'); } - c.restore(); - } - } - O.Renderer=Renderer; -})(globalThis.PixelOffice = globalThis.PixelOffice || {}); diff --git a/design-prototypes/agent-office/simulation.js b/design-prototypes/agent-office/simulation.js deleted file mode 100644 index e69fa72e8..000000000 --- a/design-prototypes/agent-office/simulation.js +++ /dev/null @@ -1,94 +0,0 @@ -/* Pure, deterministic simulation: explicit time in, structured team state out. */ -(function (O) { - 'use strict'; - const desks = [[4,3.5],[7.2,3.5],[10.4,3.5],[4,6.7],[7.2,6.7],[10.4,6.7]]; - const definitions = [ - { title:'拆解需求', doing:'规划中', text:'把好想法拆成可执行的小任务,安排大家的分工。', duration:18, deps:[] }, - { title:'开发功能', doing:'开发中', text:'搭建工作台,编写交互逻辑,让每个小功能运转起来。', duration:48, deps:[0] }, - { title:'设计界面', doing:'设计中', text:'打磨布局、颜色与像素细节,给好想法一个好看的样子。', duration:35, deps:[0] }, - { title:'资料研究', doing:'研究中', text:'收集参考,梳理技术方案,把发现分享给伙伴。', duration:30, deps:[] }, - { title:'代码审查', doing:'审查中', text:'和开发伙伴一起检查实现,找出可以做得更好的地方。', duration:23, deps:[1,2,3] }, - { title:'质量验证', doing:'测试中', text:'验证关键交互和边界情况,确保作品可以放心交付。', duration:22, deps:[4] }, - ]; - const titles=['做一个有趣的 AI 工作台','为小团队设计一个灵感收集器','做一个会照顾番茄钟的助手','把今天的好想法变成小网站']; - const distance=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y); - class Simulation { - constructor() { - this.time=0; this.speed=1; this.paused=false; this.projectNumber=0; this.selected=1; this.events=[]; - this.agents=O.profiles.map((p,i)=>({ ...p,index:i,desk:{x:desks[i][0],y:desks[i][1]},home:{x:desks[i][0],y:desks[i][1]+.96},x:desks[i][0],y:desks[i][1]+.96,action:'idle',route:[],destination:'home',nextBreak:8+i*3,coffeeUntil:0 })); - this.startProject(true); - } - get active() { return this.tasks.some(t=>t.elapsedn+t.elapsed/t.duration,0)/this.tasks.length; } - get doneCount() { return this.tasks.filter(t=>t.elapsed>=t.duration).length; } - get clock() { const m=9*60+41+Math.floor(this.time/3); return String(Math.floor(m/60)%24).padStart(2,'0')+':'+String(m%60).padStart(2,'0'); } - log(text) { this.events.unshift({time:this.clock,text}); if(this.events.length>5) this.events.length=5; } - startProject(initial=false) { - if(!initial&&this.active) return false; - this.title=titles[this.projectNumber%titles.length]; this.projectNumber++; - this.tasks=definitions.map(d=>({...d,deps:[...d.deps],elapsed:0})); - if(initial) { this.tasks[0].elapsed=18; this.tasks[1].elapsed=8; this.tasks[2].elapsed=9; this.tasks[3].elapsed=19; } - this.finished=false; - this.log(initial?'阿策已拆解需求,伙伴们开始协作':'新任务已派发,阿策开始拆解需求'); - if(initial) { this.log('小寻整理了第一批参考资料'); this.log('阿绘和小码开始并行设计、开发'); } - this.refreshActions(); return true; - } - ready(i) { const t=this.tasks[i]; return t.elapsedthis.tasks[d].elapsed>=this.tasks[d].duration); } - atHome(a) { return distance(a,a.home)<.02; } - coffeeRoute(a) { - const lane=a.desk.y+1.9; - return [{...a.home},{x:a.home.x,y:lane},{x:12.15,y:lane},{x:12.15,y:1.9},{x:10.8,y:1.9}]; - } - goCoffee(a) { a.destination='coffee'; a.route=this.coffeeRoute(a).slice(1); a.action='walk'; } - goHome(a) { a.destination='home'; a.route=this.coffeeRoute(a).slice(0,-1).reverse(); a.action='walk'; } - move(a,dt) { - let remaining=dt*1.5; - while(a.route.length&&remaining>0) { - const target=a.route[0],d=distance(a,target); - if(d<=remaining) { a.x=target.x; a.y=target.y; remaining-=d; a.route.shift(); } - else { a.x+=(target.x-a.x)/d*remaining; a.y+=(target.y-a.y)/d*remaining; remaining=0; } - } - if(!a.route.length) { - if(a.destination==='coffee') { a.action='coffee'; a.coffeeUntil=this.time+5; } - else { a.action='idle'; a.nextBreak=this.time+14+a.index*2; } - } - } - refreshActions() { - for(const a of this.agents) if(!a.route.length&&this.atHome(a)) a.action=this.ready(a.index)?'work':'idle'; - } - tick(dt) { - if(this.paused||!Number.isFinite(dt)||dt<=0) return; - // Substeps preserve dependency ordering even for a long caller-supplied step. - let remaining=dt*this.speed; - while(remaining>0) { const step=Math.min(.1,remaining); this.step(step); remaining-=step; } - } - step(dt) { - this.time+=dt; - for(const a of this.agents) { - const t=this.tasks[a.index]; - if(a.route.length) { this.move(a,dt); continue; } - if(a.action==='coffee') { - if(this.ready(a.index)||this.time>=a.coffeeUntil) this.goHome(a); - continue; - } - if(this.atHome(a)&&this.ready(a.index)) { - a.action='work'; const before=t.elapsed; t.elapsed=Math.min(t.duration,t.elapsed+dt); - if(before=t.duration) { this.log(a.name+'完成了'+t.title); a.action='idle'; a.nextBreak=this.time+2+a.index; } - } else { - a.action='idle'; - const coffeeBusy=this.agents.some(other=>other!==a&&other.destination==='coffee'&&(other.route.length||other.action==='coffee')); - if(this.time>=a.nextBreak&&!coffeeBusy) this.goCoffee(a); - } - } - if(!this.active&&!this.finished) { this.finished=true; this.log('作品完成!大家一起把想法变成了现实'); } - } - status(index) { - const a=this.agents[index],t=this.tasks[index]; - if(a.action==='walk') return {label:a.destination==='home'?'回工位':'走动中',state:'walking'}; - if(a.action==='coffee') return {label:'咖啡时间',state:'coffee'}; - if(a.action==='work') return {label:t.doing,state:'working'}; - return {label:t.elapsed>=t.duration?'已完成':'准备中',state:t.elapsed>=t.duration?'done':'waiting'}; - } - } - O.Simulation=Simulation; O.desks=desks; -})(globalThis.PixelOffice = globalThis.PixelOffice || {}); diff --git a/design-prototypes/agent-office/simulation.test.cjs b/design-prototypes/agent-office/simulation.test.cjs deleted file mode 100644 index 83746032f..000000000 --- a/design-prototypes/agent-office/simulation.test.cjs +++ /dev/null @@ -1,78 +0,0 @@ -// Run from any directory: node --test simulation.test.cjs -const test = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const vm = require('node:vm'); -const context = vm.createContext({}); -for (const name of ['assets.js', 'simulation.js']) { - vm.runInContext(fs.readFileSync(path.join(__dirname, name), 'utf8'), context, { filename: name }); -} -const { Simulation } = context.PixelOffice; -const snapshot = sim => JSON.stringify({ time: sim.time, tasks: sim.tasks, agents: sim.agents, events: sim.events }); - -test('paused time freezes tasks, actor motion and the event log', () => { - const sim = new Simulation(); sim.tick(10); sim.paused = true; - const before = snapshot(sim); sim.tick(120); assert.equal(snapshot(sim), before); - sim.paused = false; sim.tick(1); assert.ok(sim.time > 10); -}); - -test('speed scales explicit elapsed time exactly once', () => { - const normal = new Simulation(), fast = new Simulation(); fast.speed = 4; - normal.tick(4); fast.tick(1); - assert.ok(Math.abs(normal.time - fast.time) < 1e-8); - for (let i = 0; i < 6; i++) assert.ok(Math.abs(normal.tasks[i].elapsed - fast.tasks[i].elapsed) < 1e-8); -}); - -test('invalid time input cannot corrupt simulation state', () => { - const sim = new Simulation(), before = snapshot(sim); - for (const dt of [NaN, Infinity, -1, 0]) sim.tick(dt); - assert.equal(snapshot(sim), before); -}); - -test('dependent work starts only after handoff, and only at the desk', () => { - const sim = new Simulation(); - for (let frame = 0; frame < 1600; frame++) { - const before = sim.tasks.map(t => t.elapsed); sim.tick(.1); - for (const actor of sim.agents) { - const t = sim.tasks[actor.index]; - if (t.elapsed > before[actor.index]) { - assert.ok(t.deps.every(i => sim.tasks[i].elapsed === sim.tasks[i].duration)); - assert.ok(Math.hypot(actor.x - actor.home.x, actor.y - actor.home.y) < .02); - } - } - } - assert.equal(sim.doneCount, 6); assert.equal(sim.progress, 1); -}); - -test('coffee breaks stay inside the office, reserve one spot, and return', () => { - const sim = new Simulation(); let sawWalk = false, sawCoffee = false, sawReturn = false; - for (let frame = 0; frame < 2200; frame++) { - sim.tick(.1); - const reserved = sim.agents.filter(a => a.destination === 'coffee' && (a.route.length || a.action === 'coffee')); - assert.ok(reserved.length <= 1); - for (const a of sim.agents) { - assert.ok(a.x >= 0 && a.x <= 14 && a.y >= 0 && a.y <= 11); - sawWalk ||= a.action === 'walk'; sawCoffee ||= a.action === 'coffee'; - sawReturn ||= a.destination === 'home' && a.route.length > 0; - } - } - assert.ok(sawWalk && sawCoffee && sawReturn); assert.equal(sim.doneCount, 6); -}); - -test('duplicate dispatch is rejected, a finished project can start fresh', () => { - const sim = new Simulation(), before = snapshot(sim); - assert.equal(sim.startProject(), false); assert.equal(snapshot(sim), before); - sim.tick(240); assert.equal(sim.finished, true); assert.equal(sim.active, false); - assert.equal(sim.startProject(), true); assert.equal(sim.projectNumber, 2); - assert.equal(sim.progress, 0); assert.equal(sim.finished, false); - assert.equal(sim.startProject(), false); sim.tick(240); - assert.equal(sim.doneCount, 6); assert.equal(sim.finished, true); -}); - -test('a large explicit time step still resolves the entire dependency chain', () => { - const sim = new Simulation(); sim.tick(300); - assert.equal(sim.doneCount, 6); - assert.ok(sim.tasks.every(t => t.elapsed === t.duration)); - assert.equal(sim.events.filter(e => e.text.includes('作品完成')).length, 1); -}); diff --git a/design-prototypes/agent-office/surface.md b/design-prototypes/agent-office/surface.md deleted file mode 100644 index 410cae655..000000000 --- a/design-prototypes/agent-office/surface.md +++ /dev/null @@ -1,5 +0,0 @@ -# 办公室演示页 - -模式:Experience。 - -首先看到可活动的等距像素办公室,其次读取成员与当前项目。重点是角色的工作动作、任务状态变化和统一的像素材质。全页只保留有实际行为的控件。演示数据标识位于顶部。 diff --git a/docs/daemon-api.md b/docs/daemon-api.md index b916a3287..583d4171d 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -138,6 +138,8 @@ Session list endpoints return arrays of objects shaped like: "swarm_mode": "off", "created_at": "2026-07-04T01:23:45Z", "updated_at": "2026-07-04T01:25:00Z", + "last_user_message_at": "2026-07-04T01:24:00Z", + "last_turn_outcome": "completed", "provider": "openai", "model": "gpt-4.1", "model_name": "work-gpt", @@ -272,6 +274,7 @@ update their transcript presentation. | GET | `/api/pinned-sessions/order` | read global cross-scope pin order | | PUT | `/api/pinned-sessions/order` | set global cross-scope pin order | | GET | `/api/sessions` | compatibility session list | +| GET | `/api/desktop-office?session=&workspace=` | recent five offices and selected root/agent runtime snapshot | | GET | `/api/session-search/sessions?q=...&limit=N&cursor=...&request_id=...` | incremental global session catalog page | | GET | `/api/session-search/user-messages?q=...&limit=N&request_id=...` | advance one bounded visible-user-message search batch | | POST | `/api/session-search/requests/:request_id/cancel` | cancel/pause an incremental global search | @@ -545,6 +548,30 @@ disabled, the write is silently ignored. ## 5. Workspaces and Sessions +### `GET /api/desktop-office?session=&workspace=` + +只读桌面办公室快照,使用通用鉴权/CORS,响应 `Cache-Control: no-store`。 +`session` 与 `workspace` 可省略;默认选最近办公室。选择子会话时返回它所属的根办公室。 +显式选择不存在或已归档会话返回 `404`,并在可用时附带当前 `offices`。 + +```json +{ + "offices": [{"id":"root","workspace_hash":"abc123","title":"Review", "last_user_message_at":"2026-10-06T10:00:00Z"}], + "selected": {"id":"root","active":true,"status":"idle","last_turn_outcome":"completed","activity":{"known":true,"busy":false,"outcome":"completed","phase":"","tool":"","seq":42}}, + "agents": [{"id":"root"}], + "complete": true, + "connected": true +} +``` + +`offices` 最多五个:跨工作区按 `last_user_message_at` 降序,排除归档、子会话和没有真实用户输入的会话。打开会话、模型输出、隐藏提示及 agent 间消息不推进此时间;旧记录从 JSONL 逆向读取后缓存。目录或旧记录仍在加载时 `complete=false`,客户端可继续读取;空目录返回空数组及 `selected:null`。分叉继承保留消息的原始时间。 + +`selected` 和 `agents` 只传身份、标题、所属工作区、真实状态和当前上下文用量等展示字段;不含输入草稿、工具参数、工具输出或历史正文。`agents` 包含根会话和当前 daemon 已装载的直属/网状子会话;网状子会话的 `parent_session_id` 是树根,`agent_path` 保留实际层级。已成功结束的子会话可仍出现在原始快照中,由桌宠投影立即离座。标题/摘要限 240 UTF-8 字节。 + +运行中条目的 `activity` 来自事件分发器维护的紧凑状态,不依赖有限的重放缓冲:`known`、`busy`、`turn_id`、`outcome`、`phase`、`label`、`tool`、`seq`、`compact_id`、`transfers`。待授权/待回答优先于工具和模型阶段;`outcome` 保留 `completed`/`aborted`/`error` 差别。`transfers` 最多保留 16 个带序号的 agent 间交接标识,不含消息内容。持久化 `last_turn_outcome` 使恢复后的完成主会话继续显示睡眠状态。 + +`token_usage` 是最近请求上下文统计,配合 `context_window` 计算文件堆厚度;未知窗口不伪造比例。休眠会话可通过已有 model 端点补充窗口大小。该端点不会发起模型请求或改变会话运行状态。 + ### `GET /api/workspaces` Returns `Workspace[]`. The registry is scanned before listing. If no registry diff --git a/docs/desktop-agent-office.md b/docs/desktop-agent-office.md new file mode 100644 index 000000000..10ad5213e --- /dev/null +++ b/docs/desktop-agent-office.md @@ -0,0 +1,22 @@ +# Agent 桌面办公室 + +Windows Desktop 会显示一个透明的像素办公室。单会话有一个主角色 Maestro,多 agent 任务按真实子会话入座;无需启动额外模型,也没有演示任务循环。美术与拖动缩放来自原有 Claude 桌宠,旧的 `design-prototypes/agent-office` 事务所原型已移除。 + +## 操作 + +- 顶部显示跨工作区最近发过消息的五个主会话。顺序只看用户最后发送消息的时间,打开、切换和 agent 输出不会把会话顶到最前面。 +- “跟随”默认开启,办公室随主窗口当前会话切换。点击门牌会关闭跟随并固定办公室;再次打开跟随后恢复跟随。查看最近五个之外的当前会话不改变门牌排序。 +- 主 agent 成功完成后留在座位显示 `zzz`,下一轮自动醒来;成功完成的子 agent 立即离座,重新工作时以同样的外观返回。 +- 等待授权、等待回答、重试、压缩、失败和取消有各自状态。超过七个子角色时点击 `+N` 查看成员并打开对应会话;列表随状态更新,Escape 收起。选中房间人物后双击可打开其会话。 +- 文件堆随当前上下文占用增长,压缩后依据实际新统计变薄。工具符号与聊天工具行共用 SVG 来源。 +- 拖动房间空地移动窗口;滚轮、右下角把手和右键菜单可缩放。窗口不抢主界面焦点,透明角落可穿透点击;尺寸按原实现保存。 + +## 构建与数据流 + +由当前工作区的 `scripts/dev_desktop.bat` 启动开发版。先构建 `web/`,再配置/构建 CMake Desktop,使内嵌主界面与桌宠资源保持一致。`ACECODE_DESKTOP_PET=0` 可对本次进程禁用桌宠;macOS/Linux 当前没有原生桌宠窗口实现。 + +数据链为:会话元数据/事件分发器 → `GET /api/desktop-office` → App 级办公室控制器 → 原生 WebView 桥 → 像素场景。控制器复用现有引用计数 WS 订阅,事件触发刷新并以 3 秒轮询兜底;正在加载旧记录时短间隔继续读取。快速切换以请求代次和取消信号隔离迟到响应;页面卸载释放订阅和计时器,重载从原生缓存恢复当前选择与跟随状态。 + +断线时明确显示正在重连,恢复后重取真实快照。错误和取消不会被当作成功睡眠。只同步小型展示字段,不通过动画触发或控制 agent 任务。 + +视觉规则见 [桌宠设计](../assets/desktop_pet/DESIGN.md),协议见 [daemon API](daemon-api.md)。验收命令及本次实测范围见 [交付记录](../openspec/changes/integrate-desktop-agent-office/verification.md)。 diff --git a/docs/images/agent-office/native-multi-complete.png b/docs/images/agent-office/native-multi-complete.png new file mode 100644 index 0000000000000000000000000000000000000000..e13f56c457140838fd4c74fc1b99b2bfff80e8eb GIT binary patch literal 19526 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 HCDHx^2lbmw literal 0 HcmV?d00001 diff --git a/docs/images/agent-office/native-multi-working.png b/docs/images/agent-office/native-multi-working.png new file mode 100644 index 0000000000000000000000000000000000000000..88ed457e59703df6987912c05a60578daac02137 GIT binary patch literal 20193 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#ifdef _WIN32 +# ifndef WIN32_LEAN_AND_MEAN +# define WIN32_LEAN_AND_MEAN +# endif +# ifndef NOMINMAX +# define NOMINMAX +# endif +# include +# include +# include +# include +# include +# pragma comment(lib, "dwmapi.lib") + +namespace acecode { +// cmake/acecode_desktop.cmake 用 acecode_bin2cpp.cmake 把 +// assets/desktop_pet/agent_office_pet.html 嵌进 exe。 +const unsigned char* desktop_pet_page_data(); +std::size_t desktop_pet_page_size(); +} // namespace acecode +#endif + +namespace acecode::desktop { + +#ifdef _WIN32 +namespace { + +using Microsoft::WRL::Callback; +using Microsoft::WRL::ComPtr; +struct GdiDeleter { void operator()(HGDIOBJ value) const { if (value) ::DeleteObject(value); } }; +using UniqueRegion = std::unique_ptr, GdiDeleter>; + +constexpr wchar_t kPetWindowClass[] = L"ACECodeDesktopPetWindow"; +constexpr UINT kMsgStartDrag = WM_APP + 0x41; +constexpr UINT kMsgShowMenu = WM_APP + 0x42; +constexpr UINT kMsgStartResize = WM_APP + 0x43; +constexpr UINT_PTR kRevealTimer = 1; +constexpr UINT_PTR kResizeTimer = 2; +// 拖把手期间约 60Hz 轮询鼠标:不依赖鼠标捕获(按下时捕获在 WebView2 的子窗口手里)。 +constexpr UINT kResizePollMs = 15; +constexpr double kZoomStep = 1.1; // 滚轮一格 / 菜单一次 +// 页面第一帧画完会发 ready;万一没收到,导航完成后最多再等这么久也把窗口挪进来。 +constexpr UINT kRevealFallbackMs = 3000; +constexpr UINT kMenuDock = 1; +constexpr UINT kMenuHide = 2; +constexpr UINT kMenuZoomIn = 3; +constexpr UINT kMenuZoomOut = 4; +constexpr UINT kMenuResetSize = 5; + +std::string hresult_text(HRESULT hr) { + char buffer[16]; + std::snprintf(buffer, sizeof(buffer), "0x%08lX", static_cast(hr)); + return buffer; +} + +bool desktop_pet_disabled_by_env() { + const char* value = std::getenv("ACECODE_DESKTOP_PET"); + if (!value) return false; + const std::string text(value); + return text == "0" || text == "off" || text == "false" || text == "no"; +} + +bool chinese_ui() { + return native_locale().rfind("zh", 0) == 0; +} + +HMONITOR primary_monitor() { + return ::MonitorFromPoint(POINT{0, 0}, MONITOR_DEFAULTTOPRIMARY); +} + +DesktopPetRect work_area_of(HMONITOR monitor) { + MONITORINFO info{}; + info.cbSize = sizeof(info); + if (!monitor || !::GetMonitorInfoW(monitor, &info)) { + RECT work{}; + ::SystemParametersInfoW(SPI_GETWORKAREA, 0, &work, 0); + info.rcWork = work; + } + return {info.rcWork.left, info.rcWork.top, + info.rcWork.right - info.rcWork.left, + info.rcWork.bottom - info.rcWork.top}; +} + +int monitor_dpi(HMONITOR monitor) { + using GetDpiForMonitorFn = HRESULT(WINAPI*)(HMONITOR, int, UINT*, UINT*); + if (monitor) { + if (HMODULE shcore = ::LoadLibraryW(L"shcore.dll")) { + auto getter = reinterpret_cast( + reinterpret_cast(::GetProcAddress(shcore, "GetDpiForMonitor"))); + UINT dpi_x = 0; + UINT dpi_y = 0; + const bool ok = getter && getter(monitor, 0, &dpi_x, &dpi_y) == S_OK; + ::FreeLibrary(shcore); + if (ok && dpi_y != 0) return static_cast(dpi_y); + } + } + int dpi = 96; + if (HDC screen = ::GetDC(nullptr)) { + const int value = ::GetDeviceCaps(screen, LOGPIXELSY); + if (value > 0) dpi = value; + ::ReleaseDC(nullptr, screen); + } + return dpi; +} + +std::filesystem::path pet_root_dir() { + wchar_t buffer[MAX_PATH] = {}; + const DWORD length = ::GetEnvironmentVariableW(L"LOCALAPPDATA", buffer, MAX_PATH); + std::filesystem::path base; + if (length > 0 && length < MAX_PATH) { + base = buffer; + } else { + base = std::filesystem::temp_directory_path(); + } + return base / L"ACECode" / L"desktop-pet"; +} + +std::filesystem::path pet_user_data_dir() { + return pet_root_dir() / L"webview2"; +} + +// 用户调过的大小(逻辑倍数,与 DPI 无关);0 表示没调过,用默认整数倍。 +double load_logical_scale() { + std::ifstream in(pet_root_dir() / L"settings.json"); + if (!in) return 0.0; + const auto json = nlohmann::json::parse(in, nullptr, false); + if (json.is_discarded() || !json.is_object() || !json.contains("scale") || !json["scale"].is_number()) return 0.0; + const double scale = json["scale"].get(); + return std::isfinite(scale) && scale > 0.0 ? scale : 0.0; +} + +void save_logical_scale(double scale) { + std::error_code ec; + std::filesystem::create_directories(pet_root_dir(), ec); + nlohmann::json json = nlohmann::json::object(); + if (scale > 0.0) json["scale"] = std::round(scale * 1000.0) / 1000.0; + std::ofstream out(pet_root_dir() / L"settings.json", std::ios::trunc); + if (out) out << json.dump(); +} + +// 与 tao/winit 的透明窗口同一招:空区域的 blur-behind 让 DWM 按像素 alpha 合成 +// 这个窗口(区域为空所以不会真的模糊)。WebView2 背景设成全透明后,页面里 +// 没画东西的地方(原来的草地)就直接透出桌面。 +void enable_per_pixel_alpha(HWND hwnd) { + UniqueRegion empty(::CreateRectRgn(0, 0, -1, -1)); + DWM_BLURBEHIND blur{}; + blur.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION; + blur.fEnable = TRUE; + blur.hRgnBlur = empty.get(); + const HRESULT hr = ::DwmEnableBlurBehindWindow(hwnd, &blur); + if (FAILED(hr)) { + LOG_WARN("[desktop-pet] DwmEnableBlurBehindWindow failed: " + hresult_text(hr)); + } +} + +// 窗口区域只留房间轮廓:透明角落既不绘制也不接收点击,点击会落到后面的窗口。 +void apply_hit_region(HWND hwnd, double scale, const std::array& overlay = {}) { + const auto polygon = desktop_pet_hit_polygon(scale); + std::vector points; + points.reserve(polygon.size()); + for (const auto& point : polygon) points.push_back(POINT{point.x, point.y}); + UniqueRegion region(::CreatePolygonRgn(points.data(), static_cast(points.size()), WINDING)); + UniqueRegion toolbar(::CreateRectRgn(0, 0, + static_cast(std::lround(kDesktopPetSceneWidth * scale)), + static_cast(std::lround(kDesktopPetToolbarHeight * scale)))); + if (region && toolbar) ::CombineRgn(region.get(), region.get(), toolbar.get(), RGN_OR); + if (region && overlay[2] > overlay[0] && overlay[3] > overlay[1]) { + RECT bounds{}; + ::GetClientRect(hwnd, &bounds); + UniqueRegion popup(::CreateRectRgn( + static_cast(overlay[0] * bounds.right), static_cast(overlay[1] * bounds.bottom), + static_cast(std::ceil(overlay[2] * bounds.right)), static_cast(std::ceil(overlay[3] * bounds.bottom)))); + if (popup) ::CombineRgn(region.get(), region.get(), popup.get(), RGN_OR); + } + // SetWindowRgn takes ownership only on success. + if (region && ::SetWindowRgn(hwnd, region.get(), TRUE)) (void)region.release(); +} + +} // namespace + +struct DesktopPet::Impl : std::enable_shared_from_this { + explicit Impl(WebHost& owner) : host(owner) {} + WebHost& host; // Required GUI host; DesktopPet is destroyed before this host. + nlohmann::json office_snapshot = {{"follow", true}}; + bool page_ready = false; + std::array hit_overlay{}; + HWND hwnd = nullptr; + webview::detail::mswebview2::loader loader; + ComPtr environment; + ComPtr controller; + ComPtr webview; + EventRegistrationToken message_token{}; + EventRegistrationToken navigation_token{}; + DesktopPetPlacement placement; + double logical_scale = 0.0; // 用户调过的大小,0 = 默认 + bool revealed = false; + bool user_moved = false; + bool closed = false; + bool resizing = false; + DesktopPetRect resize_start{}; + POINT resize_cursor{}; + + ~Impl() { close(); } + + void bind_bridge() { + const auto weak = weak_from_this(); + host.bind("aceDesktop_updateOffice", [weak](const std::string& request) { + const auto self = weak.lock(); + if (!self || self->closed || request.size() > 512 * 1024) return std::string("false"); + try { + const auto args = nlohmann::json::parse(request); + if (!args.is_array() || args.size() != 1 || !args[0].is_string()) return std::string("false"); + const auto value = nlohmann::json::parse(args[0].get()); + if (!value.is_object() || value.value("version", 0) != 1 || + !value.contains("agents") || !value["agents"].is_array() || + !value.contains("offices") || !value["offices"].is_array() || value["offices"].size() > 5) { + return std::string("false"); + } + self->office_snapshot = value; + self->publish_snapshot(); + return std::string("true"); + } catch (...) { return std::string("false"); } + }); + host.bind("aceDesktop_getOfficeState", [weak](const std::string&) { + const auto self = weak.lock(); + return self && !self->closed ? self->office_snapshot.dump() : std::string("{}"); + }); + } + + void publish_snapshot() { + if (closed || !page_ready || !webview || !office_snapshot.contains("version")) return; + webview->PostWebMessageAsJson(utf8_to_wide(office_snapshot.dump()).c_str()); + } + + void office_action(const std::wstring& message) { + if (message.size() > 8192) return; + const auto value = nlohmann::json::parse(wide_to_utf8(message), nullptr, false); + if (!value.is_object()) return; + const auto type_field = value.find("type"); + if (type_field == value.end() || !type_field->is_string()) return; + const auto type = type_field->get(); + if (type == "overlay") { + hit_overlay = {}; + const auto rect = value.find("rect"); + if (rect != value.end() && rect->is_array() && rect->size() == 4) { + for (std::size_t i = 0; i < 4; ++i) { + if (!(*rect)[i].is_number()) { hit_overlay = {}; break; } + const double coordinate = (*rect)[i].get(); + hit_overlay[i] = std::isfinite(coordinate) ? std::clamp(coordinate, 0.0, 1.0) : 0.0; + } + } + RECT bounds{}; + ::GetClientRect(hwnd, &bounds); + apply_hit_region(hwnd, bounds.right / static_cast(kDesktopPetSceneWidth), hit_overlay); + return; + } + if (type != "select" && type != "follow" && type != "open") return; + host.eval("window.dispatchEvent(new CustomEvent('ace-desktop-office-action',{detail:" + + value.dump() + "}));"); + } + + bool start() { + const HMONITOR monitor = primary_monitor(); + const DesktopPetRect work = work_area_of(monitor); + logical_scale = load_logical_scale(); + placement = place_desktop_pet(work, monitor_dpi(monitor), logical_scale); + + HINSTANCE instance = ::GetModuleHandleW(nullptr); + WNDCLASSEXW window_class{}; + window_class.cbSize = sizeof(window_class); + window_class.hInstance = instance; + window_class.lpfnWndProc = &Impl::window_proc; + window_class.lpszClassName = kPetWindowClass; + window_class.hCursor = ::LoadCursorW(nullptr, MAKEINTRESOURCEW(32512)); // IDC_ARROW + // 纯黑在 per-pixel alpha 合成下就是全透明:WebView2 首帧之前窗口什么也不显示。 + window_class.hbrBackground = static_cast(::GetStockObject(BLACK_BRUSH)); + if (!::RegisterClassExW(&window_class) && + ::GetLastError() != ERROR_CLASS_ALREADY_EXISTS) { + LOG_WARN("[desktop-pet] RegisterClassExW failed: " + std::to_string(::GetLastError())); + return false; + } + + // 先放在工作区外面完成 WebView2 初始化与首帧,再挪进来(同 WebHost 的 + // OffscreenUntilReady:父窗口可见时 WebView2 初始化更可靠,也不会闪出白底)。 + hwnd = ::CreateWindowExW( + WS_EX_TOOLWINDOW | WS_EX_TOPMOST | WS_EX_NOACTIVATE, + kPetWindowClass, + L"ACECode Desktop Pet", + WS_POPUP, + work.x + work.width + 10000, + work.y + work.height + 10000, + placement.window.width, + placement.window.height, + nullptr, + nullptr, + instance, + this); + if (!hwnd) { + LOG_WARN("[desktop-pet] CreateWindowExW failed: " + std::to_string(::GetLastError())); + return false; + } + enable_per_pixel_alpha(hwnd); + apply_hit_region(hwnd, placement.scale); + ::ShowWindow(hwnd, SW_SHOWNOACTIVATE); + return create_environment(); + } + + bool create_environment() { + std::error_code ec; + const auto user_data = pet_user_data_dir(); + std::filesystem::create_directories(user_data, ec); + if (ec) { + LOG_WARN("[desktop-pet] failed to create WebView2 profile directory: " + ec.message()); + return false; + } + const auto weak = weak_from_this(); + auto completed = Callback( + [weak](HRESULT result, ICoreWebView2Environment* value) -> HRESULT { + if (const auto self = weak.lock()) self->environment_ready(result, value); + return S_OK; + }); + const HRESULT hr = loader.create_environment_with_options( + nullptr, user_data.wstring().c_str(), nullptr, completed.Get()); + if (FAILED(hr)) { + LOG_WARN("[desktop-pet] failed to start WebView2 environment: " + hresult_text(hr)); + return false; + } + return true; + } + + void environment_ready(HRESULT result, ICoreWebView2Environment* value) { + if (closed) return; + if (FAILED(result) || !value) { + LOG_WARN("[desktop-pet] WebView2 environment failed: " + hresult_text(result)); + close(); + return; + } + environment = value; + const auto weak = weak_from_this(); + const HRESULT hr = environment->CreateCoreWebView2Controller( + hwnd, + Callback( + [weak](HRESULT created, ICoreWebView2Controller* controller_value) -> HRESULT { + if (const auto self = weak.lock()) { + self->controller_ready(created, controller_value); + } else if (controller_value) { + controller_value->Close(); + } + return S_OK; + }) + .Get()); + if (FAILED(hr)) { + LOG_WARN("[desktop-pet] CreateCoreWebView2Controller failed: " + hresult_text(hr)); + close(); + } + } + + void controller_ready(HRESULT result, ICoreWebView2Controller* value) { + if (closed) { + if (value) value->Close(); + return; + } + if (FAILED(result) || !value) { + LOG_WARN("[desktop-pet] WebView2 controller failed: " + hresult_text(result)); + close(); + return; + } + controller = value; + ComPtr controller2; + if (SUCCEEDED(controller.As(&controller2))) { + // 全透明背景;进程级 WEBVIEW2_DEFAULT_BACKGROUND_COLOR(主窗口用的浅色)在这里被覆盖。 + controller2->put_DefaultBackgroundColor(COREWEBVIEW2_COLOR{0, 0, 0, 0}); + } + RECT bounds{}; + ::GetClientRect(hwnd, &bounds); + controller->put_Bounds(bounds); + if (FAILED(controller->get_CoreWebView2(&webview)) || !webview) { + LOG_WARN("[desktop-pet] WebView2 core unavailable"); + close(); + return; + } + configure_settings(); + + const auto weak = weak_from_this(); + webview->add_WebMessageReceived( + Callback( + [weak](ICoreWebView2*, ICoreWebView2WebMessageReceivedEventArgs* args) -> HRESULT { + LPWSTR raw = nullptr; + if (args && SUCCEEDED(args->TryGetWebMessageAsString(&raw)) && raw) { + const std::wstring message(raw); + ::CoTaskMemFree(raw); + if (const auto self = weak.lock()) self->on_web_message(message); + } + return S_OK; + }) + .Get(), + &message_token); + webview->add_NavigationCompleted( + Callback( + [weak](ICoreWebView2*, ICoreWebView2NavigationCompletedEventArgs* args) -> HRESULT { + BOOL success = FALSE; + if (args) args->get_IsSuccess(&success); + if (const auto self = weak.lock()) self->navigation_completed(success != FALSE); + return S_OK; + }) + .Get(), + &navigation_token); + + const std::string page(reinterpret_cast(acecode::desktop_pet_page_data()), + acecode::desktop_pet_page_size()); + const HRESULT hr = webview->NavigateToString(utf8_to_wide(page).c_str()); + if (FAILED(hr)) { + LOG_WARN("[desktop-pet] NavigateToString failed: " + hresult_text(hr)); + close(); + } + } + + void configure_settings() { + ComPtr settings; + if (FAILED(webview->get_Settings(&settings)) || !settings) return; + settings->put_AreDefaultContextMenusEnabled(FALSE); + settings->put_AreDevToolsEnabled(FALSE); + settings->put_IsStatusBarEnabled(FALSE); + // Ctrl+滚轮缩放会打破整数倍像素对齐。 + settings->put_IsZoomControlEnabled(FALSE); + ComPtr settings3; + if (SUCCEEDED(settings.As(&settings3))) { + settings3->put_AreBrowserAcceleratorKeysEnabled(FALSE); + } + ComPtr settings5; + if (SUCCEEDED(settings.As(&settings5))) settings5->put_IsPinchZoomEnabled(FALSE); + ComPtr settings6; + if (SUCCEEDED(settings.As(&settings6))) settings6->put_IsSwipeNavigationEnabled(FALSE); + } + + void navigation_completed(bool success) { + if (closed || revealed) return; + if (!success) { + LOG_WARN("[desktop-pet] page navigation failed"); + close(); + return; + } + ::SetTimer(hwnd, kRevealTimer, kRevealFallbackMs, nullptr); + } + + void on_web_message(const std::wstring& message) { + if (closed || !hwnd) return; + if (message == L"ready") { + page_ready = true; + reveal(); + publish_snapshot(); + } else if (!message.empty() && message.front() == L'{') { + office_action(message); + } else if (message == L"drag") { + // 回调里不直接进模态拖动循环,回到窗口过程再开始。 + ::PostMessageW(hwnd, kMsgStartDrag, 0, 0); + } else if (message == L"menu") { + ::PostMessageW(hwnd, kMsgShowMenu, 0, 0); + } else if (message == L"resize") { + ::PostMessageW(hwnd, kMsgStartResize, 0, 0); + } else if (message == L"size-reset") { + reset_size(); + } else if (message.rfind(L"zoom ", 0) == 0) { + int steps = 0; + double fx = 1.0, fy = 1.0; + if (::swscanf_s(message.c_str() + 5, L"%d %lf %lf", &steps, &fx, &fy) >= 1) zoom_by(steps, fx, fy); + } + } + + void reveal() { + if (revealed || closed || !hwnd) return; + revealed = true; + ::KillTimer(hwnd, kRevealTimer); + apply_placement(placement); + LOG_INFO("[desktop-pet] shown at " + std::to_string(placement.window.x) + "," + + std::to_string(placement.window.y) + " size " + + std::to_string(placement.window.width) + "x" + + std::to_string(placement.window.height) + " scale " + + std::to_string(placement.scale)); + } + + void apply_placement(const DesktopPetPlacement& next) { + placement = next; + // 尺寸变了会同步收到 WM_SIZE,在那里更新 WebView2 边界和命中区域。 + ::SetWindowPos(hwnd, HWND_TOPMOST, next.window.x, next.window.y, + next.window.width, next.window.height, + SWP_NOACTIVATE | SWP_SHOWWINDOW); + } + + int window_dpi() const { return monitor_dpi(::MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY)); } + DesktopPetRect window_work_area() const { return work_area_of(::MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY)); } + DesktopPetRect window_rect() const { + RECT r{}; + ::GetWindowRect(hwnd, &r); + return {r.left, r.top, r.right - r.left, r.bottom - r.top}; + } + + // 缩放后告诉页面当前是默认大小的百分之几,页面闪一下提示。 + void notify_size(int dpi, const DesktopPetRect& work) { + if (!webview) return; + const double base = desktop_pet_scale(dpi, work.width, work.height); + const int percent = static_cast(std::lround(placement.scale / base * 100.0)); + webview->PostWebMessageAsString((L"size " + std::to_wstring(percent)).c_str()); + } + + void apply_scale(double device_scale, double fx, double fy) { + const int dpi = window_dpi(); + const DesktopPetRect work = window_work_area(); + const double scale = clamp_desktop_pet_scale(device_scale, dpi, work); + apply_placement(scale_desktop_pet(window_rect(), scale, fx, fy, work)); + logical_scale = scale * 96.0 / dpi; + notify_size(dpi, work); + } + + void zoom_by(int steps, double fx, double fy) { + if (!revealed || closed || steps == 0) return; + const double current = window_rect().width / static_cast(kDesktopPetSceneWidth); + // 停在右下角时锚在右下角(缩放后还贴着角);拖动过之后以鼠标所在点为中心。 + if (!user_moved) { fx = 1.0; fy = 1.0; } + apply_scale(current * std::pow(kZoomStep, steps), fx, fy); + save_logical_scale(logical_scale); + } + + void start_resize() { + if (!revealed || closed || resizing) return; + resizing = true; + resize_start = window_rect(); + ::GetCursorPos(&resize_cursor); + ::SetTimer(hwnd, kResizeTimer, kResizePollMs, nullptr); + } + + void resize_tick() { + const int button = ::GetSystemMetrics(SM_SWAPBUTTON) ? VK_RBUTTON : VK_LBUTTON; + if (!(::GetAsyncKeyState(button) & 0x8000)) { + finish_resize(); + return; + } + POINT cursor{}; + ::GetCursorPos(&cursor); + // 把手在左上方:往左上拖变大、右下角不动;横竖两个方向取放大得多的那个。 + const double by_x = resize_start.width - (cursor.x - resize_cursor.x); + const double by_y = (resize_start.height - (cursor.y - resize_cursor.y)) * + kDesktopPetSceneWidth / static_cast(kDesktopPetSceneHeight); + const int dpi = window_dpi(); + const DesktopPetRect work = window_work_area(); + const double scale = clamp_desktop_pet_scale(std::max(by_x, by_y) / kDesktopPetSceneWidth, dpi, work); + const auto next = scale_desktop_pet(resize_start, scale, 1.0, 1.0, work); + if (next.window.width == placement.window.width && next.window.x == placement.window.x && + next.window.y == placement.window.y) return; + apply_placement(next); + logical_scale = scale * 96.0 / dpi; + notify_size(dpi, work); + } + + void finish_resize() { + ::KillTimer(hwnd, kResizeTimer); + resizing = false; + save_logical_scale(logical_scale); + } + + void reset_size() { + if (!revealed || closed) return; + logical_scale = 0.0; + save_logical_scale(0.0); + const int dpi = window_dpi(); + const DesktopPetRect work = window_work_area(); + if (!user_moved) dock_to_corner(); + else apply_placement(scale_desktop_pet(window_rect(), desktop_pet_scale(dpi, work.width, work.height), 1.0, 1.0, work)); + notify_size(dpi, work); + } + + void dock_to_corner() { + const HMONITOR monitor = revealed + ? ::MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY) + : primary_monitor(); + apply_placement(place_desktop_pet(work_area_of(monitor), monitor_dpi(monitor), logical_scale)); + } + + void start_drag() { + if (!hwnd) return; + // 与主窗口的 WebHost::start_window_drag 相同:放开 WebView2 子窗口的鼠标捕获, + // 交给系统的标题栏拖动循环,按钮松开时循环自己结束。 + ::ReleaseCapture(); + ::SendMessageW(hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0); + } + + void show_menu() { + if (!hwnd) return; + HMENU menu = ::CreatePopupMenu(); + if (!menu) return; + const bool zh = chinese_ui(); + ::AppendMenuW(menu, MF_STRING, kMenuDock, zh ? L"回到右下角" : L"Move to corner"); + ::AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + ::AppendMenuW(menu, MF_STRING, kMenuZoomIn, zh ? L"放大(也可以滚轮)" : L"Zoom in (or scroll)"); + ::AppendMenuW(menu, MF_STRING, kMenuZoomOut, zh ? L"缩小" : L"Zoom out"); + ::AppendMenuW(menu, MF_STRING, kMenuResetSize, zh ? L"恢复默认大小" : L"Reset size"); + ::AppendMenuW(menu, MF_SEPARATOR, 0, nullptr); + ::AppendMenuW(menu, MF_STRING, kMenuHide, zh ? L"隐藏桌面宠物" : L"Hide desktop pet"); + POINT cursor{}; + ::GetCursorPos(&cursor); + // KB135788:弹菜单前把宿主设为前台,菜单后补一条消息,点空白处菜单才会消失。 + ::SetForegroundWindow(hwnd); + const UINT command = static_cast(::TrackPopupMenuEx( + menu, TPM_RETURNCMD | TPM_RIGHTBUTTON | TPM_NONOTIFY, cursor.x, cursor.y, hwnd, nullptr)); + ::PostMessageW(hwnd, WM_NULL, 0, 0); + ::DestroyMenu(menu); + if (command == kMenuDock) { + user_moved = false; + dock_to_corner(); + } else if (command == kMenuZoomIn) { + zoom_by(1, 0.5, 0.5); + } else if (command == kMenuZoomOut) { + zoom_by(-1, 0.5, 0.5); + } else if (command == kMenuResetSize) { + reset_size(); + } else if (command == kMenuHide) { + LOG_INFO("[desktop-pet] hidden from context menu"); + close(); + } + } + + void handle_dpi_changed(int dpi) { + const HMONITOR monitor = ::MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY); + const DesktopPetRect work = work_area_of(monitor); + if (!revealed) { + // 还在屏幕外:只换尺寸,位置等 reveal 时按主屏算。 + const DesktopPetPlacement next = place_desktop_pet(work_area_of(primary_monitor()), dpi, logical_scale); + placement.scale = next.scale; + placement.window.width = next.window.width; + placement.window.height = next.window.height; + RECT current{}; + ::GetWindowRect(hwnd, ¤t); + ::SetWindowPos(hwnd, nullptr, current.left, current.top, next.window.width, + next.window.height, SWP_NOACTIVATE | SWP_NOZORDER); + return; + } + if (user_moved) { + RECT current{}; + ::GetWindowRect(hwnd, ¤t); + apply_placement(resize_desktop_pet( + {current.left, current.top, current.right - current.left, + current.bottom - current.top}, + dpi, work, logical_scale)); + } else { + apply_placement(place_desktop_pet(work, dpi, logical_scale)); + } + } + + LRESULT handle_message(UINT message, WPARAM wparam, LPARAM lparam) { + switch (message) { + case WM_MOUSEACTIVATE: + return MA_NOACTIVATE; + case WM_SIZE: { + RECT bounds{}; + ::GetClientRect(hwnd, &bounds); + if (controller) controller->put_Bounds(bounds); + if (bounds.right > 0) apply_hit_region(hwnd, bounds.right / static_cast(kDesktopPetSceneWidth), hit_overlay); + break; + } + case WM_MOVE: + if (controller) controller->NotifyParentWindowPositionChanged(); + break; + case WM_EXITSIZEMOVE: + if (revealed) user_moved = true; + break; + case WM_DPICHANGED: + handle_dpi_changed(HIWORD(wparam)); + return 0; + case WM_DISPLAYCHANGE: + if (revealed && !user_moved) dock_to_corner(); + break; + case WM_SETTINGCHANGE: + if (wparam == SPI_SETWORKAREA && revealed && !user_moved) dock_to_corner(); + break; + case WM_TIMER: + if (wparam == kResizeTimer) { + resize_tick(); + return 0; + } + if (wparam == kRevealTimer) { + ::KillTimer(hwnd, kRevealTimer); + LOG_WARN("[desktop-pet] page ready signal missing; revealing after timeout"); + reveal(); + return 0; + } + break; + case kMsgStartDrag: + start_drag(); + return 0; + case kMsgShowMenu: + show_menu(); + return 0; + case kMsgStartResize: + start_resize(); + return 0; + default: + break; + } + return ::DefWindowProcW(hwnd, message, wparam, lparam); + } + + static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { + if (message == WM_NCCREATE) { + const auto* create = reinterpret_cast(lparam); + auto* self = static_cast(create->lpCreateParams); + ::SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast(self)); + if (self) self->hwnd = window; + } + auto* self = reinterpret_cast(::GetWindowLongPtrW(window, GWLP_USERDATA)); + if (!self || self->hwnd != window) return ::DefWindowProcW(window, message, wparam, lparam); + return self->handle_message(message, wparam, lparam); + } + + void close() { + if (closed) return; + closed = true; + if (webview) { + webview->remove_WebMessageReceived(message_token); + webview->remove_NavigationCompleted(navigation_token); + } + if (controller) controller->Close(); + webview.Reset(); + controller.Reset(); + environment.Reset(); + if (hwnd) { + HWND window = hwnd; + hwnd = nullptr; + ::SetWindowLongPtrW(window, GWLP_USERDATA, 0); + ::DestroyWindow(window); + } + } +}; +#endif + +DesktopPet::DesktopPet(WebHost& host) { +#ifdef _WIN32 + if (desktop_pet_disabled_by_env()) { + LOG_INFO("[desktop-pet] disabled by ACECODE_DESKTOP_PET"); + return; + } + auto impl = std::make_shared(host); + impl->bind_bridge(); + if (impl->start()) { + impl_ = std::move(impl); + } else { + impl->close(); + } +#else + (void)host; +#endif +} + +DesktopPet::~DesktopPet() { +#ifdef _WIN32 + if (impl_) impl_->close(); +#endif +} + +} // namespace acecode::desktop diff --git a/src/apps/desktop/desktop_pet.hpp b/src/apps/desktop/desktop_pet.hpp new file mode 100644 index 000000000..d027bb311 --- /dev/null +++ b/src/apps/desktop/desktop_pet.hpp @@ -0,0 +1,36 @@ +#pragma once + +// 桌面 agent 办公室:acecode-desktop 启动时在主屏右下角拉起一个透明、置顶、 +// 不抢焦点、不进任务栏的小窗口,里面是真实会话驱动的像素 AI 办公室 +// (assets/desktop_pet/agent_office_pet.html,构建期嵌进 exe)。 +// Web 应用经快照桥更新;GUI 线程拥有窗口和桥回调。 +// +// 交互:左键拖动挪位置;单击小人头顶报状态;右键菜单「回到右下角 / 隐藏桌面宠物」 +// (隐藏只到本次退出)。环境变量 ACECODE_DESKTOP_PET=0 可整体关闭。 +// +// 目前只有 Windows 实现(独立的 WebView2 环境,用户数据在 +// %LOCALAPPDATA%\ACECode\desktop-pet\webview2,不进 ACECode 数据目录, +// 以免被数据目录迁移当成持续写入的活文件);其它平台构造即空操作。 +// 必须在 GUI 线程构造与析构;任何失败只记日志,不影响主窗口。 + +#include + +namespace acecode::desktop { + +class WebHost; + +class DesktopPet { +public: + explicit DesktopPet(WebHost& host); + ~DesktopPet(); + + DesktopPet(const DesktopPet&) = delete; + DesktopPet& operator=(const DesktopPet&) = delete; + +private: + struct Impl; + // DesktopPet owns the state; WebView callbacks briefly share it via weak locks. + std::shared_ptr impl_; +}; + +} // namespace acecode::desktop diff --git a/src/apps/desktop/desktop_pet_layout.cpp b/src/apps/desktop/desktop_pet_layout.cpp new file mode 100644 index 000000000..9bc0c01fd --- /dev/null +++ b/src/apps/desktop/desktop_pet_layout.cpp @@ -0,0 +1,154 @@ +#include "desktop_pet_layout.hpp" + +#include +#include + +namespace acecode::desktop { +namespace { + +constexpr int kBaseDpi = 96; +constexpr int kEdgeMarginDip = 8; +constexpr double kMinLogicalScale = 0.5; +constexpr double kMaxWorkAreaFraction = 0.9; +// 命中区域比房间轮廓外扩的场景像素数:盖住描边和轮廓线的取整误差。 +constexpr int kHitMargin = 3; + +// 与 agent_office_pet.html 里的场景常量一致:VIEW 原点 (156, 54), +// 房间世界坐标 x∈[-3, 88]、y∈[-3, 72](含墙厚),地台底 z=-4,墙顶 z=40。 +constexpr int kViewOriginX = 156; +constexpr int kViewOriginY = 54 + kDesktopPetToolbarHeight; +constexpr int kRoomMinX = -3; +constexpr int kRoomMaxX = 88; +constexpr int kRoomMinY = -3; +constexpr int kRoomMaxY = 72; +constexpr int kRoomBottomZ = -4; +constexpr int kWallTopZ = 40; + +int effective_dpi(int dpi) { + return dpi > 0 ? dpi : kBaseDpi; +} + +// 等距投影,同页面里的 P():屏幕 x = ox + 2(x − y),屏幕 y = oy + x + y − z。 +DesktopPetPoint project(int x, int y, int z) { + return {kViewOriginX + 2 * (x - y), kViewOriginY + x + y - z}; +} + +DesktopPetRect sized_at(int x, int y, double scale) { + return {x, y, + static_cast(std::lround(kDesktopPetSceneWidth * scale)), + static_cast(std::lround(kDesktopPetSceneHeight * scale))}; +} + +int clamp_origin(int value, int size, int area_start, int area_size) { + const int max_value = area_start + area_size - size; + return std::max(area_start, std::min(value, max_value)); +} + +double scale_for(int dpi, const DesktopPetRect& work_area, double preferred_logical_scale) { + if (preferred_logical_scale > 0.0) { + return clamp_desktop_pet_scale( + preferred_logical_scale * effective_dpi(dpi) / kBaseDpi, dpi, work_area); + } + return desktop_pet_scale(dpi, work_area.width, work_area.height); +} + +} // namespace + +int desktop_pet_scale(int dpi, int work_width, int work_height) { + // round(dpi / 96 × 1.25),整数运算避免浮点边界抖动。 + int scale = std::max(1, (effective_dpi(dpi) * 125 + 4800) / 9600); + while (scale > 1 && + (kDesktopPetSceneWidth * scale > work_width / 2 || + kDesktopPetSceneHeight * scale > work_height / 2)) { + --scale; + } + return scale; +} + +double clamp_desktop_pet_scale(double scale, int dpi, const DesktopPetRect& work_area) { + const double min_scale = kMinLogicalScale * effective_dpi(dpi) / kBaseDpi; + double max_scale = std::min( + work_area.width * kMaxWorkAreaFraction / kDesktopPetSceneWidth, + work_area.height * kMaxWorkAreaFraction / kDesktopPetSceneHeight); + if (!(max_scale > min_scale)) max_scale = min_scale; // 工作区异常小时只保证下限 + if (!std::isfinite(scale)) return min_scale; + return std::clamp(scale, min_scale, max_scale); +} + +DesktopPetPlacement place_desktop_pet(const DesktopPetRect& work_area, int dpi, + double preferred_logical_scale) { + DesktopPetPlacement placement; + placement.scale = scale_for(dpi, work_area, preferred_logical_scale); + const int margin = (kEdgeMarginDip * effective_dpi(dpi) + kBaseDpi / 2) / kBaseDpi; + placement.window = sized_at(0, 0, placement.scale); + placement.window.x = clamp_origin( + work_area.x + work_area.width - placement.window.width - margin, + placement.window.width, work_area.x, work_area.width); + placement.window.y = clamp_origin( + work_area.y + work_area.height - placement.window.height - margin, + placement.window.height, work_area.y, work_area.height); + return placement; +} + +DesktopPetPlacement resize_desktop_pet(const DesktopPetRect& current, int dpi, + const DesktopPetRect& work_area, + double preferred_logical_scale) { + DesktopPetPlacement placement; + placement.scale = scale_for(dpi, work_area, preferred_logical_scale); + placement.window = sized_at(current.x, current.y, placement.scale); + placement.window.x = clamp_origin(placement.window.x, placement.window.width, + work_area.x, work_area.width); + placement.window.y = clamp_origin(placement.window.y, placement.window.height, + work_area.y, work_area.height); + return placement; +} + +DesktopPetPlacement scale_desktop_pet(const DesktopPetRect& current, double new_scale, + double anchor_fx, double anchor_fy, + const DesktopPetRect& work_area) { + anchor_fx = std::isfinite(anchor_fx) ? std::clamp(anchor_fx, 0.0, 1.0) : 1.0; + anchor_fy = std::isfinite(anchor_fy) ? std::clamp(anchor_fy, 0.0, 1.0) : 1.0; + const double anchor_x = current.x + anchor_fx * current.width; + const double anchor_y = current.y + anchor_fy * current.height; + DesktopPetPlacement placement; + placement.scale = new_scale; + placement.window = sized_at(0, 0, new_scale); + placement.window.x = clamp_origin( + static_cast(std::lround(anchor_x - anchor_fx * placement.window.width)), + placement.window.width, work_area.x, work_area.width); + placement.window.y = clamp_origin( + static_cast(std::lround(anchor_y - anchor_fy * placement.window.height)), + placement.window.height, work_area.y, work_area.height); + return placement; +} + +std::vector desktop_pet_hit_polygon(double scale) { + if (!(scale > 0.0)) scale = 1.0; + // 房间外包六边形,从左墙顶端起顺时针:左墙顶 → 墙角顶 → 后墙右顶 → + // 地台右角 → 地台前角 → 地台左角。每个点向外推 kHitMargin。 + const DesktopPetPoint wall_left = project(kRoomMinX, kRoomMaxY, kWallTopZ); + const DesktopPetPoint wall_corner = project(kRoomMinX, kRoomMinY, kWallTopZ); + const DesktopPetPoint wall_right = project(kRoomMaxX, kRoomMinY, kWallTopZ); + const DesktopPetPoint floor_right = project(kRoomMaxX, kRoomMinY, kRoomBottomZ); + const DesktopPetPoint floor_front = project(kRoomMaxX, kRoomMaxY, kRoomBottomZ); + const DesktopPetPoint floor_left = project(kRoomMinX, kRoomMaxY, kRoomBottomZ); + const std::vector outline = { + {wall_left.x - kHitMargin, wall_left.y - kHitMargin}, + {wall_corner.x, wall_corner.y - kHitMargin}, + {wall_right.x + kHitMargin, wall_right.y - kHitMargin}, + {floor_right.x + kHitMargin, floor_right.y + kHitMargin}, + {floor_front.x, floor_front.y + kHitMargin}, + {floor_left.x - kHitMargin, floor_left.y + kHitMargin}, + }; + std::vector polygon; + polygon.reserve(outline.size()); + for (const auto& point : outline) { + polygon.push_back({ + static_cast(std::lround(std::clamp(point.x, 0, kDesktopPetSceneWidth) * scale)), + static_cast(std::lround(std::clamp(point.y, 0, kDesktopPetSceneHeight) * scale)), + }); + } + return polygon; +} + +} // namespace acecode::desktop diff --git a/src/apps/desktop/desktop_pet_layout.hpp b/src/apps/desktop/desktop_pet_layout.hpp new file mode 100644 index 000000000..4d33465c8 --- /dev/null +++ b/src/apps/desktop/desktop_pet_layout.hpp @@ -0,0 +1,65 @@ +#pragma once + +// 桌面宠物(像素办公室 demo)窗口的纯几何计算,不依赖任何平台 API, +// 由 desktop_pet.cpp 的 Win32 宿主调用,单测在 tests/desktop/desktop_pet_layout_test.cpp。 +// +// 场景是 assets/desktop_pet/agent_office_pet.html 里 344×220 的像素画布。 +// 缩放倍数 scale = 每个场景像素占多少设备像素:首次运行取整数倍(像素最整齐), +// 用户用滚轮 / 把手缩放后可以是任意小数,页面负责把非整数倍画得均匀。 +// 画布里房间以外(原来的草地)是透明的,命中区域只取房间轮廓, +// 透明角落的点击会落到后面的窗口上。 + +#include + +namespace acecode::desktop { + +inline constexpr int kDesktopPetSceneWidth = 344; +inline constexpr int kDesktopPetSceneHeight = 252; +inline constexpr int kDesktopPetToolbarHeight = 32; + +struct DesktopPetRect { + int x = 0; + int y = 0; + int width = 0; + int height = 0; +}; + +struct DesktopPetPoint { + int x = 0; + int y = 0; +}; + +struct DesktopPetPlacement { + DesktopPetRect window; + double scale = 1.0; +}; + +// 首次运行的默认倍数(整数)。目标是逻辑宽度约 430px(dpi/96 × 1.25 四舍五入), +// 至少 1;放不下时逐级缩小,保证窗口不超过工作区宽/高的一半。 +int desktop_pet_scale(int dpi, int work_width, int work_height); + +// 用户缩放的上下限:最小是逻辑 0.5 倍(172 逻辑像素宽),最大不超过工作区宽/高的 90%。 +// 非法输入(NaN、负数)按下限处理。 +double clamp_desktop_pet_scale(double scale, int dpi, const DesktopPetRect& work_area); + +// 把窗口贴在工作区(已扣除任务栏)右下角,留 8 个逻辑像素边距。 +// preferred_logical_scale > 0 表示用户调过大小(逻辑倍数,与 DPI 无关),否则用默认整数倍。 +DesktopPetPlacement place_desktop_pet(const DesktopPetRect& work_area, int dpi, + double preferred_logical_scale = 0.0); + +// 保持左上角不动、只按新 DPI 换尺寸;用户拖动过之后 DPI 变化走这条。 +DesktopPetPlacement resize_desktop_pet(const DesktopPetRect& current, int dpi, + const DesktopPetRect& work_area, + double preferred_logical_scale = 0.0); + +// 缩放到 new_scale(调用方先 clamp):窗口内相对位置 (anchor_fx, anchor_fy) 那一点 +// 在屏幕上保持不动,再把整个窗口夹回工作区。停靠右下角时锚点取 (1, 1)。 +DesktopPetPlacement scale_desktop_pet(const DesktopPetRect& current, double new_scale, + double anchor_fx, double anchor_fy, + const DesktopPetRect& work_area); + +// 房间轮廓(地台 + 两面墙的外包六边形)外扩少量边距,按 scale 换成窗口内设备像素坐标, +// 顺时针,已夹在窗口范围内。 +std::vector desktop_pet_hit_polygon(double scale); + +} // namespace acecode::desktop diff --git a/src/apps/desktop/main.cpp b/src/apps/desktop/main.cpp index 7b8429a29..34969b08d 100644 --- a/src/apps/desktop/main.cpp +++ b/src/apps/desktop/main.cpp @@ -19,6 +19,7 @@ #include "context_items.hpp" #include "platform/native_ui/context_picker.hpp" #include "desktop_about.hpp" +#include "desktop_pet.hpp" #include "desktop_restart.hpp" #include "dpi_win.hpp" #include "edge_app_launcher.hpp" @@ -2773,11 +2774,10 @@ int main(int argc, char** argv) { startup_timeline.snapshot_json() + ";\n"); startup_navigation_started = true; host.navigate(url); - if (url == onboarding_url()) { - close_splash_once(); - } + if (url == onboarding_url()) close_splash_once(); - // 5. 阻塞主循环 + // 5. GUI-owned office is destroyed before the WebHost. + DesktopPet desktop_pet(host); host.run(); // 6. quit:写 last_active + stop_all diff --git a/src/apps/web/routes/routes_desktop_office.cpp b/src/apps/web/routes/routes_desktop_office.cpp new file mode 100644 index 000000000..bc00f7ef3 --- /dev/null +++ b/src/apps/web/routes/routes_desktop_office.cpp @@ -0,0 +1,115 @@ +#include "web/server_impl.hpp" +#include "session/session_recent_activity.hpp" + +namespace acecode::web { +using nlohmann::json; +namespace { +json office_item(const json& source) { + json item = json::object(); + for (const auto* key : {"id", "active", "status", "workspace_hash", "workspace_name", + "parent_session_id", "agent_path", "archived", "no_workspace", "title", "summary", + "last_user_message_at", "last_turn_outcome", "updated_at", "context_window", "token_usage"}) { + if (source.contains(key)) item[key] = source[key]; + } + for (const auto* key : {"title", "summary"}) { + if (item.contains(key) && item[key].is_string()) + item[key] = truncate_utf8_prefix(item[key].get_ref(), 240); + } + return item; +} +} // namespace + +void WebServer::Impl::register_desktop_office() { + CROW_ROUTE(app, "/api/desktop-office").methods(crow::HTTPMethod::Options) + ([this](const crow::request& req) { return with_cors(req, crow::response(204)); }); + + CROW_ROUTE(app, "/api/desktop-office").methods(crow::HTTPMethod::GET) + ([this](const crow::request& req) { + if (auto rejected = require_auth(req)) return std::move(*rejected); + const auto respond = [&](int status, const json& body) { + crow::response response(status, body.dump()); + response.add_header("Content-Type", "application/json"); + response.add_header("Cache-Control", "no-store"); + return with_cors(req, std::move(response)); + }; + const auto recent = global_session_search->recent_user_sessions(5); + json offices = json::array(); + for (const auto& entry : recent.entries) { + auto item = entry.active ? session_info_to_json(*entry.active, &entry.meta) + : session_meta_to_json(entry.meta, entry.workspace_hash); + item["last_user_message_at"] = entry.meta.last_user_message_at; + item["last_turn_outcome"] = entry.meta.last_turn_outcome; + item["workspace_name"] = entry.workspace_name; + offices.push_back(office_item(item)); + } + std::string selected = req.url_params.get("session") ? req.url_params.get("session") : ""; + std::string workspace = req.url_params.get("workspace") ? req.url_params.get("workspace") : ""; + if (selected.empty() && !offices.empty()) { + selected = offices.front().value("id", std::string{}); + workspace = offices.front().value("workspace_hash", std::string{}); + } + json result{{"offices", offices}, {"selected", nullptr}, {"agents", json::array()}, + {"complete", recent.progress.complete}, {"connected", true}}; + if (selected.empty()) return respond(200, result); + const auto active = deps.session_client ? deps.session_client->list_sessions() + : std::vector{}; + // A child selected in the main window belongs to its root's office. + for (const auto& session : active) { + if (session.id == selected && !session.parent_session_id.empty()) { + selected = session.parent_session_id; + workspace = session.workspace_hash; + break; + } + } + auto ws = resolve_session_workspace(selected, workspace); + // Global discovery also includes workspaces that have no visible sidebar + // registration. Their persisted cwd is still authoritative for storage. + for (const auto& entry : recent.entries) { + if (entry.meta.id != selected) continue; + acecode::desktop::WorkspaceMeta source; + source.hash = entry.meta.no_workspace ? std::string{} : entry.workspace_hash; + source.cwd = entry.meta.cwd; + source.name = entry.workspace_name; + ws = std::move(source); + break; + } + if (!ws) return respond(404, {{"error", "office session not found"}, {"offices", offices}}); + auto meta = find_session_meta_for_workspace(*ws, selected); + if (!meta || meta->archived) { + return respond(404, {{"error", "office session unavailable"}, {"offices", offices}}); + } + if (!meta->parent_session_id.empty()) { + selected = meta->parent_session_id; + meta = find_session_meta_for_workspace(*ws, selected); + if (!meta || meta->archived) return respond(404, {{"error", "office root unavailable"}}); + } + if (meta->last_user_message_at.empty()) { + const auto old = read_session_recent_activity(SessionStorage::session_path( + SessionStorage::get_project_dir(meta->cwd), selected)); + meta->last_user_message_at = old.last_user_message_at; + if (meta->last_turn_outcome.empty()) meta->last_turn_outcome = old.last_turn_outcome; + } + auto root = session_meta_to_json(*meta, ws->hash); + const auto decorate = [&](const json& source, const std::string& id) { + auto item = office_item(source); + if (deps.session_registry) { + if (const auto entry = deps.session_registry->acquire(id); entry && entry->loop) { + item["activity"] = entry->loop->events().activity_snapshot(); + } + } + return item; + }; + for (const auto& session : active) { + if (session.id == selected) root = session_info_to_json(session, &*meta); + } + root = decorate(std::move(root), selected); + result["selected"] = root; + result["agents"].push_back(root); + for (const auto& child : active) { + if (child.parent_session_id != selected) continue; + result["agents"].push_back(decorate(session_info_to_json(child, nullptr), child.id)); + } + return respond(200, result); + }); +} +} // namespace acecode::web diff --git a/src/apps/web/server.cpp b/src/apps/web/server.cpp index a1599a01e..199260c53 100644 --- a/src/apps/web/server.cpp +++ b/src/apps/web/server.cpp @@ -137,6 +137,7 @@ void WebServer::Impl::register_routes() { register_workspaces(); register_pinned_sessions(); register_sessions(); + register_desktop_office(); register_task_suggestions(); register_models(); register_image_generation(); diff --git a/src/apps/web/server_helpers.cpp b/src/apps/web/server_helpers.cpp index 0067109a3..e444839d3 100644 --- a/src/apps/web/server_helpers.cpp +++ b/src/apps/web/server_helpers.cpp @@ -860,6 +860,8 @@ json WebServer::Impl::session_info_to_json(const SessionInfo& s, const SessionMe o["model_name"] = model_name; o["model_preset"] = o["model_name"]; o["context_window"] = s.context_window; + o["last_user_message_at"] = !s.last_user_message_at.empty() ? s.last_user_message_at : (m ? m->last_user_message_at : ""); + o["last_turn_outcome"] = s.active ? s.last_turn_outcome : (m ? m->last_turn_outcome : ""); o["deleted"] = model_deleted; o["message_count"] = s.message_count > 0 ? s.message_count : (m ? m->message_count : 0); o["turn_count"] = s.turn_count > 0 ? s.turn_count : (m ? m->turn_count : 0); @@ -953,6 +955,8 @@ json WebServer::Impl::session_meta_to_json(const SessionMeta& m, const std::stri o["summary"] = m.summary; o["created_at"] = m.created_at; o["updated_at"] = m.updated_at; + o["last_user_message_at"] = m.last_user_message_at; + o["last_turn_outcome"] = m.last_turn_outcome; o["provider"] = model_deleted ? "" : m.provider; o["model"] = model_deleted ? "" : m.model; o["model_name"] = m.model_preset; diff --git a/src/apps/web/server_impl.hpp b/src/apps/web/server_impl.hpp index 2f9408709..3960304ee 100644 --- a/src/apps/web/server_impl.hpp +++ b/src/apps/web/server_impl.hpp @@ -620,6 +620,7 @@ struct WebServer::Impl { void register_workspaces(); void register_pinned_sessions(); void register_sessions(); + void register_desktop_office(); void register_task_suggestions(); void register_models(); void register_experts(); diff --git a/src/domain/session/event_dispatcher.cpp b/src/domain/session/event_dispatcher.cpp index 1da610245..35f2105ea 100644 --- a/src/domain/session/event_dispatcher.cpp +++ b/src/domain/session/event_dispatcher.cpp @@ -87,6 +87,7 @@ std::uint64_t EventDispatcher::emit(SessionEventKind kind, nlohmann::json payloa { std::lock_guard lk(mu_); evt.seq = ++seq_counter_; + activity_.apply(evt); if (options.buffered) { push_to_buffer(evt, options.coalesce_key); } @@ -278,6 +279,11 @@ void EventDispatcher::unsubscribe_impl(SubscriptionId id, bool wait) { // Captured resources and queued payloads are released outside mu_. } +nlohmann::json EventDispatcher::activity_snapshot() const { + std::lock_guard lock(mu_); + return activity_.snapshot(); +} + std::size_t EventDispatcher::listener_count() const { std::lock_guard lk(mu_); const std::size_t observer_count = diff --git a/src/domain/session/event_dispatcher.hpp b/src/domain/session/event_dispatcher.hpp index 763389a01..3959764aa 100644 --- a/src/domain/session/event_dispatcher.hpp +++ b/src/domain/session/event_dispatcher.hpp @@ -25,6 +25,7 @@ // "实时新帧抢在历史旧帧之前送达、被客户端按 seq 误判为过期而丢弃" 的问题。 #include "session_client.hpp" +#include "session_activity_state.hpp" #include #include @@ -92,6 +93,9 @@ class EventDispatcher { // 测试用: 当前活跃 listener 数。 std::size_t listener_count() const; + // Coherent presentation snapshot even when transient events are not replayed. + nlohmann::json activity_snapshot() const; + private: struct BufferedEvent { SessionEvent event; @@ -124,6 +128,7 @@ class EventDispatcher { std::atomic seq_counter_{0}; std::atomic next_sub_id_{1}; + SessionActivityState activity_; // Guarded by mu_, no callbacks or IO. std::size_t buffer_capacity_; std::size_t buffer_byte_capacity_; std::size_t buffered_bytes_ = 0; diff --git a/src/domain/session/global_session_catalog_meta.hpp b/src/domain/session/global_session_catalog_meta.hpp index 9de994268..43db56f20 100644 --- a/src/domain/session/global_session_catalog_meta.hpp +++ b/src/domain/session/global_session_catalog_meta.hpp @@ -10,6 +10,8 @@ inline SessionMeta catalog_meta_from_active(const SessionInfo& active) { meta.cwd = active.cwd; meta.created_at = active.created_at; meta.updated_at = active.updated_at; + meta.last_user_message_at = active.last_user_message_at; + meta.last_turn_outcome = active.last_turn_outcome; meta.summary = active.summary; meta.provider = active.provider; meta.model = active.model; diff --git a/src/domain/session/global_session_search.cpp b/src/domain/session/global_session_search.cpp index a4c75c7de..c276578d6 100644 --- a/src/domain/session/global_session_search.cpp +++ b/src/domain/session/global_session_search.cpp @@ -1,6 +1,7 @@ #include "global_session_search.hpp" #include "session_user_message_search.hpp" +#include "session_recent_activity.hpp" #include "workspace/workspace_registry.hpp" #include "utils/utf8_path.hpp" @@ -172,6 +173,12 @@ struct GlobalSessionSearchService::Impl { std::mutex run_mu; }; + struct RecentCache { + SessionUserMessageFileSignature signature; + SessionRecentActivity activity; + }; + std::mutex recent_mu; + std::unordered_map recent_cache; GlobalSessionCatalogIndex index; std::mutex jobs_mu; std::unordered_map> jobs; @@ -250,6 +257,56 @@ void GlobalSessionSearchService::stop() { impl_->index.stop(); } +GlobalSessionSearchPage GlobalSessionSearchService::recent_user_sessions(std::size_t limit) { + impl_->index.attach_request("desktop-agent-office"); + auto snapshot = impl_->index.snapshot(); + GlobalSessionSearchPage result; + result.progress = snapshot.progress; + result.errors = std::move(snapshot.catalog.errors); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(100); + std::lock_guard lock(impl_->recent_mu); + for (auto& entry : snapshot.catalog.entries) { + if (entry.meta.archived || !entry.meta.parent_session_id.empty()) continue; + if (entry.active && !entry.active->last_user_message_at.empty()) { + entry.meta.last_user_message_at = entry.active->last_user_message_at; + entry.meta.last_turn_outcome = entry.active->last_turn_outcome; + } + if (entry.meta.last_user_message_at.empty()) { + const auto path = SessionStorage::session_path(entry.project_dir, entry.meta.id); + const auto signature = session_user_message_file_signature(path); + auto cached = impl_->recent_cache.find(path); + if (cached == impl_->recent_cache.end() || + cached->second.signature.mtime != signature.mtime || + cached->second.signature.size != signature.size) { + if (std::chrono::steady_clock::now() >= deadline) { + result.progress.complete = false; + continue; + } + try { + Impl::RecentCache value{signature, read_session_recent_activity(path)}; + cached = impl_->recent_cache.insert_or_assign(path, std::move(value)).first; + } catch (const std::exception& error) { + result.errors.push_back({entry.project_dir, entry.workspace_hash, + "recent-user-message", error.what()}); + continue; + } + } + entry.meta.last_user_message_at = cached->second.activity.last_user_message_at; + if (entry.meta.last_turn_outcome.empty()) { + entry.meta.last_turn_outcome = cached->second.activity.last_turn_outcome; + } + } + if (!entry.meta.last_user_message_at.empty()) result.entries.push_back(std::move(entry)); + } + std::sort(result.entries.begin(), result.entries.end(), [](const auto& a, const auto& b) { + if (a.meta.last_user_message_at != b.meta.last_user_message_at) + return a.meta.last_user_message_at > b.meta.last_user_message_at; + return a.meta.id < b.meta.id; + }); + if (result.entries.size() > limit) result.entries.resize(limit); + return result; +} + GlobalSessionSearchPage GlobalSessionSearchService::search_sessions( const std::string& request_id, const std::string& query, diff --git a/src/domain/session/global_session_search.hpp b/src/domain/session/global_session_search.hpp index eba6adcfe..31b5150f9 100644 --- a/src/domain/session/global_session_search.hpp +++ b/src/domain/session/global_session_search.hpp @@ -53,6 +53,8 @@ class GlobalSessionSearchService { std::size_t limit, const std::string& cursor = {}); + GlobalSessionSearchPage recent_user_sessions(std::size_t limit = 5); + GlobalSessionContentSearchPage search_user_messages_batch( const std::string& request_id, const std::string& query, diff --git a/src/domain/session/session_activity_state.cpp b/src/domain/session/session_activity_state.cpp new file mode 100644 index 000000000..4a8264ae8 --- /dev/null +++ b/src/domain/session/session_activity_state.cpp @@ -0,0 +1,99 @@ +#include "session_activity_state.hpp" +#include "inter_agent_message.hpp" +#include "utils/encoding.hpp" + +namespace acecode { +namespace { +std::string text(const nlohmann::json& value, const char* key) { + const auto it = value.find(key); + return it != value.end() && it->is_string() + ? truncate_utf8_prefix(it->get_ref(), 240) : std::string{}; +} +bool flag(const nlohmann::json& value, const char* key) { + const auto it = value.find(key); + return it != value.end() && it->is_boolean() && it->get(); +} +} // namespace + +void SessionActivityState::apply(const SessionEvent& event) { + seq_ = event.seq; + const auto& p = event.payload; + if (!p.is_object()) return; + const auto finish = [&] { + const auto outcome = text(p, "outcome"); + if (outcome.empty()) return; + busy_ = false; + known_ = true; + outcome_ = outcome; + phase_.clear(); label_.clear(); tool_.clear(); tools_.clear(); + permissions_.clear(); questions_.clear(); + }; + switch (event.kind) { + case SessionEventKind::BusyChanged: + busy_ = flag(p, "busy"); known_ = true; + if (busy_) { + outcome_.clear(); phase_ = "model_waiting"; + label_.clear(); tool_.clear(); tools_.clear(); + permissions_.clear(); questions_.clear(); + turn_id_ = text(p, "turn_id"); + } else finish(); + break; + case SessionEventKind::Done: finish(); break; + case SessionEventKind::AgentProgress: + phase_ = text(p, "phase"); + label_ = text(p, "label"); + tool_ = text(p, "tool"); + break; + case SessionEventKind::ToolStart: { + tool_ = text(p, "tool"); + auto id = text(p, "tool_call_id"); + if (id.empty()) id = tool_; + tools_[id] = tool_; + break; + } + case SessionEventKind::ToolEnd: { + auto id = text(p, "tool_call_id"); + if (id.empty()) id = text(p, "tool"); + tools_.erase(id); + tool_ = tools_.empty() ? std::string{} : tools_.rbegin()->second; + break; + } + case SessionEventKind::PermissionRequest: + permissions_.insert(text(p, "request_id")); break; + case SessionEventKind::PermissionClosed: + permissions_.erase(text(p, "request_id")); break; + case SessionEventKind::QuestionRequest: + questions_.insert(text(p, "request_id")); break; + case SessionEventKind::QuestionClosed: + questions_.erase(text(p, "request_id")); break; + case SessionEventKind::Message: { + const auto metadata = p.find("metadata"); + if (metadata == p.end() || !metadata->is_object()) break; + if (flag(*metadata, "compact_notice_complete")) { + compact_id_ = text(*metadata, "compact_notice_id"); + } + const auto envelope = mesh::inter_agent_envelope_from_metadata(*metadata); + if (envelope) { + transfers_.push_back({{"seq", event.seq}, + {"sender", envelope->sender}, {"recipient", envelope->recipient}, + {"sender_session_id", envelope->sender_session_id}, + {"type", mesh::inter_agent_message_type_name(envelope->type)}}); + if (transfers_.size() > 16) transfers_.erase(transfers_.begin()); + } + break; + } + default: break; + } +} + +nlohmann::json SessionActivityState::snapshot() const { + auto phase = phase_; + if (!permissions_.empty()) phase = "permission_waiting"; + else if (!questions_.empty()) phase = "question_waiting"; + else if (!tools_.empty()) phase = "tool_running"; + return {{"seq", seq_}, {"known", known_}, {"busy", busy_}, + {"turn_id", turn_id_}, {"outcome", outcome_}, {"phase", phase}, + {"label", label_}, {"tool", tool_}, {"compact_id", compact_id_}, + {"transfers", transfers_}}; +} +} // namespace acecode diff --git a/src/domain/session/session_activity_state.hpp b/src/domain/session/session_activity_state.hpp new file mode 100644 index 000000000..ed999ea88 --- /dev/null +++ b/src/domain/session/session_activity_state.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "session_client.hpp" +#include +#include +#include + +namespace acecode { + +// Compact presentation state, updated under the owning dispatcher's mutex. +// It retains no transcript, arguments, tool output or service references. +class SessionActivityState { +public: + void apply(const SessionEvent& event); + nlohmann::json snapshot() const; +private: + std::uint64_t seq_ = 0; + bool busy_ = false; + bool known_ = false; + std::string turn_id_; + std::string outcome_; + std::string phase_; + std::string label_; + std::string tool_; + std::string compact_id_; + std::map tools_; + std::unordered_set permissions_; + std::unordered_set questions_; + nlohmann::json transfers_ = nlohmann::json::array(); +}; + +} // namespace acecode diff --git a/src/domain/session/session_client.hpp b/src/domain/session/session_client.hpp index 85b36688a..70b1fb74e 100644 --- a/src/domain/session/session_client.hpp +++ b/src/domain/session/session_client.hpp @@ -281,6 +281,8 @@ struct SessionInfo { std::string worktree_path; std::string worktree_name; std::string worktree_branch; + std::string last_user_message_at; + std::string last_turn_outcome; }; // ----- AskUserQuestion 回应(client→server) ----- diff --git a/src/domain/session/session_manager.cpp b/src/domain/session/session_manager.cpp index 3fe456f84..43ea68f5b 100644 --- a/src/domain/session/session_manager.cpp +++ b/src/domain/session/session_manager.cpp @@ -1,4 +1,5 @@ #include "session_manager.hpp" +#include "session_recent_activity.hpp" #include "inter_agent_message.hpp" #include "session_history_page.hpp" #include "session_load_metrics.hpp" @@ -241,6 +242,8 @@ void SessionManager::start_session(const std::string& cwd, message_count_ = 0; turn_count_ = 0; last_user_summary_.clear(); + last_user_message_at_.clear(); + last_turn_outcome_.clear(); created_at_.clear(); pending_title_.clear(); title_source_.clear(); @@ -370,6 +373,7 @@ void SessionManager::on_message(const ChatMessage& msg) { } } + record_recent_activity_locked(msg); // Track last user message for summary if (is_visible_user_turn_message(msg)) { const std::string text = user_summary_source_text(msg); @@ -616,6 +620,7 @@ std::vector SessionManager::resume_session(const std::string& sessi // Restore persisted display/model metadata when present. Resuming is not // conversation activity, so keep the persisted activity timestamp while // still rewriting repaired counters and compatibility fields below. + restore_recent_activity_locked(persisted_meta); std::optional persisted_updated_at; if (fs::exists(meta_path_str_)) { auto meta = SessionStorage::read_meta(meta_path_str_); @@ -911,6 +916,7 @@ std::string SessionManager::fork_active_session(const std::vector& log_user_message_index_error("rebuild after fork", session_id_, index_error); } } + restore_recent_activity_locked({}); update_meta(SessionStorage::now_iso8601()); if (goal_store_ && !previous_session_id.empty()) { std::string goal_error; @@ -920,7 +926,6 @@ std::string SessionManager::fork_active_session(const std::vector& } return session_id_; } - std::string SessionManager::fork_session_to_new_id( const std::vector& retained_prefix, const std::string& title, @@ -1031,6 +1036,9 @@ std::string SessionManager::fork_session_to_new_id( meta.message_count = count; meta.turn_count = turn_count; meta.summary = last_user_summary; + const auto recent = read_session_recent_activity(new_jsonl); + meta.last_user_message_at = recent.last_user_message_at; + meta.last_turn_outcome = recent.last_turn_outcome; meta.provider = provider_name_; meta.model = model_name_; meta.model_preset = model_preset_; @@ -1205,6 +1213,8 @@ bool SessionManager::update_meta( : (persisted.id.empty() ? created_at_ : persisted.updated_at); meta.message_count = message_count_; meta.turn_count = turn_count_; + meta.last_user_message_at = last_user_message_at_; + meta.last_turn_outcome = last_turn_outcome_; meta.summary = last_user_summary_; meta.provider = provider_name_; meta.model = model_name_; @@ -1519,11 +1529,6 @@ std::string SessionManager::current_title_source() const { return title_source_; } -std::string SessionManager::current_summary() const { - std::lock_guard lk(mu_); - return last_user_summary_; -} - void SessionManager::set_input_draft(std::string draft, nlohmann::json composer_content) { std::lock_guard lk(mu_); input_draft_ = std::move(draft); @@ -1541,6 +1546,8 @@ void SessionManager::set_input_draft(std::string draft, nlohmann::json composer_ meta.updated_at = meta.created_at; meta.message_count = message_count_; meta.turn_count = turn_count_; + meta.last_user_message_at = last_user_message_at_; + meta.last_turn_outcome = last_turn_outcome_; meta.summary = last_user_summary_; meta.provider = provider_name_; meta.model = model_name_; diff --git a/src/domain/session/session_manager.hpp b/src/domain/session/session_manager.hpp index 695bc24a5..15df2b15c 100644 --- a/src/domain/session/session_manager.hpp +++ b/src/domain/session/session_manager.hpp @@ -33,6 +33,8 @@ struct SessionDisplaySnapshot { WorktreeSessionInfo worktree; std::string swarm_mode; // "" = off std::string agent_path; // mesh child path; empty for roots + std::string last_user_message_at; + std::string last_turn_outcome; }; class SessionManager { @@ -275,6 +277,7 @@ class SessionManager { TokenUsage current_last_token_usage() const; TokenUsage current_session_token_usage() const; int current_turn_count() const; + void record_turn_outcome(const std::string& outcome); // Append one model-invisible diagnostic record to the active session's // trajectory sidecar. timestamp_ms <= 0 uses the current system clock. @@ -290,6 +293,8 @@ class SessionManager { std::string current_trajectory_path() const; private: + void record_recent_activity_locked(const ChatMessage& msg); + void restore_recent_activity_locked(const SessionMeta& persisted_meta); void publish_display_snapshot_locked(); // mu_ held; display_mu_ is a leaf lock. bool ensure_created(); // Lazy creation of session files on first message // 追加一条不含可搜索用户文本的记录(检查点 / 净差异等),并同步推进用户消息 @@ -338,6 +343,8 @@ class SessionManager { int message_count_ = 0; int turn_count_ = 0; std::string last_user_summary_; + std::string last_user_message_at_; + std::string last_turn_outcome_; std::string created_at_; std::string pending_title_; std::string title_source_; diff --git a/src/domain/session/session_manager_history.cpp b/src/domain/session/session_manager_history.cpp index a7529f385..fcc00f0e4 100644 --- a/src/domain/session/session_manager_history.cpp +++ b/src/domain/session/session_manager_history.cpp @@ -1,4 +1,6 @@ #include "session_manager.hpp" +#include "session_recent_activity.hpp" +#include "turn_timing.hpp" #include "session_history_page.hpp" #include "utils/logger.hpp" #include @@ -6,15 +8,48 @@ namespace acecode { namespace fs = std::filesystem; +void SessionManager::record_turn_outcome(const std::string& outcome) { + if (!is_valid_turn_timing_status(outcome)) return; + std::lock_guard lock(mu_); + if (last_turn_outcome_ == outcome) return; + last_turn_outcome_ = outcome; + update_meta(); +} + +void SessionManager::record_recent_activity_locked(const ChatMessage& msg) { + if (is_recent_user_message(msg)) { + last_user_message_at_ = msg.timestamp.empty() ? SessionStorage::now_iso8601() : msg.timestamp; + last_turn_outcome_.clear(); + } else if (is_turn_timing_message(msg)) { + last_turn_outcome_ = decode_turn_timing(msg.metadata.at("turn_timing"))->status; + } +} + +void SessionManager::restore_recent_activity_locked(const SessionMeta& persisted_meta) { + last_user_message_at_ = persisted_meta.last_user_message_at; + last_turn_outcome_ = persisted_meta.last_turn_outcome; + if (last_user_message_at_.empty()) { + const auto recent = read_session_recent_activity(jsonl_path_); + last_user_message_at_ = recent.last_user_message_at; + if (last_turn_outcome_.empty()) last_turn_outcome_ = recent.last_turn_outcome; + } +} + void SessionManager::publish_display_snapshot_locked() { SessionDisplaySnapshot snapshot{ pending_title_, title_source_, last_user_summary_, turn_count_, last_token_usage_, session_token_usage_, worktree_, swarm_mode_, agent_path_, + last_user_message_at_, last_turn_outcome_, }; std::lock_guard lock(display_mu_); display_snapshot_ = std::move(snapshot); } +std::string SessionManager::current_summary() const { + std::lock_guard lk(mu_); + return last_user_summary_; +} + SessionDisplaySnapshot SessionManager::display_snapshot() const { std::lock_guard lock(display_mu_); return display_snapshot_; diff --git a/src/domain/session/session_recent_activity.cpp b/src/domain/session/session_recent_activity.cpp new file mode 100644 index 000000000..6a5796de3 --- /dev/null +++ b/src/domain/session/session_recent_activity.cpp @@ -0,0 +1,48 @@ +#include "session_recent_activity.hpp" +#include "inter_agent_message.hpp" +#include "jsonl_scanner.hpp" +#include "session_serializer.hpp" +#include "session_user_message_search.hpp" +#include "turn_timing.hpp" + +namespace acecode { +namespace { +bool consume(const ChatMessage& message, SessionRecentActivity& result) { + if (result.last_turn_outcome.empty() && is_turn_timing_message(message)) { + result.last_turn_outcome = decode_turn_timing(message.metadata.at("turn_timing"))->status; + } + if (!is_recent_user_message(message)) return false; + result.last_user_message_at = message.timestamp; + return true; +} +} // namespace + +bool is_recent_user_message(const ChatMessage& message) { + const auto tool = message.metadata.is_object() ? message.metadata.find("source_tool") : message.metadata.end(); + const bool tool_injected = tool != message.metadata.end() && tool->is_string() && !tool->get_ref().empty(); + return !tool_injected && is_searchable_visible_user_message(message) && !mesh::is_inter_agent_message(message); +} + +SessionRecentActivity recent_activity_from_messages(const std::vector& messages) { + SessionRecentActivity result; + for (auto it = messages.rbegin(); it != messages.rend(); ++it) { + if (consume(*it, result)) break; + } + return result; +} + +SessionRecentActivity read_session_recent_activity(const std::string& jsonl_path) { + SessionRecentActivity result; + SessionFileReader reader(jsonl_path); + if (!reader.valid()) return result; + JsonlScanner scanner(reader, true); + while (const auto record = scanner.next()) { + try { + if (consume(deserialize_message(record->text), result)) break; + } catch (const nlohmann::json::exception&) { + // A damaged line is not a message or a completion signal. + } + } + return result; +} +} // namespace acecode diff --git a/src/domain/session/session_recent_activity.hpp b/src/domain/session/session_recent_activity.hpp new file mode 100644 index 000000000..c042738c1 --- /dev/null +++ b/src/domain/session/session_recent_activity.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "llm/llm_provider.hpp" +#include +#include + +namespace acecode { + +struct SessionRecentActivity { + std::string last_user_message_at; + std::string last_turn_outcome; +}; + +// Visible human input only; internal mesh messages and compact summaries do not +// make a conversation recent. Reuses the existing persisted message flags. +bool is_recent_user_message(const ChatMessage& message); +SessionRecentActivity recent_activity_from_messages(const std::vector& messages); +// Reads backwards using the existing bounded JSONL scanner. Does not rewrite +// old metadata or load an entire long transcript into memory. +SessionRecentActivity read_session_recent_activity(const std::string& jsonl_path); + +} // namespace acecode diff --git a/src/domain/session/session_storage.cpp b/src/domain/session/session_storage.cpp index 201e56583..62ba97eeb 100644 --- a/src/domain/session/session_storage.cpp +++ b/src/domain/session/session_storage.cpp @@ -296,6 +296,8 @@ bool SessionStorage::write_meta(const std::string& meta_path, const SessionMeta& j["cwd"] = meta.cwd; j["created_at"] = meta.created_at; j["updated_at"] = meta.updated_at; + if (!meta.last_user_message_at.empty()) j["last_user_message_at"] = meta.last_user_message_at; + if (!meta.last_turn_outcome.empty()) j["last_turn_outcome"] = meta.last_turn_outcome; j["message_count"] = meta.message_count; j["summary"] = meta.summary; j["provider"] = meta.provider; @@ -424,6 +426,8 @@ SessionMeta SessionStorage::read_meta(const std::string& meta_path) { meta.pre_plan_permission_mode = normalize_pre_plan_permission_mode_name( j.value("pre_plan_permission_mode", std::string{})); meta.turn_count = (std::max)(0, j.value("turn_count", 0)); + meta.last_user_message_at = j.value("last_user_message_at", std::string{}); + meta.last_turn_outcome = j.value("last_turn_outcome", std::string{}); if (j.contains("last_token_usage")) { meta.last_token_usage = token_usage_from_json(j["last_token_usage"]); } diff --git a/src/domain/session/session_storage.hpp b/src/domain/session/session_storage.hpp index 86e50c474..dfb9aa5d1 100644 --- a/src/domain/session/session_storage.hpp +++ b/src/domain/session/session_storage.hpp @@ -120,6 +120,8 @@ struct SessionMeta { // parent_session_id 恒为树根会话 id(界面归属、冒泡与级联删除都按它走), // 真实父子关系由路径表达。根会话与非网状会话为空,序列化省略。 std::string agent_path; + std::string last_user_message_at; // ISO 8601; human input, not background activity. + std::string last_turn_outcome; // completed | aborted | error; empty = unknown/pending. }; class SessionStorage { diff --git a/src/engine/agent/turn/turn_finalizer.cpp b/src/engine/agent/turn/turn_finalizer.cpp index aae4c5ed8..1b0064b21 100644 --- a/src/engine/agent/turn/turn_finalizer.cpp +++ b/src/engine/agent/turn/turn_finalizer.cpp @@ -165,7 +165,10 @@ void TurnFinalizer::step(Step selected, Frame& frame) { case Step::RecordOutcome: outcome_.record(status); break; case Step::ClearBusy: busy_ = false; break; case Step::BusyEvent: events_.emit(SessionEventKind::BusyChanged, frame.idle); break; - case Step::Done: events_.emit(SessionEventKind::Done, frame.done); break; + case Step::Done: + if (session_manager_) session_manager_->record_turn_outcome(status); + events_.emit(SessionEventKind::Done, frame.done); + break; case Step::AfterNormal: after_normal(*frame.turn); break; case Step::ContinueHook: continue_goal(); break; } diff --git a/src/host/session_host/session_registry.cpp b/src/host/session_host/session_registry.cpp index 10baa9308..68479f006 100644 --- a/src/host/session_host/session_registry.cpp +++ b/src/host/session_host/session_registry.cpp @@ -2217,6 +2217,8 @@ std::vector SessionRegistry::list_active() const { info.title_source = display.title_source; info.summary = display.summary; info.turn_count = display.turn_count; + info.last_user_message_at = display.last_user_message_at; + info.last_turn_outcome = display.last_turn_outcome; info.last_token_usage = display.last_token_usage; info.session_token_usage = display.session_token_usage; const auto& worktree = display.worktree; diff --git a/tests/desktop/desktop_pet_layout_test.cpp b/tests/desktop/desktop_pet_layout_test.cpp new file mode 100644 index 000000000..f30050352 --- /dev/null +++ b/tests/desktop/desktop_pet_layout_test.cpp @@ -0,0 +1,208 @@ +// 桌面宠物(像素办公室 demo)窗口几何的纯逻辑测试(可移植)。 +// +// Win32 透明窗口 + WebView2 只能在 Windows 实机看效果,但下面这些决定 +// 「窗口多大、贴在哪、哪里能点」的算术在这里锁住: +// 1. 整数倍缩放随 DPI 取值(场景 344×252 像素,整数倍才不糊); +// 2. 小屏幕上缩放逐级回退,窗口不超过工作区一半; +// 3. 贴工作区右下角并留 8 个逻辑像素边距(工作区已扣任务栏,可能不从 0 开始); +// 4. 用户拖动过之后 DPI 变化只换尺寸、不跳回角落,且不被推出屏幕; +// 5. 命中区域是房间外包六边形:透明角落不接点击,房间内部都能点到; +// 6. 用户缩放的上下限(逻辑 0.5 倍到工作区 90%); +// 7. 用户调过的大小跨 DPI 按逻辑倍数换算; +// 8. 滚轮 / 把手缩放时锚点不动,放不下时推回工作区; +// 9. 小数倍缩放时命中区域按四舍五入换算。 + +#include + +#include "desktop/desktop_pet_layout.hpp" + +#include +#include + +using namespace acecode::desktop; + +namespace { + +// 射线法判断点是否在多边形内部,只给测试用。 +bool inside(const std::vector& polygon, int x, int y) { + bool result = false; + for (std::size_t i = 0, j = polygon.size() - 1; i < polygon.size(); j = i++) { + const auto& a = polygon[i]; + const auto& b = polygon[j]; + if ((a.y > y) != (b.y > y)) { + const double cross = a.x + static_cast(y - a.y) * (b.x - a.x) / (b.y - a.y); + if (x < cross) result = !result; + } + } + return result; +} + +constexpr DesktopPetRect kFullHd{0, 0, 1920, 1040}; + +} // namespace + +// 场景 1:常见 DPI 下的缩放倍数。 +// 触发:工作区足够大(1920×1040),DPI 取 96/120/144/168/192。 +// 期望:round(dpi/96 × 1.25) → 1/2/2/2/3。96 DPI 时 1.25 四舍五入为 1(344px 宽), +// 192 DPI 时 2.5 进位为 3(逻辑宽度约 516px),都在「约 430 逻辑像素」附近。 +TEST(DesktopPetLayout, ScaleFollowsDpiWithIntegerSteps) { + EXPECT_EQ(desktop_pet_scale(96, 3840, 2160), 1); + EXPECT_EQ(desktop_pet_scale(120, 3840, 2160), 2); + EXPECT_EQ(desktop_pet_scale(144, 3840, 2160), 2); + EXPECT_EQ(desktop_pet_scale(168, 3840, 2160), 2); + EXPECT_EQ(desktop_pet_scale(192, 3840, 2160), 3); +} + +// 场景 1b:拿不到 DPI(0 或负数)时按 96 处理,不能算出 0 倍窗口。 +TEST(DesktopPetLayout, InvalidDpiFallsBackToBase) { + EXPECT_EQ(desktop_pet_scale(0, 1920, 1040), 1); + EXPECT_EQ(desktop_pet_scale(-5, 1920, 1040), 1); +} + +// 场景 2:小工作区上缩放回退。 +// 触发:192 DPI 本该取 3 倍(1032×756),但工作区只有 1366×728。 +// 期望:3 倍宽 1032 > 683(一半宽)→ 退到 2 倍(688 > 683 仍超)→ 1 倍。 +// 回退到 1 为止,再小也不会出现 0 倍。 +TEST(DesktopPetLayout, ScaleShrinksToFitHalfOfWorkArea) { + EXPECT_EQ(desktop_pet_scale(192, 1366, 728), 1); + EXPECT_EQ(desktop_pet_scale(192, 1600, 1080), 2); // 688 ≤ 800、504 ≤ 450 + EXPECT_EQ(desktop_pet_scale(192, 100, 100), 1); +} + +// 场景 3:贴右下角。 +// 触发:96 DPI、工作区 1920×1040(底部 40px 是任务栏,已被扣掉)。 +// 期望:窗口 344×252,右边距与下边距都是 8px → x = 1920-344-8,y = 1040-252-8。 +TEST(DesktopPetLayout, PlacesAtBottomRightWithMargin) { + const auto placement = place_desktop_pet(kFullHd, 96); + EXPECT_EQ(placement.scale, 1); + EXPECT_EQ(placement.window.width, 344); + EXPECT_EQ(placement.window.height, 252); + EXPECT_EQ(placement.window.x, 1920 - 344 - 8); + EXPECT_EQ(placement.window.y, 1040 - 252 - 8); +} + +// 场景 3b:工作区不从原点开始(任务栏在左侧/顶部,或副屏在主屏左边为负坐标)。 +// 触发:工作区 {-1920, 60, 1920, 1020},144 DPI(边距 8×1.5=12px,2 倍窗口 688×504)。 +// 期望:右下角按工作区自身的右/下边算,而不是按屏幕原点。 +TEST(DesktopPetLayout, PlacementRespectsOffsetWorkArea) { + const auto placement = place_desktop_pet({-1920, 60, 1920, 1020}, 144); + EXPECT_EQ(placement.scale, 2); + EXPECT_EQ(placement.window.x, -1920 + 1920 - 688 - 12); + EXPECT_EQ(placement.window.y, 60 + 1020 - 504 - 12); +} + +// 场景 4:拖动过之后换 DPI(比如拖到另一块屏)。 +// 触发 a:1080p 工作区,左上角 (100, 120),新 DPI 144 → 2 倍 688×504,原位放得下。 +// 触发 b:4K 工作区 3840×2080,左上角 (3500, 1900),新 DPI 192 → 3 倍 1032×756。 +// 期望:左上角尽量不动;原位置放不下新尺寸时往回推,整个窗口留在工作区内 +// (x ≤ 3840-1032,y ≤ 2080-756),不会一半跑到屏幕外。 +TEST(DesktopPetLayout, ResizeKeepsOriginButStaysInsideWorkArea) { + const auto kept = resize_desktop_pet({100, 120, 344, 252}, 144, kFullHd); + EXPECT_EQ(kept.scale, 2); + EXPECT_EQ(kept.window.x, 100); + EXPECT_EQ(kept.window.y, 120); + EXPECT_EQ(kept.window.width, 688); + + const auto pushed = resize_desktop_pet({3500, 1900, 344, 252}, 192, {0, 0, 3840, 2080}); + EXPECT_EQ(pushed.scale, 3); + EXPECT_EQ(pushed.window.x, 3840 - 1032); + EXPECT_EQ(pushed.window.y, 2080 - 756); +} + +// 场景 5:命中区域 = 房间外包六边形。 +// 触发:1 倍缩放下取多边形,再按 2 倍取一次。 +// 期望: +// - 6 个顶点、全部落在 344×252 画布内; +// - 画布四个透明角(原来的草地)都在多边形外 → 点击穿透到后面的窗口; +// - 房间地板中心、左墙门口、后墙窗户、最前面的柜台这些有内容的点都在多边形内; +// - 2 倍缩放的顶点恰好是 1 倍的两倍(窗口区域按设备像素给)。 +TEST(DesktopPetLayout, HitPolygonCoversRoomAndLeavesCornersOpen) { + const auto polygon = desktop_pet_hit_polygon(1); + ASSERT_EQ(polygon.size(), 6u); + for (const auto& point : polygon) { + EXPECT_GE(point.x, 0); + EXPECT_LE(point.x, kDesktopPetSceneWidth); + EXPECT_GE(point.y, 0); + EXPECT_LE(point.y, kDesktopPetSceneHeight); + } + + EXPECT_FALSE(inside(polygon, 2, 2)) << "左上角是透明草地,不该接点击"; + EXPECT_FALSE(inside(polygon, 340, 2)) << "右上角是透明草地"; + EXPECT_FALSE(inside(polygon, 2, 248)) << "左下角是透明草地"; + EXPECT_FALSE(inside(polygon, 340, 248)) << "右下角是透明草地"; + + EXPECT_TRUE(inside(polygon, 172, 162)) << "地板中间"; + EXPECT_TRUE(inside(polygon, 30, 142)) << "左墙上的门"; + EXPECT_TRUE(inside(polygon, 240, 92)) << "后墙的窗户"; + EXPECT_TRUE(inside(polygon, 188, 237)) << "房间最前面的柜台"; + + const auto doubled = desktop_pet_hit_polygon(2); + ASSERT_EQ(doubled.size(), polygon.size()); + for (std::size_t i = 0; i < polygon.size(); ++i) { + EXPECT_EQ(doubled[i].x, polygon[i].x * 2); + EXPECT_EQ(doubled[i].y, polygon[i].y * 2); + } +} + +// 场景 6:缩放上下限。 +// 触发:96 DPI、1920×1040 工作区,分别请求 0.1 倍、10 倍、2.3 倍和 NaN。 +// 期望:下限 = 逻辑 0.5 倍(96 DPI 下就是 0.5);上限 = min(1920×0.9/344, 1040×0.9/252), +// 这里高度先顶到(约 4.2545);中间值原样返回;NaN 当成下限,不能让窗口尺寸变成非法值。 +// 192 DPI 时下限跟着变成 1.0(仍是逻辑 0.5 倍,172 逻辑像素宽)。 +TEST(DesktopPetLayout, ClampKeepsScaleBetweenHalfAndNinetyPercent) { + EXPECT_DOUBLE_EQ(clamp_desktop_pet_scale(0.1, 96, kFullHd), 0.5); + EXPECT_NEAR(clamp_desktop_pet_scale(10.0, 96, kFullHd), 1040 * 0.9 / 252, 1e-9); + EXPECT_DOUBLE_EQ(clamp_desktop_pet_scale(2.3, 96, kFullHd), 2.3); + EXPECT_DOUBLE_EQ(clamp_desktop_pet_scale(std::nan(""), 96, kFullHd), 0.5); + EXPECT_DOUBLE_EQ(clamp_desktop_pet_scale(0.2, 192, {0, 0, 3840, 2080}), 1.0); +} + +// 场景 7:用户调过大小(逻辑 1.25 倍)后在 144 DPI 下摆放。 +// 期望:设备倍数 = 1.25 × 144/96 = 1.875,窗口 645×473(472.5 四舍五入), +// 仍贴右下角,边距 12px;请求超大时被夹到上限,高度正好是工作区的 90%(936)。 +TEST(DesktopPetLayout, PreferredLogicalScaleFollowsDpi) { + const auto placement = place_desktop_pet(kFullHd, 144, 1.25); + EXPECT_DOUBLE_EQ(placement.scale, 1.875); + EXPECT_EQ(placement.window.width, 645); + EXPECT_EQ(placement.window.height, 473); + EXPECT_EQ(placement.window.x, 1920 - 645 - 12); + EXPECT_EQ(placement.window.y, 1040 - 473 - 12); + + const auto huge = place_desktop_pet(kFullHd, 96, 20.0); + EXPECT_EQ(huge.window.height, 936); + EXPECT_LE(huge.window.width, 1920); +} + +// 场景 8:以锚点缩放。 +// 触发:窗口 {1000, 500, 344×252} 放大到 2 倍。 +// 期望:锚点 (1, 1)(停靠右下角时)→ 右下角 (1344, 720) 不动,新窗口左上角 (656, 280); +// 锚点 (0.5, 0.5)(鼠标在正中滚轮)→ 中心 (1172, 610) 不动,左上角 (828, 390); +// 锚点 (0, 0) 但右下方放不下 → 整个窗口推回工作区内,不会有一半跑出屏幕。 +TEST(DesktopPetLayout, ScaleAroundAnchorKeepsPointFixed) { + const DesktopPetRect current{1000, 500, 344, 252}; + const auto corner = scale_desktop_pet(current, 2.0, 1.0, 1.0, kFullHd); + EXPECT_EQ(corner.window.width, 688); + EXPECT_EQ(corner.window.height, 504); + EXPECT_EQ(corner.window.x, 656); + EXPECT_EQ(corner.window.y, 248); + + const auto center = scale_desktop_pet(current, 2.0, 0.5, 0.5, kFullHd); + EXPECT_EQ(center.window.x, 828); + EXPECT_EQ(center.window.y, 374); + + const auto pushed = scale_desktop_pet({1500, 700, 344, 252}, 2.0, 0.0, 0.0, kFullHd); + EXPECT_EQ(pushed.window.x, 1920 - 688); + EXPECT_EQ(pushed.window.y, 1040 - 504); +} + +// 场景 9:小数倍缩放(1.5 倍)时的命中区域。 +// 期望:每个顶点等于 1 倍顶点 × 1.5 后四舍五入,窗口区域和画面按同一比例对齐。 +TEST(DesktopPetLayout, HitPolygonSupportsFractionalScale) { + const auto base = desktop_pet_hit_polygon(1.0); + const auto scaled = desktop_pet_hit_polygon(1.5); + ASSERT_EQ(scaled.size(), base.size()); + for (std::size_t i = 0; i < base.size(); ++i) { + EXPECT_EQ(scaled[i].x, static_cast(std::lround(base[i].x * 1.5))); + EXPECT_EQ(scaled[i].y, static_cast(std::lround(base[i].y * 1.5))); + } +} diff --git a/tests/session/session_activity_state_test.cpp b/tests/session/session_activity_state_test.cpp new file mode 100644 index 000000000..ffb4066da --- /dev/null +++ b/tests/session/session_activity_state_test.cpp @@ -0,0 +1,73 @@ +#include +#include "session/event_dispatcher.hpp" +#include "session/session_activity_state.hpp" + +using namespace acecode; +using nlohmann::json; + +TEST(SessionActivityState, CompletionSurvivesReplayEvictionAndNewTurnWakes) { + EventDispatcher events(1); + events.emit(SessionEventKind::BusyChanged, {{"busy", true}, {"turn_id", "first"}}); + events.emit(SessionEventKind::Done, {{"outcome", "completed"}}); + for (int i = 0; i < 5; ++i) events.emit(SessionEventKind::Token, {{"text", "x"}}); + EXPECT_EQ(events.activity_snapshot()["outcome"], "completed"); + EXPECT_FALSE(events.activity_snapshot()["busy"].get()); + events.emit(SessionEventKind::BusyChanged, {{"busy", true}, {"turn_id", "next"}}); + EXPECT_EQ(events.activity_snapshot()["outcome"], ""); + EXPECT_TRUE(events.activity_snapshot()["busy"].get()); +} + +TEST(SessionActivityState, PendingInteractionsOutrankOtherActivityUntilAllClose) { + EventDispatcher events; + events.emit(SessionEventKind::BusyChanged, {{"busy", true}}); + events.emit(SessionEventKind::ToolStart, {{"tool", "bash"}, {"tool_call_id", "t"}}); + events.emit(SessionEventKind::PermissionRequest, {{"request_id", "one"}}); + events.emit(SessionEventKind::PermissionRequest, {{"request_id", "two"}}); + events.emit(SessionEventKind::AgentProgress, {{"phase", "thinking"}, {"label", "Thinking"}}); + events.emit(SessionEventKind::PermissionClosed, {{"request_id", "one"}}); + EXPECT_EQ(events.activity_snapshot()["phase"], "permission_waiting"); + events.emit(SessionEventKind::QuestionRequest, {{"request_id", "q"}}); + events.emit(SessionEventKind::PermissionClosed, {{"request_id", "two"}}); + EXPECT_EQ(events.activity_snapshot()["phase"], "question_waiting"); + events.emit(SessionEventKind::QuestionClosed, {{"request_id", "q"}}); + EXPECT_EQ(events.activity_snapshot()["phase"], "tool_running"); + events.emit(SessionEventKind::ToolEnd, {{"tool", "bash"}, {"tool_call_id", "t"}, {"output", "private output"}}); + EXPECT_EQ(events.activity_snapshot()["tool"], ""); + EXPECT_EQ(events.activity_snapshot().dump().find("private output"), std::string::npos); +} + +TEST(SessionActivityState, CancellationAndFailureAreNeverSuccessfulCompletion) { + for (const auto* outcome : {"aborted", "error"}) { + EventDispatcher events; + events.emit(SessionEventKind::BusyChanged, {{"busy", true}}); + events.emit(SessionEventKind::BusyChanged, {{"busy", false}, {"outcome", outcome}}); + EXPECT_EQ(events.activity_snapshot()["outcome"], outcome); + } + EventDispatcher events; + events.emit(SessionEventKind::BusyChanged, {{"busy", false}}); + EXPECT_EQ(events.activity_snapshot()["outcome"], ""); +} + +TEST(SessionActivityState, TransientProgressAndCompactionAreSnapshotState) { + EventDispatcher events; + EventDispatcher::EmitOptions transient; + transient.buffered = false; + events.emit(SessionEventKind::AgentProgress, {{"phase", "compacting"}}, transient); + EXPECT_EQ(events.activity_snapshot()["phase"], "compacting"); + events.emit(SessionEventKind::Message, {{"metadata", {{"compact_notice_id", "c1"}, {"compact_notice_complete", false}}}}); + EXPECT_EQ(events.activity_snapshot()["compact_id"], ""); + events.emit(SessionEventKind::Message, {{"metadata", {{"compact_notice_id", "c1"}, {"compact_notice_complete", true}}}}); + EXPECT_EQ(events.activity_snapshot()["compact_id"], "c1"); +} + +TEST(SessionActivityState, MeshTransfersRetainIdentityWithoutPrivateBodyAndAreBounded) { + EventDispatcher events; + for (int i = 0; i < 20; ++i) events.emit(SessionEventKind::Message, { + {"content", "private content"}, + {"metadata", {{"inter_agent", {{"type", "MESSAGE"}, {"sender", "/root/a"}, + {"recipient", "/root/b"}, {"sender_session_id", "child-a"}}}}}}); + const auto snapshot = events.activity_snapshot(); + ASSERT_EQ(snapshot["transfers"].size(), 16u); + EXPECT_EQ(snapshot["transfers"].back()["sender_session_id"], "child-a"); + EXPECT_EQ(snapshot.dump().find("private content"), std::string::npos); +} diff --git a/tests/session/session_recent_activity_test.cpp b/tests/session/session_recent_activity_test.cpp new file mode 100644 index 000000000..8be4e11aa --- /dev/null +++ b/tests/session/session_recent_activity_test.cpp @@ -0,0 +1,142 @@ +#include +#include "session/session_recent_activity.hpp" +#include "session/session_manager.hpp" +#include "session/session_storage.hpp" +#include "session/global_session_search.hpp" +#include "session/turn_timing.hpp" +#include "utils/cwd_hash.hpp" +#include "utils/uuid.hpp" +#include "test_support/memory/memory_test_home.hpp" +#include +#include +#include +#include + +using namespace acecode; +namespace fs = std::filesystem; +namespace { +class RecentFixture : public testing::Test { +protected: + acecode_test::MemoryTestHome home{"office-recency"}; + fs::path root = home.root() / "catalog"; + void SetUp() override { fs::create_directories(root); } + void TearDown() override { std::error_code ec; fs::remove_all(root, ec); } +}; +ChatMessage input(const char* time, const char* text = "hello") { + ChatMessage result; result.role = "user"; result.content = text; + result.timestamp = time; result.uuid = generate_uuid(); return result; +} +} + +TEST_F(RecentFixture, ReverseReadIgnoresBackgroundMessagesAndDamagedTail) { + const auto file = (root / "session.jsonl").string(); + auto user = input("2026-10-06T09:10:00Z"); + ASSERT_TRUE(SessionStorage::append_message(file, user)); + auto internal = input("2026-10-06T09:11:00Z"); + internal.metadata = {{"hidden_goal_context", true}}; + ASSERT_TRUE(SessionStorage::append_message(file, internal)); + auto mesh = input("2026-10-06T09:12:00Z"); + mesh.metadata = {{"inter_agent", {{"type", "MESSAGE"}, {"sender", "/root/a"}, {"recipient", "/root"}}}}; + ASSERT_TRUE(SessionStorage::append_message(file, mesh)); + ASSERT_TRUE(SessionStorage::append_message(file, make_turn_timing_message( + {user.uuid, 1, 2, 1, "completed"}, "2026-10-06T09:13:00Z"))); + std::ofstream(file, std::ios::app) << "{broken\n"; + auto automatic = input("2026-10-06T09:14:00Z"); + automatic.metadata = {{"source_tool", "tool_search"}}; + ASSERT_TRUE(SessionStorage::append_message(file, automatic)); + const auto recent = read_session_recent_activity(file); + EXPECT_EQ(recent.last_user_message_at, user.timestamp); + EXPECT_EQ(recent.last_turn_outcome, "completed"); + auto next = input("2026-10-06T10:00:00Z"); + ASSERT_TRUE(SessionStorage::append_message(file, next)); + EXPECT_TRUE(read_session_recent_activity(file).last_turn_outcome.empty()); +} + +TEST_F(RecentFixture, ManagerPersistsOnlyHumanRecencyAndKeepsItOnResume) { + SessionManager manager; + manager.start_session(root.string(), "test", "test"); + const auto user = input("2026-10-06T09:10:00Z"); + manager.on_message(user); + const auto id = manager.current_session_id(); + const auto directory = manager.current_project_dir(); + const auto metaPath = SessionStorage::meta_path(directory, id); + auto reply = user; reply.role = "assistant"; reply.timestamp = "2026-10-06T10:00:00Z"; + manager.on_message(reply); + manager.set_session_title("renamed"); + EXPECT_EQ(SessionStorage::read_meta(metaPath).last_user_message_at, user.timestamp); + manager.on_message(make_turn_timing_message({user.uuid,1,2,1,"aborted"},reply.timestamp)); + EXPECT_EQ(SessionStorage::read_meta(metaPath).last_turn_outcome, "aborted"); + manager.resume_session(id); + EXPECT_EQ(manager.display_snapshot().last_user_message_at, user.timestamp); + EXPECT_EQ(manager.display_snapshot().last_turn_outcome, "aborted"); + manager.on_message(input("2026-10-06T11:00:00Z")); + EXPECT_EQ(SessionStorage::read_meta(metaPath).last_user_message_at, "2026-10-06T11:00:00Z"); + EXPECT_TRUE(SessionStorage::read_meta(metaPath).last_turn_outcome.empty()); + manager.finalize(); + std::error_code ec; fs::remove_all(directory, ec); +} + +TEST_F(RecentFixture, GlobalFiveUseUserTimeAcrossProjectsAndExcludeChildrenAndArchive) { + for (int i = 0; i < 8; ++i) { + const auto directory = root / (i % 2 == 0 ? "project-a" : "project-b"); + fs::create_directories(directory); + SessionMeta meta; + meta.id = "20261006-10000" + std::to_string(i) + "-abcd"; + meta.cwd = directory.string(); meta.created_at = "2026-10-06T00:00:00Z"; + meta.updated_at = "2026-10-06T23:00:0" + std::to_string(9-i) + "Z"; + meta.last_user_message_at = "2026-10-06T10:00:0" + std::to_string(i) + "Z"; + if (i == 7) meta.archived = true; + if (i == 6) meta.parent_session_id = "parent"; + // One legacy entry must derive its real input time from JSONL. + const auto message = input(meta.last_user_message_at.c_str()); + ASSERT_TRUE(SessionStorage::append_message(SessionStorage::session_path(directory.string(),meta.id),message)); + if (i == 5) meta.last_user_message_at.clear(); + ASSERT_TRUE(SessionStorage::write_meta(SessionStorage::meta_path(directory.string(),meta.id),meta)); + } + GlobalSessionSearchService service(root.string()); + service.start(); service.notify_startup_interaction(); + GlobalSessionSearchPage result; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + do { + result = service.recent_user_sessions(); + if (result.progress.complete) break; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } while (std::chrono::steady_clock::now() < deadline); + service.stop(); + ASSERT_TRUE(result.progress.complete); + ASSERT_EQ(result.entries.size(),5u); + EXPECT_EQ(result.entries.front().meta.id,"20261006-100005-abcd"); + EXPECT_EQ(result.entries.back().meta.id,"20261006-100001-abcd"); +} + +TEST_F(RecentFixture, ForkUsesRetainedInputTimeWithoutPretendingItWasSentNow) { + SessionManager manager; + manager.start_session(root.string(), "test", "test"); + const auto first = input("2026-10-06T09:10:00Z"); + const auto second = input("2026-10-06T10:00:00Z"); + manager.on_message(first); manager.on_message(second); + const auto source = manager.current_session_id(); + const auto fork = manager.fork_session_to_new_id({first}, "branch", source, first.uuid); + ASSERT_FALSE(fork.empty()); + const auto meta = SessionStorage::read_meta(SessionStorage::meta_path(manager.current_project_dir(), fork)); + EXPECT_EQ(meta.last_user_message_at, first.timestamp); + EXPECT_EQ(manager.current_session_id(), source); + EXPECT_EQ(manager.display_snapshot().last_user_message_at, second.timestamp); + const auto active_fork = manager.fork_active_session({first}); + ASSERT_FALSE(active_fork.empty()); + EXPECT_EQ(manager.display_snapshot().last_user_message_at, first.timestamp); +} + +TEST_F(RecentFixture, InternalTurnOutcomePersistsWithoutChangingHumanRecency) { + SessionManager manager; + manager.start_session(root.string(), "test", "test"); + auto internal = input("2026-10-06T10:00:00Z"); + internal.metadata = {{"inter_agent", {{"type", "MESSAGE"}, {"sender", "/root"}, {"recipient", "/root/a"}}}}; + manager.on_message(internal); + manager.record_turn_outcome("completed"); + EXPECT_TRUE(manager.display_snapshot().last_user_message_at.empty()); + EXPECT_EQ(manager.display_snapshot().last_turn_outcome, "completed"); + const auto id = manager.current_session_id(); + manager.resume_session(id); + EXPECT_EQ(manager.display_snapshot().last_turn_outcome, "completed"); +} diff --git a/tests/web/desktop_office_route_test.cpp b/tests/web/desktop_office_route_test.cpp new file mode 100644 index 000000000..fb5628309 --- /dev/null +++ b/tests/web/desktop_office_route_test.cpp @@ -0,0 +1,155 @@ +#include +#include +#include "web/server.hpp" +#include "desktop/daemon_supervisor.hpp" +#include "workspace/workspace_registry.hpp" +#include "session_host/local_session_client.hpp" +#include "session_host/session_registry.hpp" +#include "test_support/agent/stub_provider.hpp" +#include "test_support/memory/memory_test_home.hpp" +#include "utils/joining_thread.hpp" +#include "utils/cwd_hash.hpp" +#include "utils/paths.hpp" +#include +#include +#include + +using namespace acecode; +using nlohmann::json; +using namespace std::chrono_literals; +namespace { +class DesktopOfficeHttp : public testing::Test { +protected: + acecode_test::MemoryTestHome home{"office-http"}; + std::string cwd = home.workspace_cwd("main"); + std::string projects = (home.root() / ".acecode" / "projects").string(); + AppConfig cfg; + ToolExecutor tools; + desktop::WorkspaceRegistry workspaces; + std::unique_ptr registry; + std::unique_ptr client; + std::unique_ptr server; + JoiningThread worker; // Joined before the server and its dependencies disappear. + + void SetUp() override { + cfg.web.bind = "127.0.0.1"; + cfg.web.port = desktop::pick_free_loopback_port(); + workspaces.register_new(projects, cwd); + SessionRegistryDeps sessions; + sessions.cwd = cwd; sessions.tools = &tools; + // Shared with the registry's provider accessor for the fixture lifetime. + auto provider = std::make_shared(); + provider->push_text("complete"); + sessions.provider_accessor = [provider] { return provider; }; + registry = std::make_unique(std::move(sessions)); + client = std::make_unique(*registry); + web::WebServerDeps deps; + deps.web_cfg = &cfg.web; deps.app_config = &cfg; deps.cwd = cwd; + deps.config_path = (home.root() / "config.json").string(); + deps.sync_model_reasoning = false; deps.token = "office-fixture"; + deps.projects_dir = projects; deps.workspace_registry = &workspaces; + deps.session_client = client.get(); deps.session_registry = registry.get(); + server = std::make_unique(std::move(deps)); + worker = JoiningThread([this] { server->run(); }); + const auto deadline = std::chrono::steady_clock::now() + 3s; + while (std::chrono::steady_clock::now() < deadline) { + if (cpr::Get(cpr::Url{url("/api/health")}, cpr::Timeout{200}).status_code == 200) return; + std::this_thread::sleep_for(10ms); + } + FAIL() << "office HTTP server did not start"; + } + void TearDown() override { + if (server) server->stop(); + worker.join(); server.reset(); client.reset(); registry.reset(); + } + std::string url(const std::string& path) const { + return "http://127.0.0.1:" + std::to_string(cfg.web.port) + path; + } + json snapshot(const std::string& id = {}) { + json result; + const auto deadline = std::chrono::steady_clock::now() + 3s; + do { + const auto response = cpr::Get(cpr::Url{url("/api/desktop-office")}, + cpr::Parameters{{"session", id}}, cpr::Timeout{2000}); + EXPECT_EQ(response.status_code, 200) << response.text; + if (response.status_code != 200) return {}; + result = json::parse(response.text); + if (result.value("complete", false)) return result; + std::this_thread::sleep_for(10ms); + } while (std::chrono::steady_clock::now() < deadline); + ADD_FAILURE() << "office catalog did not settle"; + return result; + } + std::string active(const std::string& parent = {}, bool no_workspace = false) { + SessionOptions opts; + opts.cwd = cwd; opts.parent_session_id = parent; opts.no_workspace = no_workspace; + if (!parent.empty()) opts.subagent_depth = 1; + const auto id = registry->create(opts); + auto entry = registry->acquire(id); + ChatMessage message; message.role = "user"; message.content = "test input"; + message.timestamp = "2026-10-06T10:00:00Z"; + entry->sm->on_message(message); + return id; + } +}; +} // namespace + +TEST_F(DesktopOfficeHttp, EmptyAndAuthenticationAndMissingSession) { + const auto empty = snapshot(); + ASSERT_TRUE(empty.contains("offices")); + EXPECT_TRUE(empty["offices"].empty()); + EXPECT_TRUE(empty["selected"].is_null()); + EXPECT_TRUE(empty["agents"].empty()); + const auto denied = cpr::Get(cpr::Url{url("/api/desktop-office")}, + cpr::Header{{"Origin", "https://untrusted.invalid"}}); + EXPECT_NE(denied.status_code, 200); + const auto missing = cpr::Get(cpr::Url{url("/api/desktop-office?session=missing")}); + EXPECT_EQ(missing.status_code, 404); + EXPECT_EQ(cpr::Options(cpr::Url{url("/api/desktop-office")}).status_code, 204); +} + +TEST_F(DesktopOfficeHttp, SnapshotCarriesRealRuntimeAndRoutesMeshChildToRoot) { + const auto root = active(); + const auto child = active(root); + auto entry = registry->acquire(root); + auto worker_entry = registry->acquire(child); + entry->loop->events().emit(SessionEventKind::BusyChanged, {{"busy", true}, {"turn_id", "root-turn"}}); + worker_entry->loop->events().emit(SessionEventKind::BusyChanged, {{"busy", true}}); + worker_entry->loop->events().emit(SessionEventKind::ToolStart, {{"tool", "file_read"}, {"tool_call_id", "read-1"}}); + worker_entry->loop->events().emit(SessionEventKind::PermissionRequest, {{"request_id", "permission-1"}}); + auto result = snapshot(child); + ASSERT_EQ(result["selected"]["id"], root); + ASSERT_EQ(result["offices"].size(), 1u); + ASSERT_EQ(result["agents"].size(), 2u); + EXPECT_EQ(result["agents"][1]["activity"]["phase"], "permission_waiting"); + EXPECT_EQ(result["agents"][1]["activity"]["tool"], "file_read"); + entry->loop->events().emit(SessionEventKind::Done, {{"outcome", "completed"}}); + worker_entry->loop->events().emit(SessionEventKind::Done, {{"outcome", "aborted"}}); + result = snapshot(root); + EXPECT_EQ(result["selected"]["activity"]["outcome"], "completed"); + EXPECT_EQ(result["agents"][1]["activity"]["outcome"], "aborted"); + EXPECT_FALSE(result["selected"].contains("input_draft")); + EXPECT_FALSE(result["selected"].contains("session_path")); +} + +TEST_F(DesktopOfficeHttp, HiddenWorkspaceAndNoWorkspaceAreSelectable) { + const auto hidden_cwd = home.workspace_cwd("not-in-sidebar"); + const auto directory = SessionStorage::get_project_dir(hidden_cwd); + std::filesystem::create_directories(directory); + SessionMeta meta; + meta.id = "20261006-110000-aabb"; meta.cwd = hidden_cwd; meta.title = "Hidden office"; + meta.created_at = "2026-10-06T11:00:00Z"; meta.updated_at = meta.created_at; + meta.last_user_message_at = meta.created_at; meta.last_turn_outcome = "completed"; + meta.last_token_usage.prompt_tokens = 1200; meta.last_token_usage.has_data = true; + ASSERT_TRUE(SessionStorage::write_meta(SessionStorage::meta_path(directory, meta.id), meta)); + auto result = snapshot(); + ASSERT_EQ(result["offices"].size(), 1u); + EXPECT_EQ(result["selected"]["id"], meta.id); + EXPECT_EQ(result["selected"]["token_usage"]["prompt_tokens"], 1200); + EXPECT_EQ(result["selected"]["last_turn_outcome"], "completed"); + const auto unbound = active({}, true); + result = snapshot(unbound); + EXPECT_EQ(result["selected"]["id"], unbound); + EXPECT_EQ(result["selected"]["workspace_hash"], ""); + EXPECT_TRUE(result["selected"]["no_workspace"].get()); +} diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index 45d622a78..81c4b88f0 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -1,6 +1,12 @@ // Human-reviewed product terminology and phrases where generic machine // translation is ambiguous without ACECode context. export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ + '待命': 'Standing by', + '等待授权': 'Waiting for approval', + '等待回答': 'Waiting for an answer', + '整理上下文': 'Compacting context', + '正在重试': 'Retrying', + '思考中': 'Thinking', '选择文件': 'Select file', '无法读取目录': 'Unable to read directory', '空目录': 'Empty directory', diff --git a/web/scripts/test-desktop-office.mjs b/web/scripts/test-desktop-office.mjs new file mode 100644 index 000000000..20c1bdac6 --- /dev/null +++ b/web/scripts/test-desktop-office.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { toolIconSvg } from '../src/lib/toolIcons.js'; +const modulePath = process.env.ACE_PLAYWRIGHT_MODULE; +const { chromium } = await import(modulePath ? pathToFileURL(path.resolve(modulePath)).href : 'playwright'); +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = process.env.ACE_OFFICE_CAPTURE_DIR || fs.mkdtempSync(path.join(os.tmpdir(),'ace-office-browser-')); +fs.mkdirSync(output,{recursive:true}); +const browser = await chromium.launch({channel:process.env.ACE_BROWSER_CHANNEL || 'msedge',headless:true}); +const errors=[],external=[]; +const actor=(id,extra={})=>({id,root:id==='root',name:id==='root'?'Maestro':id,state:'work',label:'工作中',busy:true,seed:id.length*173,contextKnown:true,contextRatio:.7,contextTokens:7000,contextLimit:10000,transfers:[],...extra}); +const data=(extra={})=>({version:1,follow:true,connected:true,complete:true,seed:137, + selected:{sessionId:'root',workspaceHash:'project',title:'修复登录流程'}, + offices:['修复登录流程','研究消息分发','整理设计文档','检查任务取消','验证桌面发布'].map((title,i)=>({sessionId:i?'root'+i:'root',workspaceHash:'project',title,workspaceName:'ACECode'})), + agents:[actor('root',{tool:'file_read',iconSvg:toolIconSvg('file_read')}),actor('代码审查',{state:'think',label:'思考中'}),actor('回归测试',{state:'permission',label:'等待授权'})],overflow:0,...extra}); +try { + const page=await browser.newPage({viewport:{width:688,height:504},deviceScaleFactor:1}); + page.on('pageerror',e=>errors.push(e.message));page.on('request',r=>{if(/^https?:/.test(r.url()))external.push(r.url());}); + await page.addInitScript(()=>{window.officeActions=[];window.chrome={webview:{postMessage:value=>officeActions.push(value),addEventListener:(type,listener)=>{window.hostMessage=listener;}}};}); + await page.goto(pathToFileURL(path.join(root,'assets/desktop_pet/agent_office_pet.html')).href); + await page.waitForFunction(()=>!!window.AgentOffice&&window.officeActions.includes('ready')); + assert.equal(await page.evaluate(()=>AgentOffice.agents.length),0,'production starts without demo workers'); + const apply=async snapshot=>{await page.evaluate(value=>window.hostMessage({data:value}),snapshot);await page.waitForTimeout(90);}; + await apply(data()); + assert.equal(await page.locator('.office-tab').count(),5); + assert.equal(await page.locator('#officeFollow').getAttribute('aria-checked'),'true'); + assert.equal(await page.evaluate(()=>AgentOffice.agents.length),3); + const originalLayout=await page.evaluate(()=>AgentOffice.layoutSignature); + const look=await page.evaluate(()=>AgentOffice.agents.find(a=>a.id==='代码审查').look); + await page.locator('.office-tab').nth(1).click(); + assert.ok(await page.evaluate(()=>officeActions.some(raw=>{try{return JSON.parse(raw).type==='select'&&JSON.parse(raw).session_id==='root1';}catch{return false;}}))); + await page.locator('#officeFollow').click(); + assert.ok(await page.evaluate(()=>officeActions.some(raw=>{try{return JSON.parse(raw).type==='follow'&&JSON.parse(raw).follow===false;}catch{return false;}}))); + await page.locator('.office-tab').nth(2).focus(); + await page.keyboard.press('Enter'); + assert.ok(await page.evaluate(()=>officeActions.some(raw=>{try{const m=JSON.parse(raw);return m.type==='select'&&m.session_id==='root2';}catch{return false;}}))); + await page.screenshot({path:path.join(output,'office-working-688.png')}); + // Completion removes workers immediately while the main agent displays zzz. + await apply(data({agents:[actor('root',{state:'sleep',label:'zzz',busy:false,contextRatio:.15})],completed:['代码审查','回归测试']})); + assert.deepEqual(await page.evaluate(()=>AgentOffice.agents.map(a=>a.state)),['sleep']); + await page.screenshot({path:path.join(output,'office-sleep-688.png')}); + await apply(data()); + assert.equal(await page.evaluate(()=>AgentOffice.agents.find(a=>a.id==='代码审查').look),look); + assert.equal(await page.evaluate(()=>AgentOffice.layoutSignature),originalLayout); + const toolImage=await page.locator('.office-tool:not([hidden])').first().getAttribute('src'); + assert.equal(decodeURIComponent(toolImage.split(',')[1]),toolIconSvg('file_read').replaceAll('currentColor','#27324f')); + // Over-capacity members remain reachable, and the host receives their hit box. + const many=[actor('root'),...Array.from({length:9},(_,i)=>actor('成员'+i))]; + await apply(data({agents:many,overflow:2})); + assert.equal(await page.evaluate(()=>AgentOffice.agents.length),8); + await page.locator('#officeOverflow').click(); + assert.equal(await page.locator('#officeMembers button').count(),10); + assert.ok(await page.evaluate(()=>officeActions.some(raw=>{try{const m=JSON.parse(raw);return m.type==='overlay'&&m.rect?.[2]>m.rect?.[0];}catch{return false;}}))); + await apply(data({agents:many.map((a,i)=>i===9?{...a,state:'permission',label:'等待授权'}:a),overflow:2})); + assert.match(await page.locator('#officeMembers button').last().textContent(),/等待授权/); + await page.locator('#officeMembers button').last().click(); + assert.ok(await page.evaluate(()=>officeActions.some(raw=>{try{const m=JSON.parse(raw);return m.type==='open'&&m.session_id==='成员8';}catch{return false;}}))); + await page.locator('#officeOverflow').click(); + await apply(data({follow:false})); + assert.equal(await page.locator('#officeMembers').isHidden(),true); + assert.ok(await page.evaluate(()=>officeActions.some(raw=>{try{const m=JSON.parse(raw);return m.type==='overlay'&&m.rect===null;}catch{return false;}}))); + + // The narrowest native scale keeps all five controls and the follow switch clickable. + for(const [width,height] of [[172,126],[344,252],[860,630]]) { + await page.setViewportSize({width,height});await page.waitForTimeout(90); + const bounds=await page.locator('.office-controls button').evaluateAll(buttons=>buttons.map(b=>{const r=b.getBoundingClientRect();return {x:r.x,y:r.y,right:r.right,bottom:r.bottom};})); + assert.equal(bounds.length,6); + assert.ok(bounds.every(r=>r.x>=0&&r.y>=0&&r.right<=width&&r.bottom<=32*width/344+1),JSON.stringify({width,bounds})); + await page.screenshot({path:path.join(output,`office-size-${width}.png`)}); + } + await page.setViewportSize({width:688,height:504}); + await apply(data({connected:false})); + assert.match(await page.locator('#officeNotice').textContent(),/连接已中断/); + assert.ok(await page.evaluate(()=>{const notices=officeActions.map(raw=>{try{return JSON.parse(raw);}catch{return {};}}).filter(m=>m.type==='overlay');return notices.at(-1)?.rect?.[3]>notices.at(-1)?.rect?.[1];}),'native region includes disconnected notice'); + await apply(data({agents:[],offices:[],selected:null})); + assert.match(await page.locator('#officeNotice').textContent(),/发送消息后/); + assert.ok(await page.evaluate(()=>{const notices=officeActions.map(raw=>{try{return JSON.parse(raw);}catch{return {};}}).filter(m=>m.type==='overlay');return Array.isArray(notices.at(-1)?.rect);}), 'native region includes empty-state notice'); + assert.deepEqual(errors,[]);assert.deepEqual(external,[]); + console.log(JSON.stringify({output,checks:22,errors,external},null,2)); +} finally {await browser.close();} diff --git a/web/src/App.jsx b/web/src/App.jsx index b1ef39bc5..73b6d0c70 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1,3 +1,4 @@ +import { useDesktopOffice } from './lib/useDesktopOffice.js'; // 顶层 App:鉴权 gate(401 → TokenPrompt)+ 主壳。 // // 视觉对齐设计稿方向 C:顶部 41px TopBar + 270px Sidebar + 主区(单会话/4宫格/9宫格) @@ -250,6 +251,7 @@ export function App() { ); const [activeRef, setActiveRef] = useState(null); + useDesktopOffice(activeRef); const workbenchOwner = sessionWorkbench.ownerFor(activeRef); const [sessionTitleTarget, setSessionTitleTarget] = useState(null); const [sessionActionsTarget, setSessionActionsTarget] = useState(null); diff --git a/web/src/components/ActivityLine.jsx b/web/src/components/ActivityLine.jsx index 6b6b81ba1..6ecc6f247 100644 --- a/web/src/components/ActivityLine.jsx +++ b/web/src/components/ActivityLine.jsx @@ -13,6 +13,7 @@ export function ActivityLine({ icon = null, running = false, spinnerStatic = false, + preferIconWhileRunning = false, trailing = null, expandable = false, expanded = false, @@ -37,7 +38,7 @@ export function ActivityLine({ className="flex h-4 w-4 shrink-0 items-center justify-center" aria-hidden="true" > - {running ? ( + {running && !preferIconWhileRunning ? ( ) : icon} diff --git a/web/src/components/Icon.jsx b/web/src/components/Icon.jsx index ac5a23f48..bcb49d6af 100644 --- a/web/src/components/Icon.jsx +++ b/web/src/components/Icon.jsx @@ -1,4 +1,5 @@ import { createElement } from 'react'; +import { toolIconName } from '../lib/toolIcons.js'; import { fileTypeIconForPath } from '../lib/fileTypeIcons.js'; import { ICON_VIEW_BOX, INTERFACE_ICONS, iconStrokeWidth } from '../lib/interfaceIcons.js'; @@ -218,7 +219,8 @@ export function FileTypeIcon({ ); } -export function ToolSummaryIcon({ icon, ok, className = '' }) { +export function ToolSummaryIcon({ icon, tool = '', ok, className = '' }) { + if (tool) return ; if (!ok) return ; const mapped = TOOL_ICON_MAP.get(icon) || (ICONS[icon] ? icon : 'ok'); const statusIcon = mapped === 'ok' || mapped === 'warning' || mapped === 'error'; diff --git a/web/src/components/SubagentPanel.jsx b/web/src/components/SubagentPanel.jsx index 111921ebf..35904fdb8 100644 --- a/web/src/components/SubagentPanel.jsx +++ b/web/src/components/SubagentPanel.jsx @@ -43,7 +43,7 @@ import { taskStatsParts, taskStatusLabel, } from '../lib/subagentTasks.js'; -import { VsIcon } from './Icon.jsx'; +import { ToolSummaryIcon, VsIcon } from './Icon.jsx'; import { TranscriptItems } from './TranscriptItems.jsx'; import { AttachmentTextLoaderContext } from './AttachmentTextLoaderContext.jsx'; @@ -88,6 +88,7 @@ function TaskCard({ task, nowMs, onAbort, onOpenTranscript }) { {stats.length > 0 && (
+ {task.lastTool && } {stats.join(' · ')}
)} diff --git a/web/src/components/ToolBlock.jsx b/web/src/components/ToolBlock.jsx index f6272fcde..3dfc4861d 100644 --- a/web/src/components/ToolBlock.jsx +++ b/web/src/components/ToolBlock.jsx @@ -406,7 +406,7 @@ export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessio : 'submit'} > } + icon={} label={completedSummary.verb || title || tool || 'AskUserQuestion'} detail={completedSummary.object || ''} trailing={liveElapsed > 0 ? {formatElapsed(liveElapsed)} : null} @@ -438,7 +438,7 @@ export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessio className="ace-tool-activity min-w-0" > } + icon={} label={completedSummary.verb || title || tool || '工具完成'} detail={bashCommand || completedSummary.object || ''} trailing={( @@ -498,6 +498,8 @@ export const ToolBlock = memo(function ToolBlock({ entry, onReviewToggle, sessio > } spinnerStatic={!liveProgress} label={genericSummary.verb || title || displayOverride || tool || '正在执行工具'} detail={bashCommand || genericSummary.object || ''} diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index 367ad002e..b9b86c13e 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -252,6 +252,7 @@ export const sourceCatalogs = { "s_19cfa7d09d8dd287": "派遣中…", "s_1a0bf11a28779888": "选择后填入真实聊天框,不会自动发送", "s_1a16ea81ee6a29e4": "网站无法完成跳转,请稍后重试。", + "s_1a474c3207ed49f4": "待命", "s_1a4c6ebd7bbdbaa8": "还没有填写能力介绍。", "s_1a52bda1e037f2c3": "执行、编程或处理任务时使用", "s_1a7224bf1e427ee4": "分叉失败:无 session_id", @@ -641,6 +642,7 @@ export const sourceCatalogs = { "s_4382c23e9e50479f": "Agent 已完成计划,请求批准后退出 Plan 模式。", "s_43911903e1cf7e63": "已复制预览内容", "s_43cad95bdcdcc30d": "升级失败", + "s_43ce458cae49a1ad": "思考中", "s_43d7d2b51d75fa42": "创建项目", "s_43db2741a745b179": "启用服务器", "s_43f3dc12a6077835": "正在启动…", @@ -935,6 +937,7 @@ export const sourceCatalogs = { "s_6258a2c6f9c89143": "复制全文", "s_62731d95bc805803": "正在导出…", "s_629bb52ce0e5270f": "已将 {{p0}} 添加到聊天", + "s_62b72265c96e5e34": "正在重试", "s_62c850eabc4ba70e": "部分搜索数据加载失败:", "s_6309a3bb5ba4c714": "保存失败", "s_63448df0ea0c5be2": "插话中断", @@ -1091,6 +1094,7 @@ export const sourceCatalogs = { "s_73e5df48000d6a22": "选择任务位置", "s_73fab2d02ab591cb": "模型检测失败{{p0}}:{{p1}}{{p2}}", "s_7409a608060f5804": "未指定", + "s_741a285fa14f8523": "整理上下文", "s_742025b1a0eccc8c": "类型无效", "s_743342489c23fe24": "刷新记忆列表", "s_744a61203be24c0b": "每 {{p0}} {{p1}}", @@ -1148,6 +1152,7 @@ export const sourceCatalogs = { "s_78e5c1be3c1a2347": "壶铃", "s_78ea07a31cd96f65": "从此版本开始记录新的 token 用量,历史会话不会回填", "s_79167440a990362e": "会话已导出:{{p0}}", + "s_79209170acff4896": "等待授权", "s_79383e9981843085": "读取 Web 核心信息", "s_7948c26c9630a0cc": "资深开发工程师", "s_79612d07708f27c8": "平台状态", @@ -2233,6 +2238,7 @@ export const sourceCatalogs = { "s_ed100f18801746c9": "命令规则已保存", "s_ed147f03761ab80e": "将元素添加到聊天", "s_ed150cce119ea114": "已记住 Git Bash 路径", + "s_ed16bd1db4b68498": "等待回答", "s_ed1790d489e671a9": "ACECode 本地工具继承全局", "s_ed21620162d871e5": "加载会话记录…", "s_ed2f18de3cdc1c5f": "关于 ACECode", @@ -2687,6 +2693,7 @@ export const sourceCatalogs = { "s_19cfa7d09d8dd287": "Dispatching…", "s_1a0bf11a28779888": "Fill in the real chat box after selection, it will not be sent automatically", "s_1a16ea81ee6a29e4": "The site could not complete the redirect. Try again later.", + "s_1a474c3207ed49f4": "Standing by", "s_1a4c6ebd7bbdbaa8": "No competency introduction has been filled in yet.", "s_1a52bda1e037f2c3": "Used while executing, coding, or handling a task", "s_1a7224bf1e427ee4": "Fork failed: No session_id", @@ -3076,6 +3083,7 @@ export const sourceCatalogs = { "s_4382c23e9e50479f": "The Agent has completed the plan and exits Plan mode after requesting approval.", "s_43911903e1cf7e63": "Preview content copied", "s_43cad95bdcdcc30d": "Upgrade failed", + "s_43ce458cae49a1ad": "Thinking", "s_43d7d2b51d75fa42": "Create project", "s_43db2741a745b179": "Enable server", "s_43f3dc12a6077835": "Starting...", @@ -3370,6 +3378,7 @@ export const sourceCatalogs = { "s_6258a2c6f9c89143": "Copy all", "s_62731d95bc805803": "Exporting…", "s_629bb52ce0e5270f": "Added {{p0}} to chat", + "s_62b72265c96e5e34": "Retrying", "s_62c850eabc4ba70e": "Partial search data failed to load:", "s_6309a3bb5ba4c714": "Save failed", "s_63448df0ea0c5be2": "Interjection interrupted", @@ -3526,6 +3535,7 @@ export const sourceCatalogs = { "s_73e5df48000d6a22": "Choose task location", "s_73fab2d02ab591cb": "Model test failed {{p0}}: {{p1}}{{p2}}", "s_7409a608060f5804": "not specified", + "s_741a285fa14f8523": "Compacting context", "s_742025b1a0eccc8c": "Invalid type", "s_743342489c23fe24": "Refresh memories", "s_744a61203be24c0b": "per {{p0}}{{p1}}", @@ -3583,6 +3593,7 @@ export const sourceCatalogs = { "s_78e5c1be3c1a2347": "Kettlebell", "s_78ea07a31cd96f65": "Starting from this version, new token usage will be recorded, and historical sessions will not be backfilled.", "s_79167440a990362e": "Session exported:{{p0}}", + "s_79209170acff4896": "Waiting for approval", "s_79383e9981843085": "Read web core information", "s_7948c26c9630a0cc": "Senior development engineer", "s_79612d07708f27c8": "Platform status", @@ -4668,6 +4679,7 @@ export const sourceCatalogs = { "s_ed100f18801746c9": "Command rules saved", "s_ed147f03761ab80e": "Add element to chat", "s_ed150cce119ea114": "Git Bash path memorized", + "s_ed16bd1db4b68498": "Waiting for an answer", "s_ed1790d489e671a9": "Inherit global ACECode local tools", "s_ed21620162d871e5": "Loading session records…", "s_ed2f18de3cdc1c5f": "About ACECode", diff --git a/web/src/lib/api.js b/web/src/lib/api.js index a25f97521..c0ded40d6 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -616,6 +616,10 @@ export function createApi(base = null) { ); }, refreshModelCatalog: () => request('POST', '/api/models/catalog/refresh', {}, base), + getDesktopOffice: ({sessionId = '', workspaceHash = ''} = {}, {signal} = {}) => { + const query = new URLSearchParams({session: sessionId, workspace: workspaceHash}); + return request('GET', `/api/desktop-office?${query}`, undefined, base, {signal}); + }, getSessionModel: (sid, workspaceHash = '') => { const qs = workspaceHash ? `?workspace=${encodeURIComponent(workspaceHash)}` : ''; return request('GET', `/api/sessions/${encodeURIComponent(sid)}/model${qs}`, undefined, base); diff --git a/web/src/lib/desktopNotify.js b/web/src/lib/desktopNotify.js index defbeaae6..bd56c7927 100644 --- a/web/src/lib/desktopNotify.js +++ b/web/src/lib/desktopNotify.js @@ -155,10 +155,10 @@ export function focusSession(workspaceHash, sessionId) { return false; } try { - window.aceDesktop_focusSession(JSON.stringify({ + window.aceDesktop_focusSession({ workspace_hash: workspaceHash || '', session_id: sessionId, - })); + }); return true; } catch { return false; diff --git a/web/src/lib/desktopNotify.test.js b/web/src/lib/desktopNotify.test.js index d26f96be5..fdee75cf1 100644 --- a/web/src/lib/desktopNotify.test.js +++ b/web/src/lib/desktopNotify.test.js @@ -14,6 +14,7 @@ import { shouldSuppress, truncateForNotification, maybeNotify, + focusSession, noteHostWindowFocus, isHostWindowFocused, } from './desktopNotify.js'; @@ -286,3 +287,20 @@ run('maybeNotify 桥可用 + 窗口聚焦 → 不投递完成通知', () => { assert.equal(called, 0); global.window = prev; }); + +run('focusSession passes a structured native argument and supports no-workspace sessions', () => { + const previous = globalThis.window; + let request; + globalThis.window = {aceDesktop_focusSession: value => {request = value;}}; + try { + assert.equal(focusSession('', 'office-root'), true); + // Native receives the JSON-encoded argument array, whose first item must + // be an object rather than a second, nested JSON string. + const nativeArgs = JSON.parse(JSON.stringify([request])); + assert.deepEqual(nativeArgs, [{workspace_hash:'', session_id:'office-root'}]); + assert.equal(focusSession('workspace', ''), false); + } finally { + if (previous === undefined) delete globalThis.window; + else globalThis.window = previous; + } +}); diff --git a/web/src/lib/desktopOffice.test.js b/web/src/lib/desktopOffice.test.js new file mode 100644 index 000000000..4ce907922 --- /dev/null +++ b/web/src/lib/desktopOffice.test.js @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict'; +import { recentOffices, officeActor, projectDesktopOffice, stableOfficeSeed } from './desktopOfficeState.js'; +import { createDesktopOfficeController } from './desktopOfficeController.js'; +import { toolIconName, toolIconSvg } from './toolIcons.js'; +import { interfaceIconSvg } from './interfaceIcons.js'; + +function test(name, fn) {fn(); console.log(`[pass] desktop office: ${name}`);} +async function check(name, fn) {await fn(); console.log(`[pass] desktop office: ${name}`);} +const session = (id, overrides = {}) => ({id, active:true, last_user_message_at:'2026-10-06T10:00:00Z', ...overrides}); +const snapshot = (id, overrides = {}) => ({selected:session(id),offices:[session(id)],agents:[session(id)],complete:true,...overrides}); + +test('recent five depend only on human send time across workspaces', () => { + const rows = Array.from({length:7}, (_,i) => session(String(i),{workspace_hash:`w${i%2}`,last_user_message_at:`2026-10-06T10:00:0${i}Z`,updated_at:`2026-10-06T20:00:0${9-i}Z`})); + rows.push(session('archived',{archived:true,last_user_message_at:'2099-01-01'})); + rows.push(session('child',{parent_session_id:'6',last_user_message_at:'2099-01-01'})); + rows.push(session('empty',{last_user_message_at:''})); + assert.deepEqual(recentOffices(rows).map(s=>s.id),['6','5','4','3','2']); +}); + +test('main sleeps on completion and wakes; workers leave on completion', () => { + assert.equal(officeActor(session('root',{last_turn_outcome:'completed'}),'root').state,'sleep'); + assert.equal(officeActor(session('root',{busy:true,last_turn_outcome:'completed'}),'root').state,'think'); + assert.equal(officeActor(session('child',{last_turn_outcome:'completed'}),'root').present,false); + assert.equal(officeActor(session('child',{busy:true,last_turn_outcome:'completed'}),'root').present,true); + assert.equal(officeActor(session('root'),'root').state,'idle'); +}); + +test('waiting, retry, cancellation and errors are distinct', () => { + for(const [phase,expected] of [['permission_waiting','permission'],['question_waiting','question'],['compacting','compact'],['model_retry','retry']]) { + assert.equal(officeActor(session('root',{busy:true,activity:{phase}}),'root').state,expected); + } + assert.equal(officeActor(session('root',{last_turn_outcome:'aborted'}),'root').state,'stopped'); + assert.equal(officeActor(session('root',{last_turn_outcome:'error'}),'root').state,'error'); +}); + +test('failed mesh workers with internal input stay visible for the current root turn', () => { + const child=session('mesh',{last_user_message_at:'',updated_at:'2026-10-06T10:01:00Z',last_turn_outcome:'error'}); + assert.equal(projectDesktopOffice(snapshot('root',{agents:[session('root'),child]})).agents.length,2); + const newer=session('root',{last_user_message_at:'2026-10-06T10:02:00Z'}); + assert.equal(projectDesktopOffice({...snapshot('root'),selected:newer,agents:[newer,child]}).agents.length,1); +}); + +test('paper thickness follows current context rather than lifetime totals', () => { + const base=session('root',{context_window:1000,token_usage:{has_data:true,prompt_tokens:900},session_token_usage:{total_tokens:9000000}}); + const full=officeActor(base,'root'); + assert.equal(full.contextRatio,.9); + const compacted=officeActor({...base,token_usage:{has_data:true,prompt_tokens:150}},'root'); + assert.equal(compacted.contextRatio,.15); + assert.equal(officeActor(session('root'),'root').contextKnown,false); +}); + +test('stable identity and nested mesh children belong to one office with overflow', () => { + const agents=[session('root'),...Array.from({length:9},(_,i)=>session(`child${i}`,{parent_session_id:'root',agent_path:`/root/a/child${i}`,busy:true}))]; + const view=projectDesktopOffice(snapshot('root',{agents})); + assert.equal(view.agents.length,10);assert.equal(view.overflow,2); + assert.equal(view.agents[0].name,'Maestro'); + assert.equal(stableOfficeSeed('root'),stableOfficeSeed('root')); + assert.notEqual(stableOfficeSeed('root'),stableOfficeSeed('another')); + assert.equal(view.agents.find(a=>a.id==='child0').seed,officeActor(agents[1],'root').seed); +}); + +test('tool SVG is exactly the shared chat icon source', () => { + for(const tool of ['file_read','file_write','file_edit','apply_patch','grep','glob','web_search','bash','spawn_subagent','agent_send_message','AskUserQuestion','TodoWrite','task_complete','vision_analyze','skill_view','mcp__server__tool','unknown']) { + const icon=toolIconName(tool); + assert.equal(toolIconSvg(tool),interfaceIconSvg(icon,14)); + assert.match(toolIconSvg(tool),/^publications.push(value), + retainSession:id=>retained.add(id),releaseSession:id=>{retained.delete(id);released.push(id);}, + schedule:fn=>{timers.set(++timerId,fn);return timerId;},cancel:id=>timers.delete(id)}); + return {controller,publications,retained,released,timers}; +} +const deferred=()=>{let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b;});return {promise,resolve,reject};}; + +await check('manual choice turns follow off and keeps office fixed', async () => { + const requests=[];const f=fixture(async ref=>{requests.push(ref.sessionId);return snapshot(ref.sessionId||'recent');}); + f.controller.setActive({sessionId:'a'});await f.controller.refresh(); + assert.equal(f.controller.state.follow,true);assert.equal(f.controller.state.selected.sessionId,'a'); + f.controller.select({session_id:'b'});await f.controller.refresh(); + f.controller.setActive({sessionId:'c'});await f.controller.refresh(); + assert.equal(f.controller.state.selected.sessionId,'b');assert.equal(f.controller.state.follow,false); + f.controller.setFollow(true);await f.controller.refresh(); + assert.equal(f.controller.state.selected.sessionId,'c');f.controller.dispose(); + assert.equal(f.retained.size,0);assert.equal(f.timers.size,0); +}); + +await check('late responses cannot replace the new office or leak subscriptions', async () => { + const a=deferred(),b=deferred();const f=fixture(ref=>ref.sessionId==='a'?a.promise:b.promise); + f.controller.setActive({sessionId:'a'});const old=f.controller.refresh(); + f.controller.select({sessionId:'b'});const next=f.controller.refresh(); + b.resolve(snapshot('b'));await next;a.resolve(snapshot('a'));await old; + assert.equal(f.controller.state.selected.sessionId,'b');assert.deepEqual([...f.retained],['b']); + f.controller.dispose();assert.equal(f.retained.size,0); +}); + +await check('disposing with an in-flight response prevents further publication', async () => { + const pending=deferred();const f=fixture(()=>pending.promise); + const request=f.controller.refresh();f.controller.dispose();const count=f.publications.length; + pending.resolve(snapshot('late'));await request;assert.equal(f.publications.length,count); + assert.equal(f.retained.size,0); +}); + +await check('offline is explicit and reconnect recovers from a fresh snapshot', async () => { + let fail=false;const f=fixture(async()=>{if(fail)throw new Error('offline');return snapshot('root');}); + await f.controller.refresh();fail=true;await f.controller.refresh();assert.equal(f.controller.state.connected,false); + fail=false;const releases=f.released.length;f.controller.reconnected();assert.equal(f.released.length,releases);await f.controller.refresh();assert.equal(f.controller.state.connected,true); + assert.equal(f.controller.state.selected.sessionId,'root');f.controller.dispose(); +}); + +await check('completion releases a child while the selected office stays subscribed', async () => { + let done=false;const f=fixture(async()=>snapshot('root',{agents:[session('root'),session('child',{busy:!done,last_turn_outcome:done?'completed':''})]})); + await f.controller.refresh();assert.equal(f.retained.has('child'),true); + done=true;await f.controller.refresh();assert.equal(f.retained.has('child'),false); + assert.equal(f.controller.state.agents.some(a=>a.id==='child'),false); + assert.equal(f.retained.has('root'),true);f.controller.dispose(); +}); diff --git a/web/src/lib/desktopOfficeController.js b/web/src/lib/desktopOfficeController.js new file mode 100644 index 000000000..60058f674 --- /dev/null +++ b/web/src/lib/desktopOfficeController.js @@ -0,0 +1,104 @@ +import { officeSessionRef, projectDesktopOffice } from './desktopOfficeState.js'; + +// One App-owned controller; every asynchronous response belongs to a selection +// generation. Inject IO, timers and publishing so lifecycle races are testable. +export function createDesktopOfficeController({ + fetchSnapshot, fetchModel, publish, retainSession = () => {}, releaseSession = () => {}, + schedule = setTimeout, cancel = clearTimeout, initial = {}, +} = {}) { + let follow = initial.follow !== false, active = {}, selected = initial.selected || {}; + let generation = 0, disposed = false, timer = null, timerDelay = Infinity, request = null; + let snapshot = {}, connected = true, queued = false; + const retained = new Set(); + const emit = () => !disposed && publish(projectDesktopOffice(snapshot, {follow, connected})); + const syncRetained = () => { + const wanted = new Set(); + for (const session of [...(snapshot.offices || []), ...(snapshot.agents || [])]) { + if (session.active && (session.id === snapshot.selected?.id || session.busy || session.status === 'running')) wanted.add(session.id); + } + for (const id of retained) if (!wanted.has(id)) {releaseSession(id); retained.delete(id);} + for (const id of wanted) if (!retained.has(id)) {retainSession(id); retained.add(id);} + }; + function later(delay) { + if (disposed) return; + if (timer !== null && timerDelay <= delay) return; + if (timer !== null) cancel(timer); + timerDelay = delay; + timer = schedule(() => {timer = null; timerDelay = Infinity; void refresh();}, delay); + } + async function refresh() { + if (disposed) return; + if (request) {queued = true; return;} + const version = generation, controller = new AbortController(); + request = controller; + try { + const wanted = follow && active.sessionId ? active : selected; + const next = await fetchSnapshot(wanted, {signal: controller.signal}); + if (disposed || version !== generation || controller.signal.aborted) return; + // Dormant sessions need their model window; never use lifetime token totals. + if (next.selected && !next.selected.context_window && fetchModel) { + try { + const model = await fetchModel(next.selected.id, next.selected.workspace_hash || ''); + if (disposed || version !== generation || controller.signal.aborted) return; + const limit = Number(model?.context_window ?? model?.contextWindow) || 0; + next.selected.context_window = limit; + for (const agent of next.agents || []) if (agent.id === next.selected.id) agent.context_window = limit; + } catch { /* Unknown context remains unknown until a later snapshot. */ } + } + snapshot = next; connected = true; + if (next.selected) selected = officeSessionRef(next.selected); + syncRetained(); emit(); + } catch (error) { + if (disposed || version !== generation || controller.signal.aborted) return; + if (error?.status === 404) { + selected = {}; snapshot = {...snapshot, selected:null, agents:[], offices:error.body?.offices || snapshot.offices || []}; + if (follow) active = {}; + syncRetained(); + } else connected = false; + emit(); + } finally { + if (request === controller) request = null; + if (!disposed && version === generation) { + const delay = queued ? 100 : snapshot.complete === false ? 500 : 3000; + queued = false; later(delay); + } + } + } + function change() { + generation++; + request?.abort(); request = null; queued = false; + snapshot = {...snapshot, selected:null, agents:[]}; + syncRetained(); emit(); later(0); + } + return { + start() {later(0);}, + refresh, + setActive(value) { + const next = officeSessionRef(value); + if (next.sessionId === active.sessionId && next.workspaceHash === active.workspaceHash) return; + active = next; + if (follow && next.sessionId) change(); + }, + select(value) {selected = officeSessionRef(value); follow = false; change();}, + setFollow(value) {follow = !!value; change();}, + onEvent(message) { + if (['token','reasoning','tool_update'].includes(message.type)) return; + if (request) queued = true; + else later(100); + }, + disconnected() {connected = false; emit(); later(500);}, + reconnected() { + generation++; request?.abort(); request = null; queued = false; + // Preserve retained subscriptions while recovering: releasing them from + // an onopen callback would close/reopen an office-only connection forever. + later(0); + }, + get state() {return projectDesktopOffice(snapshot, {follow, connected});}, + dispose() { + disposed = true; generation++; request?.abort(); request = null; + if (timer !== null) cancel(timer); + for (const id of retained) releaseSession(id); + retained.clear(); + }, + }; +} diff --git a/web/src/lib/desktopOfficeState.js b/web/src/lib/desktopOfficeState.js new file mode 100644 index 000000000..857c5b59f --- /dev/null +++ b/web/src/lib/desktopOfficeState.js @@ -0,0 +1,81 @@ +import { normalizeTokenBudget } from './tokenBudget.js'; +import { toolIconName, toolIconSvg } from './toolIcons.js'; + +export const OFFICE_LIMIT = 5; +export const OFFICE_WORKER_SEATS = 7; +const idOf = value => String(value?.id || value?.sessionId || value?.session_id || ''); +export const officeSessionRef = value => ({ + sessionId: idOf(value), + workspaceHash: String(value?.workspaceHash || value?.workspace_hash || ''), +}); +export function stableOfficeSeed(value) { + let hash = 2166136261; + for (const c of String(value)) hash = Math.imul(hash ^ c.codePointAt(0), 16777619); + return hash >>> 0; +} +export function recentOffices(sessions = []) { + const unique = new Map(); + for (const session of sessions) { + if (!idOf(session) || session.archived || session.parent_session_id || !session.last_user_message_at) continue; + unique.set(idOf(session), session); + } + return [...unique.values()].sort((a,b) => + String(b.last_user_message_at).localeCompare(String(a.last_user_message_at)) || idOf(a).localeCompare(idOf(b))) + .slice(0, OFFICE_LIMIT); +} + +export function officeActor(session, rootId) { + const id = idOf(session), root = id === rootId, a = session.activity || {}; + const busy = session.busy === true || session.status === 'running' || a.busy === true; + const outcome = String(a.outcome || session.last_turn_outcome || ''); + const phase = String(a.phase || ''), tool = String(a.tool || ''); + let state = 'idle', label = '待命'; + if (busy) { + if (phase === 'permission_waiting') { state = 'permission'; label = '等待授权'; } + else if (phase === 'question_waiting') { state = 'question'; label = '等待回答'; } + else if (phase === 'compacting' || phase === 'context_repair') { state = 'compact'; label = '整理上下文'; } + else if (phase === 'model_retry') { state = 'retry'; label = '正在重试'; } + else if (tool || phase === 'tool_running') { state = 'work'; label = tool || '工作中'; } + else { state = 'think'; label = String(a.label || '思考中'); } + } else if (outcome === 'completed') { state = root ? 'sleep' : 'done'; label = root ? 'zzz' : '已完成'; } + else if (outcome === 'error' || outcome === 'errored') { state = 'error'; label = '执行失败'; } + else if (outcome === 'aborted' || outcome === 'interrupted') { state = 'stopped'; label = '已停止'; } + const context = normalizeTokenBudget({usage: session.token_usage, contextWindow: session.context_window}); + const icon = tool ? toolIconName(tool) : ''; + return { + id, root, name: root ? 'Maestro' : String(session.title || session.agent_path?.split('/').pop() || 'Agent'), + path: String(session.agent_path || (root ? '/root' : '')), + state, label, tool, icon, iconSvg: tool ? toolIconSvg(tool) : '', busy, + seed: stableOfficeSeed(id), contextKnown: context.known, + contextRatio: context.usedRatio, contextTokens: context.usedTokens, contextLimit: context.limitTokens, + compactId: String(a.compact_id || ''), seq: Number(a.seq) || 0, + transfers: Array.isArray(a.transfers) ? a.transfers : [], + // Completed workers leave immediately. Failed/stopped workers remain visible + // for their current parent turn, so they cannot masquerade as success. + present: root || busy || state === 'error' || state === 'stopped', + lastActivityAt: String(session.updated_at || session.last_user_message_at || ''), + }; +} + +export function projectDesktopOffice(snapshot = {}, { follow = true, connected = true } = {}) { + const selected = snapshot.selected || null; + const rootId = idOf(selected); + const rootTime = String(selected?.last_user_message_at || ''); + const agents = (snapshot.agents || []).map(session => officeActor(session, rootId)) + .filter(actor => actor.present && (actor.root || actor.busy || !rootTime || actor.lastActivityAt >= rootTime)); + agents.sort((a,b) => Number(b.root) - Number(a.root) || a.id.localeCompare(b.id)); + return { + version: 1, follow, connected, complete: snapshot.complete !== false, + offices: recentOffices(snapshot.offices).map(session => ({ + ...officeSessionRef(session), title: String(session.title || session.summary || '未命名会话'), + busy: session.busy === true || session.status === 'running', + lastUserMessageAt: session.last_user_message_at, + workspaceName: String(session.workspace_name || ''), + })), + selected: selected ? { ...officeSessionRef(selected), title: String(selected.title || selected.summary || '未命名会话') } : null, + seed: stableOfficeSeed(rootId || 'empty-office'), + agents, + completed: (snapshot.agents || []).filter(session => session.id !== rootId && officeActor(session, rootId).state === 'done').map(session => session.id), + overflow: Math.max(0, agents.length - OFFICE_WORKER_SEATS - 1), + }; +} diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js index 41556c201..7e0b56813 100644 --- a/web/src/lib/runTests.js +++ b/web/src/lib/runTests.js @@ -1,3 +1,4 @@ +import './desktopOffice.test.js'; import './questionPicker.test.js'; import './turnCancellation.test.js'; import './modelReasoningSync.test.js'; diff --git a/web/src/lib/toolIcons.js b/web/src/lib/toolIcons.js new file mode 100644 index 000000000..1dce6f7dc --- /dev/null +++ b/web/src/lib/toolIcons.js @@ -0,0 +1,29 @@ +import { interfaceIconSvg } from './interfaceIcons.js'; + +const GROUPS = [ + ['OpenFile', ['file_read', 'read_file', 'read', 'list_dir', 'ls', 'read_resource']], + ['Save', ['file_write', 'write_file', 'write']], + ['Edit', ['file_edit', 'edit_file', 'edit', 'apply_patch', 'multiedit']], + ['Search', ['grep', 'file_search', 'glob', 'search_files', 'find_files']], + ['BrowserGlobe', ['web_search', 'web_fetch', 'fetch', 'browse', 'open_url']], + ['TerminalReadWrite', ['bash', 'shell', 'exec_command', 'execute_command', 'write_stdin']], + ['Embedding', ['spawn_subagent', 'wait_subagent', 'agent_spawn', 'agent_wait', 'agent_list', 'agent_send_message', 'agent_followup_task', 'agent_interrupt']], + ['StatusHelp', ['askuserquestion', 'ask_user_question']], + ['List', ['todowrite', 'todo_write', 'todo_read']], + ['StatusOK', ['task_complete']], + ['Eye', ['vision_analyze', 'show_image', 'view_image']], + ['Extension', ['skill', 'skill_view', 'skills_list']], +]; +const BY_TOOL = new Map(GROUPS.flatMap(([icon, names]) => names.map(name => [name, icon]))); +const LEGACY = new Map([['R','OpenFile'],['W','Save'],['E','Edit'],['S','Search'],['$','TerminalReadWrite'],['*','Tool'],['D','StatusOK'],['!','StatusWarning']]); + +// One mapping and SVG source for chat, background tasks and the native office. +export function toolIconName(tool = '', summaryIcon = '') { + const name = String(tool).toLowerCase().replace(/^functions\./, ''); + if (name.startsWith('mcp__') || name.startsWith('mcp:')) return 'MCP'; + return BY_TOOL.get(name) || LEGACY.get(summaryIcon) || 'Tool'; +} + +export function toolIconSvg(tool = '', summaryIcon = '') { + return interfaceIconSvg(toolIconName(tool, summaryIcon), 14); +} diff --git a/web/src/lib/useDesktopOffice.js b/web/src/lib/useDesktopOffice.js new file mode 100644 index 000000000..b135d8968 --- /dev/null +++ b/web/src/lib/useDesktopOffice.js @@ -0,0 +1,56 @@ +import { useEffect, useRef } from 'react'; +import { api } from './api.js'; +import { connection } from './connection.js'; +import { focusSession } from './desktopNotify.js'; +import { createDesktopOfficeController } from './desktopOfficeController.js'; + +export function useDesktopOffice(activeRef) { + const current = useRef(activeRef); + current.current = activeRef; + const controllerRef = useRef(null); + useEffect(() => { + if (typeof window.aceDesktop_updateOffice !== 'function') return undefined; + let disposed = false, controller = null; + const onMessage = event => controller?.onEvent(event.detail || {}); + const onOpen = () => controller?.reconnected(); + const onDisconnect = () => controller?.disconnected(); + const onAction = event => { + const action = event.detail || {}; + if (action.type === 'select') controller?.select(action); + else if (action.type === 'follow') controller?.setFollow(action.follow); + else if (action.type === 'open') focusSession(action.workspace_hash, action.session_id); + }; + const start = async () => { + let initial = {}; + try { + const value = await window.aceDesktop_getOfficeState?.(); + initial = typeof value === 'string' ? JSON.parse(value) : value || {}; + } catch {} + if (disposed) return; + controller = createDesktopOfficeController({ + initial, fetchSnapshot: api.getDesktopOffice, fetchModel: api.getSessionModel, + publish: state => { + try {Promise.resolve(window.aceDesktop_updateOffice(JSON.stringify(state))).catch(() => {});} catch {} + }, + retainSession: id => connection.retainSession(id), + releaseSession: id => connection.releaseSession(id), + }); + controllerRef.current = controller; + controller.setActive(current.current); + controller.start(); + }; + connection.addEventListener('message', onMessage); + connection.addEventListener('open', onOpen); + connection.addEventListener('disconnect', onDisconnect); + window.addEventListener('ace-desktop-office-action', onAction); + void start(); + return () => { + disposed = true; controller?.dispose(); controllerRef.current = null; + connection.removeEventListener('message', onMessage); + connection.removeEventListener('open', onOpen); + connection.removeEventListener('disconnect', onDisconnect); + window.removeEventListener('ace-desktop-office-action', onAction); + }; + }, []); + useEffect(() => {controllerRef.current?.setActive(activeRef);}, [activeRef]); +} From 1f95b89fdee602341f6b0cff12476144f64e1958 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Tue, 6 Oct 2026 04:51:31 +0800 Subject: [PATCH 2/5] docs: Record desktop office validation and local delivery --- .../changes/integrate-desktop-agent-office/tasks.md | 2 +- .../integrate-desktop-agent-office/verification.md | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/openspec/changes/integrate-desktop-agent-office/tasks.md b/openspec/changes/integrate-desktop-agent-office/tasks.md index 7347664b4..c9359f825 100644 --- a/openspec/changes/integrate-desktop-agent-office/tasks.md +++ b/openspec/changes/integrate-desktop-agent-office/tasks.md @@ -21,4 +21,4 @@ - [x] 4.1 完成相关 C++ 测试与 Windows Desktop/daemon 构建、Web pnpm test/build、OpenSpec 严格校验和 diff 检查,记录实际结果。 - [x] 4.2 完成浏览器状态/交互自动化及 Windows 原生运行验证,记录截图、重连/切换和窗口缩放证据。 - [x] 4.3 更新运行说明和 API 文档,按逐项要求审查最终差异,确认无模拟数据接入生产。 -- [ ] 4.4 提交本功能并合入本地 master,验证提交归属及原工作区其它改动完整保留,不推送。 +- [x] 4.4 提交本功能并合入本地 master,验证提交归属及原工作区其它改动完整保留,不推送。 diff --git a/openspec/changes/integrate-desktop-agent-office/verification.md b/openspec/changes/integrate-desktop-agent-office/verification.md index d45badbee..1ff60f109 100644 --- a/openspec/changes/integrate-desktop-agent-office/verification.md +++ b/openspec/changes/integrate-desktop-agent-office/verification.md @@ -44,3 +44,14 @@ acecode_unit_tests --gtest_filter=DesktopPetLayout.*:SessionActivityState.*:Rece 浏览器脚本通过 `ACE_PLAYWRIGHT_MODULE` 使用已安装的 Playwright,`ACE_BROWSER_CHANNEL=msedge`;`ACE_OFFICE_CAPTURE_DIR` 可指定截图目录。原生验收对两个 WebView2 使用进程级 `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=0`,通过临时目录中的 DevToolsActivePort 连接;未修改用户全局环境或配置。 运行边界:本次验证 Windows Desktop;macOS/Linux 本次仅保留无桌宠的空实现,没有宣称跨平台原生验收。源代码交付不会自动替换用户已安装的 Desktop。 + + +## 本地交付 + +功能提交 `e80bccba355fd0b20dda3f42b2b0107d177cada0` 已从独立工作区 `codex/agent-office-live` 快进合入本地 `master`。 + +合并前主工作区的 42 个已跟踪改动文件和 188 个未跟踪文件均保留:218 个文件逐字节 SHA-256 未变;12 个与功能重叠的文件先生成三方合并结果,再在每文件写入门下恢复用户改动。唯一需要手动合并的是通知测试文件,保留消息通道测试和本次原生桥测试。合并后这些原有改动仍未提交,Git index 无无关内容。 + +主工作区在恢复上述改动后再次运行 `pnpm test`、`pnpm build` 和 `git diff --check` 均通过;办公室与通知桥定向 JS 测试通过。原 Claude 工作区七个导入文件的 SHA-256 与导入基线全部一致。旧事务所原型目录已从主工作区删除,验收创建的 Desktop/daemon/本机测试 provider 已退出。 + +未执行 push、发布或安装替换。验收记录在后续文档提交中补齐,功能提交保持独立。 From 93c934a4a791e71bcf75d3f61aa69e00c45c0770 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Tue, 6 Oct 2026 14:48:58 +0800 Subject: [PATCH 3/5] feat: Show live agent activity and hand-offs in the desktop office - Activity snapshots infer `responding` from token events (the default coding mode never emits it) and keep a bounded streaming tail plus each running tool's one-line call preview. - The office controller overlays WebSocket token/reasoning events between snapshots (sequence-gated, throttled) without extra HTTP requests. - Busy agents keep a status bubble (icon, verb, latest words or call preview) with priority-based collision avoidance; typing, reading, thinking and waiting have distinct motions. - Members walk in from the door, collect a task envelope at their dispatcher's desk, and walk back to hand in a report before leaving; mesh messages fly as labelled envelopes. Reduced motion keeps the envelopes without walking. - The page reports bubble rectangles so the Windows window region no longer clips bubbles above the walls. - Add a macOS host: non-activating NSPanel with WKWebView, a chrome.webview shim, hit-test click-through, drag, resize, zoom and context menu. Not yet compiled on macOS. --- assets/desktop_pet/DESIGN.md | 8 +- assets/desktop_pet/agent_office_pet.html | 421 ++++++++-- assets/desktop_pet/surface.md | 12 +- cmake/acecode_desktop.cmake | 7 + cmake/acecode_layer_libraries.cmake | 3 +- cmake/acecode_source_paths.cmake | 2 + docs/daemon-api.md | 6 +- docs/desktop-agent-office.md | 10 +- .../.openspec.yaml | 2 + .../design.md | 31 + .../proposal.md | 26 + .../specs/desktop-agent-office/spec.md | 29 + .../tasks.md | 19 + .../verification.md | 15 + src/apps/desktop/desktop_pet.cpp | 33 +- src/apps/desktop/desktop_pet.hpp | 8 +- src/apps/desktop/desktop_pet_layout.cpp | 52 ++ src/apps/desktop/desktop_pet_layout.hpp | 17 + src/apps/desktop/desktop_pet_mac.mm | 754 ++++++++++++++++++ src/domain/session/session_activity_state.cpp | 69 +- src/domain/session/session_activity_state.hpp | 13 +- tests/desktop/desktop_pet_layout_test.cpp | 52 +- tests/session/session_activity_state_test.cpp | 78 ++ web/scripts/i18n-en-overrides.mjs | 18 + web/scripts/test-desktop-office.mjs | 43 +- web/src/i18n/sourceCatalog.generated.js | 38 + web/src/lib/desktopOffice.test.js | 87 +- web/src/lib/desktopOfficeController.js | 46 +- web/src/lib/desktopOfficeState.js | 78 +- web/src/lib/toolIcons.js | 17 + 30 files changed, 1856 insertions(+), 138 deletions(-) create mode 100644 openspec/changes/enhance-desktop-agent-office-motion/.openspec.yaml create mode 100644 openspec/changes/enhance-desktop-agent-office-motion/design.md create mode 100644 openspec/changes/enhance-desktop-agent-office-motion/proposal.md create mode 100644 openspec/changes/enhance-desktop-agent-office-motion/specs/desktop-agent-office/spec.md create mode 100644 openspec/changes/enhance-desktop-agent-office-motion/tasks.md create mode 100644 openspec/changes/enhance-desktop-agent-office-motion/verification.md create mode 100644 src/apps/desktop/desktop_pet_mac.mm diff --git a/assets/desktop_pet/DESIGN.md b/assets/desktop_pet/DESIGN.md index bc079636f..40a9c4da3 100644 --- a/assets/desktop_pet/DESIGN.md +++ b/assets/desktop_pet/DESIGN.md @@ -102,7 +102,11 @@ components: ### 场景状态 -角色动作、气泡和工具图标共同呈现状态。等待授权、等待回答、失败、停止均有明确文案;当前工具图标只在执行工具时显示。上下文文件堆属于相应工位,依据当前上下文使用率呈现。 +角色动作、气泡和工具图标共同呈现状态。忙碌期间气泡常驻,一行「图标 动作 · 细节」:动作用短的现在进行时动词,细节用系统界面字体(路径、命令、英文推理在像素字体里难以辨认),放不下时从开头裁掉,保留最新的字和文件名;正在输出的气泡带闪烁光标。等待授权、等待回答、失败、停止均有明确文案;工具图标只在执行或准备工具时显示。上下文文件堆属于相应工位,依据当前上下文使用率呈现。 + +### 交接 + +成员加入、完成和 agent 间消息用走路与信封表达:信封带两格残影的抛物线和黄色标签(任务 / 回报 / 消息 / 新任务),落地时收件人头顶冒「!」。走路沿 2 单位网格寻路、不穿家具。 ### 溢出成员 @@ -114,7 +118,7 @@ components: - **Do** 保留原作家具、透明房间、等距像素构形与稳定会话布局。 - **Do** 让控制条、提示和成员列表保持可读,并把状态变化绑定到真实快照。 -- **Do** 为会话按钮和跟随开关保留焦点及可访问状态,减少动态效果偏好下抑制消息传递动效。 +- **Do** 为会话按钮和跟随开关保留焦点及可访问状态;减少动态效果偏好下不走路,但保留交接信封(它承载信息)。 ### Don't: diff --git a/assets/desktop_pet/agent_office_pet.html b/assets/desktop_pet/agent_office_pet.html index 9722fd446..8a53c910d 100644 --- a/assets/desktop_pet/agent_office_pet.html +++ b/assets/desktop_pet/agent_office_pet.html @@ -21,10 +21,26 @@ .scene:hover .grip{opacity:.95} .grip svg{display:block;width:100%;height:100%} .zoom-badge{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);background:#27324f;color:#fff;font-weight:700;border-radius:6px;padding:2px 9px;font-size:13px;pointer-events:none;z-index:4} -.bub{position:absolute;transform:translate(-50%,calc(-100% - 7px));background:#fff;color:#1b2540;border:2px solid #27324f;border-radius:6px;padding:1px 7px 2px;font-size:max(10px,calc(var(--k) * 6.5px));font-weight:700;white-space:nowrap;box-shadow:2px 2px 0 rgba(20,24,40,.28)} -.bub::after{content:"";position:absolute;left:50%;bottom:-6px;width:8px;height:8px;margin-left:-4px;background:inherit;border-right:2px solid #27324f;border-bottom:2px solid #27324f;transform:rotate(45deg)} -.bub.bot{background:#e6f4ff} -.tag{position:absolute;transform:translate(-50%,calc(-100% - 8px));background:#ffe27a;color:#3a2a00;border:2px solid #27324f;font-size:max(9px,calc(var(--k) * 5.4px));font-weight:700;padding:0 4px;white-space:nowrap} +.bub{position:absolute;left:0;top:0;z-index:2;display:flex;align-items:center;gap:3px;max-width:min(64vw,calc(var(--k) * 150px));background:#fff;color:#1b2540;border:2px solid #27324f;border-radius:6px;padding:1px 6px 2px;font-size:max(10px,calc(var(--k) * 6.2px));font-weight:700;white-space:nowrap;box-shadow:2px 2px 0 rgba(20,24,40,.28)} +.bub::after{content:"";position:absolute;left:var(--tail,50%);bottom:-6px;width:8px;height:8px;margin-left:-4px;background:inherit;border-right:2px solid #27324f;border-bottom:2px solid #27324f;transform:rotate(45deg)} +.bub::before{content:"";position:absolute;left:var(--tail,50%);top:100%;width:2px;margin-left:-1px;height:var(--lead,0px);background:#27324f;opacity:.55} +.bub.bot{background:#e6f4ff;transform:translate(-50%,calc(-100% - 7px))} +.bub[hidden],.bub-detail[hidden]{display:none} +.bub-label{flex:0 0 auto;max-width:calc(var(--k) * 96px);overflow:hidden;text-overflow:ellipsis} +.bub-detail{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;font:500 1em/1.3 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#4d5875} +.bub.compact .bub-detail{display:none} +.bub.think{background:#f3f0ff} +.bub.think .bub-detail{font-style:italic} +.bub.type{background:#f2fbf4} +.bub.type .bub-detail{color:#22324f} +.bub.type .bub-detail::after{content:"\258d";margin-left:1px;color:#2f8f57;animation:office-caret 1s steps(1) infinite} +.bub.wait{background:#eef4fb} +.bub.alert{background:#fff1bf} +.bub.bad{background:#ffe2dc} +.bub.flash{background:#e6f7e9} +.bub.picked{outline:2px solid #ffe14d;outline-offset:1px} +@keyframes office-caret{50%{opacity:0}} +.tag{position:absolute;transform:translate(-50%,calc(-100% - 8px));background:#ffe27a;color:#3a2a00;border:2px solid #27324f;border-radius:3px;font-size:max(10px,calc(var(--k) * 6px));font-weight:700;padding:0 4px;white-space:nowrap;z-index:3} :root{--office-paper:#fff9e9;--office-ink:#27324f;--office-line:#a8a6a4;--office-selected:#dce7f6} .office-controls{position:absolute;z-index:5;top:calc(var(--k)*2px);left:calc(var(--k)*4px);right:calc(var(--k)*4px);height:calc(var(--k)*26px);display:flex;align-items:center;gap:4px;font:12px/1.3 -apple-system,BlinkMacSystemFont,"PingFang SC","Segoe UI",sans-serif;color:var(--office-ink)} @@ -38,15 +54,15 @@ .office-follow::before{content:"";display:block;width:8px;height:8px;border:1px solid #65738b;border-radius:2px} .office-follow[aria-checked="true"]::before{background:#456b9c;border-color:#456b9c} .office-notice{position:absolute;left:50%;top:calc(var(--k)*30px);transform:translateX(-50%);max-width:90%;font:11px/1.5 -apple-system,BlinkMacSystemFont,"PingFang SC","Segoe UI",sans-serif;color:var(--office-ink);background:var(--office-paper);padding:2px 6px;border-radius:3px;z-index:4} -.office-tool{position:absolute;width:max(12px,calc(var(--k)*9px));height:max(12px,calc(var(--k)*9px));background:var(--office-paper);border:1px solid #8595ab;border-radius:3px;padding:1px} +.office-tool{flex:none;display:block;width:max(12px,calc(var(--k)*8px));height:max(12px,calc(var(--k)*8px))} +.office-tool[hidden]{display:none} .office-overflow{position:absolute;right:calc(var(--k)*13px);bottom:calc(var(--k)*14px);z-index:4} .office-members{position:absolute;z-index:6;top:calc(var(--k)*30px);left:8px;right:8px;max-height:65%;overflow:auto;background:var(--office-paper);border:1px solid var(--office-line);border-radius:5px;padding:6px;scrollbar-color:#a8b6c9 var(--office-paper);scrollbar-width:thin} .office-members button{display:block;width:100%;background:transparent;border:0;color:var(--office-ink);text-align:left;padding:6px;cursor:pointer;font:12px/1.4 "Segoe UI",sans-serif} .office-members button:hover{background:var(--office-selected)} -.bub{max-width:65vw;overflow:hidden;text-overflow:ellipsis} @media(max-width:400px){.office-controls{font-size:11px;gap:2px}.office-tab,.office-follow{padding:2px 3px}.office-tabs{gap:2px}.office-follow{gap:2px}.office-follow::before{width:6px;height:6px}} @media(max-width:250px){.office-tab,.office-follow{padding:0 1px;line-height:1}.office-follow::before{width:5px;height:5px}} -@media(prefers-reduced-motion:reduce){.grip{transition:none}} +@media(prefers-reduced-motion:reduce){.grip{transition:none}.bub.type .bub-detail::after{animation:none}} @@ -284,10 +300,19 @@ k: '#1d2130', m: '#f3f6fb', M: '#c5cdd9', y: '#ffd35c', Y: '#d9a92e', // 墨镜片 / 口罩 / 工牌卡片 }; } +// 走路 = 坐姿模板下面再接三行腿脚;手臂沿用打字的两帧补丁当摆臂 +const LEGS = { + stand: ['...pp..pp...', '...pp..pp...', '..kkk..kkk..'], + walkA: ['..pp....pp..', '.pp......pp.', '.kk......kk.'], + walkB: ['...pp..pp...', '...pp..pp...', '..kkk..kkk..'], +}; +const WALK_ARMS = { walkA: 'typeA', walkB: 'typeB' }; function charSprite(L, pose, frame, flip) { return sprite(`ch|${L.key}|${pose}|${frame}|${+flip}`, () => { - let rows = patch(BODY[pose], FRAMES[pose][frame]); + const legs = LEGS[frame]; + let rows = patch(BODY[pose], FRAMES[pose][legs ? WALK_ARMS[frame] : frame]); for (const k of L.acc) rows = patch(rows, ACC[k] && ACC[k][pose]); + if (legs) rows = rows.concat(legs); const cv = pix(rows, lookPal(L), flip); return { cv, ax: cv.width >> 1, ay: cv.height - 2 }; }); @@ -300,6 +325,8 @@ star: ['..y..', '.yyy.', 'yyyyy', '.yyy.', 'y...y'], z: ['yyyy', '..y.', '.y..', 'yyyy'], arrow: ['yyyyy', '.yyy.', '..y..'], + dot: ['yy', 'yy'], + glass: ['yyyyy', '.y.y.', '..y..', '.y.y.', 'yyyyy'], }; const miniSpr = (n, c) => sprite(`mi|${n}|${c}`, () => ({ cv: pix(MINI[n], { y: c }) })); const DIGITS = { @@ -309,7 +336,7 @@ 9: ['###', '#.#', '###', '..#', '###'], '+': ['...', '.#.', '###', '.#.', '...'], }; const digitSpr = (ch, c) => sprite(`dg|${ch}|${c}`, () => ({ cv: pix(DIGITS[ch], { '#': c }) })); -const ENV = ['wwwwwwwww', 'wWwwwwwWw', 'wwWwwwWww', 'wwwWrWwww', 'wwwwwwwww', 'wwwwwwwww']; +const ENV = ['wwwwwwwwwwwww', 'wWwwwwwwwwwWw', 'wwWwwwwwwwWww', 'wwwWwwwwwWwww', 'wwwwWwrwWwwww', 'wwwwwWrWwwwww', 'wwwwwwwwwwwww', 'wwwwwwwwwwwww']; const envSpr = () => sprite('env', () => ({ cv: pix(ENV, { w: '#fffdf4', W: '#cfc6b2', r: '#e5534b' }) })); const LEAF = [ '......l......', '..l...lG..l..', '..Gl.lGG.lG..', '...GllGGlG...', '.lGGgGGGgGGl.', 'lG..gGgGg..Gl', @@ -708,6 +735,7 @@ } if (!WINDOWS.some(w => w.p === 'x') && kinds.length) WALL_DECOR.push({ wall: 'x', kind: kinds.pop(), a: 38, ...decorData() }); BG = buildBG(); + buildBlockMap(); } function relayout(seed) { // 运行中换布局:保留谁坐哪个工位 const seats = Object.values(STN).filter(s => s.agent).map(s => [s.id, s.agent]); @@ -715,6 +743,68 @@ for (const [id, agent] of seats) if (STN[id]) STN[id].agent = agent; } +/* ---------- 6b. 走路:2 单位一格的占地图 + 8 向 BFS,家具外扩 1 单位不贴边走 ---------- */ +const GRID = 2, GW = RW / GRID, GH = RD / GRID, DOOR = [2, 59]; +let BLOCK = new Uint8Array(GW * GH); +function buildBlockMap() { + BLOCK = new Uint8Array(GW * GH); + for (const o of OBJ) { + for (let gy = Math.max(0, Math.floor((o.minY - 1) / GRID)); gy <= Math.min(GH - 1, Math.floor((o.maxY + 1 - .01) / GRID)); gy++) + for (let gx = Math.max(0, Math.floor((o.minX - 1) / GRID)); gx <= Math.min(GW - 1, Math.floor((o.maxX + 1 - .01) / GRID)); gx++) BLOCK[gy * GW + gx] = 1; + } +} +const cellOf = ([x, y]) => [Math.max(0, Math.min(GW - 1, Math.floor(x / GRID))), Math.max(0, Math.min(GH - 1, Math.floor(y / GRID)))]; +const cellPoint = i => [(i % GW) * GRID + GRID / 2, ((i / GW) | 0) * GRID + GRID / 2]; +function nearestFree(x, y) { // 目标落在家具里时,取最近的空格 + const [cx, cy] = cellOf([x, y]); + for (let r = 0; r < 10; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) { + if (Math.max(Math.abs(dx), Math.abs(dy)) !== r) continue; + const gx = cx + dx, gy = cy + dy; + if (gx >= 0 && gy >= 0 && gx < GW && gy < GH && !BLOCK[gy * GW + gx]) return gy * GW + gx; + } + return cy * GW + cx; +} +function findPath(from, to) { + const s = nearestFree(...from), t = nearestFree(...to), prev = new Int32Array(GW * GH).fill(-1), queue = [s]; + prev[s] = s; + for (let q = 0; q < queue.length && prev[t] < 0; q++) { + const i = queue[q], x = i % GW, y = (i / GW) | 0; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]) { + const nx = x + dx, ny = y + dy, n = ny * GW + nx; + if (nx < 0 || ny < 0 || nx >= GW || ny >= GH || prev[n] >= 0 || BLOCK[n]) continue; + if (dx && dy && (BLOCK[y * GW + nx] || BLOCK[ny * GW + x])) continue; // 不斜穿桌角 + prev[n] = i; queue.push(n); + } + } + if (prev[t] < 0) return [from, to]; + const cells = []; + for (let i = t; i !== s; i = prev[i]) cells.push(i); + cells.push(s); cells.reverse(); + const pts = [from]; // 只留拐点 + for (let k = 1; k < cells.length - 1; k++) { + const [ax, ay] = cellPoint(cells[k - 1]), [bx, by] = cellPoint(cells[k]), [cx, cy] = cellPoint(cells[k + 1]); + if (Math.sign(bx - ax) !== Math.sign(cx - bx) || Math.sign(by - ay) !== Math.sign(cy - by)) pts.push([bx, by]); + } + pts.push(cellPoint(t)); + const [ex, ey] = cellOf(to); + if (!BLOCK[ey * GW + ex]) pts.push(to); // 终点本身被家具占着时停在最近的空格 + return pts; +} +// 工位局部坐标 u 轴:人朝 +u(s>0)或 -u,椅子在人身后;身后是过道,桌对面是来访者站的位置 +const seatSpot = (S, du) => { const u = S.s > 0 ? du : -du; return S.axis === 'x' ? [S.x + u, S.y] : [S.x, S.y + u]; }; +const seatBehind = S => seatSpot(S, -7), deskFront = S => seatSpot(S, 15); +// stops: [{p:[x,y], wait, face, call}],相邻两站之间各寻一次路 +function route(stops) { + const out = [{ p: stops[0].p }]; + let last = stops[0].p; + for (const stop of stops.slice(1)) { + const legs = stop.direct ? [last, stop.p] : findPath(last, stop.p); + legs.slice(1).forEach((p, k) => out.push(k === legs.length - 2 ? { ...stop, p } : { p })); + last = legs[legs.length - 1]; + } + return out; +} + /* ---------- 7. 角色与状态 ---------- */ const ROLES = { conductor: { name: '调度', stat: null, screen: 'doc' }, @@ -749,16 +839,23 @@ function mkEl(cls) { const e = document.createElement('div'); e.className = cls; e.hidden = true; ov.appendChild(e); return e; } +const freeSeat = (prefer = '') => prefer && STN[prefer] && !STN[prefer].agent ? prefer + : Object.keys(STN).find(k => k !== 's_conductor' && !STN[k].agent); function addAgent(o) { - const seatId = o.seat && STN[o.seat] && !STN[o.seat].agent ? o.seat : Object.keys(STN).find(k => !STN[k].agent); + const seatId = o.seat && STN[o.seat] && !STN[o.seat].agent ? o.seat : freeSeat(); if (!seatId || AG[o.id]) return null; const lk = o.look || randomLook(); const L = { hair: lk.hair, skin: lk.skin, shirt: lk.shirt, pants: lk.pants || '#3a3f52', acc: lk.acc || [], accColor: lk.accColor || '' }; L.key = [L.hair, L.skin, L.shirt, L.pants, L.acc.join('+'), L.accColor].join('|'); const a = { id: o.id, name: o.name || o.id, role: ROLES[o.role] ? o.role : 'coder', look: L, seat: seatId, recruit: !!o.recruit, - state: 'idle', stateAt: simNow, text: '', textUntil: 0, pingUntil: 0, seed: (R() * 3000) | 0, hx: 0, hy: 0 }; + state: 'idle', stateAt: simNow, text: '', textUntil: 0, pingUntil: 0, hopUntil: 0, seed: (R() * 3000) | 0, hx: 0, hy: 0, + activityLabel: '', detail: '', icon: '', iconSvg: '', labelAt: simNow, walk: null, leaving: false, bubKey: '' }; STN[seatId].agent = a.id; AG[a.id] = a; a.bub = mkEl('bub'); + a.toolNode = document.createElement('img'); a.toolNode.className = 'office-tool'; a.toolNode.alt = ''; a.toolNode.hidden = true; + a.labelNode = document.createElement('span'); a.labelNode.className = 'bub-label'; + a.detailNode = document.createElement('span'); a.detailNode.className = 'bub-detail'; + a.bub.append(a.toolNode, a.labelNode, a.detailNode); rosterAdd(a); updateHire(); return a; } @@ -796,7 +893,8 @@ function send(from, to, label = '') { if (!AG[from] || !AG[to]) return; const el = label ? mkEl('tag') : null; if (el) el.textContent = label; - envelopes.push({ a: AG[from], b: AG[to], t0: simNow, dur: 950, el }); + AG[from].hopUntil = simNow + 450; + envelopes.push({ a: AG[from], b: AG[to], t0: simNow, dur: 1150, el }); } function floatNum(a, n, col) { floats.push({ x: a.hx + 6, y: a.hy + 2, t0: simNow, s: '+' + n, col }); } @@ -813,12 +911,16 @@ for(let i=0;i0?4:-6,2,8+i,3,3,1,i===layers-1&&actor.contextRatio>=.85?'#e8786b':i%2?'#d7ddec':'#fff8e6'); } } +// 工具类型 → 屏幕内容与动作:读类工具看屏幕(偶尔点头),写类工具敲键盘 +const SCREEN_BY_ICON = { TerminalReadWrite: 'term', Edit: 'code', Save: 'code', OpenFile: 'doc', Search: 'diff', BrowserGlobe: 'web', Eye: 'web', List: 'doc' }; +const READ_ICONS = new Set(['OpenFile', 'Search', 'BrowserGlobe', 'Eye', 'List', 'Embedding']); +const typing = a => a.state === 'type' || (a.state === 'work' && !READ_ICONS.has(a.icon)); function drawScreen(c, S) { // 只有背对镜头的人,屏幕才朝向我们 → 动态画内容 const a = S.agent && AG[S.agent]; const o = { ox: VIEW.ox + 2 * (S.x - S.y), oy: VIEW.oy + S.x + S.y }, ax = S.axis, u = -7; - if (!a) { sFaceU(c, o, ax, u, -4, 0, 11, 15, '#232a3d'); return; } - const sc = SCREEN[ROLES[a.role].screen]; - if (a.state !== 'work') { + if (!a || a.walk) { sFaceU(c, o, ax, u, -4, 0, 11, 15, '#232a3d'); return; } + const sc = SCREEN[a.state === 'type' ? 'doc' : SCREEN_BY_ICON[a.icon] || ROLES[a.role].screen]; + if (a.state !== 'work' && a.state !== 'type') { sFaceU(c, o, ax, u, -4, 0, 11, 15, '#3f7ae0'); sLineV(c, o, ax, u, ax === 'x' ? -1 : -4, ax === 'x' ? 0 : -3, 14, '#9cc2ff'); return; @@ -832,17 +934,27 @@ }); } function drawAgent(c, S) { - const a = S.agent && AG[S.agent]; if (!a) return; + const a = S.agent && AG[S.agent]; if (!a || a.walk) return; const pose = S.s > 0 ? 'front' : 'back', flip = S.axis === 'y'; - let frame = 'idle'; - if (a.state === 'work') frame = ((simNow / 170) | 0) % 2 ? 'typeA' : 'typeB'; + let frame = 'idle', hop = 0; + if (typing(a)) frame = ((simNow / (a.state === 'type' ? 120 : 170)) | 0) % 2 ? 'typeA' : 'typeB'; else if (pose === 'front' && (simNow + a.seed) % 3300 < 140) frame = 'blink'; - const hop = a.state === 'done' && ((simNow / 140) | 0) % 4 < 2 ? 2 : 0; + if (a.state === 'work' && !typing(a) && (simNow + a.seed) % 1400 < 220) hop = 1; // 读东西时偶尔点头 + if ((a.state === 'done' || simNow < a.hopUntil) && ((simNow / 140) | 0) % 4 < 2) hop = 2; const spr = charSprite(a.look, pose, frame, flip); const [sx, sy] = P(VIEW, S.x, S.y, 4); c.drawImage(spr.cv, sx - spr.ax, sy - spr.ay - hop); blits++; a.hx = sx; a.hy = sy - spr.ay - hop; } +function drawWalker(c, a) { + const w = a.walk, d = w.dir; + const pose = d === '+x' || d === '+y' ? 'front' : 'back', flip = d === '+y' || d === '-y'; + const step = ((simNow / 150) | 0) % 2; + const spr = charSprite(a.look, pose, w.moving ? (step ? 'walkA' : 'walkB') : 'stand', flip); + const [sx, sy] = P(VIEW, w.x, w.y, 0), bob = w.moving && step ? 1 : 0; + c.drawImage(spr.cv, Math.round(sx - spr.ax), Math.round(sy - spr.ay - bob)); blits++; + a.hx = Math.round(sx); a.hy = Math.round(sy - spr.ay - bob); +} function leds(c, rx) { const t = (simNow / 260) | 0; for (let r = 0; r < 6; r++) for (let k = 0; k < 2; k++) { @@ -916,13 +1028,51 @@ robot.moving = true; if (dist <= sp) { robot.x = tx; robot.y = ty; robot.i = (robot.i + 1) % robot.path.length; robot.wait = 900 + R() * 1800; - if (R() < .4) { robot.text = pick(BOT_LINES); robot.textUntil = simNow + 2200; } + // 有人在忙时机器人不闲聊,免得和真实状态气泡抢位置 + if (R() < .4 && !Object.values(AG).some(a => a.state in BUBBLE_CLASS)) { robot.text = pick(BOT_LINES); robot.textUntil = simNow + 2200; } } else { robot.x += dx / dist * sp; robot.y += dy / dist * sp; robot.dir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? '+x' : '-x') : (dy > 0 ? '+y' : '-y'); } } +// 走动的组员:进门领任务、回工位、交结果、出门;占用与机器人同样的深度排序 +const WALK_SPEED = 0.032; // 世界单位 / 毫秒,穿过房间约 2–3 秒 +const walkers = new Set(); +function startWalk(a, path, done) { + const [x, y] = path[0].p; + a.walk = { path, i: 1, x, y, dir: a.walk ? a.walk.dir : '+x', moving: false, pause: 0, done, + obj: { h: 20, minX: 0, maxX: 0, minY: 0, maxY: 0, draw: c => a.walk && drawWalker(c, a) } }; + walkers.add(a); +} +const faceDir = (fx, fy, tx, ty) => Math.abs(tx - fx) > Math.abs(ty - fy) ? (tx > fx ? '+x' : '-x') : (ty > fy ? '+y' : '-y'); +function updWalkers(dt) { + for (const a of [...walkers]) { + const w = a.walk; + if (!w) { walkers.delete(a); continue; } + if (w.pause > 0) { w.pause -= dt; w.moving = false; continue; } + if (w.i >= w.path.length) { walkers.delete(a); a.walk = null; w.done && w.done(); continue; } + const stop = w.path[w.i], [tx, ty] = stop.p, dx = tx - w.x, dy = ty - w.y, dist = Math.hypot(dx, dy), sp = dt * WALK_SPEED; + w.moving = true; + if (dist > .01) w.dir = faceDir(w.x, w.y, tx, ty); + if (dist <= sp) { + w.x = tx; w.y = ty; w.i++; + if (stop.face) w.dir = faceDir(w.x, w.y, ...stop.face); + if (stop.wait) { w.pause = stop.wait; w.moving = false; } + if (stop.call) stop.call(); + } else { w.x += dx / dist * sp; w.y += dy / dist * sp; } + } +} +function walkerObjs() { + const out = []; + for (const a of walkers) { + const w = a.walk; if (!w) continue; + Object.assign(w.obj, { minX: w.x - 2, maxX: w.x + 2, minY: w.y - 2, maxY: w.y + 2 }); + out.push(w.obj); + } + return out; +} + // 深度排序:按等距足迹做拓扑排序(只比较屏幕上重叠的对象) const sbb = o => [2 * (o.minX - o.maxY), 2 * (o.maxX - o.minY), o.minX + o.minY - o.h, o.maxX + o.maxY]; function behind(a, b) { @@ -944,13 +1094,18 @@ } function drawOverlays(c) { - // 信封(agent 之间的消息) + // 信封(agent 之间的消息):带两格残影的抛物线,落地时收件人头顶冒「!」 + const env = envSpr(), arc = t => Math.sin(Math.PI * t) * 26; for (let i = envelopes.length - 1; i >= 0; i--) { const e = envelopes[i], t = Math.min(1, (simNow - e.t0) / e.dur); - const x = e.a.hx + (e.b.hx - e.a.hx) * t, y = e.a.hy + 4 + (e.b.hy - e.a.hy) * t - Math.sin(Math.PI * t) * 22; - const s = envSpr(); c.drawImage(s.cv, Math.round(x - 5), Math.round(y - 4)); blits++; - e.x = x; e.y = y - 4; - if (t >= 1) { e.b.pingUntil = simNow + 600; if (e.el) e.el.remove(); envelopes.splice(i, 1); } + const at = u => [e.a.hx + (e.b.hx - e.a.hx) * u, e.a.hy + 4 + (e.b.hy - e.a.hy) * u - arc(u)]; + c.globalAlpha = .35; + for (const lag of [.16, .08]) if (t > lag) { const [gx, gy] = at(t - lag); c.drawImage(env.cv, Math.round(gx - 7), Math.round(gy - 5)); } + c.globalAlpha = 1; + const [x, y] = at(t); + c.drawImage(env.cv, Math.round(x - 7), Math.round(y - 5)); blits++; + e.x = x; e.y = y - 5; + if (t >= 1) { e.b.pingUntil = simNow + 900; if (e.el) e.el.remove(); envelopes.splice(i, 1); } } // 头顶状态图标 const bobT = ((simNow / 260) | 0) % 2; @@ -958,12 +1113,18 @@ if (!a.hx) continue; let n = null, col = '#ffe14d'; if (simNow < a.pingUntil) n = 'ex'; - else if (a.state === 'think') n = 'q'; + else if (a.state === 'question') n = 'q'; else if (a.state === 'error') { n = 'ex'; col = '#ff5a4f'; } else if (a.state === 'done') n = 'star'; else if (a.state === 'sleep') { n = 'z'; col = '#cfe9ff'; } - else if (a.state === 'permission' || a.state === 'question') { n = 'ex'; col = '#ffe14d'; } + else if (a.state === 'permission') n = 'ex'; + else if (a.state === 'wait') { n = 'glass'; col = '#9fd4ff'; } if (n) { const s = miniSpr(n, col); c.drawImage(s.cv, a.hx + 5, a.hy - 3 - bobT); if (a.state === 'sleep') { c.drawImage(s.cv,a.hx+10,a.hy-6-bobT);c.drawImage(s.cv,a.hx+15,a.hy-9-bobT); } blits++; } + else if (a.state === 'think' || a.state === 'compact' || a.state === 'retry') { // 思考:三个点轮流跳 + const lit = ((simNow / 280) | 0) % 3; + for (let k = 0; k < 3; k++) { const s = miniSpr('dot', k === lit ? '#ffffff' : '#b9c3dc'); c.drawImage(s.cv, a.hx + 5 + k * 4, a.hy - 2 - (k === lit ? 1 : 0)); } + blits++; + } if (a.id === selected) { const s = miniSpr('arrow', '#ffe14d'); c.drawImage(s.cv, a.hx - 3, a.hy - 10 - bobT * 2); blits++; } } // 飘字 +N @@ -973,19 +1134,89 @@ [...f.s].forEach((ch, k) => { const s = digitSpr(ch, f.col); c.drawImage(s.cv, Math.round(f.x + k * 4), Math.round(f.y - t * 12)); blits++; }); } } +// 状态气泡:忙碌期间常驻。一行「图标 动作 · 细节」,细节是正在写的最后几个字或调用的路径/命令。 +// 先放优先级高的(求助 > 主 agent > 正在输出 > 刚变化),撞上就往上叠,叠不下改成只显示动作。 +const BUBBLE_CLASS = { think: 'think', compact: 'think', retry: 'think', type: 'type', work: '', wait: 'wait', + permission: 'alert', question: 'alert', error: 'bad', stopped: 'bad', done: 'flash' }; +const ICON_STATES = new Set(['work', 'wait', 'permission', 'type']); +function bubbleContent(a) { + if (a.text && simNow < a.textUntil) return { cls: 'flash', label: a.text, detail: '', icon: '', prio: 5 }; + if (!(a.state in BUBBLE_CLASS)) return null; // 待命、zzz 不说话 + const alert = a.state === 'permission' || a.state === 'question' || a.state === 'error' || a.state === 'stopped'; + return { cls: BUBBLE_CLASS[a.state], label: a.activityLabel || STATE_TXT[a.state] || '', detail: a.detail || '', + icon: ICON_STATES.has(a.state) ? a.iconSvg : '', + prio: alert ? 4 : a.role === 'conductor' ? 3 : a.state === 'type' ? 2 : simNow - a.labelAt < 4000 ? 1 : 0 }; +} +// 气泡放不下时从开头裁:正在写的回复要看最新的字,路径要看文件名 +function fitDetailTail(node, text) { + if (!text || node.scrollWidth <= node.clientWidth + 1) return; + const chars = [...text.replace(/^…/, '')]; + let lo = 1, hi = chars.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + node.textContent = '…' + chars.slice(mid).join(''); + if (node.scrollWidth > node.clientWidth + 1) lo = mid + 1; else hi = mid; + } + node.textContent = '…' + chars.slice(lo).join(''); +} +function renderBubble(a, c) { + const key = [c.cls, c.label, c.detail, c.icon, a.id === selected, innerWidth].join('\u0001'); + if (key === a.bubKey) return; + a.bubKey = key; + a.bub.className = 'bub' + (c.cls ? ' ' + c.cls : '') + (a.id === selected ? ' picked' : ''); + a.labelNode.textContent = c.label; + a.detailNode.textContent = c.detail; a.detailNode.hidden = !c.detail; + if (c.icon) { + if (a.iconFor !== c.icon) { a.iconFor = c.icon; a.toolNode.src = safeIcon(c.icon); } + a.toolNode.alt = a.tool || ''; a.toolNode.hidden = false; + } else a.toolNode.hidden = true; + a.bub.hidden = false; a.bub.style.left = '0px'; // 在左边缘量,免得被窗口右侧挤窄 + fitDetailTail(a.detailNode, c.detail); + a.bubFullW = a.bub.offsetWidth; a.bubH = a.bub.offsetHeight; + a.bub.classList.add('compact'); a.bubCompactW = a.bub.offsetWidth; a.bub.classList.remove('compact'); +} +function layoutBubbles(k) { + const W = innerWidth, minTop = k * 30, items = []; + for (const a of Object.values(AG)) { + const c = a.hx ? bubbleContent(a) : null; + if (!c) { if (!a.bub.hidden) { a.bub.hidden = true; a.bubKey = ''; } continue; } + renderBubble(a, c); items.push([c.prio, a]); + } + items.sort((x, y) => y[0] - x[0] || y[1].labelAt - x[1].labelAt); + const placed = []; + const hits = r => placed.some(p => r.left < p.right + 2 && p.left < r.right + 2 && r.top < p.bottom + 2 && p.top < r.bottom + 2); + // 候选位置:头顶居中 → 往左/右错开(尾巴仍指着头)→ 抬高一层/两层(用引线连回头顶) + const rectAt = (a, compact, lift, side) => { + const w = compact ? a.bubCompactW : a.bubFullW, h = a.bubH, cx = a.hx * k, tip = (a.hy - 1) * k - 7; + const want = side < 0 ? cx - w + 14 : side > 0 ? cx - 14 : cx - w / 2; + const left = Math.max(2, Math.min(W - w - 2, want)), top = Math.max(minTop, tip - h - lift * (h + 3)); + return { left, top, right: left + w, bottom: top + h, compact, cx, lead: Math.max(0, tip - h - top) }; + }; + for (const [, a] of items) { + let best = null; + for (const compact of [false, true]) for (let lift = 0; lift < 3 && !best; lift++) + for (const side of [0, -1, 1]) { const r = rectAt(a, compact, lift, side); if (!hits(r)) { best = r; break; } } + best = best || rectAt(a, true, 0, 0); + placed.push(best); + a.bub.classList.toggle('compact', best.compact); + a.bub.style.left = best.left + 'px'; a.bub.style.top = best.top + 'px'; + a.bub.style.setProperty('--tail', Math.max(8, Math.min(best.right - best.left - 8, best.cx - best.left)) + 'px'); + a.bub.style.setProperty('--lead', best.lead + 'px'); + } + // 原生窗口的形状区域也裁剪绘制:气泡(含尾巴和引线)范围变化时节流上报 + const r3 = v => Math.round(v * 1000) / 1000, H = innerHeight; + const rects = placed.map(p => [r3(p.left / W), r3(p.top / H), r3(p.right / W), r3(Math.min(H, p.bottom + 8 + p.lead) / H)]); + if (!robot.bub.hidden) { const b = robot.bub.getBoundingClientRect(); rects.push([r3(b.left / W), r3(b.top / H), r3(b.right / W), r3((b.bottom + 8) / H)]); } + const key = JSON.stringify(rects); + if (key !== bubbleRegionKey && (simNow - bubbleRegionAt > 150 || rects.length !== bubbleRegions.length)) { + bubbleRegionKey = key; bubbleRegionAt = simNow; bubbleRegions = rects; reportOfficeRegions(); + } +} +let bubbleRegions = [], bubbleRegionKey = '', bubbleRegionAt = -1e9; function placeDom() { const k = cv.clientWidth / VIEW.w; if (!k) return; const put = (el, x, y) => { el.style.left = (x * k) + 'px'; el.style.top = (y * k) + 'px'; }; - for (const a of Object.values(AG)) { - if (a.text && simNow < a.textUntil && a.hx) { - if (a.bub.textContent !== a.text) a.bub.textContent = a.text; - a.bub.hidden = false; put(a.bub, a.hx, a.hy - 2); - } else a.bub.hidden = true; - if (a.toolNode) { - a.toolNode.hidden = !a.tool || a.state !== 'work'; - put(a.toolNode,a.hx-9,a.hy-12); - } - } + layoutBubbles(k); if (robot.text && simNow < robot.textUntil) { robot.bub.textContent = robot.text; robot.bub.hidden = false; put(robot.bub, robot.hx, robot.hy - 1); } else robot.bub.hidden = true; for (const e of envelopes) if (e.el && e.x !== undefined) { e.el.hidden = false; put(e.el, e.x, e.y); } @@ -996,7 +1227,7 @@ ctx.drawImage(BG, 0, 0); drawWalls(ctx); robotObj.minX = robot.x - 2; robotObj.maxX = robot.x + 2; robotObj.minY = robot.y - 2; robotObj.maxY = robot.y + 2; - for (const o of depthSort(OBJ.concat(robotObj))) o.draw(ctx); + for (const o of depthSort(OBJ.concat(robotObj, walkerObjs()))) o.draw(ctx); drawOverlays(ctx); placeDom(); } @@ -1006,13 +1237,15 @@ const wait = ms => new Promise(r => waiters.push({ t: simNow + ms, r })); const FRAME_MS = 33; // 常驻桌面:像素动画本来就是 150ms 级步进,约 30fps 足够,省 CPU/GPU let last = performance.now(), sinceDraw = FRAME_MS; +function advance(dt) { + simNow += dt; + for (let i = waiters.length - 1; i >= 0; i--) if (waiters[i].t <= simNow) { const w = waiters[i]; waiters.splice(i, 1); w.r(); } + updRobot(dt); + updWalkers(dt); +} function loop(ts) { const dt = Math.min(64, ts - last); last = ts; - if (!paused) { - simNow += dt * speed; - for (let i = waiters.length - 1; i >= 0; i--) if (waiters[i].t <= simNow) { const w = waiters[i]; waiters.splice(i, 1); w.r(); } - updRobot(dt * speed); - } + if (!paused) advance(dt * speed); if ((sinceDraw += dt) >= FRAME_MS) { sinceDraw = 0; render(); presentUpscale(); notifyReady(); } requestAnimationFrame(loop); } @@ -1117,18 +1350,64 @@ let officeSnapshot = {version:1,follow:true,connected:false,offices:[],agents:[],selected:null}; let officeIdentity = '', liveReady = false; const seenTransfers = new Set(); +const TRANSFER_TAG = {NEW_TASK:'新任务', MESSAGE:'消息', FINAL_ANSWER:'回报'}; const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); function action(value) {post(JSON.stringify(value));} function removeAgent(id) { const a=AG[id];if(!a)return; - if(STN[a.seat])STN[a.seat].agent=null; - a.bub.remove();a.toolNode?.remove();delete AG[id]; + if(STN[a.seat]&&STN[a.seat].agent===id)STN[a.seat].agent=null; + a.walk=null;walkers.delete(a); + a.bub.remove();delete AG[id]; if(selected===id)selected=null; } function clearAgents() { for(const id of Object.keys(AG))removeAgent(id); for(const e of envelopes)e.el?.remove(); - envelopes.length=0;floats.length=0; + envelopes.length=0;floats.length=0;walkers.clear(); +} +// 进出场:只在本办公室已经显示过一帧真实快照后才演(切换办公室、重连恢复都直接落座) +const motionOK=()=>liveReady&&!reducedMotion.matches; +const shortName=name=>{const s=[...String(name||'')];return s.length>8?s.slice(0,7).join('')+'…':s.join('');}; +function bossOf(item,visible) { + const parent=item.parentPath&&visible.find(x=>x.path===item.parentPath&&AG[x.id]&&!AG[x.id].leaving); + const boss=parent||visible.find(x=>x.root); + return boss&&AG[boss.id]&&boss.id!==item.id?AG[boss.id]:null; +} +const posOf=a=>a.walk?[a.walk.x,a.walk.y]:STN[a.seat]?[STN[a.seat].x,STN[a.seat].y]:DOOR; +// 新成员:进门 → 到派活的人桌前领信封 → 绕到自己椅子后面 → 坐下 +function enterOffice(a,boss,from=DOOR) { + const S=STN[a.seat]; + if(!S)return; + if(!motionOK()) { // 减少动态效果:不走路,信封直接从派活的人飞到工位 + if(boss&&liveReady){send(boss.id,a.id,'任务');say(boss.id,'派给 '+shortName(a.name),2200);} + return; + } + const stops=[{p:from}]; + const B=boss&&!boss.walk&&STN[boss.seat]; + if(B) stops.push({p:deskFront(B),wait:1000,face:[B.x,B.y],call:()=>{send(boss.id,a.id,'任务');say(boss.id,'交给你了',1500);}}); + stops.push({p:seatBehind(S)},{p:[S.x,S.y],direct:true}); + startWalk(a,route(stops),null); +} +// 离开:完成的人先到上级桌前交回报,再出门;失败/停止的人直接出门 +function leaveOffice(a,boss,report) { + const S=STN[a.seat]; + if(S&&S.agent===a.id)S.agent=null; + a.leaving=true; + const from=posOf(a),stops=[{p:from}]; + if(S&&!a.walk)stops.push({p:seatBehind(S),direct:true}); + const B=report&&boss&&!boss.walk&&STN[boss.seat]; + if(B) stops.push({p:deskFront(B),wait:1000,face:[B.x,B.y],call:()=>{send(a.id,boss.id,'回报');say(boss.id,'收到 '+shortName(a.name)+' 的结果',2600);}}); + stops.push({p:DOOR}); + startWalk(a,route(stops),()=>removeAgent(a.id)); +} +// 正在往外走的人又接到任务:掉头回工位 +function returnToSeat(a,boss) { + const seat=freeSeat(a.seat); + if(!seat){removeAgent(a.id);return false;} + a.leaving=false;a.seat=seat;STN[seat].agent=a.id; + enterOffice(a,boss,posOf(a)); + if(!motionOK()){a.walk=null;walkers.delete(a);} + return true; } function stableLook(seed) { const rand=rng(seed), choose=items=>items[Math.floor(rand()*items.length)]; @@ -1150,41 +1429,46 @@ relayout(Number(next.seed)||1); } officeSnapshot=next; - const visible=next.agents.slice(0,8), ids=new Set(visible.map(a=>a.id)); - for(const id of Object.keys(AG))if(!ids.has(id)){ - const root=visible.find(a=>a.root); - if(liveReady&&root&&(next.completed||[]).includes(id)&&!reducedMotion.matches)send(id,root.id); - removeAgent(id); + const visible=next.agents.slice(0,8), ids=new Set(visible.map(a=>a.id)), completed=new Set(next.completed||[]); + for(const id of Object.keys(AG)) { + const a=AG[id]; + if(ids.has(id)||a.leaving)continue; + const item={id,parentPath:a.parentPath||''}, boss=bossOf(item,visible), report=completed.has(id); + if(motionOK()) {a.state=report?'done':a.state;a.activityLabel=report?'已完成':a.activityLabel;a.detail='';leaveOffice(a,boss,report);} + else { + if(liveReady&&report&&boss){send(id,boss.id,'回报');say(boss.id,'收到 '+shortName(a.name)+' 的结果',2600);} + removeAgent(id); + } } for(const item of visible) { let a=AG[item.id]; + if(a&&a.leaving&&!returnToSeat(a,bossOf(item,visible)))a=null; if(!a) { a=addAgent({id:item.id,name:item.name,role:item.root?'conductor':'coder',seat:item.root?'s_conductor':undefined, look:item.root?{hair:'#3b2a22',skin:'#f7d5b5',shirt:'#2d406e',pants:'#262b38',acc:['tie']}:stableLook(item.seed)}); if(!a)continue; - a.toolNode=document.createElement('img');a.toolNode.className='office-tool';a.toolNode.hidden=true;ov.appendChild(a.toolNode); - if(liveReady&&!item.root&&!reducedMotion.matches) {const root=visible.find(x=>x.root);if(root)send(root.id,item.id);} + a.parentPath=item.parentPath||''; + if(liveReady&&!item.root)enterOffice(a,bossOf(item,visible)); } - const prior=a.state; - a.name=item.name;a.contextKnown=item.contextKnown;a.contextRatio=item.contextRatio; - a.activityLabel=item.label;a.tool=item.tool; - if(item.iconSvg&&a.iconSvg!==item.iconSvg){a.iconSvg=item.iconSvg;a.toolNode.src=safeIcon(item.iconSvg);} - a.toolNode.alt=item.tool||''; + const key=[item.state,item.label,item.tool].join('|'); + if(key!==a.activityKey){a.activityKey=key;a.labelAt=simNow;} + a.name=item.name;a.contextKnown=item.contextKnown;a.contextRatio=item.contextRatio;a.parentPath=item.parentPath||''; + a.activityLabel=item.label;a.detail=item.detail||'';a.tool=item.tool;a.icon=item.icon||'';a.iconSvg=item.iconSvg||''; if(liveReady&&item.compactId&&item.compactId!==a.compactId) { robot.text='整理好旧文件了';robot.textUntil=simNow+2600; } a.compactId=item.compactId; setState(a.id,item.state); - if(prior!==item.state&&['permission','question','error','stopped'].includes(item.state))say(a.id,item.label,5000); if(item.state==='compact'){robot.text='整理上下文';robot.textUntil=simNow+1600;} for(const transfer of item.transfers||[]) { const key=item.id+':'+transfer.seq; if(seenTransfers.has(key))continue; seenTransfers.add(key); - if(!liveReady||reducedMotion.matches)continue; + if(!liveReady)continue; const from=visible.find(x=>x.id===transfer.sender_session_id||x.path===transfer.sender); const to=visible.find(x=>x.path===transfer.recipient)||item; - if(from&&to)send(from.id,to.id); + if(!from||!to||from.id===to.id||!AG[from.id]||!AG[to.id])continue; + send(from.id,to.id,TRANSFER_TAG[transfer.type]||'消息'); } } while(seenTransfers.size>256)seenTransfers.delete(seenTransfers.values().next().value); @@ -1217,9 +1501,9 @@ $('officeFollow').addEventListener('click',()=>action({type:'follow',follow:!officeSnapshot.follow})); function reportOfficeRegions() { const rects=[$('officeMembers'),$('officeNotice')].filter(node=>node&&!node.hidden).map(node=>node.getBoundingClientRect()); - if(!rects.length){action({type:'overlay',rect:null});return;} - action({type:'overlay',rect:[Math.min(...rects.map(r=>r.left))/innerWidth,Math.min(...rects.map(r=>r.top))/innerHeight, - Math.max(...rects.map(r=>r.right))/innerWidth,Math.max(...rects.map(r=>r.bottom))/innerHeight]}); + const rect=rects.length?[Math.min(...rects.map(r=>r.left))/innerWidth,Math.min(...rects.map(r=>r.top))/innerHeight, + Math.max(...rects.map(r=>r.right))/innerWidth,Math.max(...rects.map(r=>r.bottom))/innerHeight]:null; + action({type:'overlay',rect,bubbles:bubbleRegions}); } function syncOfficeMembers() { const list=$('officeMembers'); @@ -1239,7 +1523,14 @@ cv.addEventListener('dblclick',()=>{if(selected)action({type:'open',session_id:selected,workspace_hash:officeSnapshot.selected?.workspaceHash||''});}); // A stable empty room appears until the first real snapshot; no demo workers. buildLayout(1); -window.AgentOffice={applySnapshot:applyOfficeSnapshot,get snapshot(){return officeSnapshot;},get agents(){return Object.values(AG).map(a=>({id:a.id,name:a.name,state:a.state,contextRatio:a.contextRatio,look:a.look.key,seat:a.seat}));},get layoutSignature(){return JSON.stringify({theme:THEME,seats:Object.values(STN).map(s=>[s.id,s.x,s.y,s.axis,s.s])});},layout:seed=>relayout(seed)}; +window.AgentOffice={applySnapshot:applyOfficeSnapshot,get snapshot(){return officeSnapshot;}, + get agents(){return Object.values(AG).map(a=>({id:a.id,name:a.name,state:a.state,contextRatio:a.contextRatio,look:a.look.key,seat:a.seat, + walking:!!a.walk,leaving:a.leaving,bubble:a.bub.hidden?'':a.bub.textContent,compact:a.bub.classList.contains('compact')}));}, + get envelopes(){return envelopes.map(e=>({from:e.a.id,to:e.b.id,label:e.el?e.el.textContent:''}));}, + get layoutSignature(){return JSON.stringify({theme:THEME,seats:Object.values(STN).map(s=>[s.id,s.x,s.y,s.axis,s.s])});}, + // 测试用:按 32ms 步长推进模拟时间(走路、信封、等待)后重画一帧 + advance(ms){for(let left=ms;left>0;left-=32)advance(Math.min(32,left));render();presentUpscale();}, + layout:seed=>relayout(seed)}; fit(); requestAnimationFrame(t=>{last=t;requestAnimationFrame(loop);}); diff --git a/assets/desktop_pet/surface.md b/assets/desktop_pet/surface.md index 3ef3820df..795e67192 100644 --- a/assets/desktop_pet/surface.md +++ b/assets/desktop_pet/surface.md @@ -18,14 +18,20 @@ | 真实状态 | 场景表达 | | --- | --- | | 尚未开始执行 | 待命;没有演示角色补位 | -| 推理、调用工具 | 思考或工作;工具执行时显示与聊天共源的图标 | +| 推理 | 思考点轮流跳;气泡显示「思考中」或具体进度标题,以及推理最后几个词 | +| 输出正文 | 打字动画;气泡显示「撰写回复」与最新几个字和光标,两次快照之间由 WebSocket token 实时补字 | +| 调用工具 | 读类工具看屏幕点头、写类工具敲键盘;气泡显示与聊天共源的图标、动作和调用预览 | +| 等成员回报 | 沙漏;气泡显示「等成员回报」 | | 等待授权、等待回答、重试 | 保留各自的明确状态文案 | | 主 agent 成功完成 | 留在主工位,显示 `zzz`;下一轮开始后恢复执行状态 | -| 子 agent 成功完成 | 离开工位;后续任务出现时再按真实成员恢复 | +| 子 agent 加入 | 从门口走进,到派活的人桌前领「任务」信封,再回工位 | +| 子 agent 成功完成 | 走到派活的人桌前递「回报」信封后出门;后续任务出现时再按真实成员恢复 | | 执行失败或停止 | 本轮保留失败或停止状态,不能表现为成功休息 | | 整理上下文 | 显示整理状态及机器人提示;文件堆随后跟随新上下文比例 | -文件堆反映当前上下文使用量相对窗口容量的比例,不使用会话累计消耗量。容量未知时不绘制文件堆,不能把未知当作零用量。消息信封由真实成员变化或消息记录触发;减少动态效果偏好下抑制该动效。 +文件堆反映当前上下文使用量相对窗口容量的比例,不使用会话累计消耗量。容量未知时不绘制文件堆,不能把未知当作零用量。消息信封由真实成员变化或消息记录触发;减少动态效果偏好下不走路,信封仍然飞过(它表达谁和谁在交接,不是装饰)。 + +状态气泡常驻于忙碌的 agent 头顶,按「求助 > 主 agent > 正在输出 > 刚变化」的优先级摆放:撞上就左右错开或抬高(引线连回头顶),再放不下只显示动作。有人忙时机器人不闲聊。 ## 桌面交互 diff --git a/cmake/acecode_desktop.cmake b/cmake/acecode_desktop.cmake index 18a26d9bd..7e3326e91 100644 --- a/cmake/acecode_desktop.cmake +++ b/cmake/acecode_desktop.cmake @@ -71,6 +71,10 @@ FetchContent_MakeAvailable(webview) # acecode_testable 把 agent/TUI/web assets 全部拖入桌面壳。 if(APPLE) set(ACECODE_AGENT_BROWSER_HOST_SOURCE ${ACECODE_AGENT_BROWSER_HOST_MAC_SOURCE}) + # The macOS office host is the only ARC translation unit in this target. + set(ACECODE_DESKTOP_PET_SOURCE ${ACECODE_DESKTOP_PET_MAC_SOURCE}) + set_source_files_properties(${ACECODE_DESKTOP_PET_MAC_SOURCE} + PROPERTIES COMPILE_OPTIONS "-fobjc-arc") endif() set(ACECODE_DESKTOP_SOURCES @@ -132,6 +136,9 @@ if(WIN32) target_link_libraries(acecode-desktop PRIVATE ole32 shell32 user32 gdi32 dwmapi ) +endif() + +if(WIN32 OR APPLE) set(ACECODE_DESKTOP_PET_PAGE "${CMAKE_SOURCE_DIR}/assets/desktop_pet/agent_office_pet.html") set(ACECODE_DESKTOP_PET_PAGE_CPP "${CMAKE_BINARY_DIR}/generated/desktop_pet_page_embedded.cpp") add_custom_command( diff --git a/cmake/acecode_layer_libraries.cmake b/cmake/acecode_layer_libraries.cmake index edac43a2e..79d748e64 100644 --- a/cmake/acecode_layer_libraries.cmake +++ b/cmake/acecode_layer_libraries.cmake @@ -164,7 +164,8 @@ set(ACECODE_DESKTOP_BINARY_ONLY_SOURCES ${ACECODE_DEEPIN_WINDOW_EFFECTS_SOURCE} ${ACECODE_DESKTOP_LINUX_SOURCE} ${ACECODE_AGENT_BROWSER_HOST_SOURCE} ${ACECODE_AGENT_BROWSER_HOST_MAC_SOURCE} ${ACECODE_DESKTOP_MAIN_SOURCE} ${ACECODE_DESKTOP_SPLASH_SOURCE} - ${ACECODE_DESKTOP_WEB_HOST_SOURCE} ${ACECODE_DESKTOP_PET_SOURCE}) + ${ACECODE_DESKTOP_WEB_HOST_SOURCE} ${ACECODE_DESKTOP_PET_SOURCE} + ${ACECODE_DESKTOP_PET_MAC_SOURCE}) acecode_collect_group_sources(_desktop apps/desktop) list(REMOVE_ITEM _desktop ${ACECODE_DESKTOP_BINARY_ONLY_SOURCES}) acecode_add_layer_library(acecode_desktop_support apps ${_desktop}) diff --git a/cmake/acecode_source_paths.cmake b/cmake/acecode_source_paths.cmake index 30f340a89..95d7a1dfe 100644 --- a/cmake/acecode_source_paths.cmake +++ b/cmake/acecode_source_paths.cmake @@ -8,6 +8,7 @@ set(ACECODE_DESKTOP_MAIN_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/main.cpp") set(ACECODE_DESKTOP_SPLASH_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/splash_screen.cpp") set(ACECODE_DESKTOP_WEB_HOST_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/web_host.cpp") set(ACECODE_DESKTOP_PET_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/desktop_pet.cpp") +set(ACECODE_DESKTOP_PET_MAC_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/desktop_pet_mac.mm") set(ACECODE_DESKTOP_LINUX_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/linux_desktop.cpp") set(ACECODE_AGENT_BROWSER_HOST_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/agent_browser_host.cpp") set(ACECODE_AGENT_BROWSER_HOST_MAC_SOURCE "${CMAKE_SOURCE_DIR}/src/apps/desktop/agent_browser_host_mac.mm") @@ -87,6 +88,7 @@ acecode_require_sources("shared target source paths" ${ACECODE_DESKTOP_SPLASH_SOURCE} ${ACECODE_DESKTOP_WEB_HOST_SOURCE} ${ACECODE_DESKTOP_PET_SOURCE} + ${ACECODE_DESKTOP_PET_MAC_SOURCE} ${ACECODE_DESKTOP_LINUX_SOURCE} ${ACECODE_AGENT_BROWSER_HOST_SOURCE} ${ACECODE_AGENT_BROWSER_HOST_MAC_SOURCE} diff --git a/docs/daemon-api.md b/docs/daemon-api.md index 583d4171d..8b4be394b 100644 --- a/docs/daemon-api.md +++ b/docs/daemon-api.md @@ -566,9 +566,11 @@ disabled, the write is silently ignored. `offices` 最多五个:跨工作区按 `last_user_message_at` 降序,排除归档、子会话和没有真实用户输入的会话。打开会话、模型输出、隐藏提示及 agent 间消息不推进此时间;旧记录从 JSONL 逆向读取后缓存。目录或旧记录仍在加载时 `complete=false`,客户端可继续读取;空目录返回空数组及 `selected:null`。分叉继承保留消息的原始时间。 -`selected` 和 `agents` 只传身份、标题、所属工作区、真实状态和当前上下文用量等展示字段;不含输入草稿、工具参数、工具输出或历史正文。`agents` 包含根会话和当前 daemon 已装载的直属/网状子会话;网状子会话的 `parent_session_id` 是树根,`agent_path` 保留实际层级。已成功结束的子会话可仍出现在原始快照中,由桌宠投影立即离座。标题/摘要限 240 UTF-8 字节。 +`selected` 和 `agents` 只传身份、标题、所属工作区、真实状态和当前上下文用量等展示字段;不含输入草稿、原始工具参数、工具输出或历史正文(下文 `activity.detail` / `activity.text` 两个有界展示片段除外)。`agents` 包含根会话和当前 daemon 已装载的直属/网状子会话;网状子会话的 `parent_session_id` 是树根,`agent_path` 保留实际层级。已成功结束的子会话可仍出现在原始快照中,由桌宠投影立即离座。标题/摘要限 240 UTF-8 字节。 -运行中条目的 `activity` 来自事件分发器维护的紧凑状态,不依赖有限的重放缓冲:`known`、`busy`、`turn_id`、`outcome`、`phase`、`label`、`tool`、`seq`、`compact_id`、`transfers`。待授权/待回答优先于工具和模型阶段;`outcome` 保留 `completed`/`aborted`/`error` 差别。`transfers` 最多保留 16 个带序号的 agent 间交接标识,不含消息内容。持久化 `last_turn_outcome` 使恢复后的完成主会话继续显示睡眠状态。 +运行中条目的 `activity` 来自事件分发器维护的紧凑状态,不依赖有限的重放缓冲:`known`、`busy`、`turn_id`、`outcome`、`phase`、`label`、`tool`、`detail`、`text`、`seq`、`compact_id`、`transfers`。待授权/待回答优先于工具和模型阶段;`outcome` 保留 `completed`/`aborted`/`error` 差别。`transfers` 最多保留 16 个带序号的 agent 间交接标识,不含消息内容。持久化 `last_turn_outcome` 使恢复后的完成主会话继续显示睡眠状态。 + +流式正文会把 `phase` 置为 `responding`(默认「用于编程」模式下服务端不发该进度,由 token 事件推断),推理片段对应 `reasoning`,`text` 是本段正文或推理最后不超过 160 UTF-8 字节的尾巴,换阶段或开始工具调用即清空;`detail` 是正在运行工具的一行调用预览(`display_override`,为空时取 `command_preview`),最多 120 字节,工具结束清空。桌宠页面在两次快照之间直接用 WebSocket 的 `token` / `reasoning` 事件按 `seq` 叠加实时文字(约 150ms 节流),快照追上后以快照为准。 `token_usage` 是最近请求上下文统计,配合 `context_window` 计算文件堆厚度;未知窗口不伪造比例。休眠会话可通过已有 model 端点补充窗口大小。该端点不会发起模型请求或改变会话运行状态。 diff --git a/docs/desktop-agent-office.md b/docs/desktop-agent-office.md index 10ad5213e..ba9d41ae6 100644 --- a/docs/desktop-agent-office.md +++ b/docs/desktop-agent-office.md @@ -1,22 +1,28 @@ # Agent 桌面办公室 -Windows Desktop 会显示一个透明的像素办公室。单会话有一个主角色 Maestro,多 agent 任务按真实子会话入座;无需启动额外模型,也没有演示任务循环。美术与拖动缩放来自原有 Claude 桌宠,旧的 `design-prototypes/agent-office` 事务所原型已移除。 +Windows 与 macOS Desktop 会显示一个透明的像素办公室。单会话有一个主角色 Maestro,多 agent 任务按真实子会话入座;无需启动额外模型,也没有演示任务循环。美术与拖动缩放来自原有 Claude 桌宠,旧的 `design-prototypes/agent-office` 事务所原型已移除。 ## 操作 - 顶部显示跨工作区最近发过消息的五个主会话。顺序只看用户最后发送消息的时间,打开、切换和 agent 输出不会把会话顶到最前面。 - “跟随”默认开启,办公室随主窗口当前会话切换。点击门牌会关闭跟随并固定办公室;再次打开跟随后恢复跟随。查看最近五个之外的当前会话不改变门牌排序。 - 主 agent 成功完成后留在座位显示 `zzz`,下一轮自动醒来;成功完成的子 agent 立即离座,重新工作时以同样的外观返回。 +- 忙碌的 agent 头顶常驻状态气泡:「图标 动作 · 细节」。正在输出时显示「撰写回复」和最新的几个字(打字动画 + 光标),推理时显示思考点和推理尾巴,调用工具时显示动作(读取、运行命令、修改代码…)和调用预览(路径、命令)。默认「用于编程」模式下服务端不发正文进度,桌宠由 token 事件推断。气泡互相避让:先左右错开,再抬高并用引线连回头顶,放不下时只留动作。 +- 子 agent 出现时从门口走进来,先到派活的人桌前领「任务」信封再回工位;完成后走到派活的人桌前递「回报」信封(派活的人显示「收到…的结果」)再出门;失败或停止的成员直接出门。网状协作中 agent 间消息以带「消息 / 新任务 / 回报」标签的信封在工位间飞过。系统开启「减少动态效果」时不走路,交接信封仍然显示。 - 等待授权、等待回答、重试、压缩、失败和取消有各自状态。超过七个子角色时点击 `+N` 查看成员并打开对应会话;列表随状态更新,Escape 收起。选中房间人物后双击可打开其会话。 - 文件堆随当前上下文占用增长,压缩后依据实际新统计变薄。工具符号与聊天工具行共用 SVG 来源。 - 拖动房间空地移动窗口;滚轮、右下角把手和右键菜单可缩放。窗口不抢主界面焦点,透明角落可穿透点击;尺寸按原实现保存。 ## 构建与数据流 -由当前工作区的 `scripts/dev_desktop.bat` 启动开发版。先构建 `web/`,再配置/构建 CMake Desktop,使内嵌主界面与桌宠资源保持一致。`ACECODE_DESKTOP_PET=0` 可对本次进程禁用桌宠;macOS/Linux 当前没有原生桌宠窗口实现。 +由当前工作区的 `scripts/dev_desktop.bat` 启动开发版。先构建 `web/`,再配置/构建 CMake Desktop,使内嵌主界面与桌宠资源保持一致。`ACECODE_DESKTOP_PET=0` 可对本次进程禁用桌宠;Linux 当前没有原生桌宠窗口实现。 + +macOS 版(`src/apps/desktop/desktop_pet_mac.mm`,ARC 编译)用非激活 NSPanel + WKWebView 承载同一份页面:文档开始前注入 `window.chrome.webview` 垫片,页面代码不分平台;透明区域的点击穿透靠定时检测鼠标是否落在房间、控制条或页面上报的浮层里,再切换 `ignoresMouseEvents`;拖动与把手缩放由定时器跟随鼠标。默认 1.25 倍(约 430×315 点),设置存 `~/Library/Application Support/ACECode/desktop-pet/settings.json`。 数据链为:会话元数据/事件分发器 → `GET /api/desktop-office` → App 级办公室控制器 → 原生 WebView 桥 → 像素场景。控制器复用现有引用计数 WS 订阅,事件触发刷新并以 3 秒轮询兜底;正在加载旧记录时短间隔继续读取。快速切换以请求代次和取消信号隔离迟到响应;页面卸载释放订阅和计时器,重载从原生缓存恢复当前选择与跟随状态。 +页面把成员列表、提示和状态气泡的矩形(节流后)上报给原生窗口:Windows 的窗口区域同时裁剪绘制,冒出墙顶的气泡必须并入区域才不会被切掉;macOS 用同一份矩形判断点击穿透。 + 断线时明确显示正在重连,恢复后重取真实快照。错误和取消不会被当作成功睡眠。只同步小型展示字段,不通过动画触发或控制 agent 任务。 视觉规则见 [桌宠设计](../assets/desktop_pet/DESIGN.md),协议见 [daemon API](daemon-api.md)。验收命令及本次实测范围见 [交付记录](../openspec/changes/integrate-desktop-agent-office/verification.md)。 diff --git a/openspec/changes/enhance-desktop-agent-office-motion/.openspec.yaml b/openspec/changes/enhance-desktop-agent-office-motion/.openspec.yaml new file mode 100644 index 000000000..aa32566bf --- /dev/null +++ b/openspec/changes/enhance-desktop-agent-office-motion/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-06 diff --git a/openspec/changes/enhance-desktop-agent-office-motion/design.md b/openspec/changes/enhance-desktop-agent-office-motion/design.md new file mode 100644 index 000000000..b4c59f79b --- /dev/null +++ b/openspec/changes/enhance-desktop-agent-office-motion/design.md @@ -0,0 +1,31 @@ +## Context + +见 proposal.md。前一变更 `integrate-desktop-agent-office` 已交付 Windows 办公室;本变更只增强表现层与平台覆盖。 + +## Decisions + +### 1. 状态来源 + +服务端 `SessionActivityState` 在分发器锁内折叠事件:token → `responding`,reasoning → `reasoning`,各自保留有界尾巴,换阶段或开始工具即清空;工具开始记录 `display_override`(为空取 `command_preview`)。这样快照在任意时刻都说得清「正在做什么」,打开办公室或断线重连后不依赖重放。实时性由 Web 控制器负责:token/reasoning 事件按会话写入叠加层,`seq` 大于快照才生效,快照追上即丢弃;连续正文在快照尾巴后拼接。不为每个 token 发 HTTP 请求。 + +### 2. 文案 + +动作动词与工具图标同表(`toolIcons.js`);「适合日常工作」模式下的具体进度标题优先,引擎默认的笼统文案(等待模型响应、正在调用工具 X…)不进气泡。细节按半角宽度从开头裁剪,保留最新的字和文件名。 + +### 3. 交接动画 + +走路沿 2 单位网格 BFS(8 向、不斜穿桌角),家具占地外扩 1 单位;先到派活的人(`agent_path` 的上一级,找不到时是主 agent)桌前停顿并交接信封,再回工位或出门。离开中的成员又接到任务时掉头回座。只在本办公室已显示过真实快照后演,切换办公室和重连恢复直接落座。减少动态效果时不走路但保留信封。 + +### 4. 原生窗口区域 + +Windows `SetWindowRgn` 同时裁剪绘制,气泡冒出墙顶会被切掉,所以页面把气泡矩形(含尾巴与引线)节流上报,原生端与成员列表/提示一起并入区域。macOS 没有窗口区域,定时检测鼠标是否命中房间、控制条或这些矩形,切换 `ignoresMouseEvents`。命中判定与消息解析是纯函数,跨平台单测。 + +### 5. macOS 宿主 + +NSPanel(无边框、非激活、不可成为 key window、浮动层级、所有空间可见),WKWebView 关闭背景绘制并重载 `acceptsFirstMouse`。页面按 WebView2 接口编写,宿主在文档开始前注入同名垫片,消息经 `WKScriptMessageHandler` 回到 C++,宿主经 `evaluateJavaScript` 派发。拖动与把手缩放由 60Hz 定时器跟随鼠标。ObjC 回调只持有控制器 weak_ptr;关闭时移除脚本消息处理器(否则内容控制器强引用导致泄漏)。默认 1.25 倍逻辑尺寸,设置不写入 ACECode 数据目录。 + +## Risks / Trade-offs + +- [macOS 未在本机编译] → 只用稳定的 AppKit/WebKit 公共接口与 ARC;需 macOS CI 或实机构建确认。 +- [气泡拥挤] → 优先级 + 错开/抬高/压缩三级避让;极端满员时仍可能重叠。 +- [流式尾巴进入快照] → 只保留 160 字节、换阶段清空,接口仍需鉴权,与聊天页可见内容一致。 diff --git a/openspec/changes/enhance-desktop-agent-office-motion/proposal.md b/openspec/changes/enhance-desktop-agent-office-motion/proposal.md new file mode 100644 index 000000000..d399dcdcd --- /dev/null +++ b/openspec/changes/enhance-desktop-agent-office-motion/proposal.md @@ -0,0 +1,26 @@ +## Why + +桌面 agent 办公室接入真实会话后,用户反馈三点:看不到代理之间交互的动画;代理飙字时看不到状态,气泡只出现一个图标、看不出在做什么;需要 macOS 支持。根因分别是:交接只有一个约 1 秒、9×6 像素的小信封,成员直接闪现/消失;默认「用于编程」模式下服务端不发正文进度,快照一直停在「等待模型」,WebSocket 的 token 事件又被控制器整体忽略,忙碌状态没有文字气泡;原生窗口只有 Windows 实现。 + +## What Changes + +- 会话活动快照增加流式正文/推理尾巴(≤160 字节)与工具调用预览(≤120 字节),由 token 事件推断 `responding`。 +- 控制器用 WebSocket 的 token / reasoning 事件按 seq 叠加实时文字(约 150ms 节流),不增加快照请求。 +- 忙碌 agent 常驻状态气泡「图标 动作 · 细节」,按优先级避让;打字、阅读、思考、等成员各有动作。 +- 成员走进门、到派活的人桌前领「任务」信封、回工位;完成后走去递「回报」信封再出门;网状消息带标签信封。减少动态效果时不走路但保留信封。 +- 页面上报气泡矩形,Windows 窗口区域并入(避免被裁剪),macOS 用于点击穿透判定。 +- 新增 macOS 原生桌宠:非激活 NSPanel + WKWebView,注入 `chrome.webview` 垫片复用同一页面。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `desktop-agent-office`: 状态可读性、交接动画与 macOS 宿主(以新增要求记录,原变更尚未归档)。 + +## Impact + +会话活动状态(domain)、办公室快照文档、Web 办公室状态/控制器与图标动词、桌宠页面、Windows 宿主窗口区域、新增 macOS 宿主与 CMake 登记。不改变 agent 调度、权限或模型请求。macOS 宿主未在本机编译验收,需在 macOS 构建与实机确认。 diff --git a/openspec/changes/enhance-desktop-agent-office-motion/specs/desktop-agent-office/spec.md b/openspec/changes/enhance-desktop-agent-office-motion/specs/desktop-agent-office/spec.md new file mode 100644 index 000000000..345c4a7a2 --- /dev/null +++ b/openspec/changes/enhance-desktop-agent-office-motion/specs/desktop-agent-office/spec.md @@ -0,0 +1,29 @@ +## ADDED Requirements + +### Requirement: 忙碌状态可读 + +办公室 SHALL 为忙碌的 agent 显示常驻状态气泡,内容包括动作和细节;正在输出正文时 SHALL 显示最新输出的文字并随输出更新,调用工具时 SHALL 显示动作、共源图标与调用预览。默认工作模式下没有正文进度事件时,系统 SHALL 由正文流推断输出状态。 + +#### Scenario: 默认模式下输出正文 +- **WHEN** agent 在「用于编程」模式下开始输出正文 +- **THEN** 办公室显示该 agent 在打字,气泡为「撰写回复」及最新几个字,并在两次快照之间持续更新 + +#### Scenario: 调用工具 +- **WHEN** agent 开始调用读取文件工具 +- **THEN** 气泡显示读取图标、「读取」和文件路径的末尾部分 + +### Requirement: 交接可见 + +成员加入、完成以及 agent 间消息 SHALL 以可见的交接动画表达:新成员 SHALL 从门口进入并在派活的 agent 处接收任务后入座;成功完成的成员 SHALL 先向派活的 agent 递交回报再离开;agent 间消息 SHALL 以带类型标签的信封在两者之间移动。减少动态效果偏好下 SHALL 省略走路但保留交接信封。 + +#### Scenario: 星状派活与回报 +- **WHEN** 主 agent 派出子 agent,子 agent 随后成功完成 +- **THEN** 子 agent 走到主 agent 桌前领取「任务」信封后入座,完成后回到主 agent 桌前递交「回报」信封并离开,主 agent 显示收到结果 + +### Requirement: macOS 桌面办公室 + +macOS 桌面端 SHALL 提供与 Windows 相同页面与桥协议的透明置顶办公室窗口,不抢主窗口焦点,透明区域点击穿透,支持拖动、缩放、大小记忆、右键菜单与隐藏。 + +#### Scenario: 透明区域点击 +- **WHEN** 用户点击办公室窗口中房间、控制条和浮层之外的透明区域 +- **THEN** 点击落到后面的窗口 diff --git a/openspec/changes/enhance-desktop-agent-office-motion/tasks.md b/openspec/changes/enhance-desktop-agent-office-motion/tasks.md new file mode 100644 index 000000000..eb1595254 --- /dev/null +++ b/openspec/changes/enhance-desktop-agent-office-motion/tasks.md @@ -0,0 +1,19 @@ +## 1. 状态 + +- [x] 1.1 活动快照增加正文/推理尾巴与工具调用预览,token 推断 responding;补中文场景单测。 +- [x] 1.2 控制器叠加 WebSocket token/reasoning 实时文字(节流、按 seq 让位快照);状态投影增加动作动词、细节与上级路径;补单测与 i18n。 + +## 2. 表现 + +- [x] 2.1 常驻状态气泡(图标 动作 · 细节)、优先级避让、从开头裁剪;打字 / 阅读 / 思考点 / 沙漏动作。 +- [x] 2.2 走路寻路、领任务与交回报、网状消息标签信封、离开中途接新任务掉头;减少动态效果的降级。 +- [x] 2.3 页面上报气泡区域;Windows 窗口区域并入;命中判定与解析纯函数及单测。 + +## 3. macOS + +- [x] 3.1 NSPanel + WKWebView 宿主、垫片桥、点击穿透、拖动/缩放/菜单、屏幕变化、生命周期;CMake 登记与页面嵌入。 +- [ ] 3.2 macOS 构建与实机验收(本机为 Windows,未执行)。 + +## 4. 验收 + +- [x] 4.1 C++ 定向单测、Web 全量测试与构建、浏览器自动化、Windows 原生运行。 diff --git a/openspec/changes/enhance-desktop-agent-office-motion/verification.md b/openspec/changes/enhance-desktop-agent-office-motion/verification.md new file mode 100644 index 000000000..4edfc3eb5 --- /dev/null +++ b/openspec/changes/enhance-desktop-agent-office-motion/verification.md @@ -0,0 +1,15 @@ +# 验证记录 + +验收日期:2026-10-06,Windows x64,工作区 `N:/Users/shao/acecode.worktrees/agent-office-live`,分支 `claude/agent-office-motion`(基于 master `1f95b89f`)。 + +| 范围 | 结果 | +| --- | --- | +| C++ 定向 | `SessionActivityState.*`(含 3 条新增:默认模式流式正文、推理与正文分段、工具调用预览)、`DesktopPetLayout.*`(含浮层解析与命中判定 2 条新增)、`DesktopOfficeHttp.*`、`EventDispatcher*`、`RecentFixture.*`、`GlobalSessionCatalog.*` 全部通过。 | +| C++ 全量 fast | `run_fast_tests.py --profile fast`:执行 5001、失败 0、跳过 9。 | +| 构建 | Ninja/MSVC Release 构建 `acecode`、`acecode-desktop`、`acecode_unit_tests` 通过。构建目录 468 个缺少头文件依赖记录的目标文件已删除重编(初次构建 showIncludes 未记录依赖,改头文件后会链接到旧布局对象并在运行时崩溃)。 | +| 静态 | 临时索引含新文件后,分层、所有权 `--strict --final`、行数、文档路径检查均 0 findings;`git diff --check` 通过;OpenSpec strict 通过。 | +| Web | `pnpm test` 全部通过;`pnpm build` 通过;i18n 目录补 18 条英文。 | +| 浏览器 | `web/scripts/test-desktop-office.mjs` 40 项通过、无页面错误与外部请求:常驻气泡、气泡区域上报、回报信封与致谢、出门、走进门领任务、网状消息信封、减少动态效果降级、原有切换/跟随/溢出/尺寸/断线检查。 | +| Windows 原生 | 隔离用户目录 + 本机假模型(OpenAI 兼容流式,无真实模型调用)运行开发版 Desktop,经 WebView2 调试端口采样:主 agent 推理尾巴 → 撰写回复实时文字 → 派发任务、两名成员进门领「任务」信封入座 → 成员思考、流式回复 → 完成后递「回报」信封、主 agent「收到…的结果」→ zzz。窗口扩展样式保持 TOPMOST/NOACTIVATE/TOOLWINDOW;WindowFromPoint 确认气泡可点、透明角落穿透,注入的浮层矩形并入窗口区域、清除后恢复穿透。 | + +未执行:macOS 编译与实机验收(本机为 Windows;`desktop_pet_mac.mm` 仅经代码审查);Linux 保持无桌宠。 diff --git a/src/apps/desktop/desktop_pet.cpp b/src/apps/desktop/desktop_pet.cpp index 50013ae4f..b68981b5c 100644 --- a/src/apps/desktop/desktop_pet.cpp +++ b/src/apps/desktop/desktop_pet.cpp @@ -175,8 +175,10 @@ void enable_per_pixel_alpha(HWND hwnd) { } } -// 窗口区域只留房间轮廓:透明角落既不绘制也不接收点击,点击会落到后面的窗口。 -void apply_hit_region(HWND hwnd, double scale, const std::array& overlay = {}) { +// 窗口区域只留房间轮廓、控制条和页面浮层(成员列表 / 提示 / 状态气泡): +// 透明角落既不绘制也不接收点击,点击会落到后面的窗口。区域同时裁剪绘制, +// 所以冒出墙顶的气泡必须由页面上报,否则会被切掉。 +void apply_hit_region(HWND hwnd, double scale, const std::vector& overlays = {}) { const auto polygon = desktop_pet_hit_polygon(scale); std::vector points; points.reserve(polygon.size()); @@ -186,9 +188,10 @@ void apply_hit_region(HWND hwnd, double scale, const std::array& over static_cast(std::lround(kDesktopPetSceneWidth * scale)), static_cast(std::lround(kDesktopPetToolbarHeight * scale)))); if (region && toolbar) ::CombineRgn(region.get(), region.get(), toolbar.get(), RGN_OR); - if (region && overlay[2] > overlay[0] && overlay[3] > overlay[1]) { - RECT bounds{}; - ::GetClientRect(hwnd, &bounds); + RECT bounds{}; + ::GetClientRect(hwnd, &bounds); + for (const auto& overlay : overlays) { + if (!region) break; UniqueRegion popup(::CreateRectRgn( static_cast(overlay[0] * bounds.right), static_cast(overlay[1] * bounds.bottom), static_cast(std::ceil(overlay[2] * bounds.right)), static_cast(std::ceil(overlay[3] * bounds.bottom)))); @@ -205,7 +208,7 @@ struct DesktopPet::Impl : std::enable_shared_from_this { WebHost& host; // Required GUI host; DesktopPet is destroyed before this host. nlohmann::json office_snapshot = {{"follow", true}}; bool page_ready = false; - std::array hit_overlay{}; + std::vector hit_overlays; HWND hwnd = nullptr; webview::detail::mswebview2::loader loader; ComPtr environment; @@ -255,25 +258,19 @@ struct DesktopPet::Impl : std::enable_shared_from_this { } void office_action(const std::wstring& message) { - if (message.size() > 8192) return; + if (message.size() > 16384) return; const auto value = nlohmann::json::parse(wide_to_utf8(message), nullptr, false); if (!value.is_object()) return; const auto type_field = value.find("type"); if (type_field == value.end() || !type_field->is_string()) return; const auto type = type_field->get(); if (type == "overlay") { - hit_overlay = {}; - const auto rect = value.find("rect"); - if (rect != value.end() && rect->is_array() && rect->size() == 4) { - for (std::size_t i = 0; i < 4; ++i) { - if (!(*rect)[i].is_number()) { hit_overlay = {}; break; } - const double coordinate = (*rect)[i].get(); - hit_overlay[i] = std::isfinite(coordinate) ? std::clamp(coordinate, 0.0, 1.0) : 0.0; - } - } + auto next = desktop_pet_overlays_from_message(value); + if (next == hit_overlays) return; + hit_overlays = std::move(next); RECT bounds{}; ::GetClientRect(hwnd, &bounds); - apply_hit_region(hwnd, bounds.right / static_cast(kDesktopPetSceneWidth), hit_overlay); + apply_hit_region(hwnd, bounds.right / static_cast(kDesktopPetSceneWidth), hit_overlays); return; } if (type != "select" && type != "follow" && type != "open") return; @@ -676,7 +673,7 @@ struct DesktopPet::Impl : std::enable_shared_from_this { RECT bounds{}; ::GetClientRect(hwnd, &bounds); if (controller) controller->put_Bounds(bounds); - if (bounds.right > 0) apply_hit_region(hwnd, bounds.right / static_cast(kDesktopPetSceneWidth), hit_overlay); + if (bounds.right > 0) apply_hit_region(hwnd, bounds.right / static_cast(kDesktopPetSceneWidth), hit_overlays); break; } case WM_MOVE: diff --git a/src/apps/desktop/desktop_pet.hpp b/src/apps/desktop/desktop_pet.hpp index d027bb311..3a82f41ed 100644 --- a/src/apps/desktop/desktop_pet.hpp +++ b/src/apps/desktop/desktop_pet.hpp @@ -8,9 +8,11 @@ // 交互:左键拖动挪位置;单击小人头顶报状态;右键菜单「回到右下角 / 隐藏桌面宠物」 // (隐藏只到本次退出)。环境变量 ACECODE_DESKTOP_PET=0 可整体关闭。 // -// 目前只有 Windows 实现(独立的 WebView2 环境,用户数据在 -// %LOCALAPPDATA%\ACECode\desktop-pet\webview2,不进 ACECode 数据目录, -// 以免被数据目录迁移当成持续写入的活文件);其它平台构造即空操作。 +// Windows 实现在 desktop_pet.cpp(独立的 WebView2 环境,用户数据在 +// %LOCALAPPDATA%\ACECode\desktop-pet\webview2);macOS 实现在 desktop_pet_mac.mm +// (非激活 NSPanel + WKWebView,设置在 ~/Library/Application Support/ACECode/desktop-pet)。 +// 两者都不进 ACECode 数据目录,以免被数据目录迁移当成持续写入的活文件; +// Linux 构造即空操作。 // 必须在 GUI 线程构造与析构;任何失败只记日志,不影响主窗口。 #include diff --git a/src/apps/desktop/desktop_pet_layout.cpp b/src/apps/desktop/desktop_pet_layout.cpp index 9bc0c01fd..951fe7373 100644 --- a/src/apps/desktop/desktop_pet_layout.cpp +++ b/src/apps/desktop/desktop_pet_layout.cpp @@ -3,6 +3,8 @@ #include #include +#include + namespace acecode::desktop { namespace { @@ -151,4 +153,54 @@ std::vector desktop_pet_hit_polygon(double scale) { return polygon; } +namespace { +bool overlay_from_json(const nlohmann::json& value, DesktopPetOverlay& out) { + if (!value.is_array() || value.size() != 4) return false; + for (std::size_t i = 0; i < 4; ++i) { + if (!value[i].is_number()) return false; + const double coordinate = value[i].get(); + if (!std::isfinite(coordinate)) return false; + out[i] = std::clamp(coordinate, 0.0, 1.0); + } + return out[2] > out[0] && out[3] > out[1]; +} +} // namespace + +std::vector desktop_pet_overlays_from_message(const nlohmann::json& message) { + std::vector overlays; + if (!message.is_object()) return overlays; + DesktopPetOverlay rect{}; + if (const auto it = message.find("rect"); it != message.end() && overlay_from_json(*it, rect)) { + overlays.push_back(rect); + } + if (const auto it = message.find("bubbles"); it != message.end() && it->is_array()) { + for (const auto& item : *it) { + if (overlays.size() >= kDesktopPetMaxOverlays) break; + if (overlay_from_json(item, rect)) overlays.push_back(rect); + } + } + return overlays; +} + +bool desktop_pet_hit_test(double scale, int width, int height, + const std::vector& overlays, double x, double y) { + if (width <= 0 || height <= 0 || x < 0 || y < 0 || x >= width || y >= height) return false; + if (!(scale > 0.0)) scale = 1.0; + if (y < kDesktopPetToolbarHeight * scale) return true; + for (const auto& overlay : overlays) { + if (x >= overlay[0] * width && x < overlay[2] * width && + y >= overlay[1] * height && y < overlay[3] * height) { + return true; + } + } + // 偶奇规则判断点是否在房间多边形里。 + const auto polygon = desktop_pet_hit_polygon(scale); + bool inside = false; + for (std::size_t i = 0, j = polygon.size() - 1; i < polygon.size(); j = i++) { + const double xi = polygon[i].x, yi = polygon[i].y, xj = polygon[j].x, yj = polygon[j].y; + if ((yi > y) != (yj > y) && x < (xj - xi) * (y - yi) / (yj - yi) + xi) inside = !inside; + } + return inside; +} + } // namespace acecode::desktop diff --git a/src/apps/desktop/desktop_pet_layout.hpp b/src/apps/desktop/desktop_pet_layout.hpp index 4d33465c8..501baad13 100644 --- a/src/apps/desktop/desktop_pet_layout.hpp +++ b/src/apps/desktop/desktop_pet_layout.hpp @@ -9,8 +9,11 @@ // 画布里房间以外(原来的草地)是透明的,命中区域只取房间轮廓, // 透明角落的点击会落到后面的窗口上。 +#include #include +#include + namespace acecode::desktop { inline constexpr int kDesktopPetSceneWidth = 344; @@ -62,4 +65,18 @@ DesktopPetPlacement scale_desktop_pet(const DesktopPetRect& current, double new_ // 顺时针,已夹在窗口范围内。 std::vector desktop_pet_hit_polygon(double scale); +// 页面浮层(成员列表、提示、状态气泡)在窗口里的矩形,按窗口宽高的比例给出: +// {left, top, right, bottom},均在 [0, 1]。 +using DesktopPetOverlay = std::array; +inline constexpr std::size_t kDesktopPetMaxOverlays = 24; + +// 解析页面发来的 {"type":"overlay","rect":[...]|null,"bubbles":[[...],...]}: +// 非数字 / 非有限值 / 空矩形整条丢弃,坐标夹到 [0, 1],最多保留 kDesktopPetMaxOverlays 个。 +std::vector desktop_pet_overlays_from_message(const nlohmann::json& message); + +// 窗口内一点 (x, y)(设备像素,左上为原点)是否落在桌宠上:房间轮廓、顶部控制条或任一浮层。 +// 透明区域返回 false,点击应穿透到后面的窗口。 +bool desktop_pet_hit_test(double scale, int width, int height, + const std::vector& overlays, double x, double y); + } // namespace acecode::desktop diff --git a/src/apps/desktop/desktop_pet_mac.mm b/src/apps/desktop/desktop_pet_mac.mm new file mode 100644 index 000000000..89ed3af38 --- /dev/null +++ b/src/apps/desktop/desktop_pet_mac.mm @@ -0,0 +1,754 @@ +// macOS 版桌面 agent 办公室:与 desktop_pet.cpp(Windows)共用同一份页面 +// (assets/desktop_pet/agent_office_pet.html,构建期嵌进可执行文件)、同一套桥协议 +// 和 desktop_pet_layout 的几何计算。 +// +// 窗口是透明、置顶、不抢焦点的 NSPanel(非激活面板,不能成为 key window), +// 里面放一个 WKWebView。页面按 WebView2 的 window.chrome.webview 写成,这里在 +// 文档开始前注入一个同名垫片:postMessage 转给 WKScriptMessageHandler,宿主经 +// window.__acePetDeliver 把消息派给页面的 message 监听器,页面代码无需区分平台。 +// +// macOS 没有 SetWindowRgn:透明区域的点击穿透靠定时检测鼠标是否落在房间轮廓、 +// 控制条或页面上报的浮层里,再切换 ignoresMouseEvents。拖动 / 把手缩放同样由 +// 定时器跟随鼠标,不依赖 WebKit 把鼠标事件交还给窗口。 +// +// 坐标:Cocoa 屏幕坐标原点在左下;desktop_pet_layout 按左上原点、设备像素计算。 +// 这里统一换成左上原点的「点」,并把 dpi 固定为 96(1 点 = 1 个逻辑像素, +// Retina 的倍率由 WebKit 按 devicePixelRatio 处理)。 +// +// 本文件用 ARC 编译(cmake/acecode_desktop.cmake 单独加 -fobjc-arc)。 + +#include "desktop_pet.hpp" +#include "web_host.hpp" + +#include "desktop_pet_layout.hpp" +#include "platform/native_ui/strings.hpp" + +#include "utils/logger.hpp" + +#import +#import + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#if !__has_feature(objc_arc) +#error "desktop_pet_mac.mm must be compiled with -fobjc-arc" +#endif + +@class ACECodeDesktopPetBridge; +@class ACECodeDesktopPetMenuTarget; + +namespace acecode { +// cmake/acecode_desktop.cmake 用 acecode_bin2cpp.cmake 把页面嵌进可执行文件。 +const unsigned char* desktop_pet_page_data(); +std::size_t desktop_pet_page_size(); +} // namespace acecode + +namespace acecode::desktop { +namespace { + +constexpr int kLogicalDpi = 96; +constexpr double kDefaultLogicalScale = 1.25; // 约 430×315 点,与 Windows 150% 下的逻辑大小相当 +constexpr double kZoomStep = 1.1; +constexpr NSTimeInterval kHoverPollSeconds = 1.0 / 30.0; +constexpr NSTimeInterval kGesturePollSeconds = 1.0 / 60.0; +constexpr NSTimeInterval kRevealFallbackSeconds = 3.0; +constexpr std::size_t kMaxSnapshotBytes = 512 * 1024; +constexpr std::size_t kMaxActionBytes = 16384; + +enum MenuCommand : NSInteger { kMenuDock = 1, kMenuHide, kMenuZoomIn, kMenuZoomOut, kMenuResetSize }; + +// 页面以 WebView2 的接口写成;这里在文档开始前补一个同名实现。 +constexpr const char* kBridgeShim = R"JS((() => { + const listeners = []; + const handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.acePet; + if (!handler) return; + window.chrome = window.chrome || {}; + window.chrome.webview = { + postMessage: value => handler.postMessage(typeof value === 'string' ? value : JSON.stringify(value)), + addEventListener: (type, listener) => { if (type === 'message') listeners.push(listener); }, + removeEventListener: (type, listener) => { const i = listeners.indexOf(listener); if (i >= 0) listeners.splice(i, 1); }, + }; + window.__acePetDeliver = data => { for (const listener of listeners.slice()) { try { listener({ data }); } catch (e) {} } }; +})();)JS"; + +bool desktop_pet_disabled_by_env() { + const char* value = std::getenv("ACECODE_DESKTOP_PET"); + if (!value) return false; + const std::string text(value); + return text == "0" || text == "off" || text == "false" || text == "no"; +} + +bool chinese_ui() { + return native_locale().rfind("zh", 0) == 0; +} + +NSString* ns_string(const std::string& value) { + NSString* text = [[NSString alloc] initWithBytes:value.data() + length:value.size() + encoding:NSUTF8StringEncoding]; + return text ? text : @""; +} + +// 不放进 ACECode 数据目录:数据目录迁移会把持续写入的文件当成活文件。 +std::filesystem::path pet_root_dir() { + NSArray* urls = [[NSFileManager defaultManager] + URLsForDirectory:NSApplicationSupportDirectory inDomains:NSUserDomainMask]; + NSURL* base = urls.firstObject; + const char* path = base ? base.fileSystemRepresentation : nullptr; + std::filesystem::path root = path ? std::filesystem::path(path) + : std::filesystem::temp_directory_path(); + return root / "ACECode" / "desktop-pet"; +} + +double load_logical_scale() { + std::ifstream in(pet_root_dir() / "settings.json"); + if (!in) return 0.0; + const auto json = nlohmann::json::parse(in, nullptr, false); + if (json.is_discarded() || !json.is_object() || !json.contains("scale") || !json["scale"].is_number()) return 0.0; + const double scale = json["scale"].get(); + return std::isfinite(scale) && scale > 0.0 ? scale : 0.0; +} + +void save_logical_scale(double scale) { + std::error_code ec; + std::filesystem::create_directories(pet_root_dir(), ec); + nlohmann::json json = nlohmann::json::object(); + if (scale > 0.0) json["scale"] = std::round(scale * 1000.0) / 1000.0; + std::ofstream out(pet_root_dir() / "settings.json", std::ios::trunc); + if (out) out << json.dump(); +} + +// 全局坐标翻转以主屏(菜单栏所在的屏)高度为准。 +CGFloat flip_height() { + NSScreen* primary = NSScreen.screens.firstObject; + return primary ? primary.frame.size.height : 0.0; +} + +DesktopPetRect top_left_rect(NSRect rect) { + return {static_cast(std::lround(rect.origin.x)), + static_cast(std::lround(flip_height() - (rect.origin.y + rect.size.height))), + static_cast(std::lround(rect.size.width)), + static_cast(std::lround(rect.size.height))}; +} + +NSRect cocoa_rect(const DesktopPetRect& rect) { + return NSMakeRect(rect.x, flip_height() - (rect.y + rect.height), rect.width, rect.height); +} + +} // namespace + +// GUI 线程上的全部状态;ObjC 回调只持有它的 weak_ptr。 +class PetController : public std::enable_shared_from_this { +public: + explicit PetController(WebHost& owner) : host_(owner) {} + virtual ~PetController() { close(); } + + void bind_bridge(); + bool start(); + void close(); + + void on_web_message(const std::string& message); + void navigation_finished(bool success); + void web_process_terminated(); + void poll_hover(); + void poll_gesture(); + void menu_command(NSInteger command); + void screens_changed(); + +private: + void publish_snapshot(); + void deliver(const nlohmann::json& value); + void office_action(const std::string& message); + void reveal(); + void apply_placement(const DesktopPetPlacement& next); + DesktopPetRect window_rect() const; + DesktopPetRect work_area() const; + void notify_size(); + void apply_scale(double scale, double fx, double fy); + void zoom_by(int steps, double fx, double fy); + void reset_size(); + void dock_to_corner(); + void begin_gesture(bool resize); + void end_gesture(); + void show_menu(); + void load_page(); + double current_scale() const; + + WebHost& host_; // DesktopPet is destroyed before this host. + nlohmann::json office_snapshot_ = {{"follow", true}}; + std::vector overlays_; + NSPanel* panel_ = nil; + WKWebView* webview_ = nil; + WKUserContentController* content_ = nil; + ACECodeDesktopPetBridge* bridge_ = nil; + ACECodeDesktopPetMenuTarget* menu_target_ = nil; + id screen_observer_ = nil; + NSTimer* hover_timer_ = nil; + NSTimer* gesture_timer_ = nil; + NSTimer* reveal_timer_ = nil; + double logical_scale_ = 0.0; // 用户调过的大小,0 = 默认 + bool page_ready_ = false; + bool revealed_ = false; + bool user_moved_ = false; + bool closed_ = false; + bool resizing_ = false; + bool dragging_ = false; + NSPoint gesture_mouse_ = NSZeroPoint; + DesktopPetRect gesture_start_{}; +}; + +} // namespace acecode::desktop + +using acecode::desktop::PetController; + +// 非激活面板:点击不会把 ACECode 主窗口的键盘焦点抢走。 +@interface ACECodeDesktopPetPanel : NSPanel +@end + +@implementation ACECodeDesktopPetPanel +- (BOOL)canBecomeKeyWindow { + return NO; +} +- (BOOL)canBecomeMainWindow { + return NO; +} +@end + +// 面板不会成为 key window,第一下点击就要交给页面,而不是只用来激活窗口。 +@interface ACECodeDesktopPetWebView : WKWebView +@end + +@implementation ACECodeDesktopPetWebView +- (BOOL)acceptsFirstMouse:(NSEvent*)event { + (void)event; + return YES; +} +@end + +@interface ACECodeDesktopPetBridge : NSObject +- (instancetype)initWithController:(const std::weak_ptr&)controller; +@end + +@implementation ACECodeDesktopPetBridge { + std::weak_ptr _controller; +} + +- (instancetype)initWithController:(const std::weak_ptr&)controller { + if ((self = [super init])) _controller = controller; + return self; +} + +- (void)userContentController:(WKUserContentController*)userContentController + didReceiveScriptMessage:(WKScriptMessage*)message { + (void)userContentController; + if (![message.body isKindOfClass:[NSString class]]) return; + const char* utf8 = [(NSString*)message.body UTF8String]; + if (!utf8) return; + if (const auto controller = _controller.lock()) controller->on_web_message(utf8); +} + +- (void)webView:(WKWebView*)webView didFinishNavigation:(WKNavigation*)navigation { + (void)webView; + (void)navigation; + if (const auto controller = _controller.lock()) controller->navigation_finished(true); +} + +- (void)webView:(WKWebView*)webView didFailNavigation:(WKNavigation*)navigation withError:(NSError*)error { + (void)webView; + (void)navigation; + (void)error; + if (const auto controller = _controller.lock()) controller->navigation_finished(false); +} + +- (void)webView:(WKWebView*)webView + didFailProvisionalNavigation:(WKNavigation*)navigation + withError:(NSError*)error { + (void)webView; + (void)navigation; + (void)error; + if (const auto controller = _controller.lock()) controller->navigation_finished(false); +} + +- (void)webViewWebContentProcessDidTerminate:(WKWebView*)webView { + (void)webView; + if (const auto controller = _controller.lock()) controller->web_process_terminated(); +} +@end + +@interface ACECodeDesktopPetMenuTarget : NSObject +- (instancetype)initWithController:(const std::weak_ptr&)controller; +- (void)choose:(NSMenuItem*)item; +@end + +@implementation ACECodeDesktopPetMenuTarget { + std::weak_ptr _controller; +} + +- (instancetype)initWithController:(const std::weak_ptr&)controller { + if ((self = [super init])) _controller = controller; + return self; +} + +- (void)choose:(NSMenuItem*)item { + if (const auto controller = _controller.lock()) controller->menu_command(item.tag); +} +@end + +namespace acecode::desktop { + +void PetController::bind_bridge() { + const std::weak_ptr weak = weak_from_this(); + host_.bind("aceDesktop_updateOffice", [weak](const std::string& request) { + const auto self = weak.lock(); + if (!self || self->closed_ || request.size() > kMaxSnapshotBytes) return std::string("false"); + try { + const auto args = nlohmann::json::parse(request); + if (!args.is_array() || args.size() != 1 || !args[0].is_string()) return std::string("false"); + const auto value = nlohmann::json::parse(args[0].get()); + if (!value.is_object() || value.value("version", 0) != 1 || + !value.contains("agents") || !value["agents"].is_array() || + !value.contains("offices") || !value["offices"].is_array() || value["offices"].size() > 5) { + return std::string("false"); + } + self->office_snapshot_ = value; + self->publish_snapshot(); + return std::string("true"); + } catch (...) { + return std::string("false"); + } + }); + host_.bind("aceDesktop_getOfficeState", [weak](const std::string&) { + const auto self = weak.lock(); + return self && !self->closed_ ? self->office_snapshot_.dump() : std::string("{}"); + }); +} + +bool PetController::start() { + logical_scale_ = load_logical_scale(); + const DesktopPetPlacement placement = place_desktop_pet( + work_area(), kLogicalDpi, logical_scale_ > 0.0 ? logical_scale_ : kDefaultLogicalScale); + + panel_ = [[ACECodeDesktopPetPanel alloc] + initWithContentRect:cocoa_rect(placement.window) + styleMask:NSWindowStyleMaskBorderless | NSWindowStyleMaskNonactivatingPanel + backing:NSBackingStoreBuffered + defer:NO]; + if (!panel_) { + LOG_WARN("[desktop-pet] failed to create the macOS panel"); + return false; + } + panel_.releasedWhenClosed = NO; + panel_.opaque = NO; + panel_.backgroundColor = NSColor.clearColor; + panel_.hasShadow = NO; + panel_.level = NSFloatingWindowLevel; + panel_.floatingPanel = YES; + panel_.hidesOnDeactivate = NO; + panel_.becomesKeyOnlyIfNeeded = YES; + panel_.movableByWindowBackground = NO; + panel_.acceptsMouseMovedEvents = YES; + panel_.ignoresMouseEvents = YES; // 首帧之前完全透明,也不挡点击 + panel_.alphaValue = 0.0; + panel_.collectionBehavior = NSWindowCollectionBehaviorCanJoinAllSpaces | + NSWindowCollectionBehaviorFullScreenAuxiliary | + NSWindowCollectionBehaviorStationary | + NSWindowCollectionBehaviorIgnoresCycle; + + const std::weak_ptr weak = weak_from_this(); + bridge_ = [[ACECodeDesktopPetBridge alloc] initWithController:weak]; + menu_target_ = [[ACECodeDesktopPetMenuTarget alloc] initWithController:weak]; + + content_ = [[WKUserContentController alloc] init]; + WKUserScript* shim = [[WKUserScript alloc] initWithSource:ns_string(kBridgeShim) + injectionTime:WKUserScriptInjectionTimeAtDocumentStart + forMainFrameOnly:YES]; + [content_ addUserScript:shim]; + [content_ addScriptMessageHandler:bridge_ name:@"acePet"]; + + WKWebViewConfiguration* configuration = [[WKWebViewConfiguration alloc] init]; + configuration.userContentController = content_; + // 页面是嵌入的离线资源,不需要持久的网站数据。 + configuration.websiteDataStore = [WKWebsiteDataStore nonPersistentDataStore]; + + const NSRect bounds = NSMakeRect(0, 0, placement.window.width, placement.window.height); + webview_ = [[ACECodeDesktopPetWebView alloc] initWithFrame:bounds configuration:configuration]; + if (!webview_) { + LOG_WARN("[desktop-pet] failed to create the macOS WKWebView"); + return false; + } + webview_.navigationDelegate = bridge_; + webview_.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + webview_.allowsMagnification = NO; + webview_.allowsBackForwardNavigationGestures = NO; + // 透明背景:页面里没画东西的地方直接透出桌面。 + [webview_ setValue:@NO forKey:@"drawsBackground"]; + if (@available(macOS 12.0, *)) webview_.underPageBackgroundColor = NSColor.clearColor; + panel_.contentView = webview_; + + screen_observer_ = [[NSNotificationCenter defaultCenter] + addObserverForName:NSApplicationDidChangeScreenParametersNotification + object:nil + queue:[NSOperationQueue mainQueue] + usingBlock:^(NSNotification* note) { + (void)note; + if (const auto self = weak.lock()) self->screens_changed(); + }]; + + hover_timer_ = [NSTimer timerWithTimeInterval:kHoverPollSeconds + repeats:YES + block:^(NSTimer* timer) { + const auto self = weak.lock(); + if (self) self->poll_hover(); + else [timer invalidate]; + }]; + [[NSRunLoop mainRunLoop] addTimer:hover_timer_ forMode:NSRunLoopCommonModes]; + + // 透明地挂上屏幕,让 WebKit 按可见窗口渲染首帧;页面发 ready 后再显形。 + [panel_ orderFrontRegardless]; + load_page(); + return true; +} + +void PetController::load_page() { + const std::string page(reinterpret_cast(acecode::desktop_pet_page_data()), + acecode::desktop_pet_page_size()); + [webview_ loadHTMLString:ns_string(page) baseURL:nil]; +} + +void PetController::navigation_finished(bool success) { + if (closed_ || revealed_) return; + if (!success) { + LOG_WARN("[desktop-pet] page navigation failed"); + close(); + return; + } + if (reveal_timer_) return; + const std::weak_ptr weak = weak_from_this(); + reveal_timer_ = [NSTimer scheduledTimerWithTimeInterval:kRevealFallbackSeconds + repeats:NO + block:^(NSTimer* timer) { + (void)timer; + const auto self = weak.lock(); + if (!self || self->revealed_) return; + LOG_WARN("[desktop-pet] page ready signal missing; revealing after timeout"); + self->reveal(); + }]; +} + +void PetController::web_process_terminated() { + if (closed_) return; + // WebContent 进程被系统回收后页面是空白的:重新加载,ready 后重发快照。 + LOG_WARN("[desktop-pet] web content process terminated; reloading"); + page_ready_ = false; + load_page(); +} + +void PetController::reveal() { + if (revealed_ || closed_ || !panel_) return; + revealed_ = true; + [reveal_timer_ invalidate]; + reveal_timer_ = nil; + panel_.alphaValue = 1.0; + [panel_ orderFrontRegardless]; + const auto rect = window_rect(); + LOG_INFO("[desktop-pet] shown at " + std::to_string(rect.x) + "," + std::to_string(rect.y) + + " size " + std::to_string(rect.width) + "x" + std::to_string(rect.height)); +} + +void PetController::deliver(const nlohmann::json& value) { + if (closed_ || !webview_) return; + const std::string script = "window.__acePetDeliver&&window.__acePetDeliver(" + value.dump() + ");"; + [webview_ evaluateJavaScript:ns_string(script) completionHandler:nil]; +} + +void PetController::publish_snapshot() { + if (closed_ || !page_ready_ || !office_snapshot_.contains("version")) return; + deliver(office_snapshot_); +} + +void PetController::on_web_message(const std::string& message) { + if (closed_ || !panel_) return; + if (message == "ready") { + page_ready_ = true; + reveal(); + publish_snapshot(); + } else if (!message.empty() && message.front() == '{') { + office_action(message); + } else if (message == "drag") { + begin_gesture(false); + } else if (message == "resize") { + begin_gesture(true); + } else if (message == "menu") { + // 回到下一轮事件循环再弹菜单,不在 WebKit 的回调里进入模态菜单循环。 + const std::weak_ptr weak = weak_from_this(); + dispatch_async(dispatch_get_main_queue(), ^{ + if (const auto self = weak.lock()) self->show_menu(); + }); + } else if (message == "size-reset") { + reset_size(); + } else if (message.rfind("zoom ", 0) == 0) { + int steps = 0; + double fx = 1.0, fy = 1.0; + if (std::sscanf(message.c_str() + 5, "%d %lf %lf", &steps, &fx, &fy) >= 1) zoom_by(steps, fx, fy); + } +} + +void PetController::office_action(const std::string& message) { + if (message.size() > kMaxActionBytes) return; + const auto value = nlohmann::json::parse(message, nullptr, false); + if (!value.is_object()) return; + const auto type_field = value.find("type"); + if (type_field == value.end() || !type_field->is_string()) return; + const auto type = type_field->get(); + if (type == "overlay") { + overlays_ = desktop_pet_overlays_from_message(value); + return; + } + if (type != "select" && type != "follow" && type != "open") return; + host_.eval("window.dispatchEvent(new CustomEvent('ace-desktop-office-action',{detail:" + + value.dump() + "}));"); +} + +DesktopPetRect PetController::window_rect() const { + return panel_ ? top_left_rect(panel_.frame) : DesktopPetRect{}; +} + +DesktopPetRect PetController::work_area() const { + NSScreen* screen = panel_ && panel_.screen ? panel_.screen : NSScreen.mainScreen; + if (!screen) screen = NSScreen.screens.firstObject; + return screen ? top_left_rect(screen.visibleFrame) : DesktopPetRect{0, 0, 1440, 900}; +} + +double PetController::current_scale() const { + const auto rect = window_rect(); + return rect.width > 0 ? rect.width / static_cast(kDesktopPetSceneWidth) : 1.0; +} + +void PetController::apply_placement(const DesktopPetPlacement& next) { + if (!panel_) return; + [panel_ setFrame:cocoa_rect(next.window) display:YES]; +} + +void PetController::notify_size() { + const double base = kDefaultLogicalScale; + const int percent = static_cast(std::lround(current_scale() / base * 100.0)); + deliver("size " + std::to_string(percent)); +} + +void PetController::apply_scale(double scale, double fx, double fy) { + const DesktopPetRect work = work_area(); + const double clamped = clamp_desktop_pet_scale(scale, kLogicalDpi, work); + apply_placement(scale_desktop_pet(window_rect(), clamped, fx, fy, work)); + logical_scale_ = clamped; + notify_size(); +} + +void PetController::zoom_by(int steps, double fx, double fy) { + if (!revealed_ || closed_ || steps == 0) return; + // 停在右下角时锚在右下角(缩放后还贴着角);拖动过之后以鼠标所在点为中心。 + if (!user_moved_) { fx = 1.0; fy = 1.0; } + apply_scale(current_scale() * std::pow(kZoomStep, steps), fx, fy); + save_logical_scale(logical_scale_); +} + +void PetController::reset_size() { + if (!revealed_ || closed_) return; + logical_scale_ = 0.0; + save_logical_scale(0.0); + if (!user_moved_) dock_to_corner(); + else apply_placement(scale_desktop_pet(window_rect(), kDefaultLogicalScale, 1.0, 1.0, work_area())); + notify_size(); +} + +void PetController::dock_to_corner() { + apply_placement(place_desktop_pet(work_area(), kLogicalDpi, + logical_scale_ > 0.0 ? logical_scale_ : kDefaultLogicalScale)); +} + +void PetController::screens_changed() { + if (closed_ || !revealed_) return; + if (!user_moved_) { + dock_to_corner(); + return; + } + // 拖到别处后换了显示器排列:只把窗口夹回当前工作区。 + apply_placement(scale_desktop_pet(window_rect(), current_scale(), 0.0, 0.0, work_area())); +} + +void PetController::begin_gesture(bool resize) { + if (!revealed_ || closed_ || dragging_ || resizing_) return; + dragging_ = !resize; + resizing_ = resize; + gesture_mouse_ = [NSEvent mouseLocation]; + gesture_start_ = window_rect(); + panel_.ignoresMouseEvents = NO; + const std::weak_ptr weak = weak_from_this(); + gesture_timer_ = [NSTimer timerWithTimeInterval:kGesturePollSeconds + repeats:YES + block:^(NSTimer* timer) { + const auto self = weak.lock(); + if (self) self->poll_gesture(); + else [timer invalidate]; + }]; + [[NSRunLoop mainRunLoop] addTimer:gesture_timer_ forMode:NSRunLoopCommonModes]; +} + +void PetController::end_gesture() { + [gesture_timer_ invalidate]; + gesture_timer_ = nil; + if (dragging_) user_moved_ = true; + if (resizing_) save_logical_scale(logical_scale_); + dragging_ = false; + resizing_ = false; +} + +void PetController::poll_gesture() { + if (closed_ || (!dragging_ && !resizing_)) { + end_gesture(); + return; + } + if (([NSEvent pressedMouseButtons] & 1) == 0) { + end_gesture(); + return; + } + const NSPoint mouse = [NSEvent mouseLocation]; + // 屏幕坐标 y 向上;换成左上原点后 y 方向取反。 + const double dx = mouse.x - gesture_mouse_.x; + const double dy = gesture_mouse_.y - mouse.y; + if (dragging_) { + DesktopPetRect next = gesture_start_; + next.x += static_cast(std::lround(dx)); + next.y += static_cast(std::lround(dy)); + [panel_ setFrameOrigin:cocoa_rect(next).origin]; + return; + } + // 把手在左上方:往左上拖变大、右下角不动;横竖两个方向取放大得多的那个。 + const double by_x = gesture_start_.width - dx; + const double by_y = (gesture_start_.height - dy) * kDesktopPetSceneWidth / + static_cast(kDesktopPetSceneHeight); + const DesktopPetRect work = work_area(); + const double scale = clamp_desktop_pet_scale(std::max(by_x, by_y) / kDesktopPetSceneWidth, kLogicalDpi, work); + const auto next = scale_desktop_pet(gesture_start_, scale, 1.0, 1.0, work); + const auto current = window_rect(); + if (next.window.width == current.width && next.window.x == current.x && next.window.y == current.y) return; + apply_placement(next); + logical_scale_ = scale; + notify_size(); +} + +void PetController::poll_hover() { + if (closed_ || !panel_ || !revealed_) return; + if (dragging_ || resizing_) { + panel_.ignoresMouseEvents = NO; + return; + } + const NSPoint mouse = [NSEvent mouseLocation]; + const NSRect frame = panel_.frame; + const double x = mouse.x - frame.origin.x; + const double y = frame.origin.y + frame.size.height - mouse.y; + const bool hit = desktop_pet_hit_test(current_scale(), static_cast(frame.size.width), + static_cast(frame.size.height), overlays_, x, y); + if (panel_.ignoresMouseEvents == hit) panel_.ignoresMouseEvents = !hit; +} + +void PetController::show_menu() { + if (closed_ || !panel_) return; + const bool zh = chinese_ui(); + NSMenu* menu = [[NSMenu alloc] initWithTitle:@""]; + menu.autoenablesItems = NO; + ACECodeDesktopPetMenuTarget* target = menu_target_; + const auto add = [menu, target](NSString* title, NSInteger tag) { + NSMenuItem* item = [[NSMenuItem alloc] initWithTitle:title action:@selector(choose:) keyEquivalent:@""]; + item.target = target; + item.tag = tag; + [menu addItem:item]; + }; + add(zh ? @"回到右下角" : @"Move to corner", kMenuDock); + [menu addItem:[NSMenuItem separatorItem]]; + add(zh ? @"放大(也可以滚轮)" : @"Zoom in (or scroll)", kMenuZoomIn); + add(zh ? @"缩小" : @"Zoom out", kMenuZoomOut); + add(zh ? @"恢复默认大小" : @"Reset size", kMenuResetSize); + [menu addItem:[NSMenuItem separatorItem]]; + add(zh ? @"隐藏桌面宠物" : @"Hide desktop pet", kMenuHide); + [menu popUpMenuPositioningItem:nil atLocation:[NSEvent mouseLocation] inView:nil]; +} + +void PetController::menu_command(NSInteger command) { + if (closed_) return; + switch (command) { + case kMenuDock: + user_moved_ = false; + dock_to_corner(); + break; + case kMenuZoomIn: zoom_by(1, 0.5, 0.5); break; + case kMenuZoomOut: zoom_by(-1, 0.5, 0.5); break; + case kMenuResetSize: reset_size(); break; + case kMenuHide: + LOG_INFO("[desktop-pet] hidden from context menu"); + close(); + break; + default: break; + } +} + +void PetController::close() { + if (closed_) return; + closed_ = true; + [hover_timer_ invalidate]; + [gesture_timer_ invalidate]; + [reveal_timer_ invalidate]; + hover_timer_ = nil; + gesture_timer_ = nil; + reveal_timer_ = nil; + if (screen_observer_) [[NSNotificationCenter defaultCenter] removeObserver:screen_observer_]; + screen_observer_ = nil; + // WKUserContentController 强引用消息处理器,不移除会连带页面一起泄漏。 + [content_ removeScriptMessageHandlerForName:@"acePet"]; + [content_ removeAllUserScripts]; + webview_.navigationDelegate = nil; + [webview_ stopLoading]; + [panel_ orderOut:nil]; + [panel_ close]; + webview_ = nil; + content_ = nil; + panel_ = nil; + bridge_ = nil; + menu_target_ = nil; +} + +// DesktopPet 持有的实现:只是把控制器的所有权包起来。 +struct DesktopPet::Impl : PetController { + using PetController::PetController; +}; + +DesktopPet::DesktopPet(WebHost& host) { + if (desktop_pet_disabled_by_env()) { + LOG_INFO("[desktop-pet] disabled by ACECODE_DESKTOP_PET"); + return; + } + auto impl = std::make_shared(host); + impl->bind_bridge(); + if (impl->start()) { + impl_ = std::move(impl); + } else { + impl->close(); + } +} + +DesktopPet::~DesktopPet() { + if (impl_) impl_->close(); +} + +} // namespace acecode::desktop diff --git a/src/domain/session/session_activity_state.cpp b/src/domain/session/session_activity_state.cpp index 4a8264ae8..ee099ed51 100644 --- a/src/domain/session/session_activity_state.cpp +++ b/src/domain/session/session_activity_state.cpp @@ -4,6 +4,9 @@ namespace acecode { namespace { +// Enough for a speech bubble; the office shows only the last few words. +constexpr std::size_t kStreamTailBytes = 160; + std::string text(const nlohmann::json& value, const char* key) { const auto it = value.find(key); return it != value.end() && it->is_string() @@ -13,8 +16,36 @@ bool flag(const nlohmann::json& value, const char* key) { const auto it = value.find(key); return it != value.end() && it->is_boolean() && it->get(); } +std::string utf8_tail(const std::string& value, std::size_t max_bytes) { + if (value.size() <= max_bytes) return value; + std::size_t start = value.size() - max_bytes; + while (start < value.size() && + (static_cast(value[start]) & 0xC0) == 0x80) { + ++start; + } + return value.substr(start); +} } // namespace +void SessionActivityState::enter_phase(const std::string& phase) { + // Each model step starts a fresh bubble; a phase change ends the old text. + if (phase != phase_) stream_tail_.clear(); + phase_ = phase; +} + +void SessionActivityState::append_stream(const std::string& phase, const std::string& piece) { + if (phase_ != phase) { + enter_phase(phase); + if (phase == "responding") label_.clear(); + } + if (tools_.empty()) tool_.clear(); + stream_tail_ += piece; + // Trim in batches so a long reply costs O(piece) per token. + if (stream_tail_.size() > kStreamTailBytes * 2) { + stream_tail_ = utf8_tail(stream_tail_, kStreamTailBytes); + } +} + void SessionActivityState::apply(const SessionEvent& event) { seq_ = event.seq; const auto& p = event.payload; @@ -25,37 +56,55 @@ void SessionActivityState::apply(const SessionEvent& event) { busy_ = false; known_ = true; outcome_ = outcome; - phase_.clear(); label_.clear(); tool_.clear(); tools_.clear(); - permissions_.clear(); questions_.clear(); + phase_.clear(); label_.clear(); tool_.clear(); detail_.clear(); stream_tail_.clear(); + tools_.clear(); permissions_.clear(); questions_.clear(); }; switch (event.kind) { case SessionEventKind::BusyChanged: busy_ = flag(p, "busy"); known_ = true; if (busy_) { outcome_.clear(); phase_ = "model_waiting"; - label_.clear(); tool_.clear(); tools_.clear(); - permissions_.clear(); questions_.clear(); + label_.clear(); tool_.clear(); detail_.clear(); stream_tail_.clear(); + tools_.clear(); permissions_.clear(); questions_.clear(); turn_id_ = text(p, "turn_id"); } else finish(); break; case SessionEventKind::Done: finish(); break; case SessionEventKind::AgentProgress: - phase_ = text(p, "phase"); + enter_phase(text(p, "phase")); label_ = text(p, "label"); tool_ = text(p, "tool"); break; + case SessionEventKind::Token: + case SessionEventKind::Reasoning: { + const auto it = p.find("text"); + if (it == p.end() || !it->is_string()) break; + append_stream(event.kind == SessionEventKind::Token ? "responding" : "reasoning", + it->get_ref()); + break; + } case SessionEventKind::ToolStart: { tool_ = text(p, "tool"); + auto preview = text(p, "display_override"); + if (preview.empty()) preview = text(p, "command_preview"); + detail_ = truncate_utf8_prefix(preview, 120); auto id = text(p, "tool_call_id"); if (id.empty()) id = tool_; - tools_[id] = tool_; + tools_[id] = {tool_, detail_}; + stream_tail_.clear(); break; } case SessionEventKind::ToolEnd: { auto id = text(p, "tool_call_id"); if (id.empty()) id = text(p, "tool"); tools_.erase(id); - tool_ = tools_.empty() ? std::string{} : tools_.rbegin()->second; + if (tools_.empty()) { + tool_.clear(); + detail_.clear(); + } else { + tool_ = tools_.rbegin()->second.first; + detail_ = tools_.rbegin()->second.second; + } break; } case SessionEventKind::PermissionRequest: @@ -91,9 +140,11 @@ nlohmann::json SessionActivityState::snapshot() const { if (!permissions_.empty()) phase = "permission_waiting"; else if (!questions_.empty()) phase = "question_waiting"; else if (!tools_.empty()) phase = "tool_running"; + const bool streaming = phase == "responding" || phase == "reasoning"; return {{"seq", seq_}, {"known", known_}, {"busy", busy_}, {"turn_id", turn_id_}, {"outcome", outcome_}, {"phase", phase}, - {"label", label_}, {"tool", tool_}, {"compact_id", compact_id_}, - {"transfers", transfers_}}; + {"label", label_}, {"tool", tool_}, {"detail", detail_}, + {"text", streaming ? utf8_tail(stream_tail_, kStreamTailBytes) : std::string{}}, + {"compact_id", compact_id_}, {"transfers", transfers_}}; } } // namespace acecode diff --git a/src/domain/session/session_activity_state.hpp b/src/domain/session/session_activity_state.hpp index ed999ea88..d0d94cef0 100644 --- a/src/domain/session/session_activity_state.hpp +++ b/src/domain/session/session_activity_state.hpp @@ -4,16 +4,22 @@ #include #include #include +#include namespace acecode { // Compact presentation state, updated under the owning dispatcher's mutex. -// It retains no transcript, arguments, tool output or service references. +// It keeps only short display fragments: the streaming tail of the current +// model step (bounded) and each running tool's one-line call preview. No +// transcript, arguments, tool output or service references are retained. class SessionActivityState { public: void apply(const SessionEvent& event); nlohmann::json snapshot() const; private: + void enter_phase(const std::string& phase); + void append_stream(const std::string& phase, const std::string& piece); + std::uint64_t seq_ = 0; bool busy_ = false; bool known_ = false; @@ -22,8 +28,11 @@ class SessionActivityState { std::string phase_; std::string label_; std::string tool_; + std::string detail_; + std::string stream_tail_; std::string compact_id_; - std::map tools_; + // tool_call_id -> (tool name, call preview) + std::map> tools_; std::unordered_set permissions_; std::unordered_set questions_; nlohmann::json transfers_ = nlohmann::json::array(); diff --git a/tests/desktop/desktop_pet_layout_test.cpp b/tests/desktop/desktop_pet_layout_test.cpp index f30050352..f81a02e05 100644 --- a/tests/desktop/desktop_pet_layout_test.cpp +++ b/tests/desktop/desktop_pet_layout_test.cpp @@ -10,12 +10,16 @@ // 6. 用户缩放的上下限(逻辑 0.5 倍到工作区 90%); // 7. 用户调过的大小跨 DPI 按逻辑倍数换算; // 8. 滚轮 / 把手缩放时锚点不动,放不下时推回工作区; -// 9. 小数倍缩放时命中区域按四舍五入换算。 +// 9. 小数倍缩放时命中区域按四舍五入换算; +// 10. 页面上报的浮层(成员列表 / 提示 / 状态气泡)解析:坏数据整条丢弃、坐标夹紧、条数有上限; +// 11. 点击命中判定(macOS 用它切换点击穿透):房间、控制条、浮层命中,透明角落穿透。 #include #include "desktop/desktop_pet_layout.hpp" +#include + #include #include @@ -206,3 +210,49 @@ TEST(DesktopPetLayout, HitPolygonSupportsFractionalScale) { EXPECT_EQ(scaled[i].y, static_cast(std::lround(base[i].y * 1.5))); } } + +// 场景:页面发来 overlay 消息,rect 是成员列表 / 提示的外包框,bubbles 是状态气泡列表, +// 其中混着越界坐标、NaN 以外的非数字、宽度为 0 的矩形和超量条目。 +// 期望:越界坐标夹到 [0,1];非数字、空矩形整条丢弃;rect=null 时只取气泡; +// 总数不超过 kDesktopPetMaxOverlays。 +// 回归:修复前原生端只认一个 rect,冒出墙顶的气泡被窗口区域裁掉、也点不到。 +TEST(DesktopPetLayout, OverlayMessageKeepsValidRectsOnly) { + const auto overlays = desktop_pet_overlays_from_message(nlohmann::json{ + {"type", "overlay"}, + {"rect", {0.1, 0.2, 0.5, 0.6}}, + {"bubbles", nlohmann::json::array({ + nlohmann::json::array({-0.2, 0.1, 0.3, 1.4}), + nlohmann::json::array({0.4, "x", 0.5, 0.6}), + nlohmann::json::array({0.4, 0.4, 0.4, 0.6}), + nlohmann::json::array({0.6, 0.1, 0.7})})}}); + ASSERT_EQ(overlays.size(), 2u); + EXPECT_DOUBLE_EQ(overlays[1][0], 0.0) << "左边越界夹到 0"; + EXPECT_DOUBLE_EQ(overlays[1][3], 1.0) << "下边越界夹到 1"; + + const auto bubbles_only = desktop_pet_overlays_from_message(nlohmann::json{ + {"type", "overlay"}, {"rect", nullptr}, + {"bubbles", nlohmann::json::array({nlohmann::json::array({0.2, 0.2, 0.3, 0.3})})}}); + ASSERT_EQ(bubbles_only.size(), 1u); + + nlohmann::json many = nlohmann::json::array(); + for (int i = 0; i < 40; ++i) many.push_back({0.1, 0.1, 0.2, 0.2}); + EXPECT_EQ(desktop_pet_overlays_from_message({{"bubbles", many}}).size(), kDesktopPetMaxOverlays); + EXPECT_TRUE(desktop_pet_overlays_from_message(nlohmann::json::array()).empty()); +} + +// 场景:1.25 倍(430×315)窗口,依次点:房间中央、顶部控制条、左上透明角落、 +// 左上角落里一个上报过的气泡、窗口外。 +// 期望:房间 / 控制条 / 气泡命中;没有浮层的透明角落与窗口外不命中(点击应穿透)。 +TEST(DesktopPetLayout, HitTestCoversRoomToolbarAndOverlays) { + const double scale = 1.25; + const int width = 430, height = 315; + const std::vector none; + EXPECT_TRUE(desktop_pet_hit_test(scale, width, height, none, 215, 200)) << "房间中央"; + EXPECT_TRUE(desktop_pet_hit_test(scale, width, height, none, 400, 10)) << "顶部控制条"; + EXPECT_FALSE(desktop_pet_hit_test(scale, width, height, none, 6, 60)) << "左上透明角落"; + EXPECT_FALSE(desktop_pet_hit_test(scale, width, height, none, 6, 310)) << "左下透明角落"; + const std::vector bubble{{0.0, 0.15, 0.1, 0.25}}; + EXPECT_TRUE(desktop_pet_hit_test(scale, width, height, bubble, 6, 60)) << "角落里的气泡"; + EXPECT_FALSE(desktop_pet_hit_test(scale, width, height, bubble, -1, 60)) << "窗口外"; + EXPECT_FALSE(desktop_pet_hit_test(scale, width, height, bubble, 430, 60)) << "右边界外"; +} diff --git a/tests/session/session_activity_state_test.cpp b/tests/session/session_activity_state_test.cpp index ffb4066da..d1810f4b4 100644 --- a/tests/session/session_activity_state_test.cpp +++ b/tests/session/session_activity_state_test.cpp @@ -1,3 +1,12 @@ +// 覆盖 SessionActivityState(桌面像素办公室读取的会话活动快照)的状态折叠规则: +// 1. 回合完成在事件环被挤掉后仍可读,新回合开始即唤醒; +// 2. 等待授权 / 等待回答优先于其它活动,全部关闭后回到工具执行; +// 3. 取消与失败不会被记成成功完成; +// 4. 不缓冲的进度事件与压缩完成同样进入快照; +// 5. 网状消息只保留身份、不保留正文,且有条数上限; +// 6. 流式正文让快照进入「撰写回复」并只保留有限尾巴; +// 7. 推理与正文各自成段,换阶段时旧尾巴清空; +// 8. 工具调用带一行调用预览,工具结束后预览清空。 #include #include "session/event_dispatcher.hpp" #include "session/session_activity_state.hpp" @@ -5,6 +14,8 @@ using namespace acecode; using nlohmann::json; +// 场景:事件环容量只有 1,回合完成后又来了 5 个 token 把 Done 挤出环。 +// 期望:快照仍报 outcome=completed / busy=false;下一回合 busy=true 后 outcome 清空。 TEST(SessionActivityState, CompletionSurvivesReplayEvictionAndNewTurnWakes) { EventDispatcher events(1); events.emit(SessionEventKind::BusyChanged, {{"busy", true}, {"turn_id", "first"}}); @@ -17,6 +28,9 @@ TEST(SessionActivityState, CompletionSurvivesReplayEvictionAndNewTurnWakes) { EXPECT_TRUE(events.activity_snapshot()["busy"].get()); } +// 场景:工具执行中先后来两个授权请求和一个提问,中间夹一条普通进度。 +// 期望:只要还有未关闭的授权就是 permission_waiting,其次 question_waiting, +// 都关闭后回到 tool_running;工具结束后 tool 清空,输出正文不进快照。 TEST(SessionActivityState, PendingInteractionsOutrankOtherActivityUntilAllClose) { EventDispatcher events; events.emit(SessionEventKind::BusyChanged, {{"busy", true}}); @@ -36,6 +50,8 @@ TEST(SessionActivityState, PendingInteractionsOutrankOtherActivityUntilAllClose) EXPECT_EQ(events.activity_snapshot().dump().find("private output"), std::string::npos); } +// 场景:回合以 aborted / error 结束,或 busy=false 却没有 outcome。 +// 期望:outcome 原样保留为 aborted / error;没有 outcome 时保持空串,不能当成 completed。 TEST(SessionActivityState, CancellationAndFailureAreNeverSuccessfulCompletion) { for (const auto* outcome : {"aborted", "error"}) { EventDispatcher events; @@ -48,6 +64,8 @@ TEST(SessionActivityState, CancellationAndFailureAreNeverSuccessfulCompletion) { EXPECT_EQ(events.activity_snapshot()["outcome"], ""); } +// 场景:不进回放环的 compacting 进度,以及压缩通知先「未完成」后「完成」。 +// 期望:phase=compacting 可见;只有 compact_notice_complete=true 才记 compact_id。 TEST(SessionActivityState, TransientProgressAndCompactionAreSnapshotState) { EventDispatcher events; EventDispatcher::EmitOptions transient; @@ -60,6 +78,8 @@ TEST(SessionActivityState, TransientProgressAndCompactionAreSnapshotState) { EXPECT_EQ(events.activity_snapshot()["compact_id"], "c1"); } +// 场景:连续 20 条网状 agent 间消息,正文是私有内容。 +// 期望:只保留最近 16 条的发送方 / 接收方身份,快照里找不到正文。 TEST(SessionActivityState, MeshTransfersRetainIdentityWithoutPrivateBodyAndAreBounded) { EventDispatcher events; for (int i = 0; i < 20; ++i) events.emit(SessionEventKind::Message, { @@ -71,3 +91,61 @@ TEST(SessionActivityState, MeshTransfersRetainIdentityWithoutPrivateBodyAndAreBo EXPECT_EQ(snapshot["transfers"].back()["sender_session_id"], "child-a"); EXPECT_EQ(snapshot.dump().find("private content"), std::string::npos); } + +// 场景:默认「用于编程」模式下模型开始输出正文 —— 这时服务端不会发 responding 进度, +// 只有一串 token。共推 400 个「字」(1200 字节),远超气泡需要的长度。 +// 期望:快照 phase=responding,text 是最后 160 字节以内、以最后一个字结尾的合法 UTF-8; +// 进度里遗留的「正在等待模型响应」不再当作当前文案。 +// 回归:修复前快照一直停在 model_waiting,办公室里正在飙字的 agent 只显示一个问号。 +TEST(SessionActivityState, StreamingReplyIsVisibleWithBoundedTail) { + EventDispatcher events; + events.emit(SessionEventKind::BusyChanged, {{"busy", true}}); + events.emit(SessionEventKind::AgentProgress, {{"phase", "model_waiting"}, {"label", "正在等待模型响应"}}); + for (int i = 0; i < 399; ++i) events.emit(SessionEventKind::Token, {{"text", "字"}}); + events.emit(SessionEventKind::Token, {{"text", "尾"}}); + const auto snapshot = events.activity_snapshot(); + EXPECT_EQ(snapshot["phase"], "responding"); + EXPECT_EQ(snapshot["label"], ""); + const auto tail = snapshot["text"].get(); + EXPECT_LE(tail.size(), 160u); + EXPECT_GE(tail.size(), 150u); + EXPECT_EQ(tail.size() % 3, 0u) << "每个汉字 3 字节,截断点必须落在字符边界"; + EXPECT_EQ(tail.substr(tail.size() - 3), "尾"); +} + +// 场景:先推理(reasoning 进度 + 推理片段),再开始输出正文。 +// 期望:推理阶段 text 是推理尾巴;转入正文后旧推理内容清空,只剩正文。 +TEST(SessionActivityState, ReasoningAndReplyAreSeparateBubbles) { + EventDispatcher events; + events.emit(SessionEventKind::BusyChanged, {{"busy", true}}); + events.emit(SessionEventKind::AgentProgress, {{"phase", "reasoning"}, {"label", "正在推理"}}); + events.emit(SessionEventKind::Reasoning, {{"text", "check config"}}); + EXPECT_EQ(events.activity_snapshot()["phase"], "reasoning"); + EXPECT_EQ(events.activity_snapshot()["text"], "check config"); + events.emit(SessionEventKind::Token, {{"text", "好的"}}); + EXPECT_EQ(events.activity_snapshot()["phase"], "responding"); + EXPECT_EQ(events.activity_snapshot()["text"], "好的"); +} + +// 场景:正文写到一半开始调用工具,工具带调用预览(display_override);随后工具结束。 +// 期望:工具期间 phase=tool_running、detail=预览、text 为空(正文气泡结束); +// 工具结束后 tool / detail 都清空;没有 display_override 时退回 command_preview。 +TEST(SessionActivityState, ToolCallCarriesOneLinePreviewUntilItEnds) { + EventDispatcher events; + events.emit(SessionEventKind::BusyChanged, {{"busy", true}}); + events.emit(SessionEventKind::Token, {{"text", "我先看看"}}); + events.emit(SessionEventKind::ToolStart, {{"tool", "file_read"}, {"tool_call_id", "a"}, + {"display_override", "src/main.cpp"}, {"args", {{"path", "secret-arg"}}}}); + auto snapshot = events.activity_snapshot(); + EXPECT_EQ(snapshot["phase"], "tool_running"); + EXPECT_EQ(snapshot["detail"], "src/main.cpp"); + EXPECT_EQ(snapshot["text"], ""); + EXPECT_EQ(snapshot.dump().find("secret-arg"), std::string::npos); + events.emit(SessionEventKind::ToolEnd, {{"tool", "file_read"}, {"tool_call_id", "a"}}); + snapshot = events.activity_snapshot(); + EXPECT_EQ(snapshot["tool"], ""); + EXPECT_EQ(snapshot["detail"], ""); + events.emit(SessionEventKind::ToolStart, {{"tool", "bash"}, {"tool_call_id", "b"}, + {"display_override", ""}, {"command_preview", "npm test"}}); + EXPECT_EQ(events.activity_snapshot()["detail"], "npm test"); +} diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index 81c4b88f0..6c4d917f4 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -7,6 +7,24 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '整理上下文': 'Compacting context', '正在重试': 'Retrying', '思考中': 'Thinking', + '撰写回复': 'Writing reply', + '准备': 'Preparing to ', + '准备调用工具': 'Preparing a tool call', + '写入文件': 'Writing a file', + '修改代码': 'Editing code', + '搜索代码': 'Searching code', + '查阅网页': 'Browsing the web', + '协作': 'Collaborating', + '更新待办': 'Updating todos', + '收尾': 'Wrapping up', + '使用技能': 'Using a skill', + '调用 MCP': 'Calling MCP', + '派发任务': 'Dispatching a task', + '等成员回报': 'Waiting for members', + '发消息': 'Sending a message', + '追加任务': 'Adding a follow-up task', + '叫停成员': 'Stopping a member', + '查看成员': 'Checking members', '选择文件': 'Select file', '无法读取目录': 'Unable to read directory', '空目录': 'Empty directory', diff --git a/web/scripts/test-desktop-office.mjs b/web/scripts/test-desktop-office.mjs index 20c1bdac6..adbca1baa 100644 --- a/web/scripts/test-desktop-office.mjs +++ b/web/scripts/test-desktop-office.mjs @@ -24,6 +24,13 @@ try { await page.waitForFunction(()=>!!window.AgentOffice&&window.officeActions.includes('ready')); assert.equal(await page.evaluate(()=>AgentOffice.agents.length),0,'production starts without demo workers'); const apply=async snapshot=>{await page.evaluate(value=>window.hostMessage({data:value}),snapshot);await page.waitForTimeout(90);}; + // 以模拟时间推进(走路、信封、等待),不依赖真实帧率;predicate 在页面里求值。 + const advanceUntil=async(predicate,maxMs=20000)=>page.evaluate(({source,maxMs})=>{ + const test=new Function('return ('+source+')'); + for(let t=0;tpage.evaluate(()=>AgentOffice.agents.filter(a=>!a.leaving).map(a=>a.id)); await apply(data()); assert.equal(await page.locator('.office-tab').count(),5); assert.equal(await page.locator('#officeFollow').getAttribute('aria-checked'),'true'); @@ -38,11 +45,30 @@ try { await page.keyboard.press('Enter'); assert.ok(await page.evaluate(()=>officeActions.some(raw=>{try{const m=JSON.parse(raw);return m.type==='select'&&m.session_id==='root2';}catch{return false;}}))); await page.screenshot({path:path.join(output,'office-working-688.png')}); - // Completion removes workers immediately while the main agent displays zzz. + // Busy agents keep a persistent bubble: verb plus the call preview or the latest streamed words. + await apply(data({agents:[actor('root',{tool:'file_read',iconSvg:toolIconSvg('file_read'),label:'读取',detail:'src/auth/login.cpp'}), + actor('代码审查',{state:'type',label:'撰写回复',detail:'…并发登录会覆盖旧会话'}),actor('回归测试',{state:'permission',label:'等待授权',detail:'运行命令'})]})); + const bubbles=await page.evaluate(()=>Object.fromEntries(AgentOffice.agents.map(a=>[a.id,a.bubble]))); + assert.match(bubbles.root,/读取.*src\/auth\/login\.cpp/); + assert.match(bubbles['代码审查'],/撰写回复.*覆盖旧会话/); + assert.match(bubbles['回归测试'],/等待授权/); + assert.ok(await page.locator('.bub.type .bub-detail').count()>=1,'streaming bubble keeps the typing caret style'); + assert.ok(await page.evaluate(()=>officeActions.some(raw=>{try{const m=JSON.parse(raw);return m.type==='overlay'&&m.bubbles?.length>=3;}catch{return false;}})),'bubble regions reach the native window shape'); + await page.screenshot({path:path.join(output,'office-bubbles-688.png')}); + // Completed workers walk to their dispatcher, hand in a report envelope, then leave; the main agent sleeps. await apply(data({agents:[actor('root',{state:'sleep',label:'zzz',busy:false,contextRatio:.15})],completed:['代码审查','回归测试']})); + assert.deepEqual(await present(),['root']); + assert.ok(await page.evaluate(()=>AgentOffice.agents.filter(a=>a.leaving).every(a=>a.walking&&a.state==='done'))); + assert.ok(await advanceUntil("AgentOffice.envelopes.some(e=>e.label==='回报'&&e.to==='root')"),'report envelope flies to the dispatcher'); + assert.ok(await advanceUntil("AgentOffice.agents.some(a=>a.id==='root'&&/收到.*的结果/.test(a.bubble))"),'dispatcher acknowledges the report'); + assert.ok(await advanceUntil("AgentOffice.agents.length===1"),'workers leave through the door'); assert.deepEqual(await page.evaluate(()=>AgentOffice.agents.map(a=>a.state)),['sleep']); await page.screenshot({path:path.join(output,'office-sleep-688.png')}); + // A new worker walks in, receives a task envelope at the dispatcher's desk, then sits down. await apply(data()); + assert.ok(await page.evaluate(()=>AgentOffice.agents.filter(a=>!a.root&&a.id!=='root').every(a=>a.walking)),'new workers enter on foot'); + assert.ok(await advanceUntil("AgentOffice.envelopes.some(e=>e.label==='任务'&&e.from==='root')"),'task envelope leaves the dispatcher'); + assert.ok(await advanceUntil("AgentOffice.agents.every(a=>!a.walking)"),'workers reach their seats'); assert.equal(await page.evaluate(()=>AgentOffice.agents.find(a=>a.id==='代码审查').look),look); assert.equal(await page.evaluate(()=>AgentOffice.layoutSignature),originalLayout); const toolImage=await page.locator('.office-tool:not([hidden])').first().getAttribute('src'); @@ -50,7 +76,7 @@ try { // Over-capacity members remain reachable, and the host receives their hit box. const many=[actor('root'),...Array.from({length:9},(_,i)=>actor('成员'+i))]; await apply(data({agents:many,overflow:2})); - assert.equal(await page.evaluate(()=>AgentOffice.agents.length),8); + assert.equal((await present()).length,8); await page.locator('#officeOverflow').click(); assert.equal(await page.locator('#officeMembers button').count(),10); assert.ok(await page.evaluate(()=>officeActions.some(raw=>{try{const m=JSON.parse(raw);return m.type==='overlay'&&m.rect?.[2]>m.rect?.[0];}catch{return false;}}))); @@ -60,6 +86,17 @@ try { assert.ok(await page.evaluate(()=>officeActions.some(raw=>{try{const m=JSON.parse(raw);return m.type==='open'&&m.session_id==='成员8';}catch{return false;}}))); await page.locator('#officeOverflow').click(); await apply(data({follow:false})); + await advanceUntil("AgentOffice.agents.every(a=>!a.walking)"); + // Mesh messages between members fly as labelled envelopes. + await apply(data({agents:[actor('root'),actor('代码审查',{path:'/root/review'}),actor('回归测试',{path:'/root/test', + transfers:[{seq:77,sender:'/root/review',recipient:'/root/test',sender_session_id:'代码审查',type:'MESSAGE'}]})]})); + assert.ok(await page.evaluate(()=>AgentOffice.envelopes.some(e=>e.from==='代码审查'&&e.to==='回归测试'&&e.label==='消息')),'mesh message envelope'); + // Reduced motion: no walking, but the task hand-off is still shown. + await page.emulateMedia({reducedMotion:'reduce'}); + await apply(data({agents:[actor('root'),actor('代码审查',{path:'/root/review'}),actor('回归测试',{path:'/root/test'}),actor('文档')]})); + assert.equal(await page.evaluate(()=>AgentOffice.agents.find(a=>a.id==='文档').walking),false); + assert.ok(await page.evaluate(()=>AgentOffice.envelopes.some(e=>e.to==='文档'&&e.label==='任务')),'reduced motion keeps the hand-off'); + await page.emulateMedia({reducedMotion:'no-preference'}); assert.equal(await page.locator('#officeMembers').isHidden(),true); assert.ok(await page.evaluate(()=>officeActions.some(raw=>{try{const m=JSON.parse(raw);return m.type==='overlay'&&m.rect===null;}catch{return false;}}))); @@ -79,5 +116,5 @@ try { assert.match(await page.locator('#officeNotice').textContent(),/发送消息后/); assert.ok(await page.evaluate(()=>{const notices=officeActions.map(raw=>{try{return JSON.parse(raw);}catch{return {};}}).filter(m=>m.type==='overlay');return Array.isArray(notices.at(-1)?.rect);}), 'native region includes empty-state notice'); assert.deepEqual(errors,[]);assert.deepEqual(external,[]); - console.log(JSON.stringify({output,checks:22,errors,external},null,2)); + console.log(JSON.stringify({output,checks:40,errors,external},null,2)); } finally {await browser.close();} diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index b9b86c13e..51da2431f 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -47,6 +47,7 @@ export const sourceCatalogs = { "s_05564f10bcba6ae8": "同一专家可属于多个 Tag", "s_0580fcee37e02012": "删除失败:", "s_05a531a083e68807": "复制 {{p0}} 验证码 {{p1}}", + "s_05abefa30a624a8a": "派发任务", "s_05df17e0e401d645": "让 Agent 帮你重构、修 bug、加测试", "s_05efef8465e0c666": "git 响应超时(仓库可能很大)", "s_05fdf30411d98d32": "沙箱", @@ -200,6 +201,7 @@ export const sourceCatalogs = { "s_13f6a415ac664be2": "从当前 Provider 探测真实模型列表", "s_140b07fe3f9f7d48": "需要下载", "s_1417bae3d17d6405": "收起图像", + "s_142cf02f83d4e7ab": "撰写回复", "s_14357c0d6ca270df": "请检查地址是否正确,然后重试。", "s_14381a6ef00a1253": "定时测试与问题修复", "s_145219e726b87790": "隐藏", @@ -239,15 +241,18 @@ export const sourceCatalogs = { "s_184568b57e5e6d3f": "正在生成测试图片…", "s_1847a84db6d5fc1a": "权限模式切换失败:", "s_18565a5455a0a875": "加载沙箱设置失败", + "s_18578640315b5b33": "更新待办", "s_1882a447d5f76d5f": "反馈内容不能超过 10000 字", "s_18850bf66a4ba4c6": "模型申请临时加宽沙盒权限", "s_19086bec77d5e5e5": "已自动兼容系统命名规则", + "s_190eedb7bfaa8609": "追加任务", "s_191041794ad98158": "(所有匹配)", "s_1910636fddbffe3e": "读取应用控件并操作鼠标和键盘", "s_19107d868966ff2c": "会话标题", "s_194b5dba3c63f537": "推理设置", "s_1964dafada0807b5": "Git 分支", "s_19af3dcd414d81df": "{{p0}} 个附件", + "s_19bf536853e2e78a": "协作", "s_19bfb1631d25a204": "正在保存主题…", "s_19cfa7d09d8dd287": "派遣中…", "s_1a0bf11a28779888": "选择后填入真实聊天框,不会自动发送", @@ -375,6 +380,7 @@ export const sourceCatalogs = { "s_25ec0421b03bc4f3": "拖动调整右侧栏宽度", "s_261dba7261ed8c05": "描述不能为空", "s_2623156dcd71bf2d": "星号", + "s_264333fb5458a573": "准备调用工具", "s_2657124b1481b789": "项目提供", "s_267c4a7a86dd1849": "全部类型", "s_26ab0eee67e8c707": "已调用工具", @@ -511,6 +517,7 @@ export const sourceCatalogs = { "s_3590fba381ad57f0": "选中文本操作", "s_35c52c32418a2678": "将删除全部 {{p0}} 条记录,无法恢复。需要留档请先导出。", "s_35cda8a7052dec20": "迁移失败", + "s_3615b021b8af5e82": "收尾", "s_3620d3d986f14584": "关闭模型弹窗", "s_3625f9fa2f9c629a": "拒绝执行命令", "s_364970d8168ca5fe": "发送中…", @@ -524,12 +531,14 @@ export const sourceCatalogs = { "s_377e4422342c71f7": "问题反馈已上传", "s_3785cba81d184679": "当前工作区记忆", "s_37979829426b0cdb": "工作区", + "s_37a41b2e983b06ee": "使用技能", "s_37c427d9f5dc817d": "默认模型已设为 ", "s_37d36b087f49c6d2": "选择专家组件", "s_37e4e42c314102da": "图像", "s_381117baedb79e1a": "选择 Provider 和模型后,只显示当前连接真正支持的配置。", "s_381796352dc0301d": "配置已保存但未能生效,请重试", "s_3819d5e4aa5dba68": "钢笔", + "s_38744132b501a1eb": "提问", "s_38c043e08502e710": "个性化", "s_38cd88ed8b3ddbf1": "复制完整输出", "s_38d456bbdb81317c": "按 Tag 筛选", @@ -830,6 +839,7 @@ export const sourceCatalogs = { "s_55db57105231bd95": "工作空间不可用", "s_55ef676879f7cca3": "diff 过大,请在终端查看", "s_561817f0252936a9": "放行", + "s_562834f547655a37": "搜索代码", "s_5653d97cdf569cb9": "系统防火墙、路由器或云安全组可能仍需放行代理端口。公网访问仍建议使用可信 VPN,并在上游配置 HTTPS。", "s_56627c94a9decee6": "最大输出 Token", "s_566e5f182a63185d": "正在压缩主题资源…", @@ -907,6 +917,7 @@ export const sourceCatalogs = { "s_5d7fd21f1e3ca6b4": "复制消息", "s_5d817dbb5da09259": "导入主题返回了无效结果,请刷新后重试", "s_5d937ed111134879": "模型能力", + "s_5ddcb16ab6fcb808": "查看成员", "s_5ddffd77ea7fedae": "正在加载已保存模型…", "s_5e56bec8550f499d": "请选择文件夹", "s_5e80c1e561d45b11": "选择权限模式", @@ -1217,6 +1228,7 @@ export const sourceCatalogs = { "s_7ea852a1f549b6ca": "请输入定时任务名称", "s_7ecf1cde648680d2": "已选", "s_7ecff345fb5ca29e": "Copilot 授权校验失败", + "s_7ed80e44eaf9c51c": "写入文件", "s_7edb086ee0d4c6c8": "任务完成通知已打开,但 macOS 系统权限已拒绝", "s_7ee8b2d0ae7ade97": "移除附件", "s_7efe433909425026": "自定义请求头格式不正确", @@ -1251,6 +1263,7 @@ export const sourceCatalogs = { "s_822fb37dba29381a": "选择文件", "s_82395d423099a07a": "已归档", "s_825a80f75113a9d3": "提示词缓存命中:{{p0}} / {{p1}} 输入 token({{p2}}%),缓存写入 {{p3}}", + "s_82601c48d5d64d71": "修改代码", "s_8268968d4a8759fc": "当前模型不支持只读旁路聊天,请切换模型后重试。", "s_82808f881bf7755f": "团队成员 · {{p0}} 人", "s_82a043c05e8a9446": "专家名称", @@ -1287,6 +1300,7 @@ export const sourceCatalogs = { "s_85e2f5601c1a0258": "无人值守", "s_8609635074718e14": "已引用到聊天", "s_86353011351e5d0b": "opencode 会话导入失败:", + "s_8652e90f49cd7463": "调用 MCP", "s_86542c6b657c93ab": "会话:{{p0}}", "s_86624f5719c93e79": "显示更早的 {{p0}} 条消息", "s_867698fb7b0c730c": "项目名称", @@ -1748,6 +1762,7 @@ export const sourceCatalogs = { "s_b9238eb0c9178901": "主题操作失败,请重试", "s_b92b399d2820c415": "清除已保存密钥", "s_b92d86f4f5570817": "关闭其他", + "s_b93afa1b4934b706": "查阅网页", "s_b967309734beff88": "记忆摘要", "s_b97ad406809572e1": "启用推理", "s_b97c0e55e836dee2": "所选摘要模型不可用,请重新选择模型", @@ -1853,6 +1868,7 @@ export const sourceCatalogs = { "s_c3b8f744ed2c55bf": "JSON 格式错误:", "s_c3e5589604083870": "暂无插件", "s_c3f989b790daab98": "后台服务暂不可用,请稍后重试", + "s_c43f153cb1be5ca5": "等成员回报", "s_c4ae8fa5a16b5a75": "跳过所有工具权限确认", "s_c4c5b649cbf25fbb": "记忆与上下文", "s_c52c7fbed621e8ff": "批注 {{p0}}", @@ -2097,6 +2113,7 @@ export const sourceCatalogs = { "s_dd42f689b17393c2": "正在加载更早的消息…", "s_dd7a9008edbca109": "已取消 {{p0}} 个会话的归档,{{p1}} 个失败", "s_dda206707b809157": "连接已中断", + "s_ddcf6e77b0ee1000": "准备", "s_ddde08946298195d": "还原", "s_ddfe2c796c7eb71b": "手动添加", "s_de03ee139fe2fd9f": "已找到", @@ -2415,6 +2432,7 @@ export const sourceCatalogs = { "s_fcea4baf388f169a": "运行核心测试,定位并修复可可靠复现的问题。", "s_fcf8b4bff0df782d": "打开文件夹", "s_fcffc107c99acbba": "已注册工作区", + "s_fd351bc95e9337e4": "发消息", "s_fd4b9e3b6c685bae": "继续编辑", "s_fd5fd3c4567938c7": "询问", "s_fd6529f1e944f8c7": "与当前会话共享文件;有任务运行时会排队。", @@ -2435,6 +2453,7 @@ export const sourceCatalogs = { "s_ff199a8f48c79fd8": "审计日志已导出", "s_ff20131bcd4725f2": "还没有规则。确认框里选「以后都允许」也会自动写到这里。", "s_ff509c9ba052a21c": "保存中…", + "s_ff62ea25d5d6b3a6": "叫停成员", "s_ff63d45ac706d173": "在新会话中继续", "s_ff857f2b6ac947ac": "最近整合", "s_ff8b0d734b03b1a4": "自定义模型", @@ -2488,6 +2507,7 @@ export const sourceCatalogs = { "s_05564f10bcba6ae8": "The same expert can belong to multiple Tags", "s_0580fcee37e02012": "Delete failed:", "s_05a531a083e68807": "Copy {{p0}} verification code {{p1}}", + "s_05abefa30a624a8a": "Dispatching a task", "s_05df17e0e401d645": "Ask Agent to refactor, fix bugs, and add tests", "s_05efef8465e0c666": "git response timeout (the warehouse may be large)", "s_05fdf30411d98d32": "Sandbox", @@ -2641,6 +2661,7 @@ export const sourceCatalogs = { "s_13f6a415ac664be2": "Detect the actual model list from this provider", "s_140b07fe3f9f7d48": "Download size:", "s_1417bae3d17d6405": "Collapse images", + "s_142cf02f83d4e7ab": "Writing reply", "s_14357c0d6ca270df": "Check that the address is correct, then try again.", "s_14381a6ef00a1253": "Regular testing and problem fixing", "s_145219e726b87790": "Hidden", @@ -2680,15 +2701,18 @@ export const sourceCatalogs = { "s_184568b57e5e6d3f": "Generating test image…", "s_1847a84db6d5fc1a": "Permission mode switch failed:", "s_18565a5455a0a875": "Could not load sandbox settings", + "s_18578640315b5b33": "Updating todos", "s_1882a447d5f76d5f": "Feedback cannot exceed 10,000 characters", "s_18850bf66a4ba4c6": "The model requests temporary extra sandbox permissions", "s_19086bec77d5e5e5": "Automatically compatible with system naming rules", + "s_190eedb7bfaa8609": "Adding a follow-up task", "s_191041794ad98158": "(all matches)", "s_1910636fddbffe3e": "Read application controls and operate the mouse and keyboard", "s_19107d868966ff2c": "Session title", "s_194b5dba3c63f537": "Reasoning settings", "s_1964dafada0807b5": "Git branch", "s_19af3dcd414d81df": "{{p0}} attachments", + "s_19bf536853e2e78a": "Collaborating", "s_19bfb1631d25a204": "Saving theme…", "s_19cfa7d09d8dd287": "Dispatching…", "s_1a0bf11a28779888": "Fill in the real chat box after selection, it will not be sent automatically", @@ -2816,6 +2840,7 @@ export const sourceCatalogs = { "s_25ec0421b03bc4f3": "Drag to adjust the width of the right column", "s_261dba7261ed8c05": "Description cannot be empty", "s_2623156dcd71bf2d": "Asterisk", + "s_264333fb5458a573": "Preparing a tool call", "s_2657124b1481b789": "Provided by project", "s_267c4a7a86dd1849": "All types", "s_26ab0eee67e8c707": "Tool called", @@ -2952,6 +2977,7 @@ export const sourceCatalogs = { "s_3590fba381ad57f0": "Selected text actions", "s_35c52c32418a2678": "All {{p0}} entries will be deleted and cannot be recovered. Export first if you need a copy.", "s_35cda8a7052dec20": "Migration failed", + "s_3615b021b8af5e82": "Wrapping up", "s_3620d3d986f14584": "Close model dialog", "s_3625f9fa2f9c629a": "Command denied", "s_364970d8168ca5fe": "Sending…", @@ -2965,12 +2991,14 @@ export const sourceCatalogs = { "s_377e4422342c71f7": "Problem feedback has been uploaded", "s_3785cba81d184679": "Current workspace memory", "s_37979829426b0cdb": "Workspace", + "s_37a41b2e983b06ee": "Using a skill", "s_37c427d9f5dc817d": "The default model has been set to", "s_37d36b087f49c6d2": "Select expert component", "s_37e4e42c314102da": "image", "s_381117baedb79e1a": "After choosing a provider and model, only settings supported by that connection are shown.", "s_381796352dc0301d": "Settings were saved but could not be applied. Please try again", "s_3819d5e4aa5dba68": "Pen", + "s_38744132b501a1eb": "Question", "s_38c043e08502e710": "Personalization", "s_38cd88ed8b3ddbf1": "Copy full output", "s_38d456bbdb81317c": "Filter by Tag", @@ -3271,6 +3299,7 @@ export const sourceCatalogs = { "s_55db57105231bd95": "Workspace is unavailable", "s_55ef676879f7cca3": "The diff is too large, please check it in the terminal", "s_561817f0252936a9": "Allowed", + "s_562834f547655a37": "Searching code", "s_5653d97cdf569cb9": "Your system firewall, router, or cloud security group may still need to allow the proxy port. For public Internet access, continue to use a trusted VPN and configure HTTPS upstream.", "s_56627c94a9decee6": "Maximum output tokens", "s_566e5f182a63185d": "Compressing theme files…", @@ -3348,6 +3377,7 @@ export const sourceCatalogs = { "s_5d7fd21f1e3ca6b4": "Copy message", "s_5d817dbb5da09259": "The theme import returned an invalid result. Refresh and try again", "s_5d937ed111134879": "Model capabilities", + "s_5ddcb16ab6fcb808": "Checking members", "s_5ddffd77ea7fedae": "Loading saved models…", "s_5e56bec8550f499d": "Choose a directory", "s_5e80c1e561d45b11": "Select permission mode", @@ -3658,6 +3688,7 @@ export const sourceCatalogs = { "s_7ea852a1f549b6ca": "Enter a scheduled task name", "s_7ecf1cde648680d2": "Selected", "s_7ecff345fb5ca29e": "Copilot authorization verification failed", + "s_7ed80e44eaf9c51c": "Writing a file", "s_7edb086ee0d4c6c8": "Task completion notifications are enabled, but macOS permission was denied", "s_7ee8b2d0ae7ade97": "Remove attachment", "s_7efe433909425026": "Custom request headers are invalid", @@ -3692,6 +3723,7 @@ export const sourceCatalogs = { "s_822fb37dba29381a": "Select file", "s_82395d423099a07a": "Archived", "s_825a80f75113a9d3": "Prompt cache hits: {{p0}} of {{p1}} input tokens ({{p2}}%), {{p3}} written to cache", + "s_82601c48d5d64d71": "Editing code", "s_8268968d4a8759fc": "This model does not support tool-free side chat. Switch models and try again.", "s_82808f881bf7755f": "Team Members ·{{p0}} people", "s_82a043c05e8a9446": "Expert name", @@ -3728,6 +3760,7 @@ export const sourceCatalogs = { "s_85e2f5601c1a0258": "Unattended", "s_8609635074718e14": "Referenced to chat", "s_86353011351e5d0b": "opencode session import failed:", + "s_8652e90f49cd7463": "Calling MCP", "s_86542c6b657c93ab": "Session: {{p0}}", "s_86624f5719c93e79": "Show {{p0}} earlier messages", "s_867698fb7b0c730c": "Project name", @@ -4189,6 +4222,7 @@ export const sourceCatalogs = { "s_b9238eb0c9178901": "The theme action failed. Try again.", "s_b92b399d2820c415": "Clear saved key", "s_b92d86f4f5570817": "Close other", + "s_b93afa1b4934b706": "Browsing the web", "s_b967309734beff88": "Memory summarization", "s_b97ad406809572e1": "Enable reasoning", "s_b97c0e55e836dee2": "The selected summary model is unavailable. Please select another model", @@ -4294,6 +4328,7 @@ export const sourceCatalogs = { "s_c3b8f744ed2c55bf": "JSON format error:", "s_c3e5589604083870": "No plugins", "s_c3f989b790daab98": "The backend is temporarily unavailable. Try again later.", + "s_c43f153cb1be5ca5": "Waiting for members", "s_c4ae8fa5a16b5a75": "Skip all tool permission confirmations", "s_c4c5b649cbf25fbb": "Memory and context", "s_c52c7fbed621e8ff": "Annotation {{p0}}", @@ -4538,6 +4573,7 @@ export const sourceCatalogs = { "s_dd42f689b17393c2": "Loading earlier messages…", "s_dd7a9008edbca109": "Unarchived {{p0}} sessions; {{p1}} failed", "s_dda206707b809157": "Connection interrupted", + "s_ddcf6e77b0ee1000": "Preparing to", "s_ddde08946298195d": "reduction", "s_ddfe2c796c7eb71b": "Add manually", "s_de03ee139fe2fd9f": "Found", @@ -4856,6 +4892,7 @@ export const sourceCatalogs = { "s_fcea4baf388f169a": "Run core tests to locate and fix reliably reproducible issues.", "s_fcf8b4bff0df782d": "Open folder", "s_fcffc107c99acbba": "Registered workspaces", + "s_fd351bc95e9337e4": "Sending a message", "s_fd4b9e3b6c685bae": "Continue editing", "s_fd5fd3c4567938c7": "Ask", "s_fd6529f1e944f8c7": "Share files with this conversation. The task will queue while other work is running.", @@ -4876,6 +4913,7 @@ export const sourceCatalogs = { "s_ff199a8f48c79fd8": "Audit log exported", "s_ff20131bcd4725f2": "No rules yet. Choosing \"Always allow\" in a confirmation dialog also writes here automatically.", "s_ff509c9ba052a21c": "Saving…", + "s_ff62ea25d5d6b3a6": "Stopping a member", "s_ff63d45ac706d173": "Continue in a new conversation", "s_ff857f2b6ac947ac": "Last consolidation", "s_ff8b0d734b03b1a4": "Custom models", diff --git a/web/src/lib/desktopOffice.test.js b/web/src/lib/desktopOffice.test.js index 4ce907922..8ed976f29 100644 --- a/web/src/lib/desktopOffice.test.js +++ b/web/src/lib/desktopOffice.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; -import { recentOffices, officeActor, projectDesktopOffice, stableOfficeSeed } from './desktopOfficeState.js'; -import { createDesktopOfficeController } from './desktopOfficeController.js'; -import { toolIconName, toolIconSvg } from './toolIcons.js'; +import { recentOffices, officeActor, projectDesktopOffice, stableOfficeSeed, fitTail, speechTail } from './desktopOfficeState.js'; +import { createDesktopOfficeController, OFFICE_LIVE_PUBLISH_MS } from './desktopOfficeController.js'; +import { toolActivityVerb, toolIconName, toolIconSvg } from './toolIcons.js'; import { interfaceIconSvg } from './interfaceIcons.js'; function test(name, fn) {fn(); console.log(`[pass] desktop office: ${name}`);} @@ -69,12 +69,57 @@ test('tool SVG is exactly the shared chat icon source', () => { assert.equal(toolIconName('mcp__server__tool'),'MCP'); }); +// 正在输出正文:显示「撰写回复」与最新的几个字,Markdown 标点不进气泡。 +test('streaming reply types and shows only its latest words', () => { + const actor=officeActor(session('root',{busy:true,activity:{phase:'responding',text:'第一段\n\n## 小标题\n**最后**几个字是这些'}}),'root'); + assert.equal(actor.state,'type');assert.equal(actor.label,'撰写回复'); + assert.ok(actor.detail.endsWith('最后几个字是这些'),actor.detail); + assert.ok(!/[#*]/.test(actor.detail),actor.detail); + assert.equal(actor.tool,''); +}); + +// 工具执行:动词 + 调用预览(长路径保留文件名一端);等成员回报单独一种状态。 +test('tool work names the verb and keeps the end of long previews', () => { + const read=officeActor(session('root',{busy:true,activity:{phase:'tool_running',tool:'file_read',detail:'src/apps/desktop/some/very/deep/folder/desktop_pet_mac.mm'}}),'root'); + assert.equal(read.state,'work');assert.equal(read.label,'读取');assert.equal(read.icon,'OpenFile'); + assert.ok(read.detail.startsWith('…')&&read.detail.endsWith('desktop_pet_mac.mm'),read.detail); + assert.equal(officeActor(session('root',{busy:true,activity:{tool:'wait_subagent'}}),'root').state,'wait'); + assert.equal(officeActor(session('root',{busy:true,activity:{tool:'agent_wait'}}),'root').label,'等成员回报'); + const planning=officeActor(session('root',{busy:true,activity:{phase:'tool_planning',tool:'file_write'}}),'root'); + assert.equal(planning.state,'type');assert.equal(planning.label,'准备写入文件');assert.equal(planning.icon,'Save'); + assert.equal(toolActivityVerb('bash'),'运行命令');assert.equal(toolActivityVerb('mcp__x__y'),'调用 MCP'); +}); + +// 「适合日常工作」模式的具体进度文案优先;引擎默认的笼统文案不进气泡。 +test('concrete progress titles win over generic engine labels', () => { + assert.equal(officeActor(session('root',{busy:true,activity:{phase:'model_waiting',label:'正在等待模型响应'}}),'root').label,'思考中'); + assert.equal(officeActor(session('root',{busy:true,activity:{phase:'tool_running',tool:'bash',label:'正在调用工具 bash'}}),'root').label,'运行命令'); + assert.equal(officeActor(session('root',{busy:true,activity:{phase:'tool_running',tool:'grep',label:'正在搜索代码并读取'}}),'root').label,'搜索代码并读取'); + assert.equal(officeActor(session('root',{busy:true,activity:{phase:'reasoning',label:'正在推理',text:'check the config'}}),'root').detail,'check the config'); +}); + +// 气泡宽度按半角单位计:汉字算 2,超出时保留末尾并以省略号开头。 +test('bubble text keeps the tail within its width budget', () => { + assert.equal(fitTail('abcdef',4),'…cdef'); + assert.equal(fitTail('一二三四五',6),'…三四五'); + assert.equal(fitTail(' short ',28),'short'); + assert.equal(speechTail('see [docs](http://x.test) `code`'),'see docs code'); +}); + +// 子会话路径:/root/a/b 的上级是 /root/a,主会话没有上级。 +test('mesh workers know their dispatching parent', () => { + assert.equal(officeActor(session('c',{agent_path:'/root/a/b'}),'root').parentPath,'/root/a'); + assert.equal(officeActor(session('root'),'root').parentPath,''); +}); + function fixture(fetchSnapshot) { - const publications=[],retained=new Set(),released=[],timers=new Map();let timerId=0; + const publications=[],retained=new Set(),released=[],timers=new Map(),delays=new Map();let timerId=0; const controller=createDesktopOfficeController({fetchSnapshot,publish:value=>publications.push(value), retainSession:id=>retained.add(id),releaseSession:id=>{retained.delete(id);released.push(id);}, - schedule:fn=>{timers.set(++timerId,fn);return timerId;},cancel:id=>timers.delete(id)}); - return {controller,publications,retained,released,timers}; + schedule:(fn,delay)=>{timers.set(++timerId,fn);delays.set(timerId,delay);return timerId;},cancel:id=>timers.delete(id)}); + // 只触发指定延迟的计时器(实时文字节流 vs 快照轮询),其余保持挂起。 + const fire=delay=>{for(const [id,fn] of [...timers]) if(delays.get(id)===delay){timers.delete(id);fn();}}; + return {controller,publications,retained,released,timers,fire}; } const deferred=()=>{let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b;});return {promise,resolve,reject};}; @@ -120,3 +165,33 @@ await check('completion releases a child while the selected office stays subscri assert.equal(f.controller.state.agents.some(a=>a.id==='child'),false); assert.equal(f.retained.has('root'),true);f.controller.dispose(); }); + +// 流式 token 走 WebSocket 本地叠加:不发额外快照请求,按节流间隔发布; +// 只认办公室里的成员;快照追上(seq 不再落后)后叠加层让位给服务端状态。 +await check('streamed tokens update the speaker between snapshots without extra requests', async () => { + let requests=0,serverActivity={seq:5,busy:true,phase:'model_waiting'}; + const f=fixture(async()=>{requests++;return snapshot('root',{agents:[session('root',{busy:true,activity:serverActivity})]});}); + await f.controller.refresh();requests=0;const published=f.publications.length; + f.controller.onEvent({type:'token',session_id:'root',seq:6,payload:{text:'你好'}}); + f.controller.onEvent({type:'token',session_id:'root',seq:7,payload:{text:'世界'}}); + f.controller.onEvent({type:'token',session_id:'outsider',seq:8,payload:{text:'别人'}}); + assert.equal(f.publications.length,published,'节流期间不逐 token 发布'); + f.fire(OFFICE_LIVE_PUBLISH_MS); + assert.equal(f.publications.length,published+1); + let root=f.publications.at(-1).agents[0]; + assert.equal(root.state,'type');assert.equal(root.detail,'你好世界');assert.equal(requests,0); + // 服务端快照已包含到 seq 7 的正文:以服务端为准,本地叠加被丢弃。 + serverActivity={seq:7,busy:true,phase:'responding',text:'你好世界'}; + await f.controller.refresh(); + root=f.controller.state.agents[0];assert.equal(root.detail,'你好世界'); + // 叠加层被清掉后,服务端进入工具阶段不会再被旧的正文覆盖。 + serverActivity={seq:9,busy:true,phase:'tool_running',tool:'bash',detail:'npm test'}; + await f.controller.refresh(); + root=f.controller.state.agents[0];assert.equal(root.state,'work');assert.equal(root.detail,'npm test'); + // 快照之后又来的 token 接在服务端尾巴后面(同一段正文)。 + serverActivity={seq:10,busy:true,phase:'responding',text:'前半'}; + await f.controller.refresh(); + f.controller.onEvent({type:'token',session_id:'root',seq:11,payload:{text:'后半'}}); + assert.equal(f.controller.state.agents[0].detail,'前半后半'); + f.controller.dispose();assert.equal(f.timers.size,0); +}); diff --git a/web/src/lib/desktopOfficeController.js b/web/src/lib/desktopOfficeController.js index 60058f674..037a8c8c4 100644 --- a/web/src/lib/desktopOfficeController.js +++ b/web/src/lib/desktopOfficeController.js @@ -1,5 +1,9 @@ import { officeSessionRef, projectDesktopOffice } from './desktopOfficeState.js'; +// Streaming text reaches the office a few times per second, not per token. +export const OFFICE_LIVE_PUBLISH_MS = 150; +const LIVE_TEXT_CHARS = 400; + // One App-owned controller; every asynchronous response belongs to a selection // generation. Inject IO, timers and publishing so lifecycle races are testable. export function createDesktopOfficeController({ @@ -8,9 +12,23 @@ export function createDesktopOfficeController({ } = {}) { let follow = initial.follow !== false, active = {}, selected = initial.selected || {}; let generation = 0, disposed = false, timer = null, timerDelay = Infinity, request = null; - let snapshot = {}, connected = true, queued = false; + let snapshot = {}, connected = true, queued = false, liveTimer = null; const retained = new Set(); - const emit = () => !disposed && publish(projectDesktopOffice(snapshot, {follow, connected})); + // session id -> {phase, seq, fromSeq, text}: token/reasoning events newer than the snapshot. + const live = new Map(); + const project = () => projectDesktopOffice(snapshot, {follow, connected, live}); + const emit = () => !disposed && publish(project()); + const emitLive = () => { + if (disposed || liveTimer !== null) return; + liveTimer = schedule(() => {liveTimer = null; emit();}, OFFICE_LIVE_PUBLISH_MS); + }; + const tracked = id => (snapshot.agents || []).some(session => session.id === id); + const pruneLive = () => { + for (const [id, entry] of live) { + const session = (snapshot.agents || []).find(item => item.id === id); + if (!session || entry.seq <= (Number(session.activity?.seq) || 0)) live.delete(id); + } + }; const syncRetained = () => { const wanted = new Set(); for (const session of [...(snapshot.offices || []), ...(snapshot.agents || [])]) { @@ -47,13 +65,13 @@ export function createDesktopOfficeController({ } snapshot = next; connected = true; if (next.selected) selected = officeSessionRef(next.selected); - syncRetained(); emit(); + pruneLive(); syncRetained(); emit(); } catch (error) { if (disposed || version !== generation || controller.signal.aborted) return; if (error?.status === 404) { selected = {}; snapshot = {...snapshot, selected:null, agents:[], offices:error.body?.offices || snapshot.offices || []}; if (follow) active = {}; - syncRetained(); + live.clear(); syncRetained(); } else connected = false; emit(); } finally { @@ -68,7 +86,18 @@ export function createDesktopOfficeController({ generation++; request?.abort(); request = null; queued = false; snapshot = {...snapshot, selected:null, agents:[]}; - syncRetained(); emit(); later(0); + live.clear(); syncRetained(); emit(); later(0); + } + function streamed(message) { + const id = String(message.session_id || ''); + if (!id || !tracked(id)) return; + const phase = message.type === 'token' ? 'responding' : 'reasoning'; + const seq = Number(message.seq) || 0, piece = String(message.payload?.text || ''); + const prev = live.get(id); + live.set(id, prev && prev.phase === phase + ? {...prev, seq: Math.max(seq, prev.seq), text: (prev.text + piece).slice(-LIVE_TEXT_CHARS)} + : {phase, seq, fromSeq: seq, text: piece.slice(-LIVE_TEXT_CHARS)}); + emitLive(); } return { start() {later(0);}, @@ -82,7 +111,8 @@ export function createDesktopOfficeController({ select(value) {selected = officeSessionRef(value); follow = false; change();}, setFollow(value) {follow = !!value; change();}, onEvent(message) { - if (['token','reasoning','tool_update'].includes(message.type)) return; + if (message.type === 'token' || message.type === 'reasoning') {streamed(message); return;} + if (message.type === 'tool_update') return; if (request) queued = true; else later(100); }, @@ -93,10 +123,12 @@ export function createDesktopOfficeController({ // an onopen callback would close/reopen an office-only connection forever. later(0); }, - get state() {return projectDesktopOffice(snapshot, {follow, connected});}, + get state() {return project();}, dispose() { disposed = true; generation++; request?.abort(); request = null; if (timer !== null) cancel(timer); + if (liveTimer !== null) cancel(liveTimer); + timer = null; liveTimer = null; live.clear(); for (const id of retained) releaseSession(id); retained.clear(); }, diff --git a/web/src/lib/desktopOfficeState.js b/web/src/lib/desktopOfficeState.js index 857c5b59f..a6f076806 100644 --- a/web/src/lib/desktopOfficeState.js +++ b/web/src/lib/desktopOfficeState.js @@ -1,5 +1,5 @@ import { normalizeTokenBudget } from './tokenBudget.js'; -import { toolIconName, toolIconSvg } from './toolIcons.js'; +import { toolActivityVerb, toolIconName, toolIconSvg } from './toolIcons.js'; export const OFFICE_LIMIT = 5; export const OFFICE_WORKER_SEATS = 7; @@ -24,28 +24,66 @@ export function recentOffices(sessions = []) { .slice(0, OFFICE_LIMIT); } +// Bubble text is measured in half-width units: CJK and full-width glyphs count twice. +const charUnits = ch => (/[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/.test(ch) + || ch.codePointAt(0) > 0xffff ? 2 : 1); + +// Keep the end of a line (latest words of a reply, file name of a path). +export function fitTail(text, units = 28) { + const chars = [...String(text || '').replace(/\s+/g, ' ').trim()]; + let width = 0, start = chars.length; + while (start > 0 && width + charUnits(chars[start - 1]) <= units) width += charUnits(chars[--start]); + return start > 0 ? '…' + chars.slice(start).join('') : chars.join(''); +} + +// Last visible words of streamed Markdown, without the formatting punctuation. +export function speechTail(text, units = 28) { + const plain = String(text || '') + .replace(/```[\w-]*/g, ' ') + .replace(/[`*#]+/g, '') + .replace(/^\s*>\s?/gm, '') + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1'); + return fitTail(plain, units); +} + +const GENERIC_LABEL = /^正在(?:等待模型响应|推理|准备工具调用|准备调用\s|调用工具\s)/; +const WAIT_TOOLS = new Set(['wait_subagent', 'agent_wait']); + export function officeActor(session, rootId) { const id = idOf(session), root = id === rootId, a = session.activity || {}; const busy = session.busy === true || session.status === 'running' || a.busy === true; const outcome = String(a.outcome || session.last_turn_outcome || ''); const phase = String(a.phase || ''), tool = String(a.tool || ''); - let state = 'idle', label = '待命'; + const raw = String(a.label || ''); + // Concrete progress titles (work mode) are richer than generic engine labels. + const concrete = raw && !GENERIC_LABEL.test(raw) ? raw.replace(/^正在/, '') : ''; + let state = 'idle', label = '待命', detail = ''; if (busy) { - if (phase === 'permission_waiting') { state = 'permission'; label = '等待授权'; } - else if (phase === 'question_waiting') { state = 'question'; label = '等待回答'; } + if (phase === 'permission_waiting') { + state = 'permission'; label = '等待授权'; detail = tool ? toolActivityVerb(tool) : ''; + } else if (phase === 'question_waiting') { state = 'question'; label = '等待回答'; } else if (phase === 'compacting' || phase === 'context_repair') { state = 'compact'; label = '整理上下文'; } else if (phase === 'model_retry') { state = 'retry'; label = '正在重试'; } - else if (tool || phase === 'tool_running') { state = 'work'; label = tool || '工作中'; } - else { state = 'think'; label = String(a.label || '思考中'); } + else if (phase === 'responding') { state = 'type'; label = '撰写回复'; detail = speechTail(a.text); } + else if (phase === 'tool_planning') { + state = 'type'; label = tool ? '准备' + toolActivityVerb(tool) : '准备调用工具'; + } else if (tool || phase === 'tool_running') { + state = WAIT_TOOLS.has(tool.toLowerCase()) ? 'wait' : 'work'; + label = concrete || (tool ? toolActivityVerb(tool) : '调用工具'); + detail = fitTail(a.detail, 30); + } else if (phase === 'reasoning') { state = 'think'; label = concrete || '思考中'; detail = speechTail(a.text, 24); } + else { state = 'think'; label = concrete || '思考中'; } } else if (outcome === 'completed') { state = root ? 'sleep' : 'done'; label = root ? 'zzz' : '已完成'; } else if (outcome === 'error' || outcome === 'errored') { state = 'error'; label = '执行失败'; } else if (outcome === 'aborted' || outcome === 'interrupted') { state = 'stopped'; label = '已停止'; } const context = normalizeTokenBudget({usage: session.token_usage, contextWindow: session.context_window}); - const icon = tool ? toolIconName(tool) : ''; + const shownTool = state === 'work' || state === 'wait' || state === 'permission' || phase === 'tool_planning' ? tool : ''; + const path = String(session.agent_path || (root ? '/root' : '')); return { - id, root, name: root ? 'Maestro' : String(session.title || session.agent_path?.split('/').pop() || 'Agent'), - path: String(session.agent_path || (root ? '/root' : '')), - state, label, tool, icon, iconSvg: tool ? toolIconSvg(tool) : '', busy, + id, root, name: root ? 'Maestro' : String(session.title || session.agent_path?.split('/').pop() || session.summary || 'Agent'), + path, parentPath: path.includes('/', 1) ? path.slice(0, path.lastIndexOf('/')) : '', + state, label, detail, tool: shownTool, icon: shownTool ? toolIconName(shownTool) : '', + iconSvg: shownTool ? toolIconSvg(shownTool) : '', busy, seed: stableOfficeSeed(id), contextKnown: context.known, contextRatio: context.usedRatio, contextTokens: context.usedTokens, contextLimit: context.limitTokens, compactId: String(a.compact_id || ''), seq: Number(a.seq) || 0, @@ -57,12 +95,24 @@ export function officeActor(session, rootId) { }; } -export function projectDesktopOffice(snapshot = {}, { follow = true, connected = true } = {}) { +// Streaming tokens arrive over the WebSocket faster than snapshots. The overlay +// is newer than the snapshot only while its event sequence is ahead of it. +export function withLiveActivity(session, entry) { + const a = session.activity || {}; + const seq = Number(a.seq) || 0; + if (!entry || !(entry.seq > seq)) return session; + const continues = a.phase === entry.phase && entry.fromSeq > seq; + return {...session, busy: true, activity: {...a, busy: true, phase: entry.phase, + text: (continues ? String(a.text || '') : '') + entry.text, tool: '', detail: '', seq: entry.seq}}; +} + +export function projectDesktopOffice(snapshot = {}, { follow = true, connected = true, live = null } = {}) { const selected = snapshot.selected || null; const rootId = idOf(selected); const rootTime = String(selected?.last_user_message_at || ''); - const agents = (snapshot.agents || []).map(session => officeActor(session, rootId)) - .filter(actor => actor.present && (actor.root || actor.busy || !rootTime || actor.lastActivityAt >= rootTime)); + const sessions = (snapshot.agents || []).map(session => withLiveActivity(session, live?.get(idOf(session)))); + const actors = sessions.map(session => officeActor(session, rootId)); + const agents = actors.filter(actor => actor.present && (actor.root || actor.busy || !rootTime || actor.lastActivityAt >= rootTime)); agents.sort((a,b) => Number(b.root) - Number(a.root) || a.id.localeCompare(b.id)); return { version: 1, follow, connected, complete: snapshot.complete !== false, @@ -75,7 +125,7 @@ export function projectDesktopOffice(snapshot = {}, { follow = true, connected = selected: selected ? { ...officeSessionRef(selected), title: String(selected.title || selected.summary || '未命名会话') } : null, seed: stableOfficeSeed(rootId || 'empty-office'), agents, - completed: (snapshot.agents || []).filter(session => session.id !== rootId && officeActor(session, rootId).state === 'done').map(session => session.id), + completed: actors.filter(actor => !actor.root && actor.state === 'done').map(actor => actor.id), overflow: Math.max(0, agents.length - OFFICE_WORKER_SEATS - 1), }; } diff --git a/web/src/lib/toolIcons.js b/web/src/lib/toolIcons.js index 1dce6f7dc..728563e6f 100644 --- a/web/src/lib/toolIcons.js +++ b/web/src/lib/toolIcons.js @@ -27,3 +27,20 @@ export function toolIconName(tool = '', summaryIcon = '') { export function toolIconSvg(tool = '', summaryIcon = '') { return interfaceIconSvg(toolIconName(tool, summaryIcon), 14); } + +// Short present-tense verbs for the desktop office speech bubbles. +const VERB_BY_ICON = new Map([ + ['OpenFile', '读取'], ['Save', '写入文件'], ['Edit', '修改代码'], ['Search', '搜索代码'], + ['BrowserGlobe', '查阅网页'], ['TerminalReadWrite', '运行命令'], ['Embedding', '协作'], + ['StatusHelp', '提问'], ['List', '更新待办'], ['StatusOK', '收尾'], ['Eye', '看图'], + ['Extension', '使用技能'], ['MCP', '调用 MCP'], ['Tool', '调用工具'], +]); +const VERB_BY_TOOL = new Map([ + ['spawn_subagent', '派发任务'], ['agent_spawn', '派发任务'], ['wait_subagent', '等成员回报'], + ['agent_wait', '等成员回报'], ['agent_send_message', '发消息'], ['agent_followup_task', '追加任务'], + ['agent_interrupt', '叫停成员'], ['agent_list', '查看成员'], +]); +export function toolActivityVerb(tool = '') { + const name = String(tool).toLowerCase().replace(/^functions\./, ''); + return VERB_BY_TOOL.get(name) || VERB_BY_ICON.get(toolIconName(tool)) || '调用工具'; +} From 94b78f58092b7df15f90d783b314aa428f0d8337 Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Tue, 6 Oct 2026 18:13:16 +0800 Subject: [PATCH 4/5] Integrate IM channels and desktop office for release --- CLAUDE.md | 27 + CMakeLists.txt | 9 + cmake/acecode_layer_libraries.cmake | 7 +- docs/channels.md | 271 +++++- docs/daemon-api.md | 177 +++- docs/help-source/group5.py | 12 +- docs/help-source/sources.json | 5 +- docs/help/assets/search-index.js | 12 +- docs/help/channels.html | 14 +- .../add-chat-scroll-to-bottom/.openspec.yaml | 2 + .../add-chat-scroll-to-bottom/design.md | 32 + .../add-chat-scroll-to-bottom/proposal.md | 23 + .../specs/web-chat-scroll-to-bottom/spec.md | 58 ++ .../add-chat-scroll-to-bottom/tasks.md | 65 ++ .../add-desktop-im-channels/.openspec.yaml | 2 + .../changes/add-desktop-im-channels/design.md | 248 ++++++ .../add-desktop-im-channels/proposal.md | 88 ++ .../specs/dingtalk-channel/spec.md | 40 + .../specs/discord-channel/spec.md | 40 + .../specs/feishu-channel/spec.md | 41 + .../specs/im-channels/spec.md | 249 ++++++ .../specs/line-channel/spec.md | 37 + .../specs/qq-bot-channel/spec.md | 117 +++ .../specs/telegram-channel/spec.md | 101 +++ .../specs/weixin-channel/spec.md | 46 + .../changes/add-desktop-im-channels/tasks.md | 144 ++++ .../add-desktop-im-channels/verification.md | 195 +++++ .../.openspec.yaml | 2 + .../design.md | 30 + .../proposal.md | 25 + .../specs/session-archive/spec.md | 63 ++ .../improve-archived-session-browser/tasks.md | 23 + .../design.md | 11 + .../proposal.md | 23 + .../specs/local-test-runner/spec.md | 16 + .../tasks.md | 21 + .../verification.md | 35 + scripts/refactor/run_fast_tests.py | 12 +- scripts/refactor/tests/test_run_fast_tests.py | 48 ++ src/adapters/im/dingtalk/dingtalk_api.cpp | 475 ++++++++++ src/adapters/im/dingtalk/dingtalk_api.hpp | 113 +++ .../im/dingtalk/dingtalk_protocol.cpp | 620 ++++++++++++++ .../im/dingtalk/dingtalk_protocol.hpp | 106 +++ src/adapters/im/dingtalk/dingtalk_route.cpp | 162 ++++ src/adapters/im/dingtalk/dingtalk_route.hpp | 113 +++ src/adapters/im/dingtalk/dingtalk_stream.cpp | 123 +++ src/adapters/im/dingtalk/dingtalk_stream.hpp | 86 ++ .../im/dingtalk/dingtalk_transport.cpp | 537 ++++++++++++ .../im/dingtalk/dingtalk_transport.hpp | 153 ++++ src/adapters/im/discord/discord_api.cpp | 350 ++++++++ src/adapters/im/discord/discord_api.hpp | 137 +++ src/adapters/im/discord/discord_gateway.cpp | 248 ++++++ src/adapters/im/discord/discord_gateway.hpp | 154 ++++ src/adapters/im/discord/discord_protocol.cpp | 518 +++++++++++ src/adapters/im/discord/discord_protocol.hpp | 109 +++ src/adapters/im/discord/discord_transport.cpp | 713 +++++++++++++++ src/adapters/im/discord/discord_transport.hpp | 148 ++++ src/adapters/im/feishu/feishu_api.cpp | 315 +++++++ src/adapters/im/feishu/feishu_api.hpp | 95 ++ src/adapters/im/feishu/feishu_frame.cpp | 212 +++++ src/adapters/im/feishu/feishu_frame.hpp | 59 ++ src/adapters/im/feishu/feishu_gateway.cpp | 172 ++++ src/adapters/im/feishu/feishu_gateway.hpp | 97 +++ src/adapters/im/feishu/feishu_inbound.cpp | 457 ++++++++++ src/adapters/im/feishu/feishu_outbound.cpp | 193 +++++ src/adapters/im/feishu/feishu_pacer.cpp | 41 + src/adapters/im/feishu/feishu_pacer.hpp | 34 + src/adapters/im/feishu/feishu_protocol.cpp | 397 +++++++++ src/adapters/im/feishu/feishu_protocol.hpp | 242 ++++++ src/adapters/im/feishu/feishu_text_util.hpp | 85 ++ src/adapters/im/feishu/feishu_transport.cpp | 533 ++++++++++++ src/adapters/im/feishu/feishu_transport.hpp | 119 +++ src/adapters/im/http.cpp | 162 ++++ src/adapters/im/http.hpp | 53 ++ src/adapters/im/line/line_api.cpp | 448 ++++++++++ src/adapters/im/line/line_api.hpp | 133 +++ src/adapters/im/line/line_media.cpp | 72 ++ src/adapters/im/line/line_media.hpp | 53 ++ src/adapters/im/line/line_outbound.cpp | 363 ++++++++ src/adapters/im/line/line_protocol.cpp | 452 ++++++++++ src/adapters/im/line/line_protocol.hpp | 131 +++ src/adapters/im/line/line_transport.cpp | 770 +++++++++++++++++ src/adapters/im/line/line_transport.hpp | 230 +++++ src/adapters/im/line/line_tunnel.cpp | 291 +++++++ src/adapters/im/line/line_tunnel.hpp | 96 +++ src/adapters/im/line/line_webhook.hpp | 59 ++ src/adapters/im/markdown.cpp | 347 ++++++++ src/adapters/im/markdown.hpp | 20 + src/adapters/im/qqbot/qq_api.cpp | 217 +++++ src/adapters/im/qqbot/qq_api.hpp | 65 ++ src/adapters/im/qqbot/qq_bind.cpp | 190 ++++ src/adapters/im/qqbot/qq_bind.hpp | 60 ++ src/adapters/im/qqbot/qq_gateway.cpp | 124 +++ src/adapters/im/qqbot/qq_gateway.hpp | 67 ++ src/adapters/im/qqbot/qq_protocol.cpp | 150 ++++ src/adapters/im/qqbot/qq_protocol.hpp | 73 ++ src/adapters/im/qqbot/qq_reply_budget.cpp | 87 ++ src/adapters/im/qqbot/qq_reply_budget.hpp | 81 ++ src/adapters/im/qqbot/qq_transport.cpp | 347 ++++++++ src/adapters/im/qqbot/qq_transport.hpp | 88 ++ src/adapters/im/redact.cpp | 61 ++ src/adapters/im/redact.hpp | 21 + src/adapters/im/telegram/tg_api.cpp | 109 +++ src/adapters/im/telegram/tg_api.hpp | 64 ++ src/adapters/im/telegram/tg_rate_limit.cpp | 30 + src/adapters/im/telegram/tg_rate_limit.hpp | 40 + src/adapters/im/telegram/tg_transport.cpp | 379 ++++++++ src/adapters/im/telegram/tg_transport.hpp | 85 ++ src/adapters/im/telegram/tg_update.cpp | 205 +++++ src/adapters/im/telegram/tg_update.hpp | 33 + src/adapters/im/text_chunk.cpp | 148 ++++ src/adapters/im/text_chunk.hpp | 22 + src/adapters/im/transport.cpp | 73 ++ src/adapters/im/transport.hpp | 129 +++ src/adapters/im/weixin/weixin_api.cpp | 191 +++++ src/adapters/im/weixin/weixin_api.hpp | 90 ++ src/adapters/im/weixin/weixin_login.cpp | 153 ++++ src/adapters/im/weixin/weixin_login.hpp | 64 ++ src/adapters/im/weixin/weixin_media.cpp | 197 +++++ src/adapters/im/weixin/weixin_media.hpp | 57 ++ src/adapters/im/weixin/weixin_protocol.cpp | 632 ++++++++++++++ src/adapters/im/weixin/weixin_protocol.hpp | 203 +++++ src/adapters/im/weixin/weixin_transport.cpp | 504 +++++++++++ src/adapters/im/weixin/weixin_transport.hpp | 140 +++ src/apps/daemon/daemon_shutdown_sequence.cpp | 6 +- src/apps/daemon/daemon_shutdown_sequence.hpp | 4 +- src/apps/daemon/im_channels.cpp | 83 ++ src/apps/daemon/im_channels.hpp | 47 + src/apps/daemon/rc_session_catalog.cpp | 44 + src/apps/daemon/rc_session_catalog.hpp | 19 + src/apps/daemon/worker.cpp | 62 +- src/apps/web/handlers/channels_handler.cpp | 121 +++ src/apps/web/handlers/channels_handler.hpp | 36 + src/apps/web/routes/routes_channels.cpp | 154 ++++ src/apps/web/routes/routes_ws.cpp | 2 + src/apps/web/server.cpp | 6 + src/apps/web/server.hpp | 14 + src/apps/web/server_helpers.cpp | 5 + src/apps/web/server_impl.hpp | 4 + src/base/network/websocket_client.cpp | 348 ++++++++ src/base/network/websocket_client.hpp | 78 ++ src/base/platform/crypto/aes_ecb.cpp | 110 +++ src/base/platform/crypto/aes_ecb.hpp | 21 + src/base/platform/crypto/aes_gcm.cpp | 110 +++ src/base/platform/crypto/aes_gcm.hpp | 16 + src/base/platform/crypto/digest.cpp | 87 ++ src/base/platform/crypto/digest.hpp | 17 + src/base/platform/crypto/secure_random.cpp | 36 + src/base/platform/crypto/secure_random.hpp | 17 + src/host/channels/core/access.cpp | 218 +++++ src/host/channels/core/access.hpp | 92 ++ src/host/channels/core/commands.cpp | 134 +++ src/host/channels/core/commands.hpp | 49 ++ src/host/channels/core/conversations.cpp | 586 +++++++++++++ src/host/channels/core/conversations.hpp | 98 +++ src/host/channels/core/host.cpp | 230 +++++ src/host/channels/core/host.hpp | 78 ++ src/host/channels/core/host_services.hpp | 65 ++ .../channels/core/line_webhook_server.cpp | 158 ++++ .../channels/core/line_webhook_server.hpp | 36 + src/host/channels/core/platform_adapters.cpp | 447 ++++++++++ src/host/channels/core/platform_adapters.hpp | 46 + src/host/channels/core/platform_runtime.cpp | 517 +++++++++++ src/host/channels/core/platform_runtime.hpp | 134 +++ src/host/channels/core/platforms.cpp | 123 +++ src/host/channels/core/platforms.hpp | 51 ++ src/host/channels/core/projection.cpp | 304 +++++++ src/host/channels/core/projection.hpp | 99 +++ src/host/channels/core/state_log.cpp | 222 +++++ src/host/channels/core/state_log.hpp | 27 + src/host/channels/core/store.cpp | 333 +++++++ src/host/channels/core/store.hpp | 94 ++ src/layers.tsv | 1 + tests/channels/core/access_test.cpp | 217 +++++ tests/channels/core/commands_test.cpp | 75 ++ tests/channels/core/host_test.cpp | 473 ++++++++++ .../core/line_webhook_server_test.cpp | 161 ++++ tests/channels/core/outbound_test.cpp | 209 +++++ tests/channels/core/router_test.cpp | 402 +++++++++ tests/channels/core/state_log_test.cpp | 198 +++++ tests/channels/core/store_test.cpp | 216 +++++ .../daemon/daemon_shutdown_sequence_test.cpp | 12 + tests/daemon/im_channels_e2e_test.cpp | 810 ++++++++++++++++++ tests/im/dingtalk_api_test.cpp | 202 +++++ tests/im/dingtalk_protocol_test.cpp | 355 ++++++++ tests/im/dingtalk_route_test.cpp | 134 +++ tests/im/dingtalk_send_test.cpp | 347 ++++++++ tests/im/dingtalk_stream_test.cpp | 171 ++++ tests/im/dingtalk_transport_test.cpp | 329 +++++++ tests/im/discord_api_test.cpp | 163 ++++ tests/im/discord_gateway_test.cpp | 266 ++++++ tests/im/discord_protocol_test.cpp | 308 +++++++ tests/im/discord_transport_test.cpp | 564 ++++++++++++ tests/im/feishu_api_test.cpp | 216 +++++ tests/im/feishu_frame_test.cpp | 159 ++++ tests/im/feishu_gateway_test.cpp | 207 +++++ tests/im/feishu_protocol_test.cpp | 494 +++++++++++ tests/im/feishu_transport_test.cpp | 500 +++++++++++ tests/im/line_api_test.cpp | 211 +++++ tests/im/line_protocol_test.cpp | 392 +++++++++ tests/im/line_transport_test.cpp | 591 +++++++++++++ tests/im/line_tunnel_test.cpp | 194 +++++ tests/im/markdown_test.cpp | 85 ++ tests/im/qq_api_test.cpp | 140 +++ tests/im/qq_bind_test.cpp | 137 +++ tests/im/qq_gateway_test.cpp | 131 +++ tests/im/qq_protocol_test.cpp | 116 +++ tests/im/qq_reply_budget_test.cpp | 100 +++ tests/im/qq_transport_test.cpp | 295 +++++++ tests/im/redact_test.cpp | 35 + tests/im/text_chunk_test.cpp | 110 +++ tests/im/tg_api_test.cpp | 77 ++ tests/im/tg_rate_limit_test.cpp | 48 ++ tests/im/tg_transport_test.cpp | 300 +++++++ tests/im/tg_update_test.cpp | 147 ++++ tests/im/transport_test.cpp | 87 ++ tests/im/weixin_login_test.cpp | 211 +++++ tests/im/weixin_protocol_test.cpp | 357 ++++++++ tests/im/weixin_transport_test.cpp | 563 ++++++++++++ tests/network/websocket_client_test.cpp | 199 +++++ tests/platform/aes_gcm_test.cpp | 98 +++ tests/platform/crypto_digest_test.cpp | 73 ++ tests/test_support/channels/core_fakes.hpp | 402 +++++++++ .../test_support/im/fake_dingtalk_server.hpp | 378 ++++++++ tests/test_support/im/fake_discord_server.hpp | 366 ++++++++ tests/test_support/im/fake_feishu_server.hpp | 356 ++++++++ tests/test_support/im/fake_line_server.hpp | 433 ++++++++++ tests/test_support/im/fake_qq_server.hpp | 243 ++++++ .../test_support/im/fake_telegram_server.hpp | 183 ++++ tests/test_support/im/fake_weixin_server.hpp | 269 ++++++ .../test_support/network/crow_test_server.hpp | 39 + tests/web/channels_handler_test.cpp | 79 ++ tests/web/web_server_smoke_test.cpp | 163 ++++ vcpkg.json | 6 + web/package.json | 3 +- web/pnpm-lock.yaml | 8 + web/scripts/i18n-en-overrides.mjs | 164 ++++ web/scripts/test-chat-scroll-to-bottom.mjs | 36 +- web/src/App.jsx | 30 + web/src/components/ChannelsSettings.jsx | 794 +++++++++++++++++ web/src/components/RichComposer.jsx | 78 +- web/src/components/SettingsPage.jsx | 15 +- web/src/components/Sidebar.jsx | 20 +- web/src/i18n/sourceCatalog.generated.js | 326 +++++++ web/src/lib/api.js | 21 + web/src/lib/archivedSessions.js | 27 +- web/src/lib/archivedSessions.test.js | 54 +- web/src/lib/channelsSettings.js | 673 +++++++++++++++ web/src/lib/channelsSettings.test.js | 387 +++++++++ .../lib/chatScrollFollowArchitecture.test.js | 9 +- web/src/lib/composerDomSelection.js | 51 ++ web/src/lib/composerDomSelection.test.js | 178 ++++ web/src/lib/desktopNotify.js | 38 +- web/src/lib/desktopNotify.test.js | 33 + web/src/lib/richComposerKeyboard.test.js | 30 + web/src/lib/runTests.js | 2 + web/src/lib/settingsNavigation.js | 1 + web/src/lib/settingsNavigation.test.js | 3 +- web/src/lib/settingsSearch.js | 7 + web/src/styles/globals.css | 12 +- 260 files changed, 42402 insertions(+), 135 deletions(-) create mode 100644 openspec/changes/add-chat-scroll-to-bottom/.openspec.yaml create mode 100644 openspec/changes/add-chat-scroll-to-bottom/design.md create mode 100644 openspec/changes/add-chat-scroll-to-bottom/proposal.md create mode 100644 openspec/changes/add-chat-scroll-to-bottom/specs/web-chat-scroll-to-bottom/spec.md create mode 100644 openspec/changes/add-chat-scroll-to-bottom/tasks.md create mode 100644 openspec/changes/add-desktop-im-channels/.openspec.yaml create mode 100644 openspec/changes/add-desktop-im-channels/design.md create mode 100644 openspec/changes/add-desktop-im-channels/proposal.md create mode 100644 openspec/changes/add-desktop-im-channels/specs/dingtalk-channel/spec.md create mode 100644 openspec/changes/add-desktop-im-channels/specs/discord-channel/spec.md create mode 100644 openspec/changes/add-desktop-im-channels/specs/feishu-channel/spec.md create mode 100644 openspec/changes/add-desktop-im-channels/specs/im-channels/spec.md create mode 100644 openspec/changes/add-desktop-im-channels/specs/line-channel/spec.md create mode 100644 openspec/changes/add-desktop-im-channels/specs/qq-bot-channel/spec.md create mode 100644 openspec/changes/add-desktop-im-channels/specs/telegram-channel/spec.md create mode 100644 openspec/changes/add-desktop-im-channels/specs/weixin-channel/spec.md create mode 100644 openspec/changes/add-desktop-im-channels/tasks.md create mode 100644 openspec/changes/add-desktop-im-channels/verification.md create mode 100644 openspec/changes/improve-archived-session-browser/.openspec.yaml create mode 100644 openspec/changes/improve-archived-session-browser/design.md create mode 100644 openspec/changes/improve-archived-session-browser/proposal.md create mode 100644 openspec/changes/improve-archived-session-browser/specs/session-archive/spec.md create mode 100644 openspec/changes/improve-archived-session-browser/tasks.md create mode 100644 openspec/changes/integrate-recent-work-release-0-9-35/design.md create mode 100644 openspec/changes/integrate-recent-work-release-0-9-35/proposal.md create mode 100644 openspec/changes/integrate-recent-work-release-0-9-35/specs/local-test-runner/spec.md create mode 100644 openspec/changes/integrate-recent-work-release-0-9-35/tasks.md create mode 100644 openspec/changes/integrate-recent-work-release-0-9-35/verification.md create mode 100644 src/adapters/im/dingtalk/dingtalk_api.cpp create mode 100644 src/adapters/im/dingtalk/dingtalk_api.hpp create mode 100644 src/adapters/im/dingtalk/dingtalk_protocol.cpp create mode 100644 src/adapters/im/dingtalk/dingtalk_protocol.hpp create mode 100644 src/adapters/im/dingtalk/dingtalk_route.cpp create mode 100644 src/adapters/im/dingtalk/dingtalk_route.hpp create mode 100644 src/adapters/im/dingtalk/dingtalk_stream.cpp create mode 100644 src/adapters/im/dingtalk/dingtalk_stream.hpp create mode 100644 src/adapters/im/dingtalk/dingtalk_transport.cpp create mode 100644 src/adapters/im/dingtalk/dingtalk_transport.hpp create mode 100644 src/adapters/im/discord/discord_api.cpp create mode 100644 src/adapters/im/discord/discord_api.hpp create mode 100644 src/adapters/im/discord/discord_gateway.cpp create mode 100644 src/adapters/im/discord/discord_gateway.hpp create mode 100644 src/adapters/im/discord/discord_protocol.cpp create mode 100644 src/adapters/im/discord/discord_protocol.hpp create mode 100644 src/adapters/im/discord/discord_transport.cpp create mode 100644 src/adapters/im/discord/discord_transport.hpp create mode 100644 src/adapters/im/feishu/feishu_api.cpp create mode 100644 src/adapters/im/feishu/feishu_api.hpp create mode 100644 src/adapters/im/feishu/feishu_frame.cpp create mode 100644 src/adapters/im/feishu/feishu_frame.hpp create mode 100644 src/adapters/im/feishu/feishu_gateway.cpp create mode 100644 src/adapters/im/feishu/feishu_gateway.hpp create mode 100644 src/adapters/im/feishu/feishu_inbound.cpp create mode 100644 src/adapters/im/feishu/feishu_outbound.cpp create mode 100644 src/adapters/im/feishu/feishu_pacer.cpp create mode 100644 src/adapters/im/feishu/feishu_pacer.hpp create mode 100644 src/adapters/im/feishu/feishu_protocol.cpp create mode 100644 src/adapters/im/feishu/feishu_protocol.hpp create mode 100644 src/adapters/im/feishu/feishu_text_util.hpp create mode 100644 src/adapters/im/feishu/feishu_transport.cpp create mode 100644 src/adapters/im/feishu/feishu_transport.hpp create mode 100644 src/adapters/im/http.cpp create mode 100644 src/adapters/im/http.hpp create mode 100644 src/adapters/im/line/line_api.cpp create mode 100644 src/adapters/im/line/line_api.hpp create mode 100644 src/adapters/im/line/line_media.cpp create mode 100644 src/adapters/im/line/line_media.hpp create mode 100644 src/adapters/im/line/line_outbound.cpp create mode 100644 src/adapters/im/line/line_protocol.cpp create mode 100644 src/adapters/im/line/line_protocol.hpp create mode 100644 src/adapters/im/line/line_transport.cpp create mode 100644 src/adapters/im/line/line_transport.hpp create mode 100644 src/adapters/im/line/line_tunnel.cpp create mode 100644 src/adapters/im/line/line_tunnel.hpp create mode 100644 src/adapters/im/line/line_webhook.hpp create mode 100644 src/adapters/im/markdown.cpp create mode 100644 src/adapters/im/markdown.hpp create mode 100644 src/adapters/im/qqbot/qq_api.cpp create mode 100644 src/adapters/im/qqbot/qq_api.hpp create mode 100644 src/adapters/im/qqbot/qq_bind.cpp create mode 100644 src/adapters/im/qqbot/qq_bind.hpp create mode 100644 src/adapters/im/qqbot/qq_gateway.cpp create mode 100644 src/adapters/im/qqbot/qq_gateway.hpp create mode 100644 src/adapters/im/qqbot/qq_protocol.cpp create mode 100644 src/adapters/im/qqbot/qq_protocol.hpp create mode 100644 src/adapters/im/qqbot/qq_reply_budget.cpp create mode 100644 src/adapters/im/qqbot/qq_reply_budget.hpp create mode 100644 src/adapters/im/qqbot/qq_transport.cpp create mode 100644 src/adapters/im/qqbot/qq_transport.hpp create mode 100644 src/adapters/im/redact.cpp create mode 100644 src/adapters/im/redact.hpp create mode 100644 src/adapters/im/telegram/tg_api.cpp create mode 100644 src/adapters/im/telegram/tg_api.hpp create mode 100644 src/adapters/im/telegram/tg_rate_limit.cpp create mode 100644 src/adapters/im/telegram/tg_rate_limit.hpp create mode 100644 src/adapters/im/telegram/tg_transport.cpp create mode 100644 src/adapters/im/telegram/tg_transport.hpp create mode 100644 src/adapters/im/telegram/tg_update.cpp create mode 100644 src/adapters/im/telegram/tg_update.hpp create mode 100644 src/adapters/im/text_chunk.cpp create mode 100644 src/adapters/im/text_chunk.hpp create mode 100644 src/adapters/im/transport.cpp create mode 100644 src/adapters/im/transport.hpp create mode 100644 src/adapters/im/weixin/weixin_api.cpp create mode 100644 src/adapters/im/weixin/weixin_api.hpp create mode 100644 src/adapters/im/weixin/weixin_login.cpp create mode 100644 src/adapters/im/weixin/weixin_login.hpp create mode 100644 src/adapters/im/weixin/weixin_media.cpp create mode 100644 src/adapters/im/weixin/weixin_media.hpp create mode 100644 src/adapters/im/weixin/weixin_protocol.cpp create mode 100644 src/adapters/im/weixin/weixin_protocol.hpp create mode 100644 src/adapters/im/weixin/weixin_transport.cpp create mode 100644 src/adapters/im/weixin/weixin_transport.hpp create mode 100644 src/apps/daemon/im_channels.cpp create mode 100644 src/apps/daemon/im_channels.hpp create mode 100644 src/apps/daemon/rc_session_catalog.cpp create mode 100644 src/apps/daemon/rc_session_catalog.hpp create mode 100644 src/apps/web/handlers/channels_handler.cpp create mode 100644 src/apps/web/handlers/channels_handler.hpp create mode 100644 src/apps/web/routes/routes_channels.cpp create mode 100644 src/base/network/websocket_client.cpp create mode 100644 src/base/network/websocket_client.hpp create mode 100644 src/base/platform/crypto/aes_ecb.cpp create mode 100644 src/base/platform/crypto/aes_ecb.hpp create mode 100644 src/base/platform/crypto/aes_gcm.cpp create mode 100644 src/base/platform/crypto/aes_gcm.hpp create mode 100644 src/base/platform/crypto/digest.cpp create mode 100644 src/base/platform/crypto/digest.hpp create mode 100644 src/base/platform/crypto/secure_random.cpp create mode 100644 src/base/platform/crypto/secure_random.hpp create mode 100644 src/host/channels/core/access.cpp create mode 100644 src/host/channels/core/access.hpp create mode 100644 src/host/channels/core/commands.cpp create mode 100644 src/host/channels/core/commands.hpp create mode 100644 src/host/channels/core/conversations.cpp create mode 100644 src/host/channels/core/conversations.hpp create mode 100644 src/host/channels/core/host.cpp create mode 100644 src/host/channels/core/host.hpp create mode 100644 src/host/channels/core/host_services.hpp create mode 100644 src/host/channels/core/line_webhook_server.cpp create mode 100644 src/host/channels/core/line_webhook_server.hpp create mode 100644 src/host/channels/core/platform_adapters.cpp create mode 100644 src/host/channels/core/platform_adapters.hpp create mode 100644 src/host/channels/core/platform_runtime.cpp create mode 100644 src/host/channels/core/platform_runtime.hpp create mode 100644 src/host/channels/core/platforms.cpp create mode 100644 src/host/channels/core/platforms.hpp create mode 100644 src/host/channels/core/projection.cpp create mode 100644 src/host/channels/core/projection.hpp create mode 100644 src/host/channels/core/state_log.cpp create mode 100644 src/host/channels/core/state_log.hpp create mode 100644 src/host/channels/core/store.cpp create mode 100644 src/host/channels/core/store.hpp create mode 100644 tests/channels/core/access_test.cpp create mode 100644 tests/channels/core/commands_test.cpp create mode 100644 tests/channels/core/host_test.cpp create mode 100644 tests/channels/core/line_webhook_server_test.cpp create mode 100644 tests/channels/core/outbound_test.cpp create mode 100644 tests/channels/core/router_test.cpp create mode 100644 tests/channels/core/state_log_test.cpp create mode 100644 tests/channels/core/store_test.cpp create mode 100644 tests/daemon/im_channels_e2e_test.cpp create mode 100644 tests/im/dingtalk_api_test.cpp create mode 100644 tests/im/dingtalk_protocol_test.cpp create mode 100644 tests/im/dingtalk_route_test.cpp create mode 100644 tests/im/dingtalk_send_test.cpp create mode 100644 tests/im/dingtalk_stream_test.cpp create mode 100644 tests/im/dingtalk_transport_test.cpp create mode 100644 tests/im/discord_api_test.cpp create mode 100644 tests/im/discord_gateway_test.cpp create mode 100644 tests/im/discord_protocol_test.cpp create mode 100644 tests/im/discord_transport_test.cpp create mode 100644 tests/im/feishu_api_test.cpp create mode 100644 tests/im/feishu_frame_test.cpp create mode 100644 tests/im/feishu_gateway_test.cpp create mode 100644 tests/im/feishu_protocol_test.cpp create mode 100644 tests/im/feishu_transport_test.cpp create mode 100644 tests/im/line_api_test.cpp create mode 100644 tests/im/line_protocol_test.cpp create mode 100644 tests/im/line_transport_test.cpp create mode 100644 tests/im/line_tunnel_test.cpp create mode 100644 tests/im/markdown_test.cpp create mode 100644 tests/im/qq_api_test.cpp create mode 100644 tests/im/qq_bind_test.cpp create mode 100644 tests/im/qq_gateway_test.cpp create mode 100644 tests/im/qq_protocol_test.cpp create mode 100644 tests/im/qq_reply_budget_test.cpp create mode 100644 tests/im/qq_transport_test.cpp create mode 100644 tests/im/redact_test.cpp create mode 100644 tests/im/text_chunk_test.cpp create mode 100644 tests/im/tg_api_test.cpp create mode 100644 tests/im/tg_rate_limit_test.cpp create mode 100644 tests/im/tg_transport_test.cpp create mode 100644 tests/im/tg_update_test.cpp create mode 100644 tests/im/transport_test.cpp create mode 100644 tests/im/weixin_login_test.cpp create mode 100644 tests/im/weixin_protocol_test.cpp create mode 100644 tests/im/weixin_transport_test.cpp create mode 100644 tests/network/websocket_client_test.cpp create mode 100644 tests/platform/aes_gcm_test.cpp create mode 100644 tests/platform/crypto_digest_test.cpp create mode 100644 tests/test_support/channels/core_fakes.hpp create mode 100644 tests/test_support/im/fake_dingtalk_server.hpp create mode 100644 tests/test_support/im/fake_discord_server.hpp create mode 100644 tests/test_support/im/fake_feishu_server.hpp create mode 100644 tests/test_support/im/fake_line_server.hpp create mode 100644 tests/test_support/im/fake_qq_server.hpp create mode 100644 tests/test_support/im/fake_telegram_server.hpp create mode 100644 tests/test_support/im/fake_weixin_server.hpp create mode 100644 tests/test_support/network/crow_test_server.hpp create mode 100644 tests/web/channels_handler_test.cpp create mode 100644 web/src/components/ChannelsSettings.jsx create mode 100644 web/src/lib/channelsSettings.js create mode 100644 web/src/lib/channelsSettings.test.js create mode 100644 web/src/lib/composerDomSelection.js create mode 100644 web/src/lib/composerDomSelection.test.js diff --git a/CLAUDE.md b/CLAUDE.md index 55b8b3920..f1a14766a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -343,6 +343,29 @@ Loopback requests bypass daemon token auth. Non-loopback requests require `X-ACE The frontend has pure helpers under [web/src/lib/](web/src/lib) with Node-based tests. Prefer adding data-shaping logic there rather than embedding it directly in components. +### 消息通道(QQ / 微信 / 飞书 / 钉钉 / Telegram / Discord / LINE,openspec add-desktop-im-channels) + +用户文档 [docs/channels.md](docs/channels.md),接口 [docs/daemon-api.md](docs/daemon-api.md) 第 19 节。分三层,方向只能向下:`src/adapters/im/<平台>/` 是平台传输层(QQ 官方机器人 v2 网关、微信 iLink 长轮询、飞书长连接、钉钉 Stream、Telegram 长轮询、Discord 网关、LINE webhook),不认识会话;`src/host/channels/core/` 是核心(存储、访问与配对、命令、出站投影、会话路由、平台运行时、宿主);`src/apps/daemon/im_channels.cpp` 装配(worker.cpp 已接近 1000 行上限,装配与 `/rc` 共用的 `rc_session_catalog` 都拆在外面)。WhatsApp(`src/host/channels/*.cpp` 其余文件)不经过这套核心。 + +- **平台清单只有一张表** `core/platforms.cpp`(`PlatformSpec`:凭据字段与是否密钥 / 可手填、账号字段、群成员身份是否按群隔离、换机器人是否清空机主与授权、机主绑定方式 Scan / Link / Code)。运行时、宿主、Web 接口、快照的 `credentials_public`(非密钥原样、密钥只给尾号)都按这张表走;认识各平台传输层类型的只有 `core/platform_adapters.cpp`(创建传输层、联网校验凭据、扫码流程),测试改地址走 `HostEndpoints`。加平台 = 加一行描述 + 一个 adapters 分支 + `Address::valid` 放行平台名。 +- **机主绑定三种**:QQ / 微信扫码时带出扫码人;Telegram 用 `/start <码>` 链接;飞书、钉钉、Discord、LINE 在连上之后由 `owner-link` 发 6 位一次性绑定码(`AccessControl::issue_owner_pin`,10 分钟、一次有效、码存在期间错 10 次作废),私聊发送这串数字即成为机主,这条消息不进会话。 +- **新代码过 R15 所有权检查(CI `check_ownership --strict --final`):** 不写裸 `std::thread`,线程用 `JoiningThread(&Class::method, this)`;存起来的回调捕获 `lifetime_.ref(*this)`,`LifetimeToken` 声明为最后一个成员、析构函数体先 `stop()`;同步回调用具名捕获,不要 `[&]` / `[this]`。未跟踪的新文件不在 lint 范围里,本地要用临时索引把它们加进去再跑。 +- **锁:** `PlatformRuntime` 的 `ops_mu_` 串行化开关 / 改凭据 / 接管 / 停止,`process_mu_` 让入站处理与断开互斥,`mu_` 只护状态字段;调 `transport->stop()` 时不能持 `mu_`(传输线程的 `on_status` 要拿它)。传输层在自己的锁外回调 `on_status`,快照在本对象锁外读 `transport->status()`,两边不会互锁。 +- **只给本机:** 路由用 `is_trusted_local_client_address`,远程 Web 代理转发进来的 `127.0.0.2` 也是 403;`WebServer::broadcast_local_event` 只推给 `WsConnState::trusted_local` 的连接 —— `channels_bind` 带扫码二维码内容。凭据只回尾号,二维码内容与密钥不写日志。 +- **会话列表 `channel_bound:{platform}`** 由 `sessions_for_workspace` 统一标注,侧栏显示与 `/rc` 相同的电脑图标;`/rc` 的换绑动画等专属行为仍只看 `remote_control_bound`,别把两者合并。 +- **连接后由工作线程为已有绑定恢复出站投影**(`Conversations::restore`),否则重启后在 Desktop 里输入的回复不会发到 IM。QQ 换 AppID 时清空机主与授权名单(用户 openid 按机器人隔离),并丢弃旧机器人下的绑定。 +- **QQ:** 全进程单调 `msg_seq`;被动回复额度按入站 msg_id 记账,被拒先降级主动消息,再被拒才暂存(只在内存,下一条入站时先补发);Markdown 被拒后改纯文本且不再尝试;超过直传上限的文件改发文字说明。扫码协议见 `qq_bind.hpp` 文件头(腾讯 connector 无开源许可,只参照协议)。 +- **Telegram:** update offset 存 `state.json` 游标,重启不重复处理;409 分 webhook(等用户在页面确认移除)与另一个轮询方(60 秒后重试)两种。 +- **Telegram 在 getMe 成功后就置已连接**,不要改回等第一次长轮询返回 —— 没有新消息时真实平台要等满 25 秒,设置页会一直停在“连接中”。 +- **设置页只给简要状态,细节写日志**(2026-10-05 验收反馈,仿 WorkBuddy「远程通道」):两列卡片、单色聊天图标、「连接 / 取消连接」按钮;没有凭据或没有机主时「连接」打开三步向导(创建机器人 → 连接 → 绑定机主),配好的直接连。失败原因、隐私模式、暂存待补发、绑定与收发计数由 `core/state_log.cpp` 在每次 `publish_state` 比较前后快照写进 daemon 日志(`[channels/<平台>]` 前缀,凭据尾号也不写);入站判定原因(`AccessResult::reason`)、IM 命令、QQ 扫码各阶段与发送回退在各自位置记日志。前端 `channelsSettings.test.js` 有守卫断言页面不渲染这些细节,别加回页面。`ChannelHost::publish_all` 也走 `publish_state`,否则绑定变化不进日志。 +- **微信(iLink)**:凭据全部来自扫码(`weixin_login`,二维码编码 `qrcode_img_content` 而不是 32 位令牌),设置页不能手填,校验只查齐全不联网。轮询游标与每个联系人的 `context_token` 存在 store 的 `values`(`weixin_cursor` / `weixin_context_tokens`,换机器人随 `clear_transport_state` 清掉);主动发送(Desktop 里输入的回复)也要带 context_token。-14 = 登录失效 → Failed 且停止重试,设置页点「连接」直接回到扫码。只有私聊。CDN 媒体 AES-128-ECB,上传结果在响应头 `x-encrypted-param`(`im::HttpResponse::headers` 键已小写)。 +- **钉钉 Stream**:校验 = 取 access token + 试注册一次 Stream(票据丢弃),能分出凭据错 / 应用未发布或没开 Stream 模式。每条入站要 ACK;回复优先用消息带来的 sessionWebhook,过期后走机器人 OpenAPI(要检查 errcode,HTTP 200 也可能失败)。同一应用每条消息只投给随机一个连接,靠核心的按账号文件锁保证只有一个 ACECode 在连。 +- **飞书长连接**:protobuf 帧(`feishu_frame`),每个事件帧都要 ACK,分片按 sum/seq 重组;飞书要求长连接**在线时**才能保存「使用长连接接收事件」,所以机器人未发布 / 未就绪只在 `extra.bot_warning` 里提示、不判校验失败,连接步骤的清单告诉用户回后台保存订阅、加 `im.message.receive_v1` 并发布。`domain`=lark 走 open.larksuite.com,测试经 `HostEndpoints::feishu_base` 覆盖。握手失败的响应头(`Handshake-Status` 等)`WebSocketClient` 拿不到,403 / 连接数超限目前按可重试处理。飞书、钉钉都按集群模式把事件随机分给同一应用的某个连接,同一应用接在别的工具上会被分走消息。 +- **LINE**:唯一要公网回调的平台。回调端口是 `core/line_webhook_server`(独立 Crow 应用,只监听 127.0.0.1,只有 webhook / health / media 三条路由,**绝不复用 daemon 主端口** —— 本机请求在主端口免 token,经隧道进来的也是本机地址);端口第一次由系统分配后存 cursor `line_listen_port` 沿用。公网地址留空 → 起 cloudflared 快速隧道(找不到时 `extra.cloudflared_missing`,设置页提示 winget 安装),每次连接用 PUT 重新登记 webhook;控制台的 Use webhook 只能手动开,没开时停在 Retrying(`extra.webhook_active=false`,设置页给出该做什么)。回复令牌 50 秒内免费用 reply,否则 push(计月额度,额度用完暂存到下一条消息再补发);机器人不能发文件,图片走回调端口的临时链接。 +- **Discord**:校验时 `/applications/@me` 报 Message Content Intent 关着就直接拒绝保存(否则网关 4014);保存时带回 `bot_id` / `application_id`(设置页邀请链接用)。优先 Resume,Identify 每 24 小时有上限,计数经 `on_identify_ledger` 存两个 cursor 跨重启保留;4004 / 4010–4014 停止重试。服务器频道与线程算群,@ 或回复机器人才处理;不 ping @everyone。 +- **Telegram 群隐私模式开启时 @机器人 的消息照常送达**(BotFather 默认即开启,正好符合“群里只处理 @”);关闭后机器人收到群里全部消息,仍只处理 @ 它的。早期 spec 把这条写反了,已更正。 +- 回归:`tests/daemon/im_channels_e2e_test.cpp`(生产装配 + 真实 SessionRegistry / 脚本化模型 + 真实传输层对假平台,覆盖验收清单里不依赖真实账号的部分)、`tests/im/`、`tests/network/websocket_client_test.cpp`、`tests/platform/aes_gcm_test.cpp`、`tests/channels/core/`(核心用内存版 SessionClient 与假传输层,宿主测试含两个宿主争同一把锁;`state_log_test.cpp` 守着日志内容与“不写凭据”)、`tests/web/channels_handler_test.cpp`、`web_server_smoke_test.cpp` 的 `ChannelRoutesRejectNonLocalClients` 等三条,前端 `channelsSettings.test.js` 与 `desktopNotify.test.js`。各平台假服务在 `tests/test_support/im/fake_*_server.hpp`(端到端测试只在用到的用例里创建微信 / 飞书 / 钉钉 / Discord / LINE 假服务,传输层参数经 `ImChannelDeps::tune_services` 调整),所有测试都不连真实平台。 + ## Desktop Shell The desktop shell runs a webview against workspace-local daemon processes. It does not change daemon internals; each daemon still serves one current working directory. Workspace switching currently uses whole-page navigation so browser origin follows the active loopback port. @@ -581,6 +604,10 @@ SidePanel 折叠 UI:`ChatView` 把 `SidePanel` 包到 `