feat: ship enterprise control and desktop governance

This commit is contained in:
AI Bot
2026-05-11 14:59:26 +08:00
parent 0757d07521
commit a311280238
285 changed files with 48574 additions and 2428 deletions

12
.gitignore vendored
View File

@@ -19,11 +19,23 @@
# production
/build
apps/boss-admin-web/dist/
apps/boss-admin-web/node_modules/
# misc
.DS_Store
*.pem
.playwright-cli/
.playwright-mcp/
.superpowers/
output/
admin-redesign*.png
main-*.js
android/.project
android/.settings/
android/app/.classpath
android/app/.project
android/app/.settings/
data/*.json
data/*.json.bak
android/.gradle/

View File

@@ -10,8 +10,9 @@
2. `docs/architecture/repo_map_cn.md`
3. `docs/architecture/current_runtime_and_deploy_status_cn.md`
4. `docs/architecture/api_and_service_inventory_cn.md`
5. `docs/architecture/boss_server_connection_and_deploy_cn.md`
6. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md`
5. `docs/architecture/rbac_skill_regression_matrix_cn.md`
6. `docs/architecture/boss_server_connection_and_deploy_cn.md`
7. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md`
## 当前有效目录
@@ -53,17 +54,33 @@
- `POST http://127.0.0.1:3000/api/auth/restore` 正常,已验证可用原生 restore token 恢复登录态
- `POST http://127.0.0.1:3000/api/v1/projects/master-agent/messages` 正常,已验证可通过 `Mac Studio local-agent -> 本机 Master Codex Node -> 回写项目账本` 返回真实主 Agent 回复
- `POST http://127.0.0.1:3000/api/v1/projects/[projectId]/messages` 正常,普通单线程会话当前会返回 `conversation_reply` 任务,并等待绑定设备上的真实 Codex 线程回写
- `POST http://127.0.0.1:3000/api/v1/integrations/telegram/webhook` 正常,已支持 Telegram Bot 私聊消息直连 Boss 主 Agent快速回复会立即回 Telegram异步任务完成后也会自动回推
- `GET/POST http://127.0.0.1:3000/api/v1/integrations/telegram` 正常,已支持最高管理员读取和保存 Telegram 接入配置,返回默认脱敏视图;保存 webhook 模式时会自动调用 Telegram `setWebhook`,切回 polling/关闭时会自动调用 `deleteWebhook`Web `/me/telegram` 与原生 Android `我的 > Telegram 接入` 都已接入这条配置链路,并支持把群 / Topic 路由到指定 Boss 项目
- `POST http://127.0.0.1:3000/api/auth/logout` 正常,退出后访问受保护 `/api/v1/*` 会返回 `401`
- `GET/POST http://127.0.0.1:3000/api/v1/auth/sessions` 正常,已支持查看当前账号登录会话、最高管理员查看全部活跃会话,以及撤销单个登录端;返回内容不会暴露 `sessionToken / restoreToken`
- 当前多用户 / RBAC 第一阶段已经落地:`BossState` 新增 `accountDeviceGrants / accountProjectGrants / accountSkillGrants / skillCatalog / permissionAuditLogs`,所有会话、设备、项目详情、消息读写、设备 Skill 和 `/api/state` 都会按当前登录账号过滤;最高管理员仍保持全局可见
- `GET/POST http://127.0.0.1:3000/api/v1/admin/access` 正常,仅最高管理员可用;当前支持创建/更新子账号、公司启用/停用、账号/设备归属、批量导入预览、批量导入、重置子账号密码、离职回收、授予设备/项目/Skill 权限、套用权限模板和撤销授权,返回账号时不会暴露 `passwordHash`
- `GET http://127.0.0.1:3000/api/v1/admin/overview``POST http://127.0.0.1:3000/api/v1/admin/risks/scan``POST http://127.0.0.1:3000/api/v1/admin/notifications/dispatch` 正常,仅最高管理员可用;风险扫描会把超时 SLA 幂等写入 `adminNotifications`,派发结果和处置动作写入 `adminRiskTimeline`
- `GET/POST http://127.0.0.1:3000/api/v1/admin/skills/requests` 正常,仅最高管理员可用;当前支持对指定设备创建 `install / update / uninstall / rollback / version_lock` 请求local-agent 会通过设备 token 认领、执行本机 Skill 文件操作或 Git 操作,并把完成状态和最新 Skill 清单回写
- 当前 Web `/me/access` 和原生 Android `我的 > 用户与权限` 已接入授权管理:最高管理员可在前台创建子账号、授予设备/项目/Skill 权限、套用 `只读观察员 / 项目开发者 / 设备操作者` 模板、查看同名 Skill 跨设备聚合和撤销授权;`admin/member` 不显示该入口
- 当前主 Agent 执行上下文已接入授权快照:主 Agent 生成提示词和任务时只带当前账号可见的设备、项目、线程状态文档、进展事件和 Skill并在 `MasterAgentTask` 上记录 `authorizedDeviceIds / authorizedProjectIds / authorizedSkillIds / requiredPermissions`
- 多用户 / RBAC / Skill / 主 Agent 权限和多设备控制的集中状态、回归矩阵与缺口清单见 `docs/architecture/rbac_skill_regression_matrix_cn.md`
- `GET http://127.0.0.1:3000/api/v1/user/ota/package` 正常,当前会返回最新 APK 包
- 当前这台开发机的 `launchd` 常驻 `local-agent` 已恢复:`GET http://127.0.0.1:4317/health` 现在可在数十毫秒内返回,且在手动 heartbeat 执行期间仍能正常回包
- 当前 Boss 已新增 `src/lib/execution/` 执行底座抽象层;当前生产主链仍然沿用 `local-agent -> codex exec resume`,只是执行责任已开始通过 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现收束
- 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有在显式配置 `BOSS_CLAW_*` 且可用性探测通过时,`master-agent` 当前对话里才会出现并允许选择 `claw-runtime`
- 当前已新增最小 `Telegram Gateway`Boss 服务器可直接作为 Telegram Bot webhook 入口,把 Telegram 私聊或受控群聊文本桥接进 `master-agent` 或指定 Boss 项目,并在 `master-agent task complete` 后自动把结果回推给 Telegram 用户Android 原生端已提供 `TelegramIntegrationActivity`,可查看 Bot 状态、配置 webhook、白名单、群聊触发策略和群 / Topic 路由
- 如果历史上已经保存过 `backendOverride=claw-runtime`,但当前 `Claw Runtime` 不可用,运行时会自动回退到默认后端,并在前台显示明确原因
- 当前 `oh-my-codex` 已以最小 `OmxTeamBackendAdapter` 形式接入执行底座,但默认关闭;当前已经接到 Web 群聊详情页 / 原生群资料页的编排后端选择卡,可在 `Boss Native``OMX Team` 间切换OMX 不可用时会自动回退到默认后端并明确提示原因
- 当前仓库已自带一个本地 OMX smoke runtime`scripts/omx-team-smoke.mjs`。在还没有真实 `oh-my-codex` 可执行文件时,可以先用它验证 `OmxTeamBackendAdapter -> selector -> dispatch_execution -> 回写群聊账本` 这条链
- 当前仓库已自带一个本地 smoke runtime`scripts/claw-runtime-smoke.mjs`。在还没有真实 `claw-code` 可执行文件时,可以先用它验证 `ClawBackendAdapter -> backendOverride -> 异步回流` 整条链
- 当前已新增“Boss 统一电脑控制中枢”第一批能力:主 Agent 已能把聊天输入区分为 `discussion / development / browser / desktop` 四类意图,并能把 `browser_control / desktop_control` 作为正式任务排入 `MasterAgentTask` 队列;本机 `local-agent` 已补上 `browser-control-task-runner / computer-use-task-runner` 外部 runtime 桥,可通过 `browserControl* / computerUse*` 配置接入真实 Browser Automation 与 Computer Use 执行器,未启用时会 fail closed不再假装执行成功
- 当前 browser/desktop 控制结果已经会作为 `control_summary` 正式写回会话账本,并保留目标 URL / 应用名Android 原生端会以单独控制结果卡片展示,便于把“执行什么”和“执行结果”与普通聊天正文区分开
- 当前 `scripts/browser-control-smoke.mjs` 已经能对目标 URL 做一次真实最小探测:抓取页面标题并写回聊天结果;`scripts/computer-use-smoke.mjs` 已支持 macOS `osascript` 应用激活、引号文本输入、可选回车发送、`open -a` 兜底打开和执行 artifact 落盘,作为后续接完整 Computer Use 前的稳定过渡层
- 当前默认本机配置已把 `browserAutomation / computerUse` 两项能力直接上报为在线起步态,所以 Boss App 里这台 Mac 会显示“可做浏览器控制 / 桌面控制”;如果某条链路要临时收起,只需要改 `local-agent/config.cloud.json`
- `GET http://127.0.0.1:4317/api/v1/skills` 正常,已返回本机扫描到的 Codex Skill
- `POST http://127.0.0.1:4317/api/v1/heartbeat` 正常,且会顺带触发 `thread-context` 上报
- `local-agent` 当前每 5 秒轮询一次本机 Skill lifecycle 请求;默认打开 `skillLifecycleEnabled=true`。远程 `install` 或带 `sourceUrl` 的更新必须命中 `skillLifecycleAllowedSources``skillLifecycleTrustedSources`,为空时只允许既有本地 Skill 的 `update / rollback / uninstall / version_lock`;请求携带 `checksum / expectedChecksum` 时会校验 `manifest.json``SKILL.md` 的 sha256失败会清理半安装目录或尽量恢复备份。卸载 / 更新 / 回滚前会在 `skillsDir/.boss-skill-backups` 保留备份,卸载仍限制在 `skillsDir` 目录内,版本锁写入 `.boss-skill-locks.json`
- `launchd` 已加载:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist`
服务器:
@@ -103,13 +120,14 @@ Android APK
- `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
- 当前最新 release 构建版本:`2.5.11``versionCode=24`
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
- 真机开发约束:除非用户明确要求切换设备,后续 Android 开发、ADB 安装、交互回归与问题复现统一使用 `PLB110`;如果 `PLB110` 当前不在线,应先恢复这台设备连接,不自动切到其他手机
- 真机开发约束:用户已明确切换到当前连接的 OPPO `PHZ110`ADB serial `U84XJRIB7D65ZH45`除非用户再次要求切换设备,后续 Android 开发、ADB 安装、交互回归与问题复现统一使用这台 OPPO不再回退到原 `PLB110`
- OPPO 权限回归建议命令:`adb -s U84XJRIB7D65ZH45 devices -l``./gradlew :app:assembleDebug``adb -s U84XJRIB7D65ZH45 install -r android/app/build/outputs/apk/debug/app-debug.apk``adb -s U84XJRIB7D65ZH45 shell am start -W -n com.hyzq.boss/.MainActivity -e initial_tab me`,再从 `我的 > 用户与权限` 确认最高管理员可进入权限页。
- Android 真机无线调试如果要尽量稳定,优先使用“同一局域网 + 初次 USB 启用后执行 `adb tcpip 5555` + `adb connect <phone-ip>:5555`”这条链路;它通常比只依赖系统“无线调试配对码”更稳
- Android 系统层面对“无线调试”没有真正的永久不掉线开关;重启手机、切 WiFi、切热点、ADB server 重启、USB 调试被重新切换后,都可能导致无线调试自动失效
- 真机调试时建议固定同一 SSID、避免代理/VPN 改路、开发者选项里开启“保持唤醒”,并在需要长时间稳定调试时优先保留 USB 兜底;如果必须完全避免自动断开,不要只依赖无线调试
- 当前原生活动页已经覆盖会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、主 Agent 提示词 / 记忆、技能、运维中心、关于
- 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab会话首页是简单聊天列表`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口
- 当前会话首页右上角已切回 `+` 入口:直接从首页发起独立群聊;设备页右上角仍是 `+添加`
- 当前会话首页右上角已切回 `+` 入口:直接从首页发起独立群聊;设备页右上角 `+添加` 仅最高管理员可见,子账号只保留刷新。
- 当前会话首页已升级成“项目聚合 + 线程下钻”的结构:如果某个 Codex 文件夹只导入了 1 个线程,会话列表直接显示这个线程;如果同一文件夹导入了多个线程,会话首页只显示该文件夹归档项,点进去再看这个项目下的全部线程
- 当前会话首页的数据源已分成两层:`/api/v1/conversations` 继续保留平铺线程视图给群聊创建、转发等内部能力使用;首页和原生根页改走 `/api/v1/conversations/home`,文件夹归档详情走 `/api/v1/conversation-folders/[folderKey]`
- 当前会话搜索仍然保留线程可达性:如果命中单线程项目,会直接进入该线程;如果命中多线程项目里的某条线程,结果会显示 `项目 / 线程`,点击后先进入项目文件夹页并定位到对应线程,不会把首页重新打平成线程列表
@@ -148,6 +166,7 @@ Android APK
- 当前设备导入 `review` 已补 owner/admin 鉴权,并改成真正的异步审核链:`review` 只负责排队 `device_import_resolution` 任务并返回 queued 状态,等 local-agent 完成回写后才把决议写回草稿和会话账本
- 当前原生 APP 会话页的“刷新失败”已按当前 tab 的主数据源独立判错:`会话` 只看会话请求本身,`设备` 只看设备请求,`我的` 只在 `settings + ota` 同时失败时才提示刷新失败
- 当前 `设备``我的` 根页已收口为简单列表;`运维与修复 / AI 账号 / 技能` 保留在一级 `我的``审计对话` 作为置顶会话保留在会话首页
- 当前原生 `我的` 根页已开始按登录角色过滤入口:`member` 只显示个人安全、设置、已授权 Skill 和关于;`admin / highest_admin` 才显示运维、AI 账号、附件存储和 Telegram 管理入口;`用户与权限``highest_admin` 可见
- 原生客户端当前直接调用 `https://boss.hyzq.net` 的 Boss API不再打开 WebView
- `2.0.1` 已修复华为真机上因 `Theme.SplashScreen``AppCompatActivity` 不兼容导致的启动闪退
- `2.1.0` 已在本机连接的华为真机上完成签名包覆盖安装与启动复核,原生三栏入口和子活动页声明已全部接通
@@ -160,6 +179,7 @@ Android APK
- `2.5.1` 继续收口微信式原生 UI聊天页普通态顶部已隐藏刷新按钮只保留右上角“信息”发起群聊页顶部说明和选择区已压成更轻的会话式密度候选线程继续复用微信式会话卡片
- `2.5.2` 继续补齐深层原生页:`项目目标 / 版本迭代记录 / 会话信息 / 群资料` 已进一步向设计图收口;附件消息卡片的分析状态和动作文案也压成了更轻的微信式层级
- `2.5.4` 已把 `我的` 根页收口成微信式资料区 + 白底菜单列表,并同步把 `设置 / 账号与安全 / AI 账号 / 技能 / 运维与修复` 的顶部说明从重 `soft panel` 降成轻量列表说明
- `2.5.11` 已补齐第一批遗漏功能:聊天长按“删除”接通服务端账本删除与实时刷新;原生 `我的 > 附件与存储` 可直接切换服务器文件存储 / 阿里 OSS后台通知覆盖所有会话里的主 Agent 回复browser/desktop runtime 未配置时改为明确失败而不是占位成功
- `2.5.5` 已补上群资料页的“修复群成员”主链:历史脏群会明确提示失效成员,并允许重新选择真实线程成员写回群资料;`approval_required` 群聊也已补齐“确认 / 拒绝”两条审批动作
## 本地启动
@@ -189,6 +209,7 @@ npm start
- 登录页:[http://127.0.0.1:3000/auth/login](http://127.0.0.1:3000/auth/login)
- 会话页:[http://127.0.0.1:3000/conversations](http://127.0.0.1:3000/conversations)
- 设备页:[http://127.0.0.1:3000/devices](http://127.0.0.1:3000/devices)
- 平台总后台:[http://127.0.0.1:3000/admin](http://127.0.0.1:3000/admin),生产计划独立入口为 `https://admin.boss.hyzq.net`
## 设备端本地服务
@@ -222,6 +243,8 @@ device-agent 当前职责:
- 轮询云端 `/api/v1/master-agent/tasks/claim`,并用当前电脑已登录的 `codex` 账号执行主 Agent 任务
- 将主 Agent 执行结果回写到云端 `/api/v1/master-agent/tasks/[taskId]/complete`
- 对普通单线程会话,认领到的 `conversation_reply` 任务会直接恢复到目标 Codex 线程,并把线程原始回复回写到对应聊天窗口
- 对已绑定 `codexThreadRef` 的普通单线程会话,`local-agent` 会在执行 `codex exec resume` 前先把 Boss App 里的用户消息镜像进目标 Codex Desktop 线程 rollout避免 APP 和桌面版同线程历史割裂;定位 rollout 时优先用 `state_5.sqlite`,不可用时回退扫描 `~/.codex/sessions`,并尽量刷新线程活跃时间。镜像成功后会优先调用本机常驻 `Codex Desktop Bridge` endpoint再打开 `codex://threads/{threadId}` 并发送一次安全刷新提示让桌面版切到目标线程后重新读取记录endpoint 不可用时回退原命令式刷新。刷新桥默认对短暂失败重试 2 次、间隔 120ms并保留 deep link 与尝试次数便于追踪桌面同步是否真正触发。bridge 同时提供 `GET /api/v1/codex-desktop/events` SSE 和 recent 缓冲,后续 Codex Desktop 插件可直接订阅安全元数据事件;`scripts/codex-desktop-event-consumer.mjs` 可作为本机订阅 smoke
- `scripts/codex-desktop-integration-probe.mjs` 可探测本机 Codex Desktop 能力bridge 也提供 `GET /api/v1/codex-desktop/capabilities`;探测只读 `Info.plist` 和 app 资源,明确不修改 Codex.app 签名包体
- 对群聊线程分发任务,认领到的 `dispatch_execution` 任务会把原始线程结果和主 Agent 汇总一起回写到群聊消息账本
- `local-agent``conversation_reply` 当前会优先使用 `codex exec resume <targetCodexThreadRef>`,只有缺失真实线程引用时才退回 `--ephemeral`
- `local-agent``dispatch_execution` 当前会按 `orchestrationBackendId` 分流:默认继续走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行
@@ -250,6 +273,8 @@ device-agent 当前职责:
- APK 发布脚本:`scripts/publish-apk-to-public.sh`
- `systemd` 配置:`deployment/systemd/boss-web.service`
- `Caddy` 配置:`deployment/Caddyfile`
- 平台总后台域名解析:`admin.boss.hyzq.net` 需要在 DNSPod 添加 `A` 记录到 `106.53.170.158`Caddy 已预留独立站点并把根路径跳到 `/admin`
- 服务器 Caddy 还有 `gptpluscontrol-boss-caddy-reconcile.timer` 周期性重写:如果改域名入口,必须同步更新 `/home/ubuntu/build/gptpluscontrol/deploy/server/caddy.boss_hyzq_net.gptpluscontrol.conf`,否则会再次生成重复站点块
- 邮件配置:`deployment/mail/`
- Android 原生入口:`android/app/src/main/java/com/hyzq/boss/MainActivity.java`
- Android API 客户端:`android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
@@ -344,21 +369,21 @@ npm run aab:release
- 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;因此会话页会自动出现这台设备当前真实运行的 Codex 线程窗口
- 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;如果某个项目下存在多个线程,会话首页会先显示项目归档项,而不是把所有线程平铺在首页
- 对已经绑定的生产设备,如果某些自动导入线程已经不再出现在最新 `projectCandidates[]` 中,服务端会在下一次 heartbeat 自动清理这些过时会话,避免旧线程长期滞留首页
- 登录页当前已临时切到免验证模式,点击“登录”会直接进入会话首页
- 认证现在已经有最小会话链路:登录后会写入 `boss_session` Cookie默认保持 30 天,`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 接口都要求有效会话
- 认证现在已经有最小会话链路:登录后会写入 `boss_session` Cookie默认保持 30 天,`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 接口都要求有效会话;临时免验证登录默认关闭,仅在显式设置 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 时启用
- 新增 `GET /api/auth/session``POST /api/auth/logout``POST /api/auth/restore`
- 当前同一账号已经支持多个登录端并存Web 与原生 Android 的 `我的 > 账号与安全` 可查看和撤销登录会话,最高管理员可以管理所有活跃会话
- 原生 Android 客户端当前会把 `boss_session / restore token / account` 存到 `SharedPreferences`,用于重启后恢复会话
- 验证码新增防刷与防重放60 秒冷却、15 分钟窗口限流,登录连续失败 5 次后会锁定 10 分钟
- `POST /api/auth/send-code` 现在会先按用途校验账号状态:登录 / 忘记密码要求账号已存在,注册要求账号尚未注册
- 当前登录页已临时放开成“一键进入”,账号密码验证码输入暂时不作为拦截条件
- `POST /api/auth/send-code` 与固定验证码 `000000` 仍保留给注册 / 重置密码和后续认证收口,不作为当前登录页前置条件
- 当前登录页默认走账号密码验证码校验,不再把开发兜底作为生产默认能力
- `POST /api/auth/send-code` 当前仍支持 fixed 模式,但验证码登录也必须先申请验证码并消费账本里的有效记录;不能只靠固定码直接登录
- 新注册和重置密码现在使用 `scrypt` 哈希;历史 `sha256` 密码会在下一次密码登录时自动迁移
- 原生 Android 当前把 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等复杂能力下沉到二级或更深层入口,不再把线程预算 / 转发 / 运维说明堆在主聊天页和一级我的页
- 原生 OTA 当前除了整包下载和系统安装器拉起,还会在关于页保留本地下载状态;离开关于页再回来时,仍能看到进行中 / 失败 / 待授权 / 可安装状态
- Android 本地 Gradle 验证当前必须串行执行,避免并发 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug` 相互踩坏中间产物
- 当前默认最高管理员账号:`17600003315`
- 当前默认测试密码`boss123456`
- 当前本机 Codex 节点 `mac-studio` 已绑定到 `17600003315`
- 当前默认最高管理员账号:`krisolo`
- 当前默认测试密码由线上初始化配置管理,文档不再明文记录
- 当前本机 Codex 节点 `mac-studio` 已绑定到 `krisolo`
- 主 Agent 对话当前真实执行链路是:`Boss Web -> 写入用户消息 -> 返回 queued/running -> master-agent task queue -> local-agent / OpenAI API -> complete task -> project ledger`
- `master-agent` 单聊当前已改成“快速入队 + 异步回流”:发送后会立即返回任务包和 `masterReplyState`,前台先显示“主 Agent 思考中”,真实回复稍后自动回写到账本
- 原生 Android 当前会把 `master-agent` 的等待态保留在消息流里:发送后常驻显示“主 Agent 思考中”,超时后改成“主 Agent 回复超时 + 重试等待”,收到新回复后会自动清掉,不再只靠 toast 提示
@@ -369,7 +394,8 @@ npm run aab:release
- 应用内 `GET /api/v1/user/ota` / `POST /api/v1/user/ota` / `GET /api/v1/user/ota/package` 现在已经支持 OTA 状态、检查更新、执行升级和 APK 包下载
- `GET /api/v1/app-logs` 现在已支持登录态下按 `deviceId / projectId / level / category / source / cursor` 查询日志分页
- 设备写接口 `POST /api/v1/app-logs``POST /api/v1/devices/[deviceId]/skills``POST /api/v1/workers/[workerId]/thread-context` 现在都要求有效设备 token 或匹配登录会话
- 当前认证仍是 MVP已有最小会话 Cookie,但还没有刷新令牌、跨端会话治理、吊销审计和 CSRF 防护
- 当前认证已具备最小会话 Cookie、restore token 轮换、浏览器 CSRF 基础防护、子账号 MFA 开关、基础跨端会话治理和后台高危操作审计;后续仍可继续补企业 SSO / IdP
- 当前状态存储默认继续使用 `data/boss-state.json`;已新增 `BOSS_STATE_STORE=postgres` 适配层,生产切换 PostgreSQL 时必须配置 `BOSS_DATABASE_URL`,并先使用 `scripts/boss-state-store-maintenance.mjs` 做备份、dry-run 迁移和回滚演练
- 聊天附件当前已支持真实上传、消息落账本、受保护下载和原生打开;默认存储后端为服务器文件存储
- 当前用户已可在 `我的 > 附件与存储` 切到阿里 OSS 私有桶,下载链会按附件快照生成签名地址,避免用户后续修改配置后旧附件失效
- 图片 / PDF / 文本默认自动进入主 Agent 附件分析;视频 / Office / 大文件默认手动触发

View File

@@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
@@ -14,6 +17,11 @@
android:theme="@style/AppTheme"
android:forceDarkAllowed="false">
<service
android:name=".BossBackgroundRealtimeService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
@@ -50,8 +58,11 @@
<activity android:name=".DeviceImportDraftActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".SkillInventoryActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".SecurityActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".AccessManagementActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".SettingsActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".StorageSettingsActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".AiAccountsActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".TelegramIntegrationActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".OpenAiOnboardingActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".MasterAgentPromptActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".MasterAgentTakeoverActivity" android:exported="false" android:screenOrientation="portrait" />

View File

@@ -15,6 +15,7 @@ import android.provider.Settings;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -76,11 +77,7 @@ public class AboutActivity extends BossScreenActivity {
restoreDownloadUiState();
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(otaDownloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
} else {
registerReceiver(otaDownloadReceiver, filter);
}
ContextCompat.registerReceiver(this, otaDownloadReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED);
reload();
}
@@ -491,11 +488,9 @@ public class AboutActivity extends BossScreenActivity {
persistDownloadUiState();
refreshDownloadStateSection();
if (!getPackageManager().canRequestPackageInstalls()) {
if (!canInstallDownloadedPackages()) {
showMessage("请先允许 Boss 安装未知来源应用,然后重新打开安装包。");
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
openUnknownAppSourcesSettings();
return;
}
@@ -566,7 +561,7 @@ public class AboutActivity extends BossScreenActivity {
return OtaDownloadStateMapper.failed(fileName);
}
if (downloadedApkUri != null) {
if (!getPackageManager().canRequestPackageInstalls()) {
if (!canInstallDownloadedPackages()) {
return OtaDownloadStateMapper.waitingInstallPermission(fileName);
}
return OtaDownloadStateMapper.readyToInstall(fileName);
@@ -580,9 +575,7 @@ public class AboutActivity extends BossScreenActivity {
downloadLatestApk();
break;
case OPEN_INSTALL_PERMISSION:
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
openUnknownAppSourcesSettings();
break;
case INSTALL_APK:
installDownloadedApk();
@@ -598,11 +591,9 @@ public class AboutActivity extends BossScreenActivity {
showMessage("当前没有可安装的更新包");
return;
}
if (!getPackageManager().canRequestPackageInstalls()) {
if (!canInstallDownloadedPackages()) {
showMessage("请先开启安装未知来源应用权限");
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
openUnknownAppSourcesSettings();
return;
}
Intent installIntent = new Intent(Intent.ACTION_VIEW);
@@ -622,6 +613,19 @@ public class AboutActivity extends BossScreenActivity {
return "boss-android-latest.apk";
}
private boolean canInstallDownloadedPackages() {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.O
|| getPackageManager().canRequestPackageInstalls();
}
private void openUnknownAppSourcesSettings() {
Intent intent = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
? new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()))
: new Intent(Settings.ACTION_SECURITY_SETTINGS);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
}
private void restoreDownloadUiState() {
android.content.SharedPreferences prefs = getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE);
activeDownloadId = prefs.getLong(KEY_ACTIVE_DOWNLOAD_ID, -1L);

View File

@@ -0,0 +1,598 @@
package com.hyzq.boss;
import android.app.AlertDialog;
import android.os.Bundle;
import android.text.InputType;
import android.text.TextUtils;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.Spinner;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class AccessManagementActivity extends BossScreenActivity {
@Nullable private JSONObject accessPayload;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("用户与权限", "子账号、设备、项目与 Skill");
setHeaderAction("新增", v -> showAccountDialog());
reload();
}
@Override
protected void reload() {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getAdminAccess();
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> renderAccess(response.json));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
replaceContent(BossUi.buildEmptyCard(this, "权限配置加载失败:" + error.getMessage()));
});
}
});
}
private void renderAccess(JSONObject payload) {
accessPayload = payload;
JSONArray accounts = payload.optJSONArray("accounts");
JSONArray devices = payload.optJSONArray("devices");
JSONArray projects = payload.optJSONArray("projects");
JSONArray skills = payload.optJSONArray("skills");
JSONArray skillCatalog = payload.optJSONArray("skillCatalog");
JSONArray permissionTemplates = payload.optJSONArray("permissionTemplates");
replaceContent(BossUi.buildWechatMenuRow(
this,
"权限总览",
"子账号 " + lengthOf(accounts) + " 个 · 设备 " + lengthOf(devices) + " 台 · 项目 " + lengthOf(projects) + "",
"Skill 类目 " + lengthOf(skillCatalog) + " 类 · 设备 Skill 实例 " + lengthOf(skills) + "",
null,
null
));
Button accountButton = BossUi.buildMiniActionButton(this, "创建子账号", true);
Button deviceButton = BossUi.buildMiniActionButton(this, "授权设备", false);
Button projectButton = BossUi.buildMiniActionButton(this, "授权项目", false);
Button skillButton = BossUi.buildMiniActionButton(this, "分配 Skill", false);
Button templateButton = BossUi.buildMiniActionButton(this, "套用模板", true);
accountButton.setOnClickListener(v -> showAccountDialog());
deviceButton.setOnClickListener(v -> showDeviceGrantDialog());
projectButton.setOnClickListener(v -> showProjectGrantDialog());
skillButton.setOnClickListener(v -> showSkillGrantDialog());
templateButton.setOnClickListener(v -> showTemplateGrantDialog());
appendContent(buildActionRow(accountButton, deviceButton));
appendContent(buildActionRow(projectButton, skillButton));
if (!isEmpty(permissionTemplates)) {
Button refreshAccessButton = BossUi.buildMiniActionButton(this, "刷新权限", false);
refreshAccessButton.setOnClickListener(v -> reload());
appendContent(buildActionRow(templateButton, refreshAccessButton));
templateButton.setOnClickListener(v -> showTemplateGrantDialog());
}
if (!isEmpty(permissionTemplates)) {
appendContent(BossUi.buildWechatMenuRow(
this,
"权限模板",
lengthOf(permissionTemplates) + " 个模板可用",
"一次性给账号分配设备、项目和 Skill 权限",
null,
v -> showTemplateGrantDialog()
));
} else {
appendContent(BossUi.buildWechatMenuRow(
this,
"暂无权限模板",
"模板列表为空,仍可使用单项授权。",
"等待服务端同步只读观察员、项目开发者、设备操作者等模板。",
null,
null
));
}
appendUnavailableTargetHints(devices, projects, skills);
appendContent(BossUi.buildWechatMenuRow(
this,
"已配置账号",
summarizeAccounts(accounts),
"点击右上角新增,可创建或更新子账号",
null,
null
));
JSONArray deviceGrants = grantsArray(payload, "devices");
JSONArray projectGrants = grantsArray(payload, "projects");
JSONArray skillGrants = grantsArray(payload, "skills");
appendContent(BossUi.buildWechatMenuRow(
this,
"当前授权",
"设备 " + lengthOf(deviceGrants) + " 条 · 项目 " + lengthOf(projectGrants) + " 条 · Skill " + lengthOf(skillGrants) + "",
"点击授权记录可撤销当前这条授权",
null,
null
));
appendGrantRows(deviceGrants, "设备");
appendGrantRows(projectGrants, "项目");
appendGrantRows(skillGrants, "Skill");
setRefreshing(false);
}
private void appendUnavailableTargetHints(JSONArray devices, JSONArray projects, JSONArray skills) {
if (isEmpty(devices)) {
appendContent(BossUi.buildWechatMenuRow(
this,
"暂无可授权设备",
"设备列表为空,无法分配 device.view 或 computer.control。",
"请先完成设备绑定或等待授权范围刷新。",
null,
null
));
}
if (isEmpty(projects)) {
appendContent(BossUi.buildWechatMenuRow(
this,
"暂无可授权项目",
"项目列表为空,无法分配 project.view、thread.chat 或主 Agent 协同。",
"请先导入项目或等待设备线程同步。",
null,
null
));
}
if (isEmpty(skills)) {
appendContent(BossUi.buildWechatMenuRow(
this,
"暂无可分配 Skill",
"Skill 实例为空,无法分配 skill.use。",
"请确认 local-agent 已同步 ~/.codex/skills。",
null,
null
));
}
}
private LinearLayout buildActionRow(Button left, Button right) {
LinearLayout row = new LinearLayout(this);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setPadding(BossUi.dp(this, 12), 0, BossUi.dp(this, 12), BossUi.dp(this, 10));
LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
row.setLayoutParams(rowParams);
LinearLayout.LayoutParams leftParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f);
LinearLayout.LayoutParams rightParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f);
rightParams.leftMargin = BossUi.dp(this, 8);
row.addView(left, leftParams);
row.addView(right, rightParams);
return row;
}
private void appendGrantRows(JSONArray grants, String scopeLabel) {
if (grants == null || grants.length() == 0) {
return;
}
int max = Math.min(8, grants.length());
for (int index = 0; index < max; index += 1) {
JSONObject grant = grants.optJSONObject(index);
if (grant == null) {
continue;
}
String grantId = grant.optString("grantId", "");
appendContent(BossUi.buildWechatMenuRow(
this,
scopeLabel + "授权 · " + grant.optString("account", ""),
grantTargetSummary(grant),
joinJsonArray(grant.optJSONArray("permissions")),
null,
TextUtils.isEmpty(grantId) ? null : v -> confirmRevoke(grantId)
));
}
if (grants.length() > max) {
appendContent(BossUi.buildHintPill(this, "还有 " + (grants.length() - max) + " 条授权未展开,可在 Web 端查看完整审计。"));
}
}
private void showAccountDialog() {
LinearLayout form = buildDialogForm();
EditText accountInput = BossUi.buildInput(this, "子账号,例如 worker@example.com", false);
EditText displayInput = BossUi.buildInput(this, "显示名", false);
EditText passwordInput = BossUi.buildInput(this, "初始密码 / 新密码", false);
passwordInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
Spinner roleSpinner = spinnerWith(new String[]{"成员", "管理员"});
form.addView(BossUi.buildFormCell(this, "账号", null, accountInput));
form.addView(BossUi.buildFormCell(this, "显示名", null, displayInput));
form.addView(BossUi.buildFormCell(this, "角色", "最高管理员不在手机端创建,避免误提权。", roleSpinner));
form.addView(BossUi.buildFormCell(this, "密码", "创建账号时必填;更新账号时留空表示不改密码。", passwordInput));
new AlertDialog.Builder(this)
.setTitle("创建 / 更新子账号")
.setView(form)
.setNegativeButton("取消", null)
.setPositiveButton("保存", (dialog, which) -> {
try {
JSONObject payload = new JSONObject();
payload.put("action", "upsert_account");
payload.put("account", accountInput.getText().toString().trim());
payload.put("displayName", displayInput.getText().toString().trim());
payload.put("password", passwordInput.getText().toString());
payload.put("role", roleSpinner.getSelectedItemPosition() == 1 ? "admin" : "member");
runAdminAction(payload);
} catch (JSONException error) {
showMessage("保存失败:" + error.getMessage());
}
})
.show();
}
private void showDeviceGrantDialog() {
JSONObject payload = requireAccessPayload();
if (payload == null) return;
JSONArray accounts = payload.optJSONArray("accounts");
JSONArray devices = payload.optJSONArray("devices");
if (isEmpty(accounts) || isEmpty(devices)) {
showMessage("需要先有账号和设备。");
return;
}
LinearLayout form = buildDialogForm();
Spinner accountSpinner = spinnerWith(labelsFor(accounts, "account", "displayName"));
Spinner deviceSpinner = spinnerWith(labelsFor(devices, "id", "name"));
Spinner permissionSpinner = spinnerWith(new String[]{"只读查看", "管理设备", "允许电脑控制"});
form.addView(BossUi.buildFormCell(this, "账号", null, accountSpinner));
form.addView(BossUi.buildFormCell(this, "设备", null, deviceSpinner));
form.addView(BossUi.buildFormCell(this, "权限模板", null, permissionSpinner));
confirmGrant("授权设备", form, () -> {
JSONObject body = new JSONObject();
body.put("action", "grant_device");
body.put("account", valueAt(accounts, accountSpinner.getSelectedItemPosition(), "account"));
body.put("deviceId", valueAt(devices, deviceSpinner.getSelectedItemPosition(), "id"));
body.put("permissions", new JSONArray(permissionSpinner.getSelectedItemPosition() == 1
? Arrays.asList("device.view", "device.manage")
: permissionSpinner.getSelectedItemPosition() == 2
? Arrays.asList("device.view", "computer.control")
: Arrays.asList("device.view")));
return body;
});
}
private void showProjectGrantDialog() {
JSONObject payload = requireAccessPayload();
if (payload == null) return;
JSONArray accounts = payload.optJSONArray("accounts");
JSONArray projects = payload.optJSONArray("projects");
if (isEmpty(accounts) || isEmpty(projects)) {
showMessage("需要先有账号和项目。");
return;
}
LinearLayout form = buildDialogForm();
Spinner accountSpinner = spinnerWith(labelsFor(accounts, "account", "displayName"));
Spinner projectSpinner = spinnerWith(labelsFor(projects, "id", "name"));
Spinner permissionSpinner = spinnerWith(new String[]{"只读查看", "允许聊天", "主 Agent 协同", "电脑控制"});
form.addView(BossUi.buildFormCell(this, "账号", null, accountSpinner));
form.addView(BossUi.buildFormCell(this, "项目", null, projectSpinner));
form.addView(BossUi.buildFormCell(this, "权限模板", null, permissionSpinner));
confirmGrant("授权项目", form, () -> {
JSONObject body = new JSONObject();
body.put("action", "grant_project");
body.put("account", valueAt(accounts, accountSpinner.getSelectedItemPosition(), "account"));
body.put("projectId", valueAt(projects, projectSpinner.getSelectedItemPosition(), "id"));
body.put("permissions", projectPermissionsFor(permissionSpinner.getSelectedItemPosition()));
return body;
});
}
private void showSkillGrantDialog() {
JSONObject payload = requireAccessPayload();
if (payload == null) return;
JSONArray accounts = payload.optJSONArray("accounts");
JSONArray skills = payload.optJSONArray("skills");
if (isEmpty(accounts) || isEmpty(skills)) {
showMessage("需要先有账号和已同步 Skill。");
return;
}
LinearLayout form = buildDialogForm();
Spinner accountSpinner = spinnerWith(labelsFor(accounts, "account", "displayName"));
Spinner skillSpinner = spinnerWith(labelsFor(skills, "skillId", "name"));
Spinner permissionSpinner = spinnerWith(new String[]{"可调用", "可管理"});
form.addView(BossUi.buildFormCell(this, "账号", null, accountSpinner));
form.addView(BossUi.buildFormCell(this, "Skill", null, skillSpinner));
form.addView(BossUi.buildFormCell(this, "权限模板", null, permissionSpinner));
confirmGrant("分配 Skill", form, () -> {
JSONObject skill = skills.optJSONObject(skillSpinner.getSelectedItemPosition());
JSONObject body = new JSONObject();
body.put("action", "grant_skill");
body.put("account", valueAt(accounts, accountSpinner.getSelectedItemPosition(), "account"));
body.put("skillId", skill == null ? "" : skill.optString("skillId", ""));
body.put("deviceId", skill == null ? "" : skill.optString("deviceId", ""));
body.put("permissions", new JSONArray(permissionSpinner.getSelectedItemPosition() == 1
? Arrays.asList("skill.view", "skill.use", "skill.manage")
: Arrays.asList("skill.view", "skill.use")));
return body;
});
}
private void showTemplateGrantDialog() {
JSONObject payload = requireAccessPayload();
if (payload == null) return;
JSONArray accounts = payload.optJSONArray("accounts");
JSONArray templates = payload.optJSONArray("permissionTemplates");
JSONArray devices = payload.optJSONArray("devices");
JSONArray projects = payload.optJSONArray("projects");
JSONArray skills = payload.optJSONArray("skills");
if (isEmpty(accounts) || isEmpty(templates)) {
showMessage("需要先有账号和权限模板。");
return;
}
if (isEmpty(devices) && isEmpty(projects) && isEmpty(skills)) {
showMessage("需要至少有设备、项目或 Skill。");
return;
}
LinearLayout form = buildDialogForm();
Spinner accountSpinner = spinnerWith(labelsFor(accounts, "account", "displayName"));
Spinner templateSpinner = spinnerWith(labelsFor(templates, "templateId", "name"));
Spinner deviceSpinner = spinnerWith(optionalLabelsFor(devices, "id", "name", "不授权设备"));
Spinner projectSpinner = spinnerWith(optionalLabelsFor(projects, "id", "name", "不授权项目"));
Spinner skillSpinner = spinnerWith(optionalLabelsFor(skills, "skillId", "name", "不分配 Skill"));
if (!isEmpty(devices)) {
deviceSpinner.setSelection(1);
}
if (!isEmpty(projects)) {
projectSpinner.setSelection(1);
}
if (!isEmpty(skills)) {
skillSpinner.setSelection(1);
}
form.addView(BossUi.buildFormCell(this, "账号", null, accountSpinner));
form.addView(BossUi.buildFormCell(this, "模板", "模板只作用于本次选择的账号和目标,不会全局放行。", templateSpinner));
form.addView(BossUi.buildFormCell(this, "设备", null, deviceSpinner));
form.addView(BossUi.buildFormCell(this, "项目", null, projectSpinner));
form.addView(BossUi.buildFormCell(this, "Skill", null, skillSpinner));
confirmGrant("套用权限模板", form, () -> buildTemplateApplyPayload(
valueAt(accounts, accountSpinner.getSelectedItemPosition(), "account"),
objectAt(templates, templateSpinner.getSelectedItemPosition()),
optionalObjectAt(devices, deviceSpinner.getSelectedItemPosition()),
optionalObjectAt(projects, projectSpinner.getSelectedItemPosition()),
optionalObjectAt(skills, skillSpinner.getSelectedItemPosition())
));
}
static JSONObject buildTemplateApplyPayload(
String account,
JSONObject template,
@Nullable JSONObject device,
@Nullable JSONObject project,
@Nullable JSONObject skill
) throws JSONException {
JSONObject body = new JSONObject();
body.put("action", "apply_template");
body.put("account", account == null ? "" : account.trim());
body.put("templateId", template == null ? "" : template.optString("templateId", ""));
JSONArray deviceIds = new JSONArray();
if (device != null && !TextUtils.isEmpty(device.optString("id", ""))) {
deviceIds.put(device.optString("id", ""));
}
JSONArray projectIds = new JSONArray();
if (project != null && !TextUtils.isEmpty(project.optString("id", ""))) {
projectIds.put(project.optString("id", ""));
}
JSONArray skillIds = new JSONArray();
if (skill != null && !TextUtils.isEmpty(skill.optString("skillId", ""))) {
skillIds.put(skill.optString("skillId", ""));
}
body.put("deviceIds", deviceIds);
body.put("projectIds", projectIds);
body.put("skillIds", skillIds);
return body;
}
private void confirmGrant(String title, LinearLayout form, PayloadFactory factory) {
new AlertDialog.Builder(this)
.setTitle(title)
.setView(form)
.setNegativeButton("取消", null)
.setPositiveButton("保存", (dialog, which) -> {
try {
runAdminAction(factory.create());
} catch (JSONException error) {
showMessage("保存失败:" + error.getMessage());
}
})
.show();
}
private void confirmRevoke(String grantId) {
new AlertDialog.Builder(this)
.setTitle("撤销授权")
.setMessage("只撤销当前这条授权,不影响其他设备、项目或 Skill。")
.setNegativeButton("取消", null)
.setPositiveButton("撤销", (dialog, which) -> {
try {
JSONObject payload = new JSONObject();
payload.put("action", "revoke_grant");
payload.put("grantId", grantId);
runAdminAction(payload);
} catch (JSONException error) {
showMessage("撤销失败:" + error.getMessage());
}
})
.show();
}
private void runAdminAction(JSONObject payload) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.updateAdminAccess(payload);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
showMessage("已保存");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("操作失败:" + error.getMessage());
});
}
});
}
@Nullable
private JSONObject requireAccessPayload() {
if (accessPayload == null) {
showMessage("权限数据还没加载完成。");
}
return accessPayload;
}
private LinearLayout buildDialogForm() {
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
return form;
}
private Spinner spinnerWith(String[] values) {
Spinner spinner = new Spinner(this);
spinner.setAdapter(new ArrayAdapter<>(
this,
android.R.layout.simple_spinner_dropdown_item,
values
));
return spinner;
}
private JSONArray projectPermissionsFor(int position) {
if (position == 3) {
return new JSONArray(Arrays.asList("project.view", "thread.chat", "master_agent.ask", "master_agent.takeover", "computer.control"));
}
if (position == 2) {
return new JSONArray(Arrays.asList("project.view", "thread.chat", "master_agent.ask", "master_agent.takeover"));
}
if (position == 1) {
return new JSONArray(Arrays.asList("project.view", "thread.chat"));
}
return new JSONArray(Arrays.asList("project.view"));
}
private String[] labelsFor(JSONArray array, String idKey, String nameKey) {
List<String> labels = new ArrayList<>();
if (array == null) {
return new String[0];
}
for (int index = 0; index < array.length(); index += 1) {
JSONObject item = array.optJSONObject(index);
if (item == null) {
continue;
}
String id = item.optString(idKey, "");
String name = item.optString(nameKey, "");
labels.add(TextUtils.isEmpty(name) || name.equals(id) ? id : name + " · " + id);
}
return labels.toArray(new String[0]);
}
private String[] optionalLabelsFor(JSONArray array, String idKey, String nameKey, String emptyLabel) {
List<String> labels = new ArrayList<>();
labels.add(emptyLabel);
if (array != null) {
labels.addAll(Arrays.asList(labelsFor(array, idKey, nameKey)));
}
return labels.toArray(new String[0]);
}
private String valueAt(JSONArray array, int position, String key) {
JSONObject item = array == null ? null : array.optJSONObject(position);
return item == null ? "" : item.optString(key, "");
}
@Nullable
private JSONObject objectAt(JSONArray array, int position) {
return array == null ? null : array.optJSONObject(position);
}
@Nullable
private JSONObject optionalObjectAt(JSONArray array, int position) {
if (position <= 0 || array == null) {
return null;
}
return array.optJSONObject(position - 1);
}
private JSONArray grantsArray(JSONObject payload, String key) {
JSONObject grants = payload.optJSONObject("grants");
return grants == null ? new JSONArray() : grants.optJSONArray(key);
}
private String summarizeAccounts(JSONArray accounts) {
if (accounts == null || accounts.length() == 0) {
return "暂无子账号";
}
List<String> parts = new ArrayList<>();
int max = Math.min(4, accounts.length());
for (int index = 0; index < max; index += 1) {
JSONObject account = accounts.optJSONObject(index);
if (account == null) continue;
parts.add(account.optString("displayName", account.optString("account", "")) + " · " + BossUi.formatRoleLabel(account.optString("role", "")));
}
if (accounts.length() > max) {
parts.add("+" + (accounts.length() - max));
}
return TextUtils.join("\n", parts);
}
private String grantTargetSummary(JSONObject grant) {
if (!TextUtils.isEmpty(grant.optString("skillId", ""))) {
return "Skill" + grant.optString("skillId", "");
}
if (!TextUtils.isEmpty(grant.optString("projectId", ""))) {
return "项目:" + grant.optString("projectId", "");
}
return "设备:" + grant.optString("deviceId", "");
}
private String joinJsonArray(JSONArray values) {
if (values == null || values.length() == 0) {
return "未设置权限";
}
List<String> parts = new ArrayList<>();
for (int index = 0; index < values.length(); index += 1) {
parts.add(values.optString(index, ""));
}
return TextUtils.join(" / ", parts);
}
private int lengthOf(@Nullable JSONArray array) {
return array == null ? 0 : array.length();
}
private boolean isEmpty(@Nullable JSONArray array) {
return array == null || array.length() == 0;
}
private interface PayloadFactory {
JSONObject create() throws JSONException;
}
}

View File

@@ -133,9 +133,7 @@ public class AiAccountsActivity extends BossScreenActivity {
} else {
appendContent(buildApiSection(
isPrimaryRole(currentRole) ? "主要API配置" : "备用API配置",
isPrimaryRole(currentRole)
? "主链路只在这里配置 OAuth 登录或 API 接入。"
: "主链路异常时自动切到这里,不抢占当前主控。",
isPrimaryRole(currentRole) ? "主链路" : "备用链路",
accounts,
currentRole
));
@@ -170,7 +168,7 @@ public class AiAccountsActivity extends BossScreenActivity {
if (currentRole == null) {
return "主要API与备用API";
}
return "OAuth 登录与 API 接入";
return "OAuth / API";
}
private LinearLayout buildOverviewSection(@Nullable JSONArray accounts) {
@@ -198,9 +196,9 @@ public class AiAccountsActivity extends BossScreenActivity {
private String overviewSummaryForRole(@Nullable JSONArray accounts, String targetRole) {
int count = countAccountsForRole(accounts, targetRole);
if (count <= 0) {
return "暂未配置,点进去添加。";
return "暂未配置";
}
return "已配置 " + count + ",点进去查看和编辑。";
return "已配置 " + count + "";
}
private int countAccountsForRole(@Nullable JSONArray accounts, String targetRole) {
@@ -282,7 +280,7 @@ public class AiAccountsActivity extends BossScreenActivity {
currentFastModelOverride,
currentDeepModelOverride
),
"切换后会和主Agent对话框保持同步",
"主Agent对话同步",
"切换",
v -> showMasterAgentModePicker()
));
@@ -290,7 +288,7 @@ public class AiAccountsActivity extends BossScreenActivity {
this,
"快速反应模型",
"当前:" + MasterAgentModePresets.resolveFastModel(currentFastModelOverride),
"快速问答默认使用低推理强度",
"低推理强度",
"配置",
v -> showMasterAgentModeModelPicker(true)
));
@@ -298,7 +296,7 @@ public class AiAccountsActivity extends BossScreenActivity {
this,
"深度思考模型",
"当前:" + MasterAgentModePresets.resolveDeepModel(currentDeepModelOverride),
"复杂任务默认使用高推理强度",
"高推理强度",
"配置",
v -> showMasterAgentModeModelPicker(false)
));
@@ -306,7 +304,7 @@ public class AiAccountsActivity extends BossScreenActivity {
section.addView(BossUi.buildWechatMenuRow(
this,
"OAuth 登录",
isPrimaryRole(targetRole) ? "设置主要 OAuth 登录。" : "设置备用 OAuth 登录。",
isPrimaryRole(targetRole) ? "主要 OAuth" : "备用 OAuth",
configuredMethodAccountsSummary(accounts, targetRole, true),
null,
v -> openRoleProviderChooser(targetRole, true)
@@ -314,7 +312,7 @@ public class AiAccountsActivity extends BossScreenActivity {
section.addView(BossUi.buildWechatMenuRow(
this,
"API 接入",
isPrimaryRole(targetRole) ? "设置主要 API 接入。" : "设置备用 API 接入。",
isPrimaryRole(targetRole) ? "主要 API" : "备用 API",
configuredMethodAccountsSummary(accounts, targetRole, false),
null,
v -> openRoleProviderChooser(targetRole, false)

View File

@@ -66,6 +66,53 @@ public class BossApiClient {
return response;
}
public ApiResponse loginWithPassword(String account, String password) throws IOException, JSONException {
JSONObject body = new JSONObject();
body.put("account", account);
body.put("password", password);
body.put("method", "password");
ApiResponse response = request("POST", "/api/auth/login", body, false);
if (response.ok()) {
rememberIdentity(response.json);
}
return response;
}
public ApiResponse sendVerificationCode(String account, String purpose) throws IOException, JSONException {
JSONObject body = new JSONObject();
body.put("account", account);
body.put("purpose", purpose);
return request("POST", "/api/auth/send-code", body, false);
}
public ApiResponse registerAccount(
String account,
String password,
String confirmPassword,
String code
) throws IOException, JSONException {
JSONObject body = new JSONObject();
body.put("account", account);
body.put("password", password);
body.put("confirmPassword", confirmPassword);
body.put("code", code);
return request("POST", "/api/auth/register", body, false);
}
public ApiResponse resetPassword(
String account,
String password,
String confirmPassword,
String code
) throws IOException, JSONException {
JSONObject body = new JSONObject();
body.put("account", account);
body.put("password", password);
body.put("confirmPassword", confirmPassword);
body.put("code", code);
return request("POST", "/api/auth/forgot-password", body, false);
}
public ApiResponse restoreSession() throws IOException, JSONException {
if (getRestoreToken().isEmpty()) {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_RESTORE_TOKEN"));
@@ -83,6 +130,17 @@ public class BossApiClient {
return request("GET", "/api/auth/session", null, true);
}
public ApiResponse getAuthSessions() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/auth/sessions", null);
}
public ApiResponse revokeAuthSession(String sessionId) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("action", "revoke_session");
payload.put("sessionId", sessionId);
return requestWithRestore("POST", "/api/v1/auth/sessions", payload);
}
public ApiResponse getConversations() throws IOException, JSONException {
return requestWithRestoreRaw(
"GET",
@@ -107,6 +165,12 @@ public class BossApiClient {
return requestWithRestore("GET", "/api/v1/conversation-folders/" + encode(folderKey), null);
}
public ApiResponse markConversationRead(String projectId) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("action", "mark_read");
return requestWithRestore("POST", "/api/v1/conversations/" + encode(projectId) + "/actions", payload);
}
public ApiResponse getProjectDetail(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null);
}
@@ -254,6 +318,16 @@ public class BossApiClient {
);
}
public ApiResponse decideDialogGuardIntervention(String interventionId, String decision) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("decision", decision);
return requestWithRestore(
"POST",
"/api/v1/dialog-guard/interventions/" + encode(interventionId) + "/decision",
payload
);
}
public ApiResponse retryDispatchPlan(String projectId, String planId) throws IOException, JSONException {
return requestWithRestoreRaw(
"POST",
@@ -299,6 +373,18 @@ public class BossApiClient {
return requestWithRestore("PATCH", "/api/v1/projects/" + encode(projectId) + "/dispatch-reminder", payload);
}
public ApiResponse getAttachmentStorageConfig() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/storage/config", null);
}
public ApiResponse saveAttachmentStorageConfig(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("PATCH", "/api/v1/storage/config", payload);
}
public ApiResponse validateAttachmentStorageConfig(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/storage/config/validate", payload);
}
public ApiResponse sendProjectMessage(String projectId, String body, String kind) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("body", body);
@@ -312,6 +398,14 @@ public class BossApiClient {
);
}
public ApiResponse deleteProjectMessage(String projectId, String messageId) throws IOException, JSONException {
return requestWithRestore(
"DELETE",
"/api/v1/projects/" + encode(projectId) + "/messages?messageId=" + encode(messageId),
null
);
}
public ApiResponse uploadAttachment(
String projectId,
String fileName,
@@ -484,6 +578,14 @@ public class BossApiClient {
return requestWithRestore("GET", "/api/v1/accounts", null);
}
public ApiResponse getAdminAccess() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/admin/access", null);
}
public ApiResponse updateAdminAccess(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/admin/access", payload);
}
public ApiResponse createAccount(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/accounts", payload);
}
@@ -546,6 +648,14 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/settings", payload);
}
public ApiResponse getTelegramIntegration() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/integrations/telegram", null);
}
public ApiResponse updateTelegramIntegration(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/integrations/telegram", payload);
}
public ApiResponse getOtaStatus() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/user/ota", null);
}
@@ -569,7 +679,7 @@ public class BossApiClient {
}
public String getAccountLabel() {
return prefs.getString(KEY_ACCOUNT, "17600003315");
return prefs.getString(KEY_ACCOUNT, "krisolo");
}
public String getDisplayName() {
@@ -614,9 +724,9 @@ public class BossApiClient {
int readTimeoutMs
) throws IOException, JSONException {
ApiResponse response = requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
if (response.statusCode == 401 && !getRestoreToken().isEmpty()) {
ApiResponse restored = restoreSession();
if (restored.ok()) {
if (response.statusCode == 401) {
ApiResponse recovered = !getRestoreToken().isEmpty() ? restoreSession() : autoLogin();
if (recovered.ok()) {
return requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
}
}
@@ -709,7 +819,16 @@ public class BossApiClient {
private ApiResponse executeConnection(HttpURLConnection connection, boolean expectProtected) throws IOException, JSONException {
int statusCode = connection.getResponseCode();
captureSessionCookie(connection.getHeaderFields());
JSONObject json = readJson(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
JsonBody jsonBody = readJsonBody(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
JSONObject json = jsonBody.json;
if (!jsonBody.validJson) {
int normalizedStatusCode = expectProtected && statusCode < 400 ? 401 : statusCode;
json = new JSONObject()
.put("ok", false)
.put("message", "NON_JSON_RESPONSE")
.put("statusCode", statusCode);
statusCode = normalizedStatusCode;
}
if (statusCode == 401 && !expectProtected) {
clearSession();
@@ -822,8 +941,12 @@ public class BossApiClient {
}
private JSONObject readJson(InputStream stream) throws IOException, JSONException {
return readJsonBody(stream).json;
}
private JsonBody readJsonBody(InputStream stream) throws IOException, JSONException {
if (stream == null) {
return new JSONObject();
return new JsonBody(new JSONObject(), true);
}
StringBuilder builder = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
@@ -834,9 +957,13 @@ public class BossApiClient {
}
String raw = builder.toString().trim();
if (raw.isEmpty()) {
return new JSONObject();
return new JsonBody(new JSONObject(), true);
}
try {
return new JsonBody(new JSONObject(raw), true);
} catch (JSONException error) {
return new JsonBody(new JSONObject(), false);
}
return new JSONObject(raw);
}
private String readText(InputStream stream) throws IOException {
@@ -870,9 +997,13 @@ public class BossApiClient {
private void captureSessionCookie(Map<String, List<String>> headers) {
if (headers == null) return;
List<String> setCookieHeaders = headers.get("Set-Cookie");
if (setCookieHeaders == null) {
setCookieHeaders = headers.get("set-cookie");
List<String> setCookieHeaders = null;
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
String headerName = entry.getKey();
if (headerName != null && "set-cookie".equalsIgnoreCase(headerName)) {
setCookieHeaders = entry.getValue();
break;
}
}
if (setCookieHeaders == null) return;
@@ -965,6 +1096,16 @@ public class BossApiClient {
}
}
private static class JsonBody {
final JSONObject json;
final boolean validJson;
JsonBody(JSONObject json, boolean validJson) {
this.json = json;
this.validJson = validJson;
}
}
public static class DownloadedAttachment {
public final int statusCode;
public final String fileName;

View File

@@ -0,0 +1,48 @@
package com.hyzq.boss;
import androidx.annotation.Nullable;
final class BossAppVisibilityTracker {
private volatile boolean appInForeground;
private volatile @Nullable String visibleProjectId;
void onAppForegrounded() {
appInForeground = true;
}
void onAppBackgrounded() {
appInForeground = false;
}
boolean isAppInForeground() {
return appInForeground;
}
void setVisibleProjectId(@Nullable String projectId) {
if (projectId == null) {
visibleProjectId = null;
return;
}
String normalized = projectId.trim();
visibleProjectId = normalized.isEmpty() ? null : normalized;
}
void clearVisibleProjectId(@Nullable String projectId) {
if (visibleProjectId == null) {
return;
}
if (projectId == null) {
visibleProjectId = null;
return;
}
String normalized = projectId.trim();
if (normalized.isEmpty() || visibleProjectId.equals(normalized)) {
visibleProjectId = null;
}
}
@Nullable
String getVisibleProjectId() {
return visibleProjectId;
}
}

View File

@@ -1,13 +1,68 @@
package com.hyzq.boss;
import android.app.Activity;
import android.app.Application;
import android.os.Bundle;
import androidx.appcompat.app.AppCompatDelegate;
public final class BossApplication extends Application {
private final BossAppVisibilityTracker visibilityTracker = new BossAppVisibilityTracker();
private BossNotificationRouter notificationRouter;
private int startedActivityCount;
@Override
public void onCreate() {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
super.onCreate();
notificationRouter = new BossNotificationRouter(this, visibilityTracker);
registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {}
@Override
public void onActivityStarted(Activity activity) {
startedActivityCount += 1;
if (startedActivityCount == 1) {
visibilityTracker.onAppForegrounded();
BossBackgroundRealtimeService.stop(BossApplication.this);
}
}
@Override
public void onActivityResumed(Activity activity) {}
@Override
public void onActivityPaused(Activity activity) {}
@Override
public void onActivityStopped(Activity activity) {
startedActivityCount = Math.max(0, startedActivityCount - 1);
if (startedActivityCount == 0) {
visibilityTracker.onAppBackgrounded();
BossBackgroundRealtimeService.start(BossApplication.this);
}
}
@Override
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {}
@Override
public void onActivityDestroyed(Activity activity) {}
});
}
@Override
public void onTerminate() {
BossBackgroundRealtimeService.stop(this);
super.onTerminate();
}
BossAppVisibilityTracker visibilityTracker() {
return visibilityTracker;
}
BossNotificationRouter notificationRouter() {
return notificationRouter;
}
}

View File

@@ -0,0 +1,156 @@
package com.hyzq.boss;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.IBinder;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
public class BossBackgroundRealtimeService extends Service {
static final String ACTION_START = "com.hyzq.boss.action.START_BACKGROUND_REALTIME";
static final String ACTION_STOP = "com.hyzq.boss.action.STOP_BACKGROUND_REALTIME";
static final String SERVICE_CHANNEL_ID = "boss_background_sync";
static final int SERVICE_NOTIFICATION_ID = 2002;
interface BossRealtimeRuntime {
void start();
void stop();
}
private @Nullable BossApiClient apiClient;
private @Nullable BossRealtimeRuntime realtimeRuntime;
private boolean realtimeStarted;
static void start(Context context) {
Intent intent = new Intent(context, BossBackgroundRealtimeService.class).setAction(ACTION_START);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent);
return;
}
context.startService(intent);
}
static void stop(Context context) {
Intent intent = new Intent(context, BossBackgroundRealtimeService.class).setAction(ACTION_STOP);
context.startService(intent);
}
@Override
public void onCreate() {
super.onCreate();
apiClient = createApiClient();
BossNotificationRouter notificationRouter = createNotificationRouter();
realtimeRuntime = createRealtimeRuntime(apiClient, notificationRouter);
}
BossApiClient createApiClient() {
return new BossApiClient(this);
}
BossNotificationRouter createNotificationRouter() {
BossAppVisibilityTracker tracker = getApplication() instanceof BossApplication
? ((BossApplication) getApplication()).visibilityTracker()
: new BossAppVisibilityTracker();
return new BossNotificationRouter(this, tracker);
}
BossRealtimeRuntime createRealtimeRuntime(BossApiClient apiClient, BossNotificationRouter router) {
BossRealtimeClient realtimeClient = new BossRealtimeClient(apiClient, router::maybeNotifyForRealtimeEvent);
return new BossRealtimeRuntime() {
@Override
public void start() {
realtimeClient.start();
}
@Override
public void stop() {
realtimeClient.stop();
}
};
}
@Override
public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
String action = intent == null ? ACTION_START : intent.getAction();
if (ACTION_STOP.equals(action)) {
stopSelf();
return START_NOT_STICKY;
}
if (apiClient == null || realtimeRuntime == null || !apiClient.hasSessionHints()) {
stopSelf();
return START_NOT_STICKY;
}
startForeground(SERVICE_NOTIFICATION_ID, buildForegroundNotification());
if (!realtimeStarted) {
realtimeRuntime.start();
realtimeStarted = true;
}
return START_STICKY;
}
@Override
public void onDestroy() {
if (realtimeRuntime != null && realtimeStarted) {
realtimeRuntime.stop();
realtimeStarted = false;
}
stopForeground(STOP_FOREGROUND_REMOVE);
super.onDestroy();
}
@Override
public @Nullable IBinder onBind(Intent intent) {
return null;
}
private Notification buildForegroundNotification() {
ensureChannel();
return new NotificationCompat.Builder(this, SERVICE_CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Boss 后台同步中")
.setContentText("主 Agent 新回复会通过系统通知提醒")
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentIntent(buildContentIntent())
.build();
}
private PendingIntent buildContentIntent() {
Intent intent = new Intent(this, MainActivity.class)
.putExtra(MainActivity.EXTRA_INITIAL_TAB, "conversations")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
return PendingIntent.getActivity(
this,
902,
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
}
private void ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationManager notificationManager = getSystemService(NotificationManager.class);
if (notificationManager == null || notificationManager.getNotificationChannel(SERVICE_CHANNEL_ID) != null) {
return;
}
NotificationChannel channel = new NotificationChannel(
SERVICE_CHANNEL_ID,
"Boss 后台同步",
NotificationManager.IMPORTANCE_LOW
);
channel.setDescription("保持主 Agent 后台同步与消息提醒");
notificationManager.createNotificationChannel(channel);
}
}

View File

@@ -4,6 +4,7 @@ import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Color;
import android.graphics.Typeface;
import android.os.Build;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.SpannedString;
@@ -155,8 +156,10 @@ public final class BossMarkdown {
ensureBlockSeparation(builder, false);
int start = builder.length();
appendInlineStyled(builder, TextUtils.isEmpty(text) ? "引用" : text, palette);
builder.setSpan(new QuoteSpan(palette.quoteColor, BossUi.dp(palette.context, 3), BossUi.dp(palette.context, 8)),
start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
QuoteSpan quoteSpan = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
? new QuoteSpan(palette.quoteColor, BossUi.dp(palette.context, 3), BossUi.dp(palette.context, 8))
: new QuoteSpan(palette.quoteColor);
builder.setSpan(quoteSpan, start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.append('\n');
}

View File

@@ -0,0 +1,161 @@
package com.hyzq.boss;
import android.Manifest;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.text.TextUtils;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
import org.json.JSONArray;
import org.json.JSONObject;
final class BossNotificationRouter {
static final String CHANNEL_ID = "boss_master_agent_messages";
static final int MASTER_AGENT_NOTIFICATION_ID = 2001;
private final Context appContext;
private final BossAppVisibilityTracker visibilityTracker;
private @Nullable String lastNotifiedMessageId;
BossNotificationRouter(Context context, BossAppVisibilityTracker visibilityTracker) {
this.appContext = context.getApplicationContext();
this.visibilityTracker = visibilityTracker;
}
boolean maybeNotifyForRealtimeEvent(@Nullable BossRealtimeEvent event) {
NotificationCandidate candidate = latestMasterAgentMessage(event);
if (candidate == null) {
return false;
}
if (candidate.messageId.isEmpty() || TextUtils.equals(candidate.messageId, lastNotifiedMessageId)) {
return false;
}
if (visibilityTracker.isAppInForeground()) {
return false;
}
if (!canPostNotifications()) {
return false;
}
ensureChannel();
try {
NotificationManagerCompat.from(appContext).notify(
MASTER_AGENT_NOTIFICATION_ID,
new NotificationCompat.Builder(appContext, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(candidate.title)
.setContentText(candidate.body)
.setStyle(new NotificationCompat.BigTextStyle().bigText(
candidate.body
))
.setAutoCancel(true)
.setContentIntent(buildContentIntent(candidate))
.build()
);
} catch (SecurityException ignored) {
return false;
}
lastNotifiedMessageId = candidate.messageId;
return true;
}
void resetLastNotifiedMessageId() {
lastNotifiedMessageId = null;
}
void clearMasterAgentNotification() {
NotificationManagerCompat.from(appContext).cancel(MASTER_AGENT_NOTIFICATION_ID);
}
private boolean canPostNotifications() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
&& ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
return false;
}
return NotificationManagerCompat.from(appContext).areNotificationsEnabled();
}
private @Nullable NotificationCandidate latestMasterAgentMessage(@Nullable BossRealtimeEvent event) {
if (event == null || !"project.messages.updated".equals(event.eventName)) {
return null;
}
String projectId = event.payload.optString("projectId", "").trim();
JSONObject projectMessagesPayload = event.payload.optJSONObject("projectMessagesPayload");
JSONObject project = projectMessagesPayload == null ? null : projectMessagesPayload.optJSONObject("project");
JSONArray messages = project == null ? null : project.optJSONArray("messages");
if (messages == null || messages.length() <= 0) {
return null;
}
JSONObject latestMessage = messages.optJSONObject(messages.length() - 1);
if (latestMessage == null) {
return null;
}
String sender = latestMessage.optString("sender", "");
String senderLabel = latestMessage.optString("senderLabel", "");
if (!"master".equals(sender) && !senderLabel.contains("主 Agent")) {
return null;
}
String messageId = latestMessage.optString("id", "").trim();
String projectName = project == null ? "" : project.optString("name", "").trim();
String title = "master-agent".equals(projectId) || projectName.isEmpty()
? "主 Agent"
: "主 Agent · " + projectName;
String body = latestMessage.optString("body", "你有一条新的主 Agent 回复");
return new NotificationCandidate(projectId, projectName, messageId, title, TextUtils.isEmpty(body) ? "你有一条新的主 Agent 回复" : body);
}
private PendingIntent buildContentIntent(NotificationCandidate candidate) {
Intent intent = new Intent(appContext, ProjectDetailActivity.class)
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, candidate.projectId)
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, candidate.projectName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
return PendingIntent.getActivity(
appContext,
901 + Math.abs(candidate.projectId.hashCode() % 97),
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
}
private void ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationManager notificationManager = appContext.getSystemService(NotificationManager.class);
if (notificationManager == null || notificationManager.getNotificationChannel(CHANNEL_ID) != null) {
return;
}
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
"主 Agent 消息",
NotificationManager.IMPORTANCE_DEFAULT
);
channel.setDescription("Boss 主 Agent 后台消息提醒");
notificationManager.createNotificationChannel(channel);
}
private static final class NotificationCandidate {
final String projectId;
final String projectName;
final String messageId;
final String title;
final String body;
NotificationCandidate(String projectId, String projectName, String messageId, String title, String body) {
this.projectId = projectId == null || projectId.trim().isEmpty() ? "master-agent" : projectId.trim();
this.projectName = projectName == null || projectName.trim().isEmpty() ? "主 Agent" : projectName.trim();
this.messageId = messageId == null ? "" : messageId.trim();
this.title = title == null || title.trim().isEmpty() ? "主 Agent" : title.trim();
this.body = body == null || body.trim().isEmpty() ? "你有一条新的主 Agent 回复" : body.trim();
}
}
}

View File

@@ -1,5 +1,6 @@
package com.hyzq.boss;
import android.annotation.SuppressLint;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
@@ -25,9 +26,15 @@ import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.SwitchCompat;
import androidx.core.widget.ImageViewCompat;
import android.content.res.ColorStateList;
import org.json.JSONArray;
import org.json.JSONObject;
import java.util.Locale;
public final class BossUi {
private static final int[] AVATAR_BG_COLORS = {
Color.parseColor("#1EC76F"),
@@ -371,7 +378,64 @@ public final class BossUi {
LinearLayout row = buildListRow(context, title, subtitle, meta, badge, listener);
row.setBackgroundColor(Color.WHITE);
row.setElevation(0f);
row.setPadding(dp(context, 16), dp(context, 13), dp(context, 16), dp(context, 13));
return row;
}
public static LinearLayout buildWechatSwitchRow(
Context context,
String title,
@Nullable String subtitle,
SwitchCompat switchView
) {
LinearLayout row = new LinearLayout(context);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
row.setLayoutParams(params);
row.setBackgroundColor(Color.WHITE);
row.setPadding(dp(context, 18), dp(context, 15), dp(context, 18), dp(context, 15));
LinearLayout textWrap = new LinearLayout(context);
textWrap.setOrientation(LinearLayout.VERTICAL);
textWrap.setLayoutParams(new LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
));
TextView titleView = new TextView(context);
titleView.setText(title);
titleView.setTextSize(17);
titleView.setTypeface(Typeface.DEFAULT_BOLD);
titleView.setTextColor(context.getColor(R.color.boss_text_primary));
textWrap.addView(titleView);
if (!TextUtils.isEmpty(subtitle)) {
TextView subtitleView = new TextView(context);
subtitleView.setText(subtitle);
subtitleView.setTextSize(14);
subtitleView.setTextColor(context.getColor(R.color.boss_text_muted));
subtitleView.setPadding(0, dp(context, 4), 0, 0);
textWrap.addView(subtitleView);
}
row.addView(textWrap);
ViewParent currentParent = switchView.getParent();
if (currentParent instanceof ViewGroup) {
((ViewGroup) currentParent).removeView(switchView);
}
LinearLayout.LayoutParams switchParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
switchParams.leftMargin = dp(context, 12);
switchView.setLayoutParams(switchParams);
row.addView(switchView);
return row;
}
@@ -501,11 +565,11 @@ public final class BossUi {
);
params.leftMargin = dp(context, 12);
params.rightMargin = dp(context, 12);
params.bottomMargin = dp(context, 12);
params.bottomMargin = dp(context, 1);
card.setLayoutParams(params);
card.setPadding(dp(context, 14), dp(context, 14), dp(context, 14), dp(context, 14));
card.setBackground(createRoundedBackground(Color.WHITE, dp(context, 18)));
card.setElevation(dp(context, 1));
card.setPadding(dp(context, 14), dp(context, 13), dp(context, 14), dp(context, 13));
card.setBackgroundColor(Color.WHITE);
card.setElevation(0f);
if (listener != null) {
card.setClickable(true);
card.setFocusable(true);
@@ -944,7 +1008,34 @@ public final class BossUi {
}
public static LinearLayout buildEmptyCard(Context context, String text) {
return buildCard(context, "暂无内容", text, "下拉或点击顶部刷新按钮重试。");
LinearLayout card = new LinearLayout(context);
card.setOrientation(LinearLayout.VERTICAL);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
params.leftMargin = dp(context, 16);
params.rightMargin = dp(context, 16);
params.topMargin = dp(context, 28);
card.setLayoutParams(params);
card.setGravity(Gravity.CENTER_HORIZONTAL);
card.setPadding(dp(context, 16), dp(context, 18), dp(context, 16), dp(context, 18));
TextView titleView = new TextView(context);
titleView.setText("暂无内容");
titleView.setTextSize(16);
titleView.setTypeface(Typeface.DEFAULT_BOLD);
titleView.setTextColor(context.getColor(R.color.boss_text_primary));
card.addView(titleView);
TextView bodyView = new TextView(context);
bodyView.setText(text);
bodyView.setTextSize(13);
bodyView.setGravity(Gravity.CENTER);
bodyView.setTextColor(context.getColor(R.color.boss_text_muted));
bodyView.setPadding(0, dp(context, 8), 0, 0);
card.addView(bodyView);
return card;
}
public static TextView buildHintPill(Context context, String text) {
@@ -965,6 +1056,7 @@ public final class BossUi {
return pill;
}
@SuppressLint("WrongConstant")
public static LinearLayout buildMessageBubble(
Context context,
String senderLabel,
@@ -1006,6 +1098,410 @@ public final class BossUi {
return wrapper;
}
public static LinearLayout buildMasterAgentMessageBubble(
Context context,
String senderLabel,
String body,
@Nullable String meta,
@Nullable String kindLabel
) {
LinearLayout wrapper = buildMessageBubble(context, senderLabel, body, meta, false, kindLabel);
View bubble = findMessageBodyContainer(wrapper);
if (bubble != null) {
GradientDrawable background = createRoundedBackground(Color.parseColor("#EAF5FF"), dp(context, 18));
background.setStroke(dp(context, 1), Color.parseColor("#D1E8FF"));
bubble.setBackground(background);
}
return wrapper;
}
public static LinearLayout buildThreadProcessFoldCard(
Context context,
int itemCount,
@Nullable String preview,
@Nullable String detail
) {
LinearLayout card = new LinearLayout(context);
card.setOrientation(LinearLayout.VERTICAL);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
params.leftMargin = dp(context, 12);
params.rightMargin = dp(context, 12);
params.bottomMargin = dp(context, 10);
card.setLayoutParams(params);
card.setPadding(dp(context, 14), dp(context, 12), dp(context, 14), dp(context, 12));
GradientDrawable background = createRoundedBackground(Color.parseColor("#F7F8F7"), dp(context, 16));
background.setStroke(dp(context, 1), Color.parseColor("#E4E9E5"));
card.setBackground(background);
card.setClickable(true);
card.setFocusable(true);
LinearLayout header = new LinearLayout(context);
header.setOrientation(LinearLayout.HORIZONTAL);
header.setGravity(Gravity.CENTER_VERTICAL);
header.setLayoutParams(new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
TextView titleView = new TextView(context);
titleView.setTextSize(14);
titleView.setTypeface(Typeface.DEFAULT_BOLD);
titleView.setTextColor(context.getColor(R.color.boss_green));
titleView.setLayoutParams(new LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
));
header.addView(titleView);
TextView arrowView = new TextView(context);
arrowView.setTextSize(16);
arrowView.setTypeface(Typeface.DEFAULT_BOLD);
arrowView.setTextColor(context.getColor(R.color.boss_text_muted));
arrowView.setPadding(dp(context, 8), 0, 0, 0);
header.addView(arrowView);
card.addView(header);
TextView previewView = new TextView(context);
previewView.setText(TextUtils.isEmpty(preview) ? "线程正在输出过程内容" : preview);
previewView.setTextSize(13);
previewView.setLineSpacing(0f, 1.24f);
previewView.setTextColor(context.getColor(R.color.boss_text_muted));
previewView.setPadding(0, dp(context, 6), 0, 0);
previewView.setMaxLines(2);
previewView.setEllipsize(TextUtils.TruncateAt.END);
card.addView(previewView);
TextView detailView = new TextView(context);
detailView.setText(detail);
detailView.setTextSize(13);
detailView.setLineSpacing(0f, 1.28f);
detailView.setTextColor(context.getColor(R.color.boss_text_primary));
detailView.setPadding(0, dp(context, 10), 0, 0);
detailView.setVisibility(View.GONE);
card.addView(detailView);
final boolean[] expanded = new boolean[] {false};
View.OnClickListener toggleListener = ignored -> {
expanded[0] = !expanded[0];
titleView.setText((expanded[0] ? "收起本轮工作过程" : "查看本轮工作过程") + "" + itemCount + " 条)");
arrowView.setText(expanded[0] ? "" : "");
detailView.setVisibility(expanded[0] ? View.VISIBLE : View.GONE);
previewView.setVisibility(expanded[0] ? View.GONE : View.VISIBLE);
card.setContentDescription((expanded[0] ? "已展开" : "已折叠") + "本轮工作过程");
};
card.setOnClickListener(toggleListener);
titleView.setText("查看本轮工作过程(" + itemCount + " 条)");
arrowView.setText("");
card.setContentDescription("已折叠本轮工作过程");
return card;
}
public static LinearLayout buildControlSummaryCard(
Context context,
String title,
String body,
@Nullable String meta,
@Nullable String badge
) {
LinearLayout card = buildCard(context, title, body, meta, null);
card.setPadding(dp(context, 16), dp(context, 14), dp(context, 16), dp(context, 14));
card.setBackground(createRoundedBackground(Color.parseColor("#F7FAF7"), dp(context, 18)));
GradientDrawable background = (GradientDrawable) card.getBackground();
background.setStroke(dp(context, 1), Color.parseColor("#DCEBDD"));
if (!TextUtils.isEmpty(badge)) {
View badgeView = buildHintPill(context, badge);
if (badgeView.getParent() != null) {
((ViewGroup) badgeView.getParent()).removeView(badgeView);
}
card.addView(badgeView, 0);
}
return card;
}
public static LinearLayout buildExecutionProgressCard(
Context context,
@Nullable JSONObject progress,
@Nullable String meta
) {
LinearLayout card = new LinearLayout(context);
card.setOrientation(LinearLayout.VERTICAL);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
params.leftMargin = dp(context, 12);
params.rightMargin = dp(context, 12);
params.bottomMargin = dp(context, 12);
card.setLayoutParams(params);
card.setPadding(dp(context, 18), dp(context, 16), dp(context, 18), dp(context, 16));
GradientDrawable background = createRoundedBackground(Color.WHITE, dp(context, 22));
background.setStroke(dp(context, 1), Color.parseColor("#E5E9E7"));
card.setBackground(background);
card.setElevation(dp(context, 1));
LinearLayout titleRow = new LinearLayout(context);
titleRow.setOrientation(LinearLayout.HORIZONTAL);
titleRow.setGravity(Gravity.CENTER_VERTICAL);
titleRow.setLayoutParams(new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
));
TextView title = sectionTitle(context, "进度");
title.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
titleRow.addView(title);
TextView pin = new TextView(context);
pin.setText("");
pin.setTextSize(18);
pin.setTextColor(Color.parseColor("#9AA09D"));
titleRow.addView(pin);
card.addView(titleRow);
JSONArray steps = progress == null ? null : progress.optJSONArray("steps");
if (steps == null || steps.length() == 0) {
card.addView(progressLine(context, "等待执行进度回写", "running"));
} else {
for (int i = 0; i < steps.length(); i += 1) {
JSONObject step = steps.optJSONObject(i);
if (step == null) {
continue;
}
String text = step.optString("text", "").trim();
if (TextUtils.isEmpty(text)) {
continue;
}
card.addView(progressLine(context, text, step.optString("status", "pending")));
}
}
card.addView(divider(context));
card.addView(sectionTitle(context, "分支详情"));
JSONObject branch = progress == null ? null : progress.optJSONObject("branch");
if (branch != null) {
String changeText = formatChangeText(branch);
if (!TextUtils.isEmpty(changeText)) {
card.addView(changeRow(context, branch));
}
String gitStatus = branch.optString("gitStatus", "").trim();
card.addView(detailRow(context, "", TextUtils.isEmpty(gitStatus) ? "Git 操作" : gitStatus, "", false));
String ghStatus = branch.optString("githubCliStatus", "").trim();
if ("unavailable".equals(ghStatus)) {
card.addView(detailRow(context, "", "GitHub CLI 不可用", "", false, true));
} else if ("available".equals(ghStatus)) {
card.addView(detailRow(context, "", "GitHub CLI 可用", "", false));
}
} else {
card.addView(detailRow(context, "", "Git 操作", "等待执行器回写", false));
}
JSONArray artifacts = progress == null ? null : progress.optJSONArray("artifacts");
if (artifacts != null && artifacts.length() > 0) {
card.addView(divider(context));
card.addView(sectionTitle(context, "生成结果"));
int shown = 0;
for (int i = 0; i < artifacts.length(); i += 1) {
JSONObject artifact = artifacts.optJSONObject(i);
String label = artifact == null ? "" : artifact.optString("label", "").trim();
if (TextUtils.isEmpty(label)) {
continue;
}
String icon = "image".equals(artifact.optString("kind", "")) ? "" : "";
card.addView(detailRow(context, icon, label, "", false));
shown += 1;
if (shown >= 6) {
break;
}
}
if (artifacts.length() > shown) {
card.addView(detailRow(context, "", "再显示 " + (artifacts.length() - shown) + "", "", false, true));
}
}
JSONArray agents = progress == null ? null : progress.optJSONArray("agents");
if (agents != null && agents.length() > 0) {
card.addView(divider(context));
card.addView(sectionTitle(context, "后台智能体"));
for (int i = 0; i < agents.length(); i += 1) {
JSONObject agent = agents.optJSONObject(i);
String name = agent == null ? "" : agent.optString("name", "").trim();
if (TextUtils.isEmpty(name)) {
continue;
}
String role = agent.optString("role", "").trim();
card.addView(detailRow(
context,
"",
TextUtils.isEmpty(role) ? name : name + "" + role + "",
"",
false
));
}
}
if (!TextUtils.isEmpty(meta)) {
TextView metaView = secondaryText(context, meta);
metaView.setPadding(0, dp(context, 10), 0, 0);
card.addView(metaView);
}
return card;
}
private static View progressLine(Context context, String text, String status) {
LinearLayout row = new LinearLayout(context);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.TOP);
row.setPadding(0, dp(context, 10), 0, 0);
TextView check = new TextView(context);
LinearLayout.LayoutParams checkParams = new LinearLayout.LayoutParams(dp(context, 28), dp(context, 28));
check.setLayoutParams(checkParams);
check.setGravity(Gravity.CENTER);
check.setText("");
check.setTextSize(15);
check.setTypeface(Typeface.DEFAULT_BOLD);
int color = "failed".equals(status) ? Color.parseColor("#E44B4B") :
"running".equals(status) ? Color.parseColor("#1EC76F") : Color.parseColor("#9AA09D");
check.setTextColor(Color.WHITE);
check.setBackground(createRoundedBackground(color, dp(context, 14)));
row.addView(check);
TextView body = new TextView(context);
LinearLayout.LayoutParams bodyParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f);
bodyParams.leftMargin = dp(context, 12);
body.setLayoutParams(bodyParams);
body.setText(text);
body.setTextSize(18);
body.setLineSpacing(0f, 1.22f);
body.setTextColor(context.getColor(R.color.boss_text_primary));
row.addView(body);
return row;
}
private static View divider(Context context) {
View divider = new View(context);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
dp(context, 1)
);
params.topMargin = dp(context, 16);
params.bottomMargin = dp(context, 14);
divider.setLayoutParams(params);
divider.setBackgroundColor(Color.parseColor("#ECEFEE"));
return divider;
}
private static TextView sectionTitle(Context context, String text) {
TextView view = new TextView(context);
view.setText(text);
view.setTextSize(17);
view.setTypeface(Typeface.DEFAULT_BOLD);
view.setTextColor(Color.parseColor("#8B918F"));
return view;
}
private static TextView secondaryText(Context context, String text) {
TextView view = new TextView(context);
view.setText(text);
view.setTextSize(12);
view.setTextColor(context.getColor(R.color.boss_text_muted));
return view;
}
private static View detailRow(Context context, String iconText, String label, String value, boolean valueIsChange) {
return detailRow(context, iconText, label, value, valueIsChange, false);
}
private static View detailRow(Context context, String iconText, String label, String value, boolean valueIsChange, boolean muted) {
LinearLayout row = new LinearLayout(context);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
row.setPadding(0, dp(context, 8), 0, 0);
TextView icon = new TextView(context);
icon.setText(iconText);
icon.setGravity(Gravity.CENTER);
icon.setTextSize(18);
icon.setTextColor(muted ? Color.parseColor("#9AA09D") : context.getColor(R.color.boss_text_primary));
row.addView(icon, new LinearLayout.LayoutParams(dp(context, 34), LinearLayout.LayoutParams.WRAP_CONTENT));
TextView labelView = new TextView(context);
labelView.setText(label);
labelView.setTextSize(17);
labelView.setTextColor(muted ? Color.parseColor("#8B918F") : context.getColor(R.color.boss_text_primary));
row.addView(labelView, new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
if (!TextUtils.isEmpty(value)) {
TextView valueView = new TextView(context);
valueView.setText(value);
valueView.setTextSize(16);
valueView.setTextColor(valueIsChange ? Color.parseColor("#00A94F") : context.getColor(R.color.boss_text_muted));
row.addView(valueView);
}
return row;
}
private static View changeRow(Context context, JSONObject branch) {
LinearLayout row = new LinearLayout(context);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.CENTER_VERTICAL);
row.setPadding(0, dp(context, 8), 0, 0);
TextView icon = new TextView(context);
icon.setText("");
icon.setGravity(Gravity.CENTER);
icon.setTextSize(18);
icon.setTextColor(context.getColor(R.color.boss_text_primary));
row.addView(icon, new LinearLayout.LayoutParams(dp(context, 34), LinearLayout.LayoutParams.WRAP_CONTENT));
TextView labelView = new TextView(context);
labelView.setText("变更");
labelView.setTextSize(17);
labelView.setTextColor(context.getColor(R.color.boss_text_primary));
row.addView(labelView, new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
int additions = branch.optInt("additions", 0);
if (additions > 0) {
TextView additionsView = new TextView(context);
additionsView.setText("+" + String.format(Locale.US, "%,d", additions));
additionsView.setTextSize(16);
additionsView.setTextColor(Color.parseColor("#00A94F"));
row.addView(additionsView);
}
int deletions = branch.optInt("deletions", 0);
if (deletions > 0) {
TextView deletionsView = new TextView(context);
deletionsView.setText("-" + String.format(Locale.US, "%,d", deletions));
deletionsView.setTextSize(16);
deletionsView.setTextColor(Color.parseColor("#C52828"));
LinearLayout.LayoutParams deletionParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
deletionParams.leftMargin = dp(context, 8);
row.addView(deletionsView, deletionParams);
}
return row;
}
private static String formatChangeText(JSONObject branch) {
int additions = branch.optInt("additions", 0);
int deletions = branch.optInt("deletions", 0);
StringBuilder builder = new StringBuilder();
if (additions > 0) {
builder.append("+").append(String.format(Locale.US, "%,d", additions));
}
if (deletions > 0) {
if (builder.length() > 0) {
builder.append(" ");
}
builder.append("-").append(String.format(Locale.US, "%,d", deletions));
}
return builder.toString();
}
public static LinearLayout buildAttachmentMessageCard(
Context context,
String senderLabel,
@@ -1160,6 +1656,23 @@ public final class BossUi {
return wrapper;
}
public static LinearLayout buildMasterAgentForwardSingleBubble(
Context context,
String senderLabel,
String body,
@Nullable String meta,
@Nullable String sourceLabel
) {
LinearLayout wrapper = buildForwardSingleBubble(context, senderLabel, body, meta, sourceLabel, false);
View bubble = findMessageBodyContainer(wrapper);
if (bubble != null) {
GradientDrawable background = createRoundedBackground(Color.parseColor("#EAF5FF"), dp(context, 18));
background.setStroke(dp(context, 1), Color.parseColor("#D1E8FF"));
bubble.setBackground(background);
}
return wrapper;
}
public static LinearLayout buildForwardBundleCard(
Context context,
String senderLabel,
@@ -1235,6 +1748,14 @@ public final class BossUi {
return buildMessageBubble(context, effectiveSender, body, "发送中", true, null);
}
@Nullable
private static View findMessageBodyContainer(LinearLayout wrapper) {
if (wrapper == null || wrapper.getChildCount() < 2) {
return null;
}
return wrapper.getChildAt(1);
}
public static void applyMessageSelectionState(Context context, View messageView, boolean selected) {
if (messageView == null) {
return;
@@ -1248,6 +1769,61 @@ public final class BossUi {
}
}
public static View wrapIncomingMessageWithSourceAvatar(
Context context,
View messageView,
@Nullable String avatarLabel,
@Nullable String sourceName
) {
if (messageView == null || TextUtils.isEmpty(avatarLabel)) {
return messageView;
}
ViewParent parent = messageView.getParent();
if (parent instanceof ViewGroup) {
((ViewGroup) parent).removeView(messageView);
}
LinearLayout row = new LinearLayout(context);
row.setOrientation(LinearLayout.HORIZONTAL);
row.setGravity(Gravity.TOP);
LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
row.setLayoutParams(rowParams);
TextView avatar = buildAvatarCircle(
context,
avatarLabel,
Color.parseColor("#E5F6EC"),
context.getColor(R.color.boss_green),
36
);
LinearLayout.LayoutParams avatarParams = new LinearLayout.LayoutParams(dp(context, 36), dp(context, 36));
avatarParams.leftMargin = dp(context, 8);
avatarParams.rightMargin = dp(context, 8);
avatarParams.topMargin = dp(context, 16);
avatar.setLayoutParams(avatarParams);
if (!TextUtils.isEmpty(sourceName)) {
avatar.setContentDescription("来自 " + sourceName);
}
row.addView(avatar);
LinearLayout.LayoutParams contentParams = new LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f
);
if (messageView.getLayoutParams() instanceof LinearLayout.LayoutParams) {
LinearLayout.LayoutParams previous = (LinearLayout.LayoutParams) messageView.getLayoutParams();
contentParams.topMargin = previous.topMargin;
contentParams.bottomMargin = previous.bottomMargin;
}
messageView.setLayoutParams(contentParams);
row.addView(messageView);
return row;
}
public static TextView buildMessagePlaceholder(Context context, String text) {
TextView placeholder = new TextView(context);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
@@ -1327,9 +1903,6 @@ public final class BossUi {
@Nullable String meta,
boolean outgoing
) {
if (outgoing && !TextUtils.isEmpty(meta)) {
return meta;
}
if (TextUtils.isEmpty(meta)) {
return senderLabel;
}

View File

@@ -18,6 +18,7 @@ import java.util.Map;
public class ConversationInfoActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name";
public static final String EXTRA_TAKEOVER_ENABLED = "takeover_enabled";
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private String projectId;
@@ -178,17 +179,9 @@ public class ConversationInfoActivity extends BossScreenActivity {
takeoverInheritedFromGlobal = agentControls != null && agentControls.optBoolean("takeoverInheritedFromGlobal", false);
configureScreen("会话信息", buildSubtitle(threadMeta, participantCount));
appendContent(BossUi.buildSimpleProfileHeader(
this,
projectName,
"单线程会话",
buildHeaderDetail(project, threadMeta, participantCount)
));
appendThreadStatusSummary(threadStatusPayload);
appendTakeoverControl();
appendContent(BossUi.buildWechatMenuRow(
appendConversationInfoItem(BossUi.buildWechatMenuRow(
this,
"发起群聊",
"选择其他线程加入新群",
@@ -197,7 +190,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
v -> openGroupCreate()
));
appendContent(BossUi.buildWechatMenuRow(
appendConversationInfoItem(BossUi.buildWechatMenuRow(
this,
"线程详情",
"查看当前线程聊天与项目",
@@ -206,7 +199,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
v -> openProject(projectId, projectName)
));
appendContent(BossUi.buildWechatMenuRow(
appendConversationInfoItem(BossUi.buildWechatMenuRow(
this,
"线程状态",
"状态文档和最近进展事件",
@@ -215,7 +208,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
v -> openThreadStatus()
));
appendContent(BossUi.buildWechatMenuRow(
appendConversationInfoItem(BossUi.buildWechatMenuRow(
this,
"参与线程",
participantCount <= 0 ? "暂无参与线程" : "" + participantCount + "",
@@ -225,7 +218,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
));
if (participants == null || participants.length() == 0) {
appendContent(BossUi.buildWechatMenuRow(
appendConversationInfoItem(BossUi.buildWechatMenuRow(
this,
"暂无参与线程",
"下拉刷新后重试",
@@ -237,7 +230,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
for (int i = 0; i < participants.length(); i++) {
JSONObject participant = participants.optJSONObject(i);
if (participant == null) continue;
appendContent(buildParticipantRow(participant));
appendConversationInfoItem(buildParticipantRow(participant));
}
}
@@ -246,86 +239,34 @@ public class ConversationInfoActivity extends BossScreenActivity {
private void appendTakeoverControl() {
SwitchCompat takeoverSwitch = new SwitchCompat(this);
takeoverSwitch.setText("开启");
takeoverSwitch.setShowText(false);
takeoverSwitch.setText(null);
takeoverSwitch.setChecked(takeoverEnabled);
takeoverSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> saveTakeoverSetting(isChecked));
appendContent(BossUi.buildFormCell(
appendConversationInfoItem(BossUi.buildWechatSwitchRow(
this,
"主 Agent 协同接管",
takeoverInheritedFromGlobal
? "当前跟随全局默认开启。主 Agent 会协同推进,但不会抢走你直接控制线程开发的能力。"
: "这个线程单独开启主 Agent 协同推进。不会抢走你直接控制线程开发的能力。",
? "跟随全局默认开启"
: "线程单独开启",
takeoverSwitch
));
}
private void appendThreadStatusSummary(@Nullable JSONObject threadStatusPayload) {
if (threadStatusPayload == null) {
return;
private void appendConversationInfoItem(android.view.View view) {
android.view.ViewGroup.LayoutParams currentParams = view.getLayoutParams();
LinearLayout.LayoutParams params;
if (currentParams instanceof LinearLayout.LayoutParams) {
params = (LinearLayout.LayoutParams) currentParams;
} else {
params = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
);
}
JSONObject document = threadStatusPayload.optJSONObject("threadStatusDocument");
if (document == null) {
return;
}
JSONArray recentProgressEvents = threadStatusPayload.optJSONArray("recentProgressEvents");
int eventCount = recentProgressEvents == null ? 0 : recentProgressEvents.length();
String body = buildThreadStatusSummaryBody(document, eventCount);
String meta = buildThreadStatusSummaryMeta(document, eventCount);
appendContent(BossUi.buildCard(this, "线程状态摘要", body, meta));
}
private String buildThreadStatusSummaryBody(JSONObject document, int eventCount) {
return joinNonEmptyLines(
formatSummaryLine("当前目标", document.optString("projectGoal", "")),
formatSummaryLine("当前进度", document.optString("currentProgress", "")),
formatSummaryLine("当前阻塞", document.optString("currentBlockers", "")),
formatSummaryLine("建议下一步", document.optString("recommendedNextStep", "")),
eventCount > 0 ? "最近进展:" + eventCount + "" : ""
);
}
private String buildThreadStatusSummaryMeta(JSONObject document, int eventCount) {
return joinNonEmptyParts(
projectFolderName,
eventCount > 0 ? "最近 " + eventCount + " 条进展" : "暂无进展",
document.optString("updatedAt", "").isEmpty() ? "" : "更新于 " + document.optString("updatedAt", "")
);
}
private String formatSummaryLine(String label, String value) {
String trimmed = value == null ? "" : value.trim();
if (trimmed.isEmpty()) {
return "";
}
return label + "" + trimmed;
}
private String joinNonEmptyLines(String... values) {
StringBuilder builder = new StringBuilder();
for (String value : values) {
if (value == null || value.trim().isEmpty()) {
continue;
}
if (builder.length() > 0) {
builder.append('\n');
}
builder.append(value.trim());
}
return builder.toString();
}
private String joinNonEmptyParts(String... values) {
StringBuilder builder = new StringBuilder();
for (String value : values) {
if (value == null || value.trim().isEmpty()) {
continue;
}
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(value.trim());
}
return builder.toString();
params.bottomMargin = BossUi.dp(this, 8);
view.setLayoutParams(params);
appendContent(view);
}
private LinearLayout buildParticipantRow(JSONObject participant) {
@@ -445,6 +386,10 @@ public class ConversationInfoActivity extends BossScreenActivity {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
Intent result = new Intent();
result.putExtra(EXTRA_PROJECT_NAME, projectName);
result.putExtra(EXTRA_TAKEOVER_ENABLED, enabled);
setResult(RESULT_OK, result);
showMessage(enabled ? "已开启主 Agent 协同接管" : "已关闭主 Agent 协同接管");
reload();
});
@@ -517,25 +462,6 @@ public class ConversationInfoActivity extends BossScreenActivity {
return folder + " · " + suffix;
}
private String buildHeaderDetail(JSONObject project, @Nullable JSONObject threadMeta, int count) {
StringBuilder builder = new StringBuilder();
String threadId = resolveThreadId(project, threadMeta);
if (!threadId.isEmpty()) {
builder.append(threadId);
}
if (!projectFolderName.isEmpty()) {
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(projectFolderName);
}
if (builder.length() > 0) {
builder.append(" · ");
}
builder.append(count <= 0 ? "暂无参与线程" : count + " 个参与线程");
return builder.toString();
}
private String resolveThreadId(JSONObject project, @Nullable JSONObject threadMeta) {
if (threadMeta != null) {
String threadId = threadMeta.optString("threadId", "");

View File

@@ -1,11 +1,17 @@
package com.hyzq.boss;
import android.Manifest;
import android.content.res.ColorStateList;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.View;
@@ -23,6 +29,7 @@ import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.DiffUtil;
@@ -45,14 +52,17 @@ import java.util.function.Supplier;
public class MainActivity extends AppCompatActivity {
public static final String EXTRA_INITIAL_TAB = "initial_tab";
private static final int REQUEST_POST_NOTIFICATIONS = 2101;
private static final String UI_PREFS = "boss_native_client";
private static final String KEY_LAST_ROOT_TAB = "last_root_tab";
private static final String KEY_NOTIFICATION_PERMISSION_REQUESTED = "notification_permission_requested";
private static final long ROOT_BACK_EXIT_WINDOW_MS = 1_500L;
private static final long CONVERSATION_AUTO_REFRESH_MS = 12_000L;
private static final long REALTIME_REFRESH_DEBOUNCE_MS = 350L;
private static final long REALTIME_REFRESH_THROTTLE_MS = 900L;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final ExecutorService sessionExecutor = Executors.newSingleThreadExecutor();
private final Handler uiHandler = new Handler(Looper.getMainLooper());
private BossApiClient apiClient;
@@ -64,7 +74,16 @@ public class MainActivity extends AppCompatActivity {
private View mainTopBar;
private TextView loginTitle;
private TextView loginHint;
private EditText loginAccountInput;
private EditText loginPasswordInput;
private EditText loginConfirmPasswordInput;
private EditText loginCodeInput;
private View loginCodeRow;
private Button loginSendCodeButton;
private Button loginButton;
private Button loginModeButton;
private Button registerModeButton;
private Button forgotModeButton;
private ProgressBar loginProgress;
private ImageButton backButton;
@@ -92,6 +111,7 @@ public class MainActivity extends AppCompatActivity {
private String activeTab = "conversations";
private String preferredEntryTab = "conversations";
private @Nullable String requestedInitialTab;
private String authMode = "login";
private boolean userSelectedTab = false;
private long lastRootBackPressedAt = 0L;
private @Nullable JSONObject sessionData;
@@ -107,9 +127,11 @@ public class MainActivity extends AppCompatActivity {
private boolean conversationQuickActionsVisible = false;
private boolean conversationAutoRefreshArmed = false;
private boolean conversationAutoRefreshEnabled = false;
private boolean conversationRootUsesGroupedHomeFeed = false;
private boolean rootTabRefreshInFlight = false;
private boolean pendingRootTabRefresh = false;
private boolean realtimeRefreshScheduled = false;
private boolean notificationPermissionRequestScheduled = false;
private final java.util.HashMap<String, Long> recentRealtimeEventTimestamps = new java.util.HashMap<>();
private final Set<String> selectedConversationProjectIds = new LinkedHashSet<>();
private @Nullable RootPagerAdapter rootPagerAdapter;
@@ -142,8 +164,21 @@ public class MainActivity extends AppCompatActivity {
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
apiClient = new BossApiClient(this);
realtimeClient = new BossRealtimeClient(apiClient, new BossRealtimeClient.Listener() {
apiClient = createApiClient();
realtimeClient = createRealtimeClient(apiClient);
bindViews();
bindActions();
configureBackNavigation();
applyInitialTab(getIntent());
bootstrapSession();
}
BossApiClient createApiClient() {
return new BossApiClient(this);
}
BossRealtimeClient createRealtimeClient(BossApiClient client) {
return new BossRealtimeClient(client, new BossRealtimeClient.Listener() {
@Override
public void onRealtimeEvent(BossRealtimeEvent event) {
handleRealtimeEvent(event);
@@ -154,10 +189,6 @@ public class MainActivity extends AppCompatActivity {
runOnUiThread(() -> handleRealtimeConnectionChanged(connected));
}
});
bindViews();
bindActions();
applyInitialTab(getIntent());
bootstrapSession();
}
@Override
@@ -171,32 +202,44 @@ public class MainActivity extends AppCompatActivity {
}
}
@Override
public void onBackPressed() {
private void configureBackNavigation() {
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
if (handleRootBackPressed()) {
return;
}
setEnabled(false);
getOnBackPressedDispatcher().onBackPressed();
}
});
}
private boolean handleRootBackPressed() {
if (contentPanel.getVisibility() == View.VISIBLE && conversationSearchMode) {
exitConversationSearchMode(true);
return;
return true;
}
if (contentPanel.getVisibility() == View.VISIBLE && conversationQuickActionsVisible) {
hideConversationQuickActions(true);
return;
return true;
}
if (contentPanel.getVisibility() == View.VISIBLE && !"conversations".equals(activeTab)) {
setActiveTab("conversations", false);
persistLastRootTab("conversations");
return;
return true;
}
if (contentPanel.getVisibility() == View.VISIBLE) {
long now = System.currentTimeMillis();
if (now - lastRootBackPressedAt < ROOT_BACK_EXIT_WINDOW_MS) {
moveTaskToBack(true);
return;
return true;
}
lastRootBackPressedAt = now;
showMessage("再按一次返回,应用进入后台");
return;
return true;
}
super.onBackPressed();
return false;
}
@Override
@@ -204,6 +247,7 @@ public class MainActivity extends AppCompatActivity {
cancelConversationAutoRefresh();
cancelRealtimeRefreshSchedule();
stopRealtimeUpdates();
sessionExecutor.shutdownNow();
executor.shutdownNow();
super.onDestroy();
}
@@ -214,6 +258,17 @@ public class MainActivity extends AppCompatActivity {
conversationAutoRefreshEnabled = true;
updateConversationAutoRefresh();
updateRealtimeSubscription();
maybeRequestNotificationPermission();
if (
contentPanel != null &&
contentPanel.getVisibility() == View.VISIBLE &&
"conversations".equals(activeTab) &&
apiClient != null &&
apiClient.hasSessionHints() &&
!rootTabRefreshInFlight
) {
refreshConversationsData();
}
}
@Override
@@ -232,7 +287,16 @@ public class MainActivity extends AppCompatActivity {
mainTopBar = findViewById(R.id.main_top_bar);
loginTitle = findViewById(R.id.login_title);
loginHint = findViewById(R.id.login_hint);
loginAccountInput = findViewById(R.id.login_account_input);
loginPasswordInput = findViewById(R.id.login_password_input);
loginConfirmPasswordInput = findViewById(R.id.login_confirm_password_input);
loginCodeInput = findViewById(R.id.login_code_input);
loginCodeRow = findViewById(R.id.login_code_row);
loginSendCodeButton = findViewById(R.id.login_send_code_button);
loginButton = findViewById(R.id.login_button);
loginModeButton = findViewById(R.id.login_mode_button);
registerModeButton = findViewById(R.id.register_mode_button);
forgotModeButton = findViewById(R.id.forgot_mode_button);
loginProgress = findViewById(R.id.login_progress);
backButton = findViewById(R.id.back_button);
topTitle = findViewById(R.id.top_title);
@@ -259,12 +323,17 @@ public class MainActivity extends AppCompatActivity {
loginTitle.setText(WechatSurfaceMapper.loginTitle());
loginHint.setText(WechatSurfaceMapper.loginHintText());
loginButton.setText(WechatSurfaceMapper.loginButtonLabel());
setAuthMode("login", WechatSurfaceMapper.loginHintText());
BossWindowInsets.applyStatusBarInset(loginShell);
BossWindowInsets.applyStatusBarInset(mainTopBar);
}
private void bindActions() {
loginButton.setOnClickListener(v -> performAutoLogin());
loginButton.setOnClickListener(v -> performPrimaryAuthAction());
loginSendCodeButton.setOnClickListener(v -> sendAuthVerificationCode());
loginModeButton.setOnClickListener(v -> setAuthMode("login", "请输入账号和密码登录。"));
registerModeButton.setOnClickListener(v -> setAuthMode("register", "注册后会自动登录并进入会话。"));
forgotModeButton.setOnClickListener(v -> setAuthMode("forgot", "通过验证码重置密码后再登录。"));
backButton.setVisibility(View.GONE);
backButton.setOnClickListener(v -> {
if (conversationSearchMode) {
@@ -348,7 +417,7 @@ public class MainActivity extends AppCompatActivity {
}
setLoginLoading(true, "正在恢复上次登录状态...");
executor.execute(() -> {
sessionExecutor.execute(() -> {
try {
BossApiClient.ApiResponse sessionResponse = apiClient.getSession();
if (!sessionResponse.ok()) {
@@ -365,15 +434,52 @@ public class MainActivity extends AppCompatActivity {
} catch (Exception ignored) {
// Fall back to login panel.
}
runOnUiThread(() -> setLoginLoading(false, WechatSurfaceMapper.loginHintText()));
runOnUiThread(() -> setLoginLoading(false, "登录已过期,请重新输入账号密码。"));
});
}
private void performAutoLogin() {
setLoginLoading(true, "正在创建会话...");
executor.execute(() -> {
private void performPrimaryAuthAction() {
String account = inputText(loginAccountInput);
String password = inputText(loginPasswordInput);
String confirmPassword = inputText(loginConfirmPasswordInput);
String code = inputText(loginCodeInput);
if (account.isEmpty()) {
setLoginLoading(false, "请先填写账号。");
return;
}
if (password.isEmpty()) {
setLoginLoading(false, "请先填写密码。");
return;
}
if (!"login".equals(authMode) && confirmPassword.isEmpty()) {
setLoginLoading(false, "请再次确认密码。");
return;
}
if (!"login".equals(authMode) && !password.equals(confirmPassword)) {
setLoginLoading(false, "两次输入的密码不一致。");
return;
}
if (!"login".equals(authMode) && code.isEmpty()) {
setLoginLoading(false, "请先填写验证码。");
return;
}
if ("register".equals(authMode)) {
performRegisterAndLogin(account, password, confirmPassword, code);
return;
}
if ("forgot".equals(authMode)) {
performPasswordReset(account, password, confirmPassword, code);
return;
}
performPasswordLogin(account, password);
}
private void performPasswordLogin(String account, String password) {
setLoginLoading(true, "正在登录...");
sessionExecutor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.autoLogin();
BossApiClient.ApiResponse response = apiClient.loginWithPassword(account, password);
if (response.ok()) {
JSONObject session = response.json.optJSONObject("session");
runOnUiThread(() -> {
@@ -389,6 +495,78 @@ public class MainActivity extends AppCompatActivity {
});
}
private void performRegisterAndLogin(String account, String password, String confirmPassword, String code) {
setLoginLoading(true, "正在注册...");
sessionExecutor.execute(() -> {
try {
BossApiClient.ApiResponse registerResponse = apiClient.registerAccount(
account,
password,
confirmPassword,
code
);
if (!registerResponse.ok()) {
runOnUiThread(() -> setLoginLoading(false, "注册失败:" + registerResponse.message()));
return;
}
BossApiClient.ApiResponse loginResponse = apiClient.loginWithPassword(account, password);
if (loginResponse.ok()) {
JSONObject session = loginResponse.json.optJSONObject("session");
runOnUiThread(() -> {
showContent();
refreshAllData(session);
});
return;
}
runOnUiThread(() -> {
setAuthMode("login", "注册成功,请用刚才的账号密码登录。");
setLoginLoading(false, "注册成功,请用刚才的账号密码登录。");
});
} catch (Exception error) {
runOnUiThread(() -> setLoginLoading(false, "注册链路异常:" + error.getMessage()));
}
});
}
private void performPasswordReset(String account, String password, String confirmPassword, String code) {
setLoginLoading(true, "正在重置密码...");
sessionExecutor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.resetPassword(account, password, confirmPassword, code);
if (response.ok()) {
runOnUiThread(() -> {
clearSecretInputs();
setAuthMode("login", "密码已重置,请使用新密码登录。");
});
return;
}
runOnUiThread(() -> setLoginLoading(false, "重置失败:" + response.message()));
} catch (Exception error) {
runOnUiThread(() -> setLoginLoading(false, "重置链路异常:" + error.getMessage()));
}
});
}
private void sendAuthVerificationCode() {
String account = inputText(loginAccountInput);
if (account.isEmpty()) {
setLoginLoading(false, "请先填写账号。");
return;
}
String purpose = "forgot".equals(authMode) ? "forgot-password" : "register";
setLoginLoading(true, "正在发送验证码...");
sessionExecutor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.sendVerificationCode(account, purpose);
runOnUiThread(() -> setLoginLoading(false, response.ok()
? "验证码已发送,请查看对应邮箱或短信。"
: "验证码发送失败:" + response.message()));
} catch (Exception error) {
runOnUiThread(() -> setLoginLoading(false, "验证码链路异常:" + error.getMessage()));
}
});
}
void refreshCurrentTab() {
if (rootTabRefreshInFlight) {
pendingRootTabRefresh = true;
@@ -413,9 +591,11 @@ public class MainActivity extends AppCompatActivity {
JSONObject session = ensureActiveSession();
BossApiClient.ApiResponse conversations = null;
boolean conversationsOk = false;
boolean usedGroupedHomeFeed = false;
try {
conversations = apiClient.getConversationHome();
conversationsOk = conversations.ok();
usedGroupedHomeFeed = conversationsOk;
} catch (Exception ignored) {
conversationsOk = false;
}
@@ -425,6 +605,7 @@ public class MainActivity extends AppCompatActivity {
if (fallbackConversations.ok()) {
conversations = fallbackConversations;
conversationsOk = true;
usedGroupedHomeFeed = false;
}
} catch (Exception ignored) {
conversationsOk = false;
@@ -433,18 +614,24 @@ public class MainActivity extends AppCompatActivity {
BossApiClient.ApiResponse finalConversations = conversations;
final boolean finalConversationsOk = conversationsOk;
final boolean finalUsedGroupedHomeFeed = usedGroupedHomeFeed;
runOnUiThread(() -> {
sessionData = session;
JSONArray refreshedConversations = finalConversations == null
? null
: WechatSurfaceMapper.normalizeConversationHomeFeed(
finalConversations.json.optJSONArray("conversations")
);
: finalUsedGroupedHomeFeed
? finalConversations.json.optJSONArray("conversations")
: WechatSurfaceMapper.normalizeConversationHomeFeed(
finalConversations.json.optJSONArray("conversations")
);
conversationsData = WechatSurfaceMapper.resolveRefreshValue(
conversationsData,
refreshedConversations,
finalConversationsOk
);
if (finalConversationsOk) {
conversationRootUsesGroupedHomeFeed = finalUsedGroupedHomeFeed;
}
maybeApplyPreferredEntry();
renderCurrentTab();
startRefreshing(false);
@@ -612,7 +799,11 @@ public class MainActivity extends AppCompatActivity {
return false;
}
JSONObject conversationItem = event.payload.optJSONObject("conversationItem");
if (conversationItem == null) {
JSONObject threadConversationItem = event.payload.optJSONObject("threadConversationItem");
JSONObject patchItem = conversationRootUsesGroupedHomeFeed
? (conversationItem != null ? conversationItem : threadConversationItem)
: (threadConversationItem != null ? threadConversationItem : conversationItem);
if (patchItem == null) {
return false;
}
runOnUiThread(() -> {
@@ -622,7 +813,7 @@ public class MainActivity extends AppCompatActivity {
}
conversationsData = WechatSurfaceMapper.mergeConversationHomeItem(
conversationsData,
conversationItem,
patchItem,
affectedProjectId
);
renderCurrentTab();
@@ -631,8 +822,11 @@ public class MainActivity extends AppCompatActivity {
}
private void scheduleRealtimeRefresh() {
realtimeRefreshScheduled = false;
refreshCurrentTab();
if (realtimeRefreshScheduled) {
return;
}
realtimeRefreshScheduled = true;
uiHandler.postDelayed(realtimeRefreshRunnable, REALTIME_REFRESH_DEBOUNCE_MS);
}
private void cancelRealtimeRefreshSchedule() {
@@ -729,6 +923,7 @@ public class MainActivity extends AppCompatActivity {
BossApiClient.ApiResponse ota = null;
BossApiClient.ApiResponse settings = null;
boolean conversationsOk = false;
boolean usedGroupedHomeFeed = false;
boolean devicesOk = false;
boolean otaOk = false;
boolean settingsOk = false;
@@ -736,6 +931,7 @@ public class MainActivity extends AppCompatActivity {
try {
conversations = apiClient.getConversationHome();
conversationsOk = conversations.ok();
usedGroupedHomeFeed = conversationsOk;
} catch (Exception ignored) {
conversationsOk = false;
}
@@ -745,6 +941,7 @@ public class MainActivity extends AppCompatActivity {
if (fallbackConversations.ok()) {
conversations = fallbackConversations;
conversationsOk = true;
usedGroupedHomeFeed = false;
}
} catch (Exception ignored) {
conversationsOk = false;
@@ -775,6 +972,7 @@ public class MainActivity extends AppCompatActivity {
BossApiClient.ApiResponse finalOta = ota;
BossApiClient.ApiResponse finalSettings = settings;
final boolean finalConversationsOk = conversationsOk;
final boolean finalUsedGroupedHomeFeed = usedGroupedHomeFeed;
final boolean finalDevicesOk = devicesOk;
final boolean finalOtaOk = otaOk;
final boolean finalSettingsOk = settingsOk;
@@ -782,14 +980,19 @@ public class MainActivity extends AppCompatActivity {
sessionData = finalSession;
JSONArray refreshedConversations = finalConversations == null
? null
: WechatSurfaceMapper.normalizeConversationHomeFeed(
finalConversations.json.optJSONArray("conversations")
);
: finalUsedGroupedHomeFeed
? finalConversations.json.optJSONArray("conversations")
: WechatSurfaceMapper.normalizeConversationHomeFeed(
finalConversations.json.optJSONArray("conversations")
);
conversationsData = WechatSurfaceMapper.resolveRefreshValue(
conversationsData,
refreshedConversations,
finalConversationsOk
);
if (finalConversationsOk) {
conversationRootUsesGroupedHomeFeed = finalUsedGroupedHomeFeed;
}
devicesData = WechatSurfaceMapper.resolveRefreshValue(
devicesData,
finalDevices == null ? null : finalDevices.json.optJSONArray("devices"),
@@ -865,6 +1068,7 @@ public class MainActivity extends AppCompatActivity {
private void showLogin(String hint) {
loginPanel.setVisibility(View.VISIBLE);
contentPanel.setVisibility(View.GONE);
setAuthMode("login", hint);
setLoginLoading(false, hint);
stopRealtimeUpdates();
}
@@ -875,15 +1079,76 @@ public class MainActivity extends AppCompatActivity {
setActiveTab(activeTab, false);
updateConversationAutoRefresh();
updateRealtimeSubscription();
scheduleNotificationPermissionRequest();
}
private void setLoginLoading(boolean loading, String hint) {
loginProgress.setVisibility(loading ? View.VISIBLE : View.GONE);
loginButton.setEnabled(!loading);
loginButton.setText(loading ? "处理中..." : WechatSurfaceMapper.loginButtonLabel());
loginSendCodeButton.setEnabled(!loading);
loginModeButton.setEnabled(!loading);
registerModeButton.setEnabled(!loading);
forgotModeButton.setEnabled(!loading);
loginButton.setText(loading ? "处理中..." : primaryAuthButtonLabel());
loginHint.setText(hint);
}
private void setAuthMode(String mode, String hint) {
authMode = ("register".equals(mode) || "forgot".equals(mode)) ? mode : "login";
boolean codeMode = !"login".equals(authMode);
loginTitle.setText(authTitle());
loginButton.setText(primaryAuthButtonLabel());
loginPasswordInput.setHint("forgot".equals(authMode) ? "新密码" : "密码");
loginConfirmPasswordInput.setVisibility(codeMode ? View.VISIBLE : View.GONE);
loginCodeRow.setVisibility(codeMode ? View.VISIBLE : View.GONE);
loginHint.setText(hint);
tintAuthModeButtons();
}
private String authTitle() {
if ("register".equals(authMode)) {
return "注册账号";
}
if ("forgot".equals(authMode)) {
return "找回密码";
}
return "登录 Boss";
}
private String primaryAuthButtonLabel() {
if ("register".equals(authMode)) {
return "注册并登录";
}
if ("forgot".equals(authMode)) {
return "重置密码";
}
return "登录";
}
private void tintAuthModeButtons() {
int selectedColor = getColor(R.color.boss_green);
int mutedColor = getColor(R.color.boss_text_muted);
loginModeButton.setTextColor("login".equals(authMode) ? selectedColor : mutedColor);
registerModeButton.setTextColor("register".equals(authMode) ? selectedColor : mutedColor);
forgotModeButton.setTextColor("forgot".equals(authMode) ? selectedColor : mutedColor);
}
private String inputText(EditText input) {
return input == null || input.getText() == null ? "" : input.getText().toString().trim();
}
private void clearSecretInputs() {
if (loginPasswordInput != null) {
loginPasswordInput.setText("");
}
if (loginConfirmPasswordInput != null) {
loginConfirmPasswordInput.setText("");
}
if (loginCodeInput != null) {
loginCodeInput.setText("");
}
}
private void setActiveTab(String tab, boolean fromUser) {
if (!"conversations".equals(tab)) {
exitConversationSelectionMode();
@@ -942,14 +1207,22 @@ public class MainActivity extends AppCompatActivity {
}
private void updateTabStyles() {
styleTab(tabConversations, "conversations".equals(activeTab));
styleTab(tabDevices, "devices".equals(activeTab));
styleTab(tabMe, "me".equals(activeTab));
styleTab(tabConversations, "conversations".equals(activeTab), R.drawable.ic_boss_tab_chat);
styleTab(tabDevices, "devices".equals(activeTab), R.drawable.ic_boss_tab_devices);
styleTab(tabMe, "me".equals(activeTab), R.drawable.ic_boss_tab_me);
}
private void styleTab(Button button, boolean active) {
button.setBackgroundResource(active ? R.drawable.bg_tab_active : R.drawable.bg_tab_inactive);
button.setTextColor(getColor(active ? R.color.boss_green : R.color.boss_text_muted));
private void styleTab(Button button, boolean active, int iconRes) {
int color = getColor(active ? R.color.boss_green : R.color.boss_text_muted);
button.setBackgroundColor(Color.TRANSPARENT);
button.setTextColor(color);
button.setTextSize(12);
button.setAllCaps(false);
button.setGravity(android.view.Gravity.CENTER);
button.setCompoundDrawablesWithIntrinsicBounds(0, iconRes, 0, 0);
button.setCompoundDrawablePadding(BossUi.dp(this, 3));
button.setCompoundDrawableTintList(ColorStateList.valueOf(color));
button.setPadding(0, BossUi.dp(this, 5), 0, BossUi.dp(this, 3));
}
private void configureTopAction(WechatSurfaceMapper.RootTopAction action) {
@@ -970,7 +1243,7 @@ public class MainActivity extends AppCompatActivity {
topSearchInput.setVisibility(View.GONE);
backButton.setVisibility(View.GONE);
searchButton.setVisibility(View.GONE);
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, false);
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, false, false, currentSessionRole());
refreshButton.setVisibility(View.VISIBLE);
configureTopAction(action);
}
@@ -1005,7 +1278,7 @@ public class MainActivity extends AppCompatActivity {
refreshButton.setEnabled(true);
return;
}
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode);
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode, currentSessionRole());
configureTopAction(action);
refreshButton.setEnabled(!"refresh".equals(action.actionKey) || !refreshing);
refreshButton.setAlpha(refreshing && "refresh".equals(action.actionKey) ? 0.45f : 1f);
@@ -1036,7 +1309,7 @@ public class MainActivity extends AppCompatActivity {
toggleConversationQuickActions();
return;
}
String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode).actionKey;
String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode, currentSessionRole()).actionKey;
if ("add_device".equals(actionKey)) {
startActivity(new Intent(this, DeviceEnrollmentActivity.class));
return;
@@ -1181,11 +1454,19 @@ public class MainActivity extends AppCompatActivity {
String matchedProjectId = item.optString("searchMatchProjectId", "").trim();
String matchedProjectLabel = item.optString("searchMatchLabel", "").trim();
if (!matchedProjectId.isEmpty() && !matchedProjectLabel.isEmpty()) {
exitConversationSearchMode(true);
openProject(matchedProjectId, matchedProjectLabel);
exitConversationSearchMode(true);
return;
}
openConversationFolder(
folderKey,
resolveConversationFolderName(item, row),
item.optString("searchMatchProjectId", ""),
item.optJSONArray("searchMatchProjectIds"),
item.optString("searchMatchLabel", "")
);
exitConversationSearchMode(true);
return;
}
openConversationFolder(
folderKey,
@@ -1543,6 +1824,7 @@ public class MainActivity extends AppCompatActivity {
}
private void prepareConversationQuickActionMenu() {
quickActionAddDevice.setVisibility("highest_admin".equals(currentSessionRole()) ? View.VISIBLE : View.GONE);
conversationQuickActionsMenu.setVisibility(View.VISIBLE);
conversationQuickActionsMenu.setAlpha(0f);
conversationQuickActionsMenu.setTranslationY(-BossUi.dp(this, 6));
@@ -1553,6 +1835,7 @@ public class MainActivity extends AppCompatActivity {
conversationQuickActionsMenu.setAlpha(0f);
conversationQuickActionsMenu.setTranslationY(-BossUi.dp(this, 6));
conversationQuickActionsMenu.setVisibility(View.GONE);
quickActionAddDevice.setVisibility(View.VISIBLE);
}
static boolean matchesConversationQuery(JSONObject item, String rawQuery) {
@@ -1706,7 +1989,7 @@ public class MainActivity extends AppCompatActivity {
(roleLabel.isEmpty() ? "主控账号已启用安全保护" : roleLabel + " · 主控账号已启用安全保护")
));
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItems()) {
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItemsForRole(currentSessionRole())) {
screenContent.addView(BossUi.buildWechatMenuRow(
this,
item.title,
@@ -1856,6 +2139,33 @@ public class MainActivity extends AppCompatActivity {
}
}
private void maybeRequestNotificationPermission() {
notificationPermissionRequestScheduled = false;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
return;
}
if (contentPanel == null || contentPanel.getVisibility() != View.VISIBLE) {
return;
}
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
return;
}
android.content.SharedPreferences prefs = getSharedPreferences(UI_PREFS, Context.MODE_PRIVATE);
if (prefs.getBoolean(KEY_NOTIFICATION_PERMISSION_REQUESTED, false)) {
return;
}
prefs.edit().putBoolean(KEY_NOTIFICATION_PERMISSION_REQUESTED, true).apply();
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, REQUEST_POST_NOTIFICATIONS);
}
private void scheduleNotificationPermissionRequest() {
if (notificationPermissionRequestScheduled) {
return;
}
notificationPermissionRequestScheduled = true;
uiHandler.postDelayed(this::maybeRequestNotificationPermission, 450L);
}
void handleRealtimeConnectionChanged(boolean connected) {
if (!connected
&& shouldMaintainConversationAutoRefresh()
@@ -1868,14 +2178,27 @@ public class MainActivity extends AppCompatActivity {
}
private void openMeEntry(String key) {
if (!WechatSurfaceMapper.canOpenMeEntryForRole(key, currentSessionRole())) {
showMessage("当前账号没有权限打开这个入口。");
return;
}
Intent intent;
switch (key) {
case "security":
intent = new Intent(this, SecurityActivity.class);
break;
case "access":
intent = new Intent(this, AccessManagementActivity.class);
break;
case "ai_accounts":
intent = new Intent(this, AiAccountsActivity.class);
break;
case "storage":
intent = new Intent(this, StorageSettingsActivity.class);
break;
case "telegram":
intent = new Intent(this, TelegramIntegrationActivity.class);
break;
case "settings":
intent = new Intent(this, SettingsActivity.class);
break;
@@ -1895,6 +2218,13 @@ public class MainActivity extends AppCompatActivity {
startActivity(intent);
}
private String currentSessionRole() {
if (sessionData == null) {
return "member";
}
return sessionData.optString("role", "member");
}
private void openSkillInventoryFromMe() {
String targetDeviceId = resolveSkillTargetDeviceId();
if (targetDeviceId == null || targetDeviceId.isEmpty()) {

View File

@@ -14,6 +14,30 @@ import java.util.Set;
public final class ProjectChatUiState {
private ProjectChatUiState() {}
public static final class MessageDisplayItem {
public static final String TYPE_MESSAGE = "message";
public static final String TYPE_PROCESS_GROUP = "process_group";
public final String type;
@Nullable
public final JSONObject message;
public final List<JSONObject> processMessages;
private MessageDisplayItem(String type, @Nullable JSONObject message, List<JSONObject> processMessages) {
this.type = type;
this.message = message;
this.processMessages = Collections.unmodifiableList(new ArrayList<>(processMessages));
}
private static MessageDisplayItem message(JSONObject message) {
return new MessageDisplayItem(TYPE_MESSAGE, message, Collections.emptyList());
}
private static MessageDisplayItem processGroup(List<JSONObject> processMessages) {
return new MessageDisplayItem(TYPE_PROCESS_GROUP, null, processMessages);
}
}
public static final class SelectionState {
public final boolean multiSelecting;
public final Set<String> selectedMessageIds;
@@ -31,6 +55,7 @@ public final class ProjectChatUiState {
public final boolean showMultiSelectBar;
public final boolean showRefresh;
public final boolean showHeaderAction;
public final boolean copyEnabled;
public final boolean forwardEnabled;
public final String backLabel;
public final String title;
@@ -42,6 +67,7 @@ public final class ProjectChatUiState {
boolean showMultiSelectBar,
boolean showRefresh,
boolean showHeaderAction,
boolean copyEnabled,
boolean forwardEnabled,
String backLabel,
String title,
@@ -52,6 +78,7 @@ public final class ProjectChatUiState {
this.showMultiSelectBar = showMultiSelectBar;
this.showRefresh = showRefresh;
this.showHeaderAction = showHeaderAction;
this.copyEnabled = copyEnabled;
this.forwardEnabled = forwardEnabled;
this.backLabel = backLabel;
this.title = title;
@@ -81,6 +108,77 @@ public final class ProjectChatUiState {
return nearBottom || forced;
}
public static List<MessageDisplayItem> buildMessageDisplayItems(@Nullable JSONArray messages) {
ArrayList<MessageDisplayItem> items = new ArrayList<>();
if (messages == null || messages.length() == 0) {
return items;
}
ArrayList<JSONObject> pendingProcessMessages = new ArrayList<>();
for (int i = 0; i < messages.length(); i++) {
JSONObject message = messages.optJSONObject(i);
if (message == null) {
continue;
}
if (isThreadProcessMessage(message)) {
pendingProcessMessages.add(message);
continue;
}
flushProcessGroup(items, pendingProcessMessages);
items.add(MessageDisplayItem.message(message));
}
flushProcessGroup(items, pendingProcessMessages);
return items;
}
public static boolean hasThreadProcessFoldCandidates(@Nullable JSONArray messages, int startIndex) {
if (messages == null || messages.length() == 0) {
return false;
}
int firstIndex = Math.max(0, startIndex);
for (int i = firstIndex; i < messages.length(); i++) {
JSONObject message = messages.optJSONObject(i);
if (message != null && isThreadProcessMessage(message)) {
return true;
}
}
return false;
}
public static String processGroupPreview(@Nullable MessageDisplayItem item) {
if (item == null || item.processMessages.isEmpty()) {
return "";
}
JSONObject latestMessage = item.processMessages.get(item.processMessages.size() - 1);
return truncate(latestMessage.optString("body", ""), 52);
}
public static String processGroupDetail(@Nullable MessageDisplayItem item) {
if (item == null || item.processMessages.isEmpty()) {
return "";
}
StringBuilder builder = new StringBuilder();
for (int i = 0; i < item.processMessages.size(); i++) {
JSONObject message = item.processMessages.get(i);
String body = compactBody(message.optString("body", ""));
if (body.isEmpty()) {
continue;
}
if (builder.length() > 0) {
builder.append("\n\n");
}
builder.append(i + 1).append(". ").append(body);
}
return builder.toString();
}
private static void flushProcessGroup(List<MessageDisplayItem> items, List<JSONObject> pendingProcessMessages) {
if (pendingProcessMessages.isEmpty()) {
return;
}
items.add(MessageDisplayItem.processGroup(pendingProcessMessages));
pendingProcessMessages.clear();
}
public static String threadExecutionConflictTitle(@Nullable JSONObject conflict) {
if (conflict == null) {
return "当前线程命中冲突保护";
@@ -149,6 +247,10 @@ public final class ProjectChatUiState {
return state != null && state.multiSelecting && state.selectedMessageIds.size() >= 2;
}
public static boolean canCopySelection(@Nullable SelectionState state) {
return state != null && state.multiSelecting && !state.selectedMessageIds.isEmpty();
}
public static SelectionState reconcileSelection(
@Nullable SelectionState current,
@Nullable List<String> availableMessageIds
@@ -181,6 +283,7 @@ public final class ProjectChatUiState {
true,
false,
false,
canCopySelection(selectionState),
canForwardSelection(selectionState),
"取消",
"已选 " + selectedCount + "",
@@ -194,6 +297,7 @@ public final class ProjectChatUiState {
!conversationInfoReady,
conversationInfoReady,
false,
false,
"返回",
isBlank(defaultTitle) ? "项目详情" : defaultTitle,
isBlank(defaultSubtitle) ? "原生页面" : defaultSubtitle
@@ -420,6 +524,13 @@ public final class ProjectChatUiState {
if ("completed".equals(taskStatus) || "failed".equals(taskStatus)) {
return new ReplyWaitSpec(false, null);
}
JSONObject replyMessage = response.optJSONObject("replyMessage");
if (replyMessage != null) {
String replyMessageId = replyMessage.optString("id", "").trim();
if (!replyMessageId.isEmpty()) {
return new ReplyWaitSpec(true, replyMessageId);
}
}
JSONObject message = response.optJSONObject("message");
return new ReplyWaitSpec(true, message == null ? null : message.optString("id", ""));
}
@@ -444,6 +555,14 @@ public final class ProjectChatUiState {
return !isBlank(latestMessageId) && !baselineMessageId.trim().equals(latestMessageId);
}
public static boolean shouldAutoRefreshConversation(
boolean shouldMaintainAutoRefresh,
boolean realtimeConnected,
boolean trackedMasterReplyTimedOut
) {
return shouldMaintainAutoRefresh && (!realtimeConnected || trackedMasterReplyTimedOut);
}
@Nullable
public static String latestMessageId(@Nullable JSONArray messages) {
if (messages == null || messages.length() == 0) {
@@ -457,10 +576,110 @@ public final class ProjectChatUiState {
return messageId.isEmpty() ? null : messageId;
}
private static boolean isThreadProcessMessage(@Nullable JSONObject message) {
if (message == null) {
return false;
}
String kind = message.optString("kind", "").trim();
if ("thread_process".equals(kind)) {
return true;
}
if (!isBlank(kind)
&& !"text".equals(kind)
&& !"conversation_reply".equals(kind)
&& !"thread_reply".equals(kind)) {
return false;
}
String sender = message.optString("sender", "").trim().toLowerCase(java.util.Locale.ROOT);
String senderLabel = message.optString("senderLabel", "").trim();
if ("user".equals(sender)
|| "master".equals(sender)
|| "ops".equals(sender)
|| "audit".equals(sender)
|| senderLabel.contains("主 Agent")
|| senderLabel.contains("审计")
|| senderLabel.contains("")) {
return false;
}
String body = compactBody(message.optString("body", ""));
if (body.isEmpty()) {
return false;
}
if (isStructuredNumberedProcessBody(body)) {
return true;
}
if (containsAny(body, FOLD_BLOCK_MARKERS)) {
return false;
}
return hasProcessProgressMarker(body);
}
private static boolean isBlank(@Nullable String value) {
return value == null || value.trim().isEmpty();
}
private static String compactBody(@Nullable String value) {
if (value == null) {
return "";
}
return value
.replace("\r\n", "\n")
.replace('\r', '\n')
.replaceAll("\\n{2,}", "\n")
.trim();
}
private static boolean containsAny(String body, String[] markers) {
String normalizedBody = body.toLowerCase(java.util.Locale.ROOT);
for (String marker : markers) {
if (normalizedBody.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
return true;
}
}
return false;
}
private static boolean isStructuredNumberedProcessBody(String body) {
String[] rawLines = body
.replace("\r\n", "\n")
.replace('\r', '\n')
.split("\n");
ArrayList<String> numberedLines = new ArrayList<>();
for (String rawLine : rawLines) {
String normalizedLine = compactBody(rawLine);
if (normalizedLine.isEmpty()) {
continue;
}
if (normalizedLine.matches("^\\d+[.)、]\\s*.+$")) {
numberedLines.add(normalizedLine);
}
}
if (numberedLines.size() < 2) {
return false;
}
String merged = android.text.TextUtils.join(" ", numberedLines)
.toLowerCase(java.util.Locale.ROOT);
return containsAny(merged, PROCESS_PROGRESS_NUMBERED_HINTS);
}
private static boolean hasProcessProgressMarker(String body) {
String normalizedBody = body.trim().toLowerCase(java.util.Locale.ROOT);
if (isStructuredNumberedProcessBody(body)) {
return true;
}
for (String marker : PROCESS_PROGRESS_PREFIXES) {
if (normalizedBody.startsWith(marker.toLowerCase(java.util.Locale.ROOT))) {
return true;
}
}
for (String marker : PROCESS_PROGRESS_CONTAINS) {
if (normalizedBody.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
return true;
}
}
return false;
}
private static String truncate(@Nullable String value, int maxLength) {
String normalized = value == null ? "" : value.trim();
if (normalized.length() <= maxLength) {
@@ -468,4 +687,83 @@ public final class ProjectChatUiState {
}
return normalized.substring(0, maxLength) + "";
}
private static final String[] PROCESS_PROGRESS_PREFIXES = new String[] {
"我先",
"我现在",
"我会先",
"我发现",
"我准备",
"接下来",
"正在",
"先看",
"先读",
"我把",
"我再",
"目前在",
"现在在",
"补一组",
"处理一下",
"先确认",
"准备",
"同步一下",
"我这边已经"
};
private static final String[] PROCESS_PROGRESS_CONTAINS = new String[] {
"我继续",
"我已经在",
"正在跑",
"正在检查",
"正在处理",
"正在同步",
"我会直接",
"我先把",
"先补",
"再接"
};
private static final String[] PROCESS_PROGRESS_NUMBERED_HINTS = new String[] {
"",
"",
"接下来",
"然后",
"检查",
"确认",
"处理",
"同步",
"",
"排查",
"推进",
"回你",
"回传",
"会把",
"我会"
};
private static final String[] FOLD_BLOCK_MARKERS = new String[] {
"失败",
"报错",
"错误",
"阻塞",
"不能",
"无法",
"崩溃",
"超时",
"exception",
"error",
"fatal",
"结论",
"最终",
"总结",
"已完成",
"已经完成",
"验证通过",
"测试通过",
"已修复",
"修好了",
"已部署",
"已安装",
"可以直接"
};
}

View File

@@ -5,6 +5,7 @@ import android.os.Bundle;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
public class SecurityActivity extends BossScreenActivity {
@@ -22,7 +23,11 @@ public class SecurityActivity extends BossScreenActivity {
try {
BossApiClient.ApiResponse response = apiClient.getSession();
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> renderSecurity(response.json.optJSONObject("session")));
BossApiClient.ApiResponse sessionsResponse = apiClient.getAuthSessions();
JSONArray sessions = sessionsResponse.ok()
? sessionsResponse.json.optJSONArray("sessions")
: new JSONArray();
runOnUiThread(() -> renderSecurity(response.json.optJSONObject("session"), sessions));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
@@ -32,7 +37,7 @@ public class SecurityActivity extends BossScreenActivity {
});
}
private void renderSecurity(@Nullable JSONObject session) {
private void renderSecurity(@Nullable JSONObject session, @Nullable JSONArray sessions) {
replaceContent();
appendContent(BossUi.buildWechatMenuRow(
this,
@@ -55,6 +60,33 @@ public class SecurityActivity extends BossScreenActivity {
));
}
appendContent(BossUi.buildWechatMenuRow(
this,
"登录会话",
"当前可管理 " + (sessions == null ? 0 : sessions.length()) + " 个登录端",
"点击非当前会话可撤销;撤销当前会话会回到登录页。",
null,
null
));
if (sessions != null) {
for (int index = 0; index < sessions.length(); index += 1) {
JSONObject item = sessions.optJSONObject(index);
if (item == null) {
continue;
}
appendContent(BossUi.buildWechatMenuRow(
this,
buildSessionTitle(item),
item.optString("account", "-")
+ " · " + BossUi.formatRoleLabel(item.optString("role", "-")),
"最近 " + item.optString("lastSeenAt", "-")
+ " · 到期 " + item.optString("expiresAt", "-"),
item.optBoolean("current", false) ? "当前" : null,
v -> confirmRevokeSession(item.optString("sessionId", ""), item.optBoolean("current", false))
));
}
}
appendContent(BossUi.buildMenuRow(this, "打开设备页", "查看已绑定设备与状态", null, v -> {
Intent intent = new Intent(this, MainActivity.class);
intent.putExtra(MainActivity.EXTRA_INITIAL_TAB, "devices");
@@ -68,6 +100,56 @@ public class SecurityActivity extends BossScreenActivity {
setRefreshing(false);
}
private String buildSessionTitle(JSONObject session) {
String method = "code".equals(session.optString("loginMethod", "password")) ? "验证码登录" : "账号密码登录";
String name = session.optString("displayName", session.optString("account", "登录端"));
return name + " · " + method;
}
private void confirmRevokeSession(String sessionId, boolean current) {
if (sessionId == null || sessionId.isEmpty()) {
showMessage("会话 ID 缺失,无法撤销。");
return;
}
new androidx.appcompat.app.AlertDialog.Builder(this)
.setTitle(current ? "退出当前会话" : "撤销登录会话")
.setMessage(current ? "撤销当前会话后需要重新登录。" : "只撤销这一端的登录态,不影响其他会话。")
.setNegativeButton("取消", null)
.setPositiveButton(current ? "退出" : "撤销", (dialog, which) -> revokeSession(sessionId, current))
.show();
}
private void revokeSession(String sessionId, boolean current) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.revokeAuthSession(sessionId);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
if (current) {
apiClient.logout();
}
runOnUiThread(() -> {
showMessage("会话已撤销");
if (current) {
Intent intent = new Intent(this, MainActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
finish();
} else {
reload();
}
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("撤销失败:" + error.getMessage());
});
}
});
}
private void logout() {
setRefreshing(true);
executor.execute(() -> {

View File

@@ -0,0 +1,207 @@
package com.hyzq.boss;
import android.os.Bundle;
import android.text.InputType;
import android.text.TextUtils;
import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import org.json.JSONObject;
public class StorageSettingsActivity extends BossScreenActivity {
private String storageMode = "server_file";
private boolean configLoaded;
private LinearLayout ossForm;
private Button serverModeButton;
private Button ossModeButton;
private EditText accessKeyIdField;
private EditText accessKeySecretField;
private EditText bucketField;
private EditText endpointField;
private EditText regionField;
private EditText prefixField;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("附件与存储", "附件上传位置与 OSS 配置");
setHeaderAction("保存", v -> saveConfig(false));
buildFormContent();
updateSaveAvailability();
reload();
}
@Override
protected void reload() {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getAttachmentStorageConfig();
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> populate(response.json.optJSONObject("config")));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
configLoaded = false;
updateSaveAvailability();
replaceContent(BossUi.buildEmptyCard(this, "附件与存储加载失败:" + error.getMessage()));
});
}
});
}
private void buildFormContent() {
serverModeButton = BossUi.buildMiniActionButton(this, "服务器文件存储", true);
ossModeButton = BossUi.buildMiniActionButton(this, "阿里 OSS", false);
serverModeButton.setOnClickListener(v -> switchMode("server_file"));
ossModeButton.setOnClickListener(v -> switchMode("oss"));
accessKeyIdField = buildTextField("AccessKey ID");
accessKeySecretField = buildTextField("AccessKey Secret");
accessKeySecretField.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
bucketField = buildTextField("Bucket");
endpointField = buildTextField("Endpoint例如 oss-cn-hangzhou.aliyuncs.com");
regionField = buildTextField("Region例如 oss-cn-hangzhou");
prefixField = buildTextField("Prefix例如 boss/");
ossForm = new LinearLayout(this);
ossForm.setOrientation(LinearLayout.VERTICAL);
ossForm.addView(BossUi.buildFormCell(this, "AccessKey ID", "阿里 OSS AccessKey ID", accessKeyIdField));
ossForm.addView(BossUi.buildFormCell(this, "AccessKey Secret", "不会回显;留空表示沿用已保存密钥", accessKeySecretField));
ossForm.addView(BossUi.buildFormCell(this, "Bucket", "附件所在 Bucket", bucketField));
ossForm.addView(BossUi.buildFormCell(this, "Endpoint", "OSS Endpoint不需要填写 https://", endpointField));
ossForm.addView(BossUi.buildFormCell(this, "Region", "Bucket 所在地域", regionField));
ossForm.addView(BossUi.buildFormCell(this, "Prefix", "可选,默认 boss/", prefixField));
Button validateButton = BossUi.buildMiniActionButton(this, "测试并保存", true);
validateButton.setOnClickListener(v -> saveConfig(true));
replaceContent(
BossUi.buildWechatMenuRow(
this,
"当前使用方式",
"服务器文件存储适合内测OSS 适合长期附件归档。",
"切换后点击保存生效",
null,
null
),
BossUi.buildInlineActionRow(this, serverModeButton, ossModeButton),
ossForm,
BossUi.buildInlineActionRow(this, validateButton)
);
updateModeUi();
}
private EditText buildTextField(String hint) {
EditText field = new EditText(this);
field.setSingleLine(true);
field.setHint(hint);
field.setTextSize(14);
field.setInputType(InputType.TYPE_CLASS_TEXT);
return field;
}
private void populate(@Nullable JSONObject config) {
buildFormContent();
if (config != null) {
storageMode = config.optString("mode", "server_file");
JSONObject aliyunOss = config.optJSONObject("aliyunOss");
if (aliyunOss != null) {
accessKeyIdField.setText(aliyunOss.optString("accessKeyId", ""));
bucketField.setText(aliyunOss.optString("bucket", ""));
endpointField.setText(aliyunOss.optString("endpoint", ""));
regionField.setText(aliyunOss.optString("region", ""));
prefixField.setText(aliyunOss.optString("prefix", "boss/"));
}
}
configLoaded = config != null;
updateModeUi();
updateSaveAvailability();
setRefreshing(false);
}
private void switchMode(String mode) {
storageMode = mode;
updateModeUi();
}
private void updateModeUi() {
boolean oss = "oss".equals(storageMode);
if (serverModeButton != null) {
serverModeButton.setText(oss ? "服务器文件存储" : "已选 服务器文件存储");
}
if (ossModeButton != null) {
ossModeButton.setText(oss ? "已选 阿里 OSS" : "阿里 OSS");
}
if (ossForm != null) {
ossForm.setVisibility(oss ? android.view.View.VISIBLE : android.view.View.GONE);
}
}
private void saveConfig(boolean validateFirst) {
if (!configLoaded) {
showMessage("配置尚未加载完成,请先刷新成功后再保存。");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
JSONObject payload = buildPayload();
BossApiClient.ApiResponse response = validateFirst && "oss".equals(storageMode)
? apiClient.validateAttachmentStorageConfig(payload)
: apiClient.saveAttachmentStorageConfig(payload);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
setRefreshing(false);
showMessage(validateFirst && "oss".equals(storageMode) ? "测试通过,配置已保存" : "附件存储配置已保存");
populate(response.json.optJSONObject("config"));
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("保存失败:" + error.getMessage());
});
}
});
}
private JSONObject buildPayload() throws org.json.JSONException {
JSONObject payload = new JSONObject();
payload.put("mode", storageMode);
if (!"oss".equals(storageMode)) {
return payload;
}
JSONObject aliyunOss = new JSONObject();
aliyunOss.put("accessKeyId", textOf(accessKeyIdField));
aliyunOss.put("bucket", textOf(bucketField));
aliyunOss.put("endpoint", textOf(endpointField));
aliyunOss.put("region", textOf(regionField));
aliyunOss.put("prefix", textOf(prefixField));
String secret = textOf(accessKeySecretField);
if (!TextUtils.isEmpty(secret)) {
aliyunOss.put("accessKeySecret", secret);
}
payload.put("ossProvider", "aliyun_oss");
payload.put("aliyunOss", aliyunOss);
return payload;
}
private String textOf(EditText field) {
return field == null || field.getText() == null ? "" : field.getText().toString().trim();
}
private void updateSaveAvailability() {
if (headerActionButton != null) {
headerActionButton.setEnabled(configLoaded);
headerActionButton.setAlpha(configLoaded ? 1f : 0.45f);
}
}
}

View File

@@ -0,0 +1,388 @@
package com.hyzq.boss;
import android.os.Bundle;
import android.text.InputType;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.LinearLayout;
import android.widget.Spinner;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.SwitchCompat;
import org.json.JSONObject;
public class TelegramIntegrationActivity extends BossScreenActivity {
private SwitchCompat enabledSwitch;
private Spinner modeSpinner;
private Spinner dmPolicySpinner;
private Spinner groupPolicySpinner;
private SwitchCompat requireMentionSwitch;
private EditText botTokenInput;
private EditText webhookSecretInput;
private EditText webhookUrlInput;
private EditText defaultProjectIdInput;
private EditText allowFromInput;
private EditText groupsInput;
private EditText groupProjectRoutesInput;
@Nullable private JSONObject currentTelegram;
private boolean telegramLoaded = false;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("Telegram 接入", "Bot 网关与白名单");
setHeaderAction("保存", v -> saveTelegram(false));
buildFormContent();
updateActionAvailability();
reload();
}
@Override
protected void reload() {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getTelegramIntegration();
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> populate(response.json.optJSONObject("telegram")));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
telegramLoaded = false;
updateActionAvailability();
replaceContent(BossUi.buildEmptyCard(this, "Telegram 配置加载失败:" + error.getMessage()));
});
}
});
}
private void buildFormContent() {
if (enabledSwitch == null) {
enabledSwitch = new SwitchCompat(this);
enabledSwitch.setText("开启 Telegram 接入");
}
if (requireMentionSwitch == null) {
requireMentionSwitch = new SwitchCompat(this);
requireMentionSwitch.setText("群聊要求 @Bot 或回复 Bot");
}
if (modeSpinner == null) {
modeSpinner = new Spinner(this);
modeSpinner.setAdapter(new ArrayAdapter<>(
this,
android.R.layout.simple_spinner_dropdown_item,
new String[]{"webhook", "polling"}
));
}
if (dmPolicySpinner == null) {
dmPolicySpinner = new Spinner(this);
dmPolicySpinner.setAdapter(new ArrayAdapter<>(
this,
android.R.layout.simple_spinner_dropdown_item,
new String[]{"allowlist", "open", "disabled"}
));
}
if (groupPolicySpinner == null) {
groupPolicySpinner = new Spinner(this);
groupPolicySpinner.setAdapter(new ArrayAdapter<>(
this,
android.R.layout.simple_spinner_dropdown_item,
new String[]{"allowlist", "open", "disabled"}
));
}
if (botTokenInput == null) {
botTokenInput = BossUi.buildInput(this, "输入 Telegram Bot Token", false);
botTokenInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
}
if (webhookSecretInput == null) {
webhookSecretInput = BossUi.buildInput(this, "留空则沿用当前 secret", false);
webhookSecretInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
}
if (webhookUrlInput == null) {
webhookUrlInput = BossUi.buildInput(this, "例如 https://boss.hyzq.net/api/v1/integrations/telegram/webhook", false);
}
if (defaultProjectIdInput == null) {
defaultProjectIdInput = BossUi.buildInput(this, "默认 master-agent", false);
}
if (allowFromInput == null) {
allowFromInput = BossUi.buildInput(this, "每行一个 Telegram 用户 ID", true);
}
if (groupsInput == null) {
groupsInput = BossUi.buildInput(this, "每行一个 Telegram 群 chat id", true);
}
if (groupProjectRoutesInput == null) {
groupProjectRoutesInput = BossUi.buildInput(this, "chatId[#topicId] projectId 可选备注", true);
}
replaceContent(buildStatusRow(currentTelegram));
appendContent(BossUi.buildWechatMenuRow(
this,
"Telegram Bot 网关",
"主 Agent 可通过 Telegram 私聊或受控群聊接收消息。",
"保存 webhook 模式后会自动同步 Telegram Webhook",
null,
null
));
appendContent(BossUi.buildWechatSwitchRow(this, "开启接入", "关闭后 Boss 不再接收 Telegram 消息", enabledSwitch));
appendContent(BossUi.buildFormCell(this, "接入模式", "Webhook 推荐用于正式运行Polling 仅作兜底。", modeSpinner));
appendContent(BossUi.buildFormCell(this, "Bot Token", "留空表示沿用当前已保存 token不会主动清空。", botTokenInput));
appendContent(BossUi.buildFormCell(this, "Webhook Secret", "Telegram webhook secret建议启用。", webhookSecretInput));
appendContent(BossUi.buildFormCell(this, "Webhook URL", "Webhook 模式下使用的公开地址。", webhookUrlInput));
appendContent(BossUi.buildFormCell(this, "默认项目", "当前默认路由到 master-agent。", defaultProjectIdInput));
appendContent(BossUi.buildFormCell(this, "私聊策略", "allowlist 更安全。", dmPolicySpinner));
appendContent(BossUi.buildFormCell(this, "允许私聊用户 ID", "每行一个 Telegram 用户 ID。", allowFromInput));
appendContent(BossUi.buildFormCell(this, "群聊策略", "群白名单建议配合 requireMention 使用。", groupPolicySpinner));
appendContent(BossUi.buildFormCell(this, "允许群聊 chat id", "每行一个 Telegram 群 chat id。", groupsInput));
appendContent(BossUi.buildFormCell(this, "群 / Topic 路由", "每行格式chatId[#topicId] projectId 可选备注;未命中时回到默认项目。", groupProjectRoutesInput));
appendContent(BossUi.buildWechatSwitchRow(this, "群聊要求 @Bot", "开启后只有 @bot_username 或回复当前 Bot 的消息才会进入主 Agent。", requireMentionSwitch));
android.widget.Button testButton = BossUi.buildSecondaryButton(this, "测试连接");
testButton.setOnClickListener(v -> saveTelegram(true));
appendContent(testButton);
TextView noteView = BossUi.buildHintPill(this, "提示:保存为 webhook 模式时会自动 setWebhook切回 polling/关闭时会自动 deleteWebhook。");
appendContent(noteView);
}
private void populate(@Nullable JSONObject telegram) {
currentTelegram = telegram;
buildFormContent();
if (telegram != null) {
enabledSwitch.setChecked(telegram.optBoolean("enabled", false));
String mode = telegram.optString("mode", "webhook");
modeSpinner.setSelection("polling".equals(mode) ? 1 : 0);
String dmPolicy = telegram.optString("dmPolicy", "allowlist");
dmPolicySpinner.setSelection(policySelection(dmPolicy));
String groupPolicy = telegram.optString("groupPolicy", "allowlist");
groupPolicySpinner.setSelection(policySelection(groupPolicy));
requireMentionSwitch.setChecked(telegram.optBoolean("requireMentionInGroups", true));
webhookUrlInput.setText(telegram.optString("webhookUrl", ""));
defaultProjectIdInput.setText(telegram.optString("defaultProjectId", "master-agent"));
allowFromInput.setText(joinLines(telegram.optJSONArray("allowFrom")));
groupsInput.setText(joinLines(telegram.optJSONArray("groups")));
groupProjectRoutesInput.setText(formatGroupProjectRoutes(telegram.optJSONArray("groupProjectRoutes")));
}
telegramLoaded = telegram != null;
updateActionAvailability();
setRefreshing(false);
}
private LinearLayout buildStatusRow(@Nullable JSONObject telegram) {
return BossUi.buildWechatMenuRow(
this,
"当前状态",
buildStatusSummary(telegram),
buildStatusMeta(telegram),
null,
null
);
}
private String buildStatusSummary(@Nullable JSONObject telegram) {
if (telegram == null) {
return "接入:加载中\n模式未加载\nBot未识别";
}
String botUsername = telegram.optString("botUsername", "").trim();
StringBuilder builder = new StringBuilder();
builder.append("接入:").append(telegram.optBoolean("enabled", false) ? "已开启" : "已关闭");
builder.append("\n模式").append("polling".equals(telegram.optString("mode", "webhook")) ? "Polling" : "Webhook");
builder.append("\nBot").append(botUsername.isEmpty() ? "未识别" : "@" + botUsername);
builder.append("\nToken").append(telegram.optBoolean("botTokenConfigured", false) ? "已配置" : "未配置");
builder.append("\nWebhook Secret").append(telegram.optBoolean("webhookSecretConfigured", false) ? "已配置" : "未配置");
builder.append("\n默认项目").append(telegram.optString("defaultProjectId", "master-agent"));
builder.append("\n已处理 update").append(telegram.optInt("processedUpdateCount", 0));
return builder.toString();
}
private String buildStatusMeta(@Nullable JSONObject telegram) {
if (telegram == null) {
return "加载完成后可测试连接或保存配置。";
}
String lastError = telegram.optString("lastError", "").trim();
if (!lastError.isEmpty()) {
return "最近错误:" + lastError;
}
return "状态正常时Telegram 消息会进入主 Agent。";
}
private int policySelection(String policy) {
switch (policy) {
case "open":
return 1;
case "disabled":
return 2;
case "allowlist":
default:
return 0;
}
}
private String joinLines(@Nullable org.json.JSONArray array) {
if (array == null || array.length() == 0) {
return "";
}
StringBuilder builder = new StringBuilder();
for (int index = 0; index < array.length(); index += 1) {
String value = array.optString(index, "").trim();
if (value.isEmpty()) {
continue;
}
if (builder.length() > 0) {
builder.append("\n");
}
builder.append(value);
}
return builder.toString();
}
private org.json.JSONArray parseLines(EditText input) {
org.json.JSONArray array = new org.json.JSONArray();
String[] lines = input.getText().toString().split("\\r?\\n");
for (String line : lines) {
String trimmed = line == null ? "" : line.trim();
if (!trimmed.isEmpty()) {
array.put(trimmed);
}
}
return array;
}
private String formatGroupProjectRoutes(@Nullable org.json.JSONArray routes) {
if (routes == null || routes.length() == 0) {
return "";
}
StringBuilder builder = new StringBuilder();
for (int index = 0; index < routes.length(); index += 1) {
JSONObject route = routes.optJSONObject(index);
if (route == null) {
continue;
}
String chatId = route.optString("chatId", "").trim();
String projectId = route.optString("projectId", "").trim();
if (chatId.isEmpty() || projectId.isEmpty()) {
continue;
}
if (builder.length() > 0) {
builder.append("\n");
}
builder.append(chatId);
if (route.has("threadId")) {
builder.append("#").append(route.optInt("threadId"));
}
builder.append(" ").append(projectId);
String label = route.optString("label", "").trim();
if (!label.isEmpty()) {
builder.append(" ").append(label);
}
}
return builder.toString();
}
private org.json.JSONArray parseGroupProjectRoutes(EditText input) throws org.json.JSONException {
org.json.JSONArray array = new org.json.JSONArray();
String[] lines = input.getText().toString().split("\\r?\\n");
for (String line : lines) {
String trimmed = line == null ? "" : line.trim();
if (trimmed.isEmpty()) {
continue;
}
String[] parts = trimmed.split("\\s+", 3);
if (parts.length < 2) {
continue;
}
String[] chatParts = parts[0].split("#", 2);
String chatId = chatParts[0].trim();
String projectId = parts[1].trim();
if (chatId.isEmpty() || projectId.isEmpty()) {
continue;
}
JSONObject route = new JSONObject();
route.put("chatId", chatId);
if (chatParts.length > 1) {
try {
route.put("threadId", Integer.parseInt(chatParts[1].trim()));
} catch (NumberFormatException ignored) {
// Invalid topic id is ignored so the chat-level route can still be saved.
}
}
route.put("projectId", projectId);
if (parts.length > 2 && !parts[2].trim().isEmpty()) {
route.put("label", parts[2].trim());
}
array.put(route);
}
return array;
}
private void saveTelegram(boolean testConnection) {
if (!telegramLoaded) {
showMessage("配置尚未加载完成,请先刷新成功后再保存。");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
JSONObject payload = new JSONObject();
payload.put("enabled", enabledSwitch.isChecked());
payload.put("mode", String.valueOf(modeSpinner.getSelectedItem()));
payload.put("botToken", emptyToNull(botTokenInput.getText().toString()));
payload.put("webhookSecret", emptyToNull(webhookSecretInput.getText().toString()));
payload.put("webhookUrl", emptyToNull(webhookUrlInput.getText().toString()));
payload.put("defaultProjectId", emptyToNull(defaultProjectIdInput.getText().toString()));
payload.put("dmPolicy", String.valueOf(dmPolicySpinner.getSelectedItem()));
payload.put("allowFrom", parseLines(allowFromInput));
payload.put("groupPolicy", String.valueOf(groupPolicySpinner.getSelectedItem()));
payload.put("groups", parseLines(groupsInput));
payload.put("groupProjectRoutes", parseGroupProjectRoutes(groupProjectRoutesInput));
payload.put("requireMentionInGroups", requireMentionSwitch.isChecked());
payload.put("testConnection", testConnection);
BossApiClient.ApiResponse response = apiClient.updateTelegramIntegration(payload);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
JSONObject telegram = response.json.optJSONObject("telegram");
populate(telegram);
String probeUsername = "";
JSONObject probe = response.json.optJSONObject("probe");
if (probe != null) {
probeUsername = probe.optString("username", "");
}
showMessage(testConnection
? (probeUsername.isEmpty() ? "Telegram 连接测试通过" : "连接测试通过:@" + probeUsername)
: "Telegram 配置已保存");
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("Telegram 配置失败:" + error.getMessage());
});
}
});
}
@Nullable
private Object emptyToNull(String value) {
String trimmed = value == null ? "" : value.trim();
return trimmed.isEmpty() ? JSONObject.NULL : trimmed;
}
private void updateActionAvailability() {
if (headerActionButton != null) {
headerActionButton.setEnabled(telegramLoaded);
headerActionButton.setAlpha(telegramLoaded ? 1f : 0.45f);
}
}
}

View File

@@ -12,6 +12,101 @@ import java.util.List;
import java.util.Map;
public final class WechatSurfaceMapper {
private static final String[] PROCESS_PREVIEW_PREFIXES = new String[] {
"我先",
"我现在",
"我会先",
"我发现",
"我准备",
"接下来",
"正在",
"先看",
"先读",
"我把",
"我再",
"目前在",
"现在在",
"补一组",
"处理一下",
"先确认",
"准备",
"同步一下",
"我这边已经"
};
private static final String[] PROCESS_PREVIEW_CONTAINS = new String[] {
"我继续",
"我已经在",
"正在跑",
"正在检查",
"正在处理",
"正在同步",
"我会直接",
"我先把",
"先补",
"再接"
};
private static final String[] PROCESS_PREVIEW_NUMBERED_HINTS = new String[] {
"",
"",
"接下来",
"然后",
"检查",
"确认",
"处理",
"同步",
"",
"排查",
"推进",
"回你",
"回传",
"会把",
"我会"
};
private static final String[] PROCESS_PREVIEW_BLOCK_MARKERS = new String[] {
"失败",
"报错",
"错误",
"阻塞",
"不能",
"无法",
"崩溃",
"超时",
"exception",
"error",
"fatal",
"结论",
"最终",
"总结",
"已完成",
"已经完成",
"验证通过"
};
private static final String[] LEAKED_TITLE_PREFIXES = new String[] {
"你当前接手的项目根目录是",
"你现在接手的项目根目录是",
"你现在以目标线程身份直接回复用户",
"你正在向主 Agent 同步当前项目状态",
"只回复对用户真正有用的内容",
"只输出 JSON"
};
private static final String[] LEAKED_TITLE_CONTAINS = new String[] {
"不要发送内部字段",
"不要自称主 Agent",
"不要解释系统如何分发",
"不要输出 JSON",
"项目名称:",
"线程名称:",
"文件夹:",
"同步原因:",
"当前消息:",
"用户当前消息:"
};
private static final List<String> ROOT_TAB_LABELS = Arrays.asList(
"会话",
"设备",
@@ -21,8 +116,11 @@ public final class WechatSurfaceMapper {
private static final List<MeMenuItem> ROOT_ME_MENU_ITEMS = Arrays.asList(
new MeMenuItem("security", "账号与安全", "修改登录密码、设备安全与身份校验"),
new MeMenuItem("settings", "设置", "默认首页、提醒方式与危险操作确认"),
new MeMenuItem("access", "用户与权限", "分配子账号、设备、项目与 Skill 权限"),
new MeMenuItem("ops", "运维与修复", "查看运维会话、修复回放与 standby 切换"),
new MeMenuItem("ai_accounts", "AI 账号", "管理主 GPT、备用 GPT 与 API 容灾"),
new MeMenuItem("storage", "附件与存储", "配置附件上传位置、服务器文件与阿里 OSS"),
new MeMenuItem("telegram", "Telegram 接入", "配置 Telegram Bot、Webhook 与白名单"),
new MeMenuItem("skills", "技能", "按设备查看 Skill 清单"),
new MeMenuItem("about", "关于", "当前版本、OTA 状态与更新内容")
);
@@ -59,14 +157,20 @@ public final class WechatSurfaceMapper {
JSONObject avatar = source.optJSONObject("avatar");
boolean isGroup = source.optBoolean("isGroup", groupAvatarMembers.size() > 1);
String conversationType = source.optString("conversationType", "");
String threadTitle = trimLocalWorkspacePrefix(
source.optString("threadTitle", source.optString("title", source.optString("projectTitle", "")))
String folderLabel = normalizeConversationTitle(source.optString("folderLabel", ""));
String threadTitle = sanitizeConversationTitle(
source.optString("threadTitle", source.optString("title", source.optString("projectTitle", ""))),
folderLabel,
source.optString("projectTitle", "")
);
String projectId = source.optString("projectId", "").trim();
if ("folder_archive".equals(conversationType)) {
threadTitle = source.optString(
"projectTitle",
source.optString("threadTitle", source.optString("title", source.optString("folderLabel", "")))
);
} else if (isPinnedSystemProject(projectId)) {
threadTitle = source.optString("projectTitle", threadTitle);
}
String pinnedLabel = source.optString("topPinnedLabel", "");
return new ConversationRow(
@@ -188,10 +292,36 @@ public final class WechatSurfaceMapper {
return titles;
}
public static String[] rootMeMenuTitlesForRole(String role) {
List<MeMenuItem> items = rootMeMenuItemsForRoleList(role);
String[] titles = new String[items.size()];
for (int i = 0; i < items.size(); i++) {
titles[i] = items.get(i).title;
}
return titles;
}
public static MeMenuItem[] rootMeMenuItems() {
return ROOT_ME_MENU_ITEMS.toArray(new MeMenuItem[0]);
}
public static MeMenuItem[] rootMeMenuItemsForRole(String role) {
List<MeMenuItem> items = rootMeMenuItemsForRoleList(role);
return items.toArray(new MeMenuItem[0]);
}
public static boolean canOpenMeEntryForRole(String key, String role) {
if (key == null || key.trim().isEmpty()) {
return false;
}
for (MeMenuItem item : rootMeMenuItemsForRoleList(role)) {
if (item.key.equals(key)) {
return true;
}
}
return false;
}
public static MeMenuItem findMeMenuItem(String key) {
for (MeMenuItem item : ROOT_ME_MENU_ITEMS) {
if (item.key.equals(key)) {
@@ -201,6 +331,34 @@ public final class WechatSurfaceMapper {
return null;
}
private static List<MeMenuItem> rootMeMenuItemsForRoleList(String role) {
if ("highest_admin".equals(role)) {
return ROOT_ME_MENU_ITEMS;
}
List<MeMenuItem> visible = new ArrayList<>();
for (MeMenuItem item : ROOT_ME_MENU_ITEMS) {
if (!isHighestAdminOnlyMeEntry(item.key) && (isAdministratorRole(role) || !isAdministratorOnlyMeEntry(item.key))) {
visible.add(item);
}
}
return visible;
}
private static boolean isAdministratorRole(String role) {
return "highest_admin".equals(role) || "admin".equals(role);
}
private static boolean isAdministratorOnlyMeEntry(String key) {
return "ops".equals(key)
|| "ai_accounts".equals(key)
|| "storage".equals(key)
|| "telegram".equals(key);
}
private static boolean isHighestAdminOnlyMeEntry(String key) {
return "access".equals(key);
}
public static String[] projectQuickActions() {
return PROJECT_QUICK_ACTIONS.toArray(new String[0]);
}
@@ -249,6 +407,10 @@ public final class WechatSurfaceMapper {
return "cancel_on_detach";
}
private static boolean isPinnedSystemProject(String projectId) {
return "master-agent".equals(projectId) || "audit-collab".equals(projectId);
}
private static String buildContextStatusLabel(JSONObject source) {
if (source.optBoolean("mustFinishBeforeCompaction", false)) {
return "必须收尾";
@@ -322,7 +484,14 @@ public final class WechatSurfaceMapper {
}
public static RootTopAction rootTopAction(String activeTab, boolean refreshing, boolean selectionMode) {
return rootTopAction(activeTab, refreshing, selectionMode, "highest_admin");
}
public static RootTopAction rootTopAction(String activeTab, boolean refreshing, boolean selectionMode, String role) {
if ("devices".equals(activeTab)) {
if (!"highest_admin".equals(role)) {
return new RootTopAction("刷新", false, true, "refresh", "refresh");
}
return new RootTopAction("添加设备", false, true, "add", "add_device");
}
if ("conversations".equals(activeTab)) {
@@ -714,9 +883,156 @@ public final class WechatSurfaceMapper {
if (preview.matches("^已从设备.+导入线程《.+》[。.]?$")) {
return "已导入线程";
}
if (isLikelyProcessPreview(preview)) {
return "";
}
return preview;
}
private static boolean isLikelyProcessPreview(String value) {
String preview = value == null ? "" : value
.replace("\r\n", "\n")
.replace('\r', '\n')
.replaceAll("\\n{2,}", "\n")
.trim();
if (preview.isEmpty()) {
return false;
}
if (containsMarker(preview, PROCESS_PREVIEW_BLOCK_MARKERS)) {
return false;
}
if (isStructuredNumberedProcessPreview(preview)) {
return true;
}
String normalized = preview.toLowerCase(java.util.Locale.ROOT);
for (String marker : PROCESS_PREVIEW_PREFIXES) {
if (normalized.startsWith(marker.toLowerCase(java.util.Locale.ROOT))) {
return true;
}
}
for (String marker : PROCESS_PREVIEW_CONTAINS) {
if (normalized.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
return true;
}
}
return false;
}
private static boolean isStructuredNumberedProcessPreview(String value) {
String[] rawLines = value
.replace("\r\n", "\n")
.replace('\r', '\n')
.split("\n");
ArrayList<String> numberedLines = new ArrayList<>();
for (String rawLine : rawLines) {
String normalizedLine = rawLine == null ? "" : rawLine.trim();
if (normalizedLine.isEmpty()) {
continue;
}
if (normalizedLine.matches("^\\d+[.)、]\\s*.+$")) {
numberedLines.add(normalizedLine);
}
}
if (numberedLines.size() < 2) {
return false;
}
String merged = android.text.TextUtils.join(" ", numberedLines)
.toLowerCase(java.util.Locale.ROOT);
return containsMarker(merged, PROCESS_PREVIEW_NUMBERED_HINTS);
}
private static boolean containsMarker(String value, String[] markers) {
String normalized = value == null ? "" : value.toLowerCase(java.util.Locale.ROOT);
for (String marker : markers) {
if (normalized.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
return true;
}
}
return false;
}
private static String normalizeConversationTitle(String value) {
String source = value == null ? "" : value.replace("\u0000", "");
String[] lines = source.split("\\r?\\n");
for (String line : lines) {
if (line == null) {
continue;
}
String trimmed = line.trim();
if (!trimmed.isEmpty()) {
return trimmed.replaceAll("\\s+", " ");
}
}
return "";
}
private static String stripTrailingConversationTitleNoise(String value) {
return value == null ? "" : value.replaceAll("['\"}\\]]{2,}$", "").trim();
}
private static boolean looksLikeLeakedConversationTitle(String value) {
String normalized = normalizeConversationTitle(value);
if (normalized.isEmpty()) {
return false;
}
for (String marker : LEAKED_TITLE_PREFIXES) {
if (normalized.startsWith(marker)) {
return true;
}
}
for (String marker : LEAKED_TITLE_CONTAINS) {
if (normalized.contains(marker)) {
return true;
}
}
return false;
}
private static String extractWorkspaceProjectName(String value) {
String normalized = normalizeConversationTitle(value).replace('\\', '/');
if (normalized.isEmpty()) {
return "";
}
String[] patterns = new String[] {
".*/Users/[^/]+/code/([^/\\s\"'`,。;!?]+).*",
".*/home/[^/]+/code/([^/\\s\"'`,。;!?]+).*",
".*[A-Za-z]:/Users/[^/]+/code/([^/\\s\"'`,。;!?]+).*"
};
for (String pattern : patterns) {
if (normalized.matches(pattern)) {
return normalized.replaceFirst(pattern, "$1").split("/")[0].trim();
}
}
return "";
}
private static String sanitizeConversationTitle(String value, String... fallbackCandidates) {
String normalized = normalizeConversationTitle(value);
String trimmed = stripTrailingConversationTitleNoise(trimLocalWorkspacePrefix(normalized));
if (!trimmed.isEmpty() && !looksLikeLeakedConversationTitle(normalized) && !looksLikeLeakedConversationTitle(trimmed)) {
return trimmed;
}
String extractedProject = extractWorkspaceProjectName(normalized);
if (!extractedProject.isEmpty() && !looksLikeLeakedConversationTitle(extractedProject)) {
return extractedProject;
}
for (String fallbackCandidate : fallbackCandidates) {
String extractedFallback = extractWorkspaceProjectName(fallbackCandidate);
if (!extractedFallback.isEmpty() && !looksLikeLeakedConversationTitle(extractedFallback)) {
return extractedFallback;
}
String normalizedFallback = stripTrailingConversationTitleNoise(
trimLocalWorkspacePrefix(normalizeConversationTitle(fallbackCandidate))
);
if (!normalizedFallback.isEmpty() && !looksLikeLeakedConversationTitle(normalizedFallback)) {
return normalizedFallback;
}
}
return trimmed;
}
private static String trimLocalWorkspacePrefix(String value) {
String label = value == null ? "" : value.trim();
if (label.isEmpty()) {

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/boss_surface" />
<corners android:radius="24dp" />
<stroke
android:width="1dp"
android:color="@color/boss_card_stroke" />
</shape>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="M7.41,8.59L6,10l6,6 6,-6 -1.41,-1.41L12,13.17 7.41,8.59Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/boss_text_muted"
android:pathData="M5,6.5C5,4.57 6.57,3 8.5,3H15.5C17.43,3 19,4.57 19,6.5V11.2C19,13.13 17.43,14.7 15.5,14.7H11.25L7.92,18.03C7.55,18.4 6.92,18.14 6.92,17.62V14.54C5.8,14.04 5,12.91 5,11.6V6.5ZM8.4,8.1C7.82,8.1 7.35,8.57 7.35,9.15C7.35,9.73 7.82,10.2 8.4,10.2C8.98,10.2 9.45,9.73 9.45,9.15C9.45,8.57 8.98,8.1 8.4,8.1ZM12,8.1C11.42,8.1 10.95,8.57 10.95,9.15C10.95,9.73 11.42,10.2 12,10.2C12.58,10.2 13.05,9.73 13.05,9.15C13.05,8.57 12.58,8.1 12,8.1ZM15.6,8.1C15.02,8.1 14.55,8.57 14.55,9.15C14.55,9.73 15.02,10.2 15.6,10.2C16.18,10.2 16.65,9.73 16.65,9.15C16.65,8.57 16.18,8.1 15.6,8.1Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/boss_text_muted"
android:pathData="M12,2.8L20,7.1V16.9L12,21.2L4,16.9V7.1L12,2.8ZM6.2,8.42V15.58L10.9,18.11V10.95L6.2,8.42ZM12,9.05L16.78,6.48L12,3.91L7.22,6.48L12,9.05ZM13.1,10.95V18.11L17.8,15.58V8.42L13.1,10.95Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/boss_text_muted"
android:pathData="M12,12.1C9.65,12.1 7.75,10.2 7.75,7.85C7.75,5.5 9.65,3.6 12,3.6C14.35,3.6 16.25,5.5 16.25,7.85C16.25,10.2 14.35,12.1 12,12.1ZM4.8,19.5C5.44,16.13 8.39,13.58 12,13.58C15.61,13.58 18.56,16.13 19.2,19.5C19.31,20.09 18.85,20.63 18.25,20.63H5.75C5.15,20.63 4.69,20.09 4.8,19.5Z" />
</vector>

View File

@@ -52,11 +52,96 @@
android:textColor="@color/boss_text_muted"
android:textSize="14sp" />
<EditText
android:id="@+id/login_account_input"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="28dp"
android:background="@drawable/bg_secondary_button"
android:hint="账号"
android:imeOptions="actionNext"
android:inputType="text"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:singleLine="true"
android:textColor="@color/boss_text_primary"
android:textColorHint="@color/boss_text_muted"
android:textSize="16sp" />
<EditText
android:id="@+id/login_password_input"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="12dp"
android:background="@drawable/bg_secondary_button"
android:hint="密码"
android:imeOptions="actionDone"
android:inputType="textPassword"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:singleLine="true"
android:textColor="@color/boss_text_primary"
android:textColorHint="@color/boss_text_muted"
android:textSize="16sp" />
<EditText
android:id="@+id/login_confirm_password_input"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="12dp"
android:background="@drawable/bg_secondary_button"
android:hint="确认密码"
android:imeOptions="actionNext"
android:inputType="textPassword"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:singleLine="true"
android:textColor="@color/boss_text_primary"
android:textColorHint="@color/boss_text_muted"
android:textSize="16sp"
android:visibility="gone" />
<LinearLayout
android:id="@+id/login_code_row"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="12dp"
android:orientation="horizontal"
android:visibility="gone">
<EditText
android:id="@+id/login_code_input"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_secondary_button"
android:hint="验证码"
android:imeOptions="actionDone"
android:inputType="number"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:singleLine="true"
android:textColor="@color/boss_text_primary"
android:textColorHint="@color/boss_text_muted"
android:textSize="16sp" />
<Button
android:id="@+id/login_send_code_button"
android:layout_width="104dp"
android:layout_height="match_parent"
android:layout_marginLeft="10dp"
android:background="@drawable/bg_secondary_button"
android:text="获取验证码"
android:textAllCaps="false"
android:textColor="@color/boss_text_primary"
android:textSize="14sp" />
</LinearLayout>
<ProgressBar
android:id="@+id/login_progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:layout_marginTop="18dp"
android:visibility="gone" />
<Button
@@ -72,6 +157,46 @@
android:textColor="@color/boss_surface"
android:textSize="18sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:gravity="center"
android:orientation="horizontal">
<Button
android:id="@+id/login_mode_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:background="@android:color/transparent"
android:text="账号登录"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textSize="14sp" />
<Button
android:id="@+id/register_mode_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginLeft="8dp"
android:background="@android:color/transparent"
android:text="注册账号"
android:textAllCaps="false"
android:textColor="@color/boss_text_muted"
android:textSize="14sp" />
<Button
android:id="@+id/forgot_mode_button"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginLeft="8dp"
android:background="@android:color/transparent"
android:text="忘记密码"
android:textAllCaps="false"
android:textColor="@color/boss_text_muted"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -188,51 +313,50 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="72dp"
android:layout_height="64dp"
android:background="@color/boss_surface"
android:elevation="10dp"
android:gravity="center"
android:orientation="horizontal"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:paddingLeft="12dp"
android:paddingRight="12dp">
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:paddingLeft="10dp"
android:paddingRight="10dp">
<Button
android:id="@+id/tab_conversations"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginRight="6dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_tab_active"
android:background="@android:color/transparent"
android:text="会话"
android:textAllCaps="false"
android:textColor="@color/boss_green"
android:textSize="12sp"
android:textStyle="bold" />
<Button
android:id="@+id/tab_devices"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginLeft="6dp"
android:layout_marginRight="6dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_tab_inactive"
android:background="@android:color/transparent"
android:text="设备"
android:textAllCaps="false"
android:textColor="@color/boss_text_muted"
android:textSize="12sp"
android:textStyle="bold" />
<Button
android:id="@+id/tab_me"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginLeft="6dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_tab_inactive"
android:background="@android:color/transparent"
android:text="我的"
android:textAllCaps="false"
android:textColor="@color/boss_text_muted"
android:textSize="12sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>

View File

@@ -80,54 +80,90 @@
android:tint="@color/boss_text_primary" />
</LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/screen_refresh_layout"
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_weight="1"
android:clipChildren="false"
android:clipToPadding="false">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/screen_refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
android:layout_height="match_parent">
<LinearLayout
android:id="@+id/project_chat_quick_actions_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_bg_app"
android:orientation="vertical"
android:paddingLeft="12dp"
android:paddingTop="10dp"
android:paddingRight="12dp"
android:paddingBottom="12dp">
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:id="@+id/project_chat_quick_actions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal" />
</LinearLayout>
<ScrollView
android:id="@+id/project_chat_scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls">
<LinearLayout
android:id="@+id/screen_content"
android:id="@+id/project_chat_quick_actions_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_bg_app"
android:orientation="vertical"
android:paddingLeft="12dp"
android:paddingTop="0dp"
android:paddingTop="10dp"
android:paddingRight="12dp"
android:paddingBottom="20dp" />
</ScrollView>
</LinearLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
android:paddingBottom="12dp">
<LinearLayout
android:id="@+id/project_chat_quick_actions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal" />
</LinearLayout>
<ScrollView
android:id="@+id/project_chat_scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true"
android:overScrollMode="ifContentScrolls">
<LinearLayout
android:id="@+id/screen_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingLeft="12dp"
android:paddingTop="0dp"
android:paddingRight="12dp"
android:paddingBottom="20dp" />
</ScrollView>
</LinearLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/project_chat_scroll_bottom"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="bottom|left"
android:layout_marginLeft="12dp"
android:layout_marginBottom="12dp"
android:background="@drawable/bg_chat_scroll_bottom_button"
android:contentDescription="回到底部"
android:elevation="8dp"
android:padding="12dp"
android:scaleType="center"
android:src="@drawable/ic_boss_arrow_down"
android:tint="@color/boss_text_primary"
android:visibility="gone" />
</FrameLayout>
<LinearLayout
android:id="@+id/project_chat_mention_panel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/boss_surface"
android:elevation="8dp"
android:orientation="vertical"
android:paddingLeft="12dp"
android:paddingTop="8dp"
android:paddingRight="12dp"
android:paddingBottom="8dp"
android:visibility="gone" />
<LinearLayout
android:id="@+id/project_chat_composer_row"
@@ -197,9 +233,22 @@
android:visibility="gone">
<Button
android:id="@+id/project_chat_multi_forward"
android:layout_width="match_parent"
android:id="@+id/project_chat_multi_copy"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_marginRight="8dp"
android:layout_weight="1"
android:background="@drawable/bg_secondary_button"
android:text="复制"
android:textAllCaps="false"
android:textColor="@color/boss_text_primary"
android:textStyle="bold" />
<Button
android:id="@+id/project_chat_multi_forward"
android:layout_width="0dp"
android:layout_height="44dp"
android:layout_weight="1"
android:background="@drawable/bg_primary_button"
android:text="转发"
android:textAllCaps="false"

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.Light.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:windowBackground">@color/boss_bg_app</item>
<item name="android:forceDarkAllowed">false</item>
</style>
</resources>

View File

@@ -13,7 +13,6 @@
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:windowBackground">@color/boss_bg_app</item>
<item name="android:forceDarkAllowed">false</item>
</style>

View File

@@ -0,0 +1,145 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class AccessManagementActivityTest {
@Test
public void renderAccessShowsTemplateApplyEntryWhenTemplatesAreAvailable() throws Exception {
TestAccessManagementActivity activity = Robolectric
.buildActivity(TestAccessManagementActivity.class, new Intent())
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccess",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAccessPayload())
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "套用模板"));
assertTrue(viewTreeContainsText(content, "一次性给账号分配设备、项目和 Skill 权限"));
}
@Test
public void renderAccessExplainsUnavailableTargetsInsteadOfBlankState() throws Exception {
TestAccessManagementActivity activity = Robolectric
.buildActivity(TestAccessManagementActivity.class, new Intent())
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccess",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject()
.put("accounts", new JSONArray())
.put("devices", new JSONArray())
.put("projects", new JSONArray())
.put("skills", new JSONArray())
.put("skillCatalog", new JSONArray())
.put("permissionTemplates", new JSONArray())
.put("grants", new JSONObject()
.put("devices", new JSONArray())
.put("projects", new JSONArray())
.put("skills", new JSONArray())))
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "暂无权限模板"));
assertTrue(viewTreeContainsText(content, "暂无可授权设备"));
assertTrue(viewTreeContainsText(content, "暂无可授权项目"));
assertTrue(viewTreeContainsText(content, "暂无可分配 Skill"));
}
@Test
public void buildTemplateApplyPayloadWritesServerTemplateContract() throws Exception {
JSONObject payload = AccessManagementActivity.buildTemplateApplyPayload(
"developer@example.com",
new JSONObject().put("templateId", "developer"),
new JSONObject().put("id", "mac-studio"),
new JSONObject().put("id", "master-agent"),
new JSONObject().put("skillId", "mac-studio:boss-server-debug")
);
assertEquals("apply_template", payload.optString("action"));
assertEquals("developer@example.com", payload.optString("account"));
assertEquals("developer", payload.optString("templateId"));
assertEquals("mac-studio", payload.optJSONArray("deviceIds").optString(0));
assertEquals("master-agent", payload.optJSONArray("projectIds").optString(0));
assertEquals("mac-studio:boss-server-debug", payload.optJSONArray("skillIds").optString(0));
}
private static JSONObject buildAccessPayload() throws Exception {
return new JSONObject()
.put("accounts", new JSONArray()
.put(new JSONObject()
.put("account", "developer@example.com")
.put("displayName", "Developer")
.put("role", "member")))
.put("devices", new JSONArray()
.put(new JSONObject()
.put("id", "mac-studio")
.put("name", "Mac Studio")))
.put("projects", new JSONArray()
.put(new JSONObject()
.put("id", "master-agent")
.put("name", "主 Agent")))
.put("skills", new JSONArray()
.put(new JSONObject()
.put("skillId", "mac-studio:boss-server-debug")
.put("deviceId", "mac-studio")
.put("name", "boss-server-debug")))
.put("skillCatalog", new JSONArray())
.put("permissionTemplates", new JSONArray()
.put(new JSONObject()
.put("templateId", "developer")
.put("name", "项目开发者")
.put("description", "允许聊天和 Skill 调用")))
.put("grants", new JSONObject()
.put("devices", new JSONArray())
.put("projects", new JSONArray())
.put("skills", new JSONArray()));
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (text != null && text.toString().contains(expectedText)) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static final class TestAccessManagementActivity extends AccessManagementActivity {
@Override
protected void reload() {
}
}
}

View File

@@ -81,6 +81,22 @@ public class BossApiClientDispatchPlansTest {
assertEquals("no-cache", connection.getRequestProperty("Pragma"));
}
@Test
public void protectedHtmlResponseReturnsJsonErrorInsteadOfThrowing() throws Exception {
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/auth/session"),
200,
"<!DOCTYPE html><html><body>login</body></html>",
""
);
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getSession();
assertEquals(401, response.statusCode);
assertEquals("NON_JSON_RESPONSE", response.message());
}
@Test
public void confirmDispatchPlanWritesApprovedTargetProjectIds() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/confirm"));
@@ -114,6 +130,19 @@ public class BossApiClientDispatchPlansTest {
assertEquals("{}", connection.requestBody());
}
@Test
public void decideDialogGuardInterventionUsesContractEndpointAndDecisionBody() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/dialog-guard/interventions/intervention-1/decision"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.decideDialogGuardIntervention("intervention-1", "allow_once");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/dialog-guard/interventions/intervention-1/decision", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals("{\"decision\":\"allow_once\"}", connection.requestBody());
}
@Test
public void retryDispatchPlanUsesProjectScopedRetryEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/retry"));
@@ -282,6 +311,153 @@ public class BossApiClientDispatchPlansTest {
assertEquals(20000, connection.readTimeoutValue);
}
@Test
public void deleteProjectMessageUsesProjectScopedDeleteEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1/messages"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.deleteProjectMessage("thread-1", "msg-1");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/thread-1/messages?messageId=msg-1", apiClient.lastPath);
assertEquals("DELETE", connection.requestMethodValue);
}
@Test
public void storageConfigMethodsUseDedicatedStorageEndpoints() throws Exception {
RecordingConnection getConnection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/storage/config"));
RecordingBossApiClient getClient = new RecordingBossApiClient(getConnection);
getClient.getAttachmentStorageConfig();
assertEquals("/api/v1/storage/config", getClient.lastPath);
assertEquals("GET", getConnection.requestMethodValue);
RecordingConnection saveConnection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/storage/config"));
RecordingBossApiClient saveClient = new RecordingBossApiClient(saveConnection);
saveClient.saveAttachmentStorageConfig(new JSONObject().put("mode", "server_file"));
assertEquals("/api/v1/storage/config", saveClient.lastPath);
assertEquals("PATCH", saveConnection.requestMethodValue);
assertEquals("{\"mode\":\"server_file\"}", saveConnection.requestBody());
RecordingConnection validateConnection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/storage/config/validate"));
RecordingBossApiClient validateClient = new RecordingBossApiClient(validateConnection);
validateClient.validateAttachmentStorageConfig(new JSONObject().put("mode", "oss"));
assertEquals("/api/v1/storage/config/validate", validateClient.lastPath);
assertEquals("POST", validateConnection.requestMethodValue);
assertEquals("{\"mode\":\"oss\"}", validateConnection.requestBody());
}
@Test
public void protectedRequestFallsBackToAutoLoginWhenNoRestoreTokenExists() throws Exception {
SequencedBossApiClient apiClient = new SequencedBossApiClient(
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/projects/project-1"),
401,
"{\"ok\":false,\"message\":\"UNAUTHORIZED\"}",
"{\"ok\":false,\"message\":\"UNAUTHORIZED\"}"
),
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/projects/project-1"),
200,
"{\"ok\":true,\"project\":{\"id\":\"project-1\",\"name\":\"北区试产线\"}}",
"{\"ok\":false}"
)
);
BossApiClient.ApiResponse response = apiClient.getProjectDetail("project-1");
assertEquals(1, apiClient.autoLoginCalls);
assertEquals(2, apiClient.protectedRequestCount);
assertEquals(200, response.statusCode);
assertEquals("北区试产线", response.json.optJSONObject("project").optString("name"));
}
@Test
public void autoLoginCapturesSessionCookieFromMixedCaseHeaderNames() throws Exception {
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/login"));
connection.responseHeaders.put(
"Set-cookie",
Collections.singletonList("boss_session=session-from-mixed-case; Path=/; HttpOnly")
);
IdentityCapturingBossApiClient apiClient = new IdentityCapturingBossApiClient(connection, prefs);
BossApiClient.ApiResponse response = apiClient.autoLogin();
assertEquals(200, response.statusCode);
assertEquals("boss_session=session-from-mixed-case", prefs.getString("session_cookie", ""));
assertEquals("krisolo", prefs.getString("account", ""));
assertEquals("Boss 超级管理员", prefs.getString("display_name", ""));
}
@Test
public void loginWithPasswordPostsCredentialsAndCapturesNativeRestoreToken() throws Exception {
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/auth/login"),
200,
"{\"ok\":true,\"account\":\"krisolo\",\"displayName\":\"Boss 超级管理员\",\"restoreToken\":\"restore-login\"}",
"{\"ok\":false}"
);
connection.responseHeaders.put(
"Set-cookie",
Collections.singletonList("boss_session=session-from-login; Path=/; HttpOnly")
);
IdentityCapturingBossApiClient apiClient = new IdentityCapturingBossApiClient(connection, prefs);
BossApiClient.ApiResponse response = apiClient.loginWithPassword("krisolo", "Admin_yqs_asd.");
assertEquals(200, response.statusCode);
assertEquals("/api/auth/login", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(
"{\"account\":\"krisolo\",\"password\":\"Admin_yqs_asd.\",\"method\":\"password\"}",
connection.requestBody()
);
assertEquals("boss_session=session-from-login", prefs.getString("session_cookie", ""));
assertEquals("restore-login", prefs.getString("restore_token", ""));
assertEquals("krisolo", prefs.getString("account", ""));
}
@Test
public void authRegistrationAndPasswordResetUseDedicatedNativeRoutes() throws Exception {
ScriptedBossApiClient apiClient = new ScriptedBossApiClient(
new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/send-code")),
new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/register")),
new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/forgot-password"))
);
BossApiClient.ApiResponse codeResponse = apiClient.sendVerificationCode("new-user", "register");
assertEquals(200, codeResponse.statusCode);
assertEquals("/api/auth/send-code", apiClient.lastPath);
assertEquals("{\"account\":\"new-user\",\"purpose\":\"register\"}", apiClient.lastConnection.requestBody());
BossApiClient.ApiResponse registerResponse = apiClient.registerAccount(
"new-user",
"New_password_123",
"New_password_123",
"123456"
);
assertEquals(200, registerResponse.statusCode);
assertEquals("/api/auth/register", apiClient.lastPath);
assertEquals(
"{\"account\":\"new-user\",\"password\":\"New_password_123\",\"confirmPassword\":\"New_password_123\",\"code\":\"123456\"}",
apiClient.lastConnection.requestBody()
);
BossApiClient.ApiResponse resetResponse = apiClient.resetPassword(
"new-user",
"Reset_password_123",
"Reset_password_123",
"654321"
);
assertEquals(200, resetResponse.statusCode);
assertEquals("/api/auth/forgot-password", apiClient.lastPath);
assertEquals(
"{\"account\":\"new-user\",\"password\":\"Reset_password_123\",\"confirmPassword\":\"Reset_password_123\",\"code\":\"654321\"}",
apiClient.lastConnection.requestBody()
);
}
@Test
public void onboardOpenAiApiAccountUsesDedicatedRouteAndSetsActive() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"));
@@ -308,7 +484,7 @@ public class BossApiClientDispatchPlansTest {
public void rememberIdentityDoesNotOverwriteSessionIdentityFromAiAccountOnboardingResponse() throws Exception {
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
prefs.edit()
.putString("account", "17600003315")
.putString("account", "krisolo")
.putString("display_name", "Boss 超级管理员")
.apply();
BossApiClient apiClient = new BossApiClient(prefs, "https://boss.hyzq.net");
@@ -321,7 +497,7 @@ public class BossApiClientDispatchPlansTest {
apiClient.rememberIdentity(onboardingResponse);
assertEquals("17600003315", apiClient.getAccountLabel());
assertEquals("krisolo", apiClient.getAccountLabel());
assertEquals("Boss 超级管理员", apiClient.getDisplayName());
}
@@ -359,7 +535,11 @@ public class BossApiClientDispatchPlansTest {
private String lastPath = "";
RecordingBossApiClient(RecordingConnection connection) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this(connection, new InMemorySharedPreferences());
}
RecordingBossApiClient(RecordingConnection connection, SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
this.connection = connection;
}
@@ -383,6 +563,7 @@ public class BossApiClientDispatchPlansTest {
private static final class ScriptedBossApiClient extends BossApiClient {
private final Map<String, RecordingConnection> connections;
private String lastPath = "";
private RecordingConnection lastConnection;
ScriptedBossApiClient(RecordingConnection... connections) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
@@ -399,6 +580,7 @@ public class BossApiClientDispatchPlansTest {
if (connection == null) {
throw new IllegalStateException("Missing scripted connection for " + path);
}
lastConnection = connection;
return connection;
}
@@ -413,6 +595,65 @@ public class BossApiClientDispatchPlansTest {
}
}
private static final class SequencedBossApiClient extends BossApiClient {
private final java.util.ArrayDeque<RecordingConnection> protectedConnections = new java.util.ArrayDeque<>();
private int autoLoginCalls;
private int protectedRequestCount;
SequencedBossApiClient(RecordingConnection... protectedConnections) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
Collections.addAll(this.protectedConnections, protectedConnections);
}
@Override
public ApiResponse autoLogin() throws org.json.JSONException {
autoLoginCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("account", "krisolo")
.put("displayName", "Boss 超级管理员"));
}
@Override
HttpURLConnection openConnection(String path) {
if (!"/api/v1/projects/project-1".equals(path)) {
throw new IllegalStateException("Unexpected path " + path);
}
protectedRequestCount += 1;
RecordingConnection connection = protectedConnections.pollFirst();
if (connection == null) {
throw new IllegalStateException("No more scripted protected responses");
}
return connection;
}
@Override
String encode(String value) {
return value;
}
@Override
void rememberIdentity(JSONObject json) {
// no-op for JVM unit test
}
}
private static final class IdentityCapturingBossApiClient extends BossApiClient {
private final RecordingConnection connection;
private String lastPath = "";
IdentityCapturingBossApiClient(RecordingConnection connection, SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
this.connection = connection;
}
@Override
HttpURLConnection openConnection(String path) {
lastPath = path;
return connection;
}
}
private static final class RecordingConnection extends HttpURLConnection {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private final Map<String, String> requestHeaders = new HashMap<>();
@@ -422,9 +663,15 @@ public class BossApiClientDispatchPlansTest {
private final int responseCodeValue;
private final String responseBody;
private final String errorBody;
private final Map<String, java.util.List<String>> responseHeaders = new HashMap<>();
RecordingConnection(URL url) {
this(url, 200, "{\"ok\":true}", "{\"ok\":false}");
this(
url,
200,
"{\"ok\":true,\"account\":\"krisolo\",\"displayName\":\"Boss 超级管理员\"}",
"{\"ok\":false}"
);
}
RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
@@ -493,6 +740,11 @@ public class BossApiClientDispatchPlansTest {
return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8));
}
@Override
public Map<String, java.util.List<String>> getHeaderFields() {
return responseHeaders;
}
String requestBody() {
return requestBody.toString(StandardCharsets.UTF_8);
}

View File

@@ -0,0 +1,116 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import android.app.NotificationManager;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.SharedPreferences;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.shadows.ShadowNotificationManager;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossBackgroundRealtimeServiceTest {
@After
public void tearDown() {
TestBossBackgroundRealtimeService.runtimeOverride = null;
}
@Test
public void manifestDeclaresForegroundDataSyncPermission() throws Exception {
Context context = RuntimeEnvironment.getApplication();
PackageManager packageManager = context.getPackageManager();
PackageInfo packageInfo = packageManager.getPackageInfo(
context.getPackageName(),
PackageManager.GET_PERMISSIONS
);
assertNotNull(packageInfo.requestedPermissions);
org.junit.Assert.assertTrue(
java.util.Arrays.asList(packageInfo.requestedPermissions)
.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC")
);
}
@Test
public void startCommandStartsForegroundSyncAndRealtimeWhenSessionExists() {
Context context = RuntimeEnvironment.getApplication();
SharedPreferences prefs = context.getSharedPreferences("boss-background-service", Context.MODE_PRIVATE);
prefs.edit()
.putString("session_cookie", "boss_session=test")
.putString("restore_token", "restore-test")
.apply();
RecordingRealtimeRuntime runtime = new RecordingRealtimeRuntime();
TestBossBackgroundRealtimeService.runtimeOverride = runtime;
TestBossBackgroundRealtimeService service = Robolectric
.buildService(TestBossBackgroundRealtimeService.class)
.create()
.startCommand(0, 1)
.get();
ShadowNotificationManager notificationManager = Shadows.shadowOf(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
);
assertEquals(1, runtime.startCount);
assertEquals(
1,
notificationManager.size()
);
assertEquals(
"Boss 后台同步中",
String.valueOf(
notificationManager
.getNotification(BossBackgroundRealtimeService.SERVICE_NOTIFICATION_ID)
.extras
.getCharSequence(android.app.Notification.EXTRA_TITLE)
)
);
service.onDestroy();
assertEquals(1, runtime.stopCount);
}
public static class TestBossBackgroundRealtimeService extends BossBackgroundRealtimeService {
static RecordingRealtimeRuntime runtimeOverride;
@Override
BossRealtimeRuntime createRealtimeRuntime(BossApiClient apiClient, BossNotificationRouter router) {
return runtimeOverride == null ? super.createRealtimeRuntime(apiClient, router) : runtimeOverride;
}
@Override
BossApiClient createApiClient() {
Context context = RuntimeEnvironment.getApplication();
SharedPreferences prefs = context.getSharedPreferences("boss-background-service", Context.MODE_PRIVATE);
return new BossApiClient(prefs, "https://boss.hyzq.net");
}
}
static final class RecordingRealtimeRuntime implements BossBackgroundRealtimeService.BossRealtimeRuntime {
int startCount;
int stopCount;
@Override
public void start() {
startCount += 1;
}
@Override
public void stop() {
stopCount += 1;
}
}
}

View File

@@ -0,0 +1,133 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import android.app.Notification;
import android.app.NotificationManager;
import android.content.Context;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.shadows.ShadowNotificationManager;
import org.robolectric.shadows.ShadowApplication;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossNotificationRouterTest {
@Test
public void visibilityTrackerMarksForegroundAndVisibleProject() {
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
tracker.onAppForegrounded();
tracker.setVisibleProjectId("master-agent");
assertTrue(tracker.isAppInForeground());
assertEquals("master-agent", tracker.getVisibleProjectId());
tracker.clearVisibleProjectId("master-agent");
tracker.onAppBackgrounded();
assertFalse(tracker.isAppInForeground());
assertNull(tracker.getVisibleProjectId());
}
@Test
public void routerNotifiesOnlyForNewMasterAgentRepliesWhileBackgrounded() throws Exception {
Context context = RuntimeEnvironment.getApplication();
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
tracker.onAppBackgrounded();
BossNotificationRouter router = new BossNotificationRouter(context, tracker);
ShadowNotificationManager notificationManager = Shadows.shadowOf(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
);
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
JSONObject message = new JSONObject()
.put("id", "m-2")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "主 Agent 已完成同步。")
.put("sentAt", "2026-04-21T10:00:00.000Z");
JSONObject payload = new JSONObject()
.put("projectId", "master-agent")
.put("projectMessagesPayload", new JSONObject().put(
"project",
new JSONObject().put("messages", new JSONArray().put(message))
));
assertTrue(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
assertFalse(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
assertEquals(1, notificationManager.size());
Notification posted = notificationManager.getNotification(BossNotificationRouter.MASTER_AGENT_NOTIFICATION_ID);
assertEquals("主 Agent", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TITLE)));
assertEquals("主 Agent 已完成同步。", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TEXT)));
}
@Test
public void routerNotifiesForMasterAgentRepliesInsideThreadConversationsWhileBackgrounded() throws Exception {
Context context = RuntimeEnvironment.getApplication();
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
tracker.onAppBackgrounded();
BossNotificationRouter router = new BossNotificationRouter(context, tracker);
ShadowNotificationManager notificationManager = Shadows.shadowOf(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
);
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
JSONObject message = new JSONObject()
.put("id", "thread-master-reply-1")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "我已接管这个线程,下一步先核对当前目标。");
JSONObject payload = new JSONObject()
.put("projectId", "aiyanjing-thread")
.put("projectMessagesPayload", new JSONObject().put(
"project",
new JSONObject()
.put("name", "AI 眼镜线程")
.put("messages", new JSONArray().put(message))
));
assertTrue(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
Notification posted = notificationManager.getNotification(BossNotificationRouter.MASTER_AGENT_NOTIFICATION_ID);
assertEquals("主 Agent · AI 眼镜线程", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TITLE)));
assertEquals("我已接管这个线程,下一步先核对当前目标。", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TEXT)));
}
@Test
public void routerSuppressesNotificationWhileAppIsForeground() throws Exception {
Context context = RuntimeEnvironment.getApplication();
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
tracker.onAppForegrounded();
BossNotificationRouter router = new BossNotificationRouter(context, tracker);
ShadowNotificationManager notificationManager = Shadows.shadowOf(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
);
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
JSONObject message = new JSONObject()
.put("id", "m-3")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "这条前台不该弹通知。");
JSONObject payload = new JSONObject()
.put("projectId", "master-agent")
.put("projectMessagesPayload", new JSONObject().put(
"project",
new JSONObject().put("messages", new JSONArray().put(message))
));
assertFalse(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
assertEquals(0, notificationManager.size());
}
}

View File

@@ -0,0 +1,42 @@
package com.hyzq.boss;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossRbacVisibilityTest {
@Test
public void memberMeMenuHidesAdministratorControlEntries() {
assertArrayEquals(
new String[]{"账号与安全", "设置", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitlesForRole("member")
);
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("access", "member"));
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("ai_accounts", "member"));
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("ops", "member"));
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("storage", "member"));
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("telegram", "member"));
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("skills", "member"));
}
@Test
public void administratorMeMenuKeepsControlEntries() {
assertArrayEquals(
new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitlesForRole("highest_admin")
);
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("access", "highest_admin"));
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("access", "admin"));
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("ai_accounts", "highest_admin"));
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("ops", "admin"));
}
}

View File

@@ -1,10 +1,13 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.Button;
import android.widget.TextView;
import org.json.JSONObject;
@@ -13,6 +16,7 @@ import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@@ -21,31 +25,41 @@ public class BossUiRootSurfaceTest {
@Test
public void renderMeRoot_usesWechatProfileHeaderAndFlatMenuRows() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "me"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
ReflectionHelpers.setField(
activity,
"sessionData",
new JSONObject()
.put("displayName", "Kris")
.put("account", "17600003315")
.put("account", "krisolo")
.put("role", "highest_admin")
);
ReflectionHelpers.callInstanceMethod(activity, "renderMeRoot");
LinearLayout content = activity.findViewById(R.id.screen_content);
assertEquals("我的页应是资料头 + 6 条菜单", 7, content.getChildCount());
LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
assertEquals("我的页应是资料头 + 9 条菜单", 10, content.getChildCount());
View header = content.getChildAt(0);
assertEquals("资料头不应保留浮层卡片感", 0f, header.getElevation(), 0.01f);
assertTrue(viewTreeContainsText(header, "Kris"));
assertTrue(viewTreeContainsText(header, "17600003315"));
assertTrue(viewTreeContainsText(header, "krisolo"));
assertTrue(viewTreeContainsText(header, "最高管理员"));
assertTrue(viewTreeContainsText(header, "主控账号已启用安全保护"));
assertTrue(viewTreeContainsText(content, "账号与安全"));
assertTrue(viewTreeContainsText(content, "设置"));
assertTrue(viewTreeContainsText(content, "用户与权限"));
assertTrue(viewTreeContainsText(content, "运维与修复"));
assertTrue(viewTreeContainsText(content, "AI 账号"));
assertTrue(viewTreeContainsText(content, "附件与存储"));
assertTrue(viewTreeContainsText(content, "Telegram 接入"));
assertTrue(viewTreeContainsText(content, "技能"));
assertTrue(viewTreeContainsText(content, "关于"));
@@ -55,6 +69,44 @@ public class BossUiRootSurfaceTest {
}
}
@Test
public void openMeEntry_storageStartsAttachmentStorageSettings() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(
activity,
"sessionData",
new JSONObject().put("role", "highest_admin")
);
ReflectionHelpers.callInstanceMethod(
activity,
"openMeEntry",
ReflectionHelpers.ClassParameter.from(String.class, "storage")
);
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
assertNotNull(started);
assertEquals(StorageSettingsActivity.class.getName(), started.getComponent().getClassName());
}
@Test
public void rootTabs_useWechatIconLabelNavigation() {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Button conversations = activity.findViewById(R.id.tab_conversations);
Button devices = activity.findViewById(R.id.tab_devices);
Button me = activity.findViewById(R.id.tab_me);
assertEquals("会话", conversations.getText().toString());
assertEquals("设备", devices.getText().toString());
assertEquals("我的", me.getText().toString());
assertNotNull("会话 tab 应显示顶部图标", conversations.getCompoundDrawables()[1]);
assertNotNull("设备 tab 应显示顶部图标", devices.getCompoundDrawables()[1]);
assertNotNull("我的 tab 应显示顶部图标", me.getCompoundDrawables()[1]);
assertEquals("底栏文字应压成微信式小字号", 12f, conversations.getTextSize() / activity.getResources().getDisplayMetrics().scaledDensity, 0.5f);
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();

View File

@@ -22,6 +22,7 @@ import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowDialog;
import java.time.Duration;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@@ -145,7 +146,7 @@ public class ConversationFolderActivityTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.reloadCount);
}

View File

@@ -7,6 +7,8 @@ import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.Intent;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
@@ -15,6 +17,7 @@ import android.widget.ListView;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.SwitchCompat;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -37,7 +40,7 @@ import java.util.concurrent.TimeUnit;
@Config(sdk = 34)
public class ConversationInfoActivityTest {
@Test
public void renderConversationUsesLightweightHeaderMenuAndThreadList() throws Exception {
public void renderConversationOmitsProfileHeaderAndStartsWithUsefulSettings() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
@@ -55,22 +58,81 @@ public class ConversationInfoActivityTest {
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content.getChildAt(0), "北区试产线回归"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "单线程会话"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "线程状态摘要"));
assertTrue(viewTreeContainsTextFragment(content.getChildAt(1), "当前进度:已经记录最近 2 条进展"));
assertTrue(viewTreeContainsTextFragment(content.getChildAt(1), "建议下一步:继续同步 Android 只读页"));
assertTrue(viewTreeContainsText(content.getChildAt(2), "主 Agent 协同接管"));
assertTrue(viewTreeContainsText(content.getChildAt(3), "发起群聊"));
assertTrue(viewTreeContainsText(content.getChildAt(3), "选择其他线程加入新群"));
assertTrue(viewTreeContainsText(content.getChildAt(4), "线程详情"));
assertTrue(viewTreeContainsText(content.getChildAt(4), "查看当前线程聊天与项目"));
assertFalse(viewTreeContainsText(content, "线程状态摘要"));
assertFalse(viewTreeContainsTextFragment(content, "当前进度:已经记录最近 2 条进展"));
assertFalse(viewTreeContainsTextFragment(content, "建议下一步:继续同步 Android 只读页"));
assertFalse(viewTreeContainsText(content, "单线程会话"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "主 Agent 协同接管"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "发起群聊"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "选择其他线程加入新群"));
assertTrue(viewTreeContainsText(content.getChildAt(2), "线程详情"));
assertTrue(viewTreeContainsText(content.getChildAt(2), "查看当前线程聊天与项目"));
assertTrue(viewTreeContainsText(content, "参与线程"));
assertTrue(viewTreeContainsText(content, "硬件审计协作"));
assertFalse(viewTreeContainsText(content, "从当前会话选择其他线程,创建新的独立群聊"));
assertFalse(viewTreeContainsText(content, "以下线程参与当前会话,点击可查看对应项目详情。"));
}
@Test
public void takeoverControlUsesWechatRowVisualSystem() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestConversationInfoActivity activity = Robolectric
.buildActivity(TestConversationInfoActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderConversation",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildThreadStatusPayload())
);
LinearLayout content = activity.findViewById(R.id.screen_content);
LinearLayout takeoverRow = (LinearLayout) content.getChildAt(0);
SwitchCompat takeoverSwitch = findFirstSwitch(takeoverRow);
assertEquals(LinearLayout.HORIZONTAL, takeoverRow.getOrientation());
assertEquals(BossUi.dp(activity, 18), takeoverRow.getPaddingLeft());
assertEquals(BossUi.dp(activity, 18), takeoverRow.getPaddingRight());
assertNotNull(takeoverSwitch);
assertEquals("", String.valueOf(takeoverSwitch.getText()));
}
@Test
public void conversationInfoRowsUseConsistentSpacingAndTakeoverHasNoDividerLines() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestConversationInfoActivity activity = Robolectric
.buildActivity(TestConversationInfoActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderConversation",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildThreadStatusPayload())
);
LinearLayout content = activity.findViewById(R.id.screen_content);
int expectedBottomMargin = BossUi.dp(activity, 8);
for (int index = 0; index < Math.min(content.getChildCount(), 6); index += 1) {
View child = content.getChildAt(index);
assertTrue(child.getLayoutParams() instanceof LinearLayout.LayoutParams);
assertEquals(expectedBottomMargin, ((LinearLayout.LayoutParams) child.getLayoutParams()).bottomMargin);
}
View takeoverRow = content.getChildAt(0);
assertTrue(takeoverRow.getBackground() instanceof ColorDrawable);
assertEquals(Color.WHITE, ((ColorDrawable) takeoverRow.getBackground()).getColor());
}
@Test
public void threadDetailMenuRowStillOpensProjectDetail() throws Exception {
Intent intent = new Intent()
@@ -235,6 +297,42 @@ public class ConversationInfoActivityTest {
assertEquals(1, apiClient.autoLoginCalls);
}
@Test
public void saveTakeoverSettingReturnsUpdatedResultState() {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestConversationInfoActivity activity = Robolectric
.buildActivity(TestConversationInfoActivity.class, intent)
.setup()
.get();
RecordingBossApiClient apiClient = new RecordingBossApiClient(
activity.getSharedPreferences("conversation-info-save-result-test", Context.MODE_PRIVATE),
"https://boss.hyzq.net"
);
apiClient.failFirstLoad = false;
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.setField(activity, "reloadEnabled", true);
ReflectionHelpers.setField(activity, "delegateReloadToSuper", true);
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
activity.reload();
ShadowLooper.shadowMainLooper().idle();
ReflectionHelpers.callInstanceMethod(
activity,
"saveTakeoverSetting",
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
ShadowLooper.shadowMainLooper().idle();
assertEquals(android.app.Activity.RESULT_OK, Shadows.shadowOf(activity).getResultCode());
Intent resultIntent = Shadows.shadowOf(activity).getResultIntent();
assertNotNull(resultIntent);
assertTrue(resultIntent.getBooleanExtra(ConversationInfoActivity.EXTRA_TAKEOVER_ENABLED, false));
assertEquals("北区试产线回归", resultIntent.getStringExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME));
}
@Test
public void matchingProjectMessagesUpdatedEventTriggersReload() throws Exception {
Intent intent = new Intent()
@@ -393,6 +491,23 @@ public class ConversationInfoActivityTest {
return null;
}
private static SwitchCompat findFirstSwitch(View root) {
if (root instanceof SwitchCompat) {
return (SwitchCompat) root;
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
SwitchCompat match = findFirstSwitch(group.getChildAt(index));
if (match != null) {
return match;
}
}
return null;
}
public static class TestConversationInfoActivity extends ConversationInfoActivity {
private boolean reloadEnabled;
private boolean delegateReloadToSuper;
@@ -474,7 +589,7 @@ public class ConversationInfoActivityTest {
200,
new JSONObject()
.put("ok", true)
.put("session", new JSONObject().put("account", "17600003315"))
.put("session", new JSONObject().put("account", "krisolo"))
);
}

View File

@@ -297,7 +297,7 @@ public class DeviceDetailActivityTest {
.put("id", "device-1")
.put("name", "Mac Studio")
.put("avatar", "M")
.put("account", "17600003315")
.put("account", "krisolo")
.put("status", "online")
.put("quota5h", 75)
.put("quota7d", 88)

View File

@@ -0,0 +1,279 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import android.content.Context;
import android.content.SharedPreferences;
import android.view.View;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
import java.time.Duration;
import java.util.function.BooleanSupplier;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class MainActivityBootstrapSessionTest {
@Test
public void bootstrapSession_withoutSessionHints_showsLoginFormAndDoesNotAutoLogin() throws Exception {
TestBootstrapSessionMainActivity activity =
Robolectric.buildActivity(TestBootstrapSessionMainActivity.class).setup().get();
SharedPreferences prefs = activity.getSharedPreferences("test-bootstrap-session", Context.MODE_PRIVATE);
prefs.edit().clear().apply();
Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(200));
View loginPanel = activity.findViewById(R.id.login_panel);
View contentPanel = activity.findViewById(R.id.content_panel);
android.widget.EditText accountInput = activity.findViewById(R.id.login_account_input);
android.widget.EditText passwordInput = activity.findViewById(R.id.login_password_input);
assertEquals(0, activity.apiClient.autoLoginCalls);
assertEquals(View.VISIBLE, loginPanel.getVisibility());
assertEquals(View.GONE, contentPanel.getVisibility());
assertNotNull(accountInput);
assertNotNull(passwordInput);
assertFalse(accountInput.getHint().toString().isEmpty());
assertFalse(passwordInput.getHint().toString().isEmpty());
}
@Test
public void bootstrapSession_withSessionHints_prefersRestoreAndDoesNotAutoLogin() throws Exception {
TestRestoreBootstrapSessionMainActivity activity =
Robolectric.buildActivity(TestRestoreBootstrapSessionMainActivity.class).setup().get();
waitFor(() -> activity.apiClient.restoreCalls > 0 && activity.apiClient.homeCalls > 0);
View loginPanel = activity.findViewById(R.id.login_panel);
View contentPanel = activity.findViewById(R.id.content_panel);
JSONObject sessionData = ReflectionHelpers.getField(activity, "sessionData");
assertEquals(0, activity.apiClient.autoLoginCalls);
assertEquals(1, activity.apiClient.getSessionCalls);
assertEquals(1, activity.apiClient.restoreCalls);
assertEquals(View.GONE, loginPanel.getVisibility());
assertEquals(View.VISIBLE, contentPanel.getVisibility());
assertNotNull(sessionData);
assertEquals("krisolo", sessionData.optString("account", ""));
}
private static void waitFor(BooleanSupplier condition) {
long deadline = System.currentTimeMillis() + 5_000L;
while (System.currentTimeMillis() < deadline) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(50));
if (condition.getAsBoolean()) {
return;
}
}
throw new AssertionError("Condition not met before timeout");
}
public static class TestBootstrapSessionMainActivity extends MainActivity {
RecordingBootstrapApiClient apiClient;
@Override
BossApiClient createApiClient() {
apiClient = new RecordingBootstrapApiClient(
getSharedPreferences("test-bootstrap-session", Context.MODE_PRIVATE)
);
return apiClient;
}
@Override
BossRealtimeClient createRealtimeClient(BossApiClient client) {
return new BossRealtimeClient(client, new BossRealtimeClient.Listener() {
@Override
public void onRealtimeEvent(BossRealtimeEvent event) {}
});
}
}
public static class TestRestoreBootstrapSessionMainActivity extends MainActivity {
RecordingRestoreBootstrapApiClient apiClient;
@Override
BossApiClient createApiClient() {
apiClient = new RecordingRestoreBootstrapApiClient(
getSharedPreferences("test-bootstrap-session-restore", Context.MODE_PRIVATE)
);
return apiClient;
}
@Override
BossRealtimeClient createRealtimeClient(BossApiClient client) {
return new BossRealtimeClient(client, new BossRealtimeClient.Listener() {
@Override
public void onRealtimeEvent(BossRealtimeEvent event) {}
});
}
}
private static final class RecordingBootstrapApiClient extends BossApiClient {
int autoLoginCalls;
int homeCalls;
int devicesCalls;
int otaCalls;
int settingsCalls;
RecordingBootstrapApiClient(SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
}
@Override
public boolean hasSessionHints() {
return false;
}
@Override
public ApiResponse autoLogin() throws java.io.IOException, org.json.JSONException {
autoLoginCalls += 1;
JSONObject session = new JSONObject()
.put("account", "krisolo")
.put("displayName", "Boss 超级管理员")
.put("restoreToken", "restore-auto");
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("session", session));
}
@Override
public ApiResponse restoreSession() throws java.io.IOException, org.json.JSONException {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_RESTORE_TOKEN"));
}
@Override
public ApiResponse getSession() throws java.io.IOException, org.json.JSONException {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_SESSION"));
}
@Override
public ApiResponse getConversationHome() throws java.io.IOException, org.json.JSONException {
homeCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("conversations", new JSONArray().put(new JSONObject()
.put("projectId", "master-agent")
.put("conversationType", "master_agent")
.put("projectTitle", "主 Agent")
.put("threadTitle", "主 Agent 汇总")
.put("lastMessagePreview", "最近会话已恢复")
.put("latestReplyLabel", "刚刚"))));
}
@Override
public ApiResponse getDevices() throws java.io.IOException, org.json.JSONException {
devicesCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("devices", new JSONArray()));
}
@Override
public ApiResponse getOtaStatus() throws java.io.IOException, org.json.JSONException {
otaCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("hasOta", false));
}
@Override
public ApiResponse getSettings() throws java.io.IOException, org.json.JSONException {
settingsCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("settings", new JSONObject().put("preferredEntryPoint", "conversations"))
.put("user", new JSONObject()));
}
}
private static final class RecordingRestoreBootstrapApiClient extends BossApiClient {
int autoLoginCalls;
int getSessionCalls;
int restoreCalls;
int homeCalls;
int devicesCalls;
int otaCalls;
int settingsCalls;
RecordingRestoreBootstrapApiClient(SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
prefs.edit()
.putString("session_cookie", "boss_session=test")
.putString("restore_token", "restore-test")
.apply();
}
@Override
public ApiResponse autoLogin() throws java.io.IOException, org.json.JSONException {
autoLoginCalls += 1;
return ApiResponse.error(500, new JSONObject().put("ok", false).put("message", "AUTO_LOGIN_SHOULD_NOT_RUN"));
}
@Override
public ApiResponse getSession() throws java.io.IOException, org.json.JSONException {
getSessionCalls += 1;
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "SESSION_EXPIRED"));
}
@Override
public ApiResponse restoreSession() throws java.io.IOException, org.json.JSONException {
restoreCalls += 1;
JSONObject session = new JSONObject()
.put("account", "krisolo")
.put("displayName", "Boss 超级管理员")
.put("restoreToken", "restore-test");
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("session", session));
}
@Override
public ApiResponse getConversationHome() throws java.io.IOException, org.json.JSONException {
homeCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("conversations", new JSONArray().put(new JSONObject()
.put("projectId", "master-agent")
.put("conversationType", "master_agent")
.put("projectTitle", "主 Agent")
.put("threadTitle", "主 Agent 汇总")
.put("lastMessagePreview", "最近会话已恢复")
.put("latestReplyLabel", "刚刚"))));
}
@Override
public ApiResponse getDevices() throws java.io.IOException, org.json.JSONException {
devicesCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("devices", new JSONArray()));
}
@Override
public ApiResponse getOtaStatus() throws java.io.IOException, org.json.JSONException {
otaCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("hasOta", false));
}
@Override
public ApiResponse getSettings() throws java.io.IOException, org.json.JSONException {
settingsCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("settings", new JSONObject().put("preferredEntryPoint", "conversations"))
.put("user", new JSONObject()));
}
}
}

View File

@@ -1,13 +1,21 @@
package com.hyzq.boss;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import android.Manifest;
import android.content.Context;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowApplication;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@@ -18,6 +26,10 @@ public class MainActivityConversationAutoRefreshTest {
org.robolectric.android.controller.ActivityController<MainActivity> controller =
Robolectric.buildActivity(MainActivity.class).setup().resume();
MainActivity activity = controller.get();
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
.edit()
.putString("session_cookie", "boss_session=test")
.apply();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
@@ -35,4 +47,53 @@ public class MainActivityConversationAutoRefreshTest {
controller.pause();
assertFalse(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
}
@Test
public void returningToVisibleConversationRootRefreshesImmediatelyOnResume() {
org.robolectric.android.controller.ActivityController<TestResumeRefreshMainActivity> controller =
Robolectric.buildActivity(TestResumeRefreshMainActivity.class).setup().resume();
TestResumeRefreshMainActivity activity = controller.get();
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
.edit()
.putString("session_cookie", "boss_session=test")
.apply();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
activity.conversationRefreshCount = 0;
controller.pause();
controller.resume();
assertEquals(1, activity.conversationRefreshCount);
}
@Test
public void showContent_doesNotRequestNotificationPermissionInSameTapFrame() {
ShadowApplication.getInstance().denyPermissions(Manifest.permission.POST_NOTIFICATIONS);
org.robolectric.android.controller.ActivityController<MainActivity> controller =
Robolectric.buildActivity(MainActivity.class).setup();
MainActivity activity = controller.get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
assertNull(Shadows.shadowOf(activity).getLastRequestedPermission());
Shadows.shadowOf(activity.getMainLooper()).idleFor(java.time.Duration.ofMillis(500));
assertNotNull(Shadows.shadowOf(activity).getLastRequestedPermission());
assertEquals(1, Shadows.shadowOf(activity).getLastRequestedPermission().requestedPermissions.length);
assertEquals(
Manifest.permission.POST_NOTIFICATIONS,
Shadows.shadowOf(activity).getLastRequestedPermission().requestedPermissions[0]
);
}
public static class TestResumeRefreshMainActivity extends MainActivity {
int conversationRefreshCount;
@Override
void refreshConversationsData() {
conversationRefreshCount += 1;
completeRealtimeTabRefresh();
}
}
}

View File

@@ -7,6 +7,7 @@ import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.Intent;
import android.Manifest;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
@@ -25,6 +26,7 @@ import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.shadow.api.Shadow;
import org.robolectric.shadows.ShadowInputMethodManager;
import org.robolectric.shadows.ShadowApplication;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@@ -148,6 +150,7 @@ public class MainActivityConversationSearchTest {
@Test
public void searchHitOnSingleThread_exitsSearchModeAndOpensProjectDetail() throws Exception {
ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.callInstanceMethod(activity, "showContent");
@@ -180,6 +183,7 @@ public class MainActivityConversationSearchTest {
@Test
public void searchHitInsideArchivedProject_opensMatchedThreadDetailAndClearsSearchState() throws Exception {
ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray()
.put(new JSONObject()
@@ -221,6 +225,7 @@ public class MainActivityConversationSearchTest {
@Test
public void archivedProjectSearchByFolderName_stillOpensFolderPage() throws Exception {
ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray()
.put(new JSONObject()

View File

@@ -90,6 +90,7 @@ public class MainActivityConversationSelectionTest {
public void topPlusAction_opensWechatStyleDropdownMenu() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.setField(activity, "sessionData", new JSONObject().put("role", "highest_admin"));
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
@@ -106,6 +107,27 @@ public class MainActivityConversationSelectionTest {
assertTrue(viewTreeContainsText(menu, "发起群聊"));
}
@Test
public void topPlusAction_hidesAddDeviceForSubAccount() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.setField(activity, "sessionData", new JSONObject().put("role", "member"));
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ImageButton actionButton = activity.findViewById(R.id.refresh_button);
actionButton.performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
View overlay = activity.findViewById(R.id.conversation_quick_actions_overlay);
View menu = activity.findViewById(R.id.conversation_quick_actions_menu);
assertEquals(View.VISIBLE, overlay.getVisibility());
assertEquals(View.VISIBLE, menu.getVisibility());
assertFalse(viewTreeContainsVisibleText(menu, "添加设备"));
assertTrue(viewTreeContainsVisibleText(menu, "扫一扫"));
assertTrue(viewTreeContainsVisibleText(menu, "发起群聊"));
}
private static View getRecyclerChild(RecyclerView recyclerView, int position) {
RecyclerView.Adapter adapter = recyclerView.getAdapter();
int viewType = adapter.getItemViewType(position);
@@ -188,6 +210,28 @@ public class MainActivityConversationSelectionTest {
return false;
}
private static boolean viewTreeContainsVisibleText(View root, String expectedText) {
if (root.getVisibility() != View.VISIBLE) {
return false;
}
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (expectedText.contentEquals(text)) {
return true;
}
}
if (!(root instanceof LinearLayout)) {
return false;
}
LinearLayout group = (LinearLayout) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsVisibleText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static boolean viewTreeContainsContentDescription(View root, String expectedText) {
CharSequence description = root.getContentDescription();
if (expectedText.contentEquals(description)) {

View File

@@ -31,7 +31,7 @@ public class MainActivityDevicesRootTest {
.put("name", "Mac Studio")
.put("status", "online")
.put("platform", "macOS")
.put("account", "17600003315")));
.put("account", "krisolo")));
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",

View File

@@ -3,6 +3,7 @@ package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Looper;
import org.json.JSONObject;
import org.json.JSONArray;
@@ -15,6 +16,7 @@ import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
import java.io.IOException;
import java.time.Duration;
import java.util.function.BooleanSupplier;
@RunWith(RobolectricTestRunner.class)
@@ -24,6 +26,15 @@ public class MainActivityRealtimeTest {
public void conversationRealtimeEventRefreshesVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -33,6 +44,8 @@ public class MainActivityRealtimeTest {
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
@@ -78,6 +91,15 @@ public class MainActivityRealtimeTest {
public void deviceScopedConversationEventRefreshesVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -87,6 +109,8 @@ public class MainActivityRealtimeTest {
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
@@ -108,6 +132,8 @@ public class MainActivityRealtimeTest {
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
@@ -129,13 +155,15 @@ public class MainActivityRealtimeTest {
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
}
@Test
public void distinctConversationEventsBackToBackBothRefreshVisibleConversationTab() throws Exception {
public void distinctConversationEventsBackToBackCoalesceIntoSingleVisibleConversationRefresh() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
@@ -161,8 +189,10 @@ public class MainActivityRealtimeTest {
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(2, activity.conversationRefreshCount);
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
}
@@ -176,6 +206,9 @@ public class MainActivityRealtimeTest {
ReflectionHelpers.ClassParameter.from(String.class, "devices"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -187,6 +220,8 @@ public class MainActivityRealtimeTest {
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.deviceRefreshCount);
assertEquals(0, activity.meRefreshCount);
}
@@ -201,6 +236,9 @@ public class MainActivityRealtimeTest {
ReflectionHelpers.ClassParameter.from(String.class, "me"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -213,6 +251,8 @@ public class MainActivityRealtimeTest {
assertEquals(0, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
assertEquals(0, activity.meRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.meRefreshCount);
}
@@ -220,6 +260,15 @@ public class MainActivityRealtimeTest {
public void burstConversationRealtimeEventsCoalesceIntoSingleFollowUpRefresh() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
ReflectionHelpers.setField(activity, "rootTabRefreshInFlight", true);
ReflectionHelpers.callInstanceMethod(
@@ -253,6 +302,7 @@ public class MainActivityRealtimeTest {
assertEquals(0, activity.conversationRefreshCount);
activity.completeRealtimeTabRefresh();
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
waitFor(() -> activity.conversationRefreshCount == 1);
assertEquals(1, activity.conversationRefreshCount);
@@ -282,7 +332,7 @@ public class MainActivityRealtimeTest {
}
@Test
public void refreshConversationsData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception {
public void refreshConversationsData_prefersGroupedHomeFeedOverFlatConversationFeed() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
@@ -294,18 +344,47 @@ public class MainActivityRealtimeTest {
Shadows.shadowOf(activity.getMainLooper()).idle();
activity.refreshConversationsData();
waitFor(() -> apiClient.homeCalls > 0 || apiClient.conversationsCalls > 0);
waitFor(() -> apiClient.homeCalls > 0);
assertEquals(1, apiClient.homeCalls);
assertEquals(0, apiClient.conversationsCalls);
}
@Test
public void refreshConversationsData_prefersGroupedHomeFeedForRootList() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
SharedPreferences prefs = activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE);
prefs.edit()
.putString("session_cookie", "boss_session=test")
.putString("restore_token", "restore-test")
.apply();
RecordingConversationSourceClient apiClient = new RecordingConversationSourceClient(
prefs
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
activity.refreshConversationsData();
waitFor(() -> apiClient.homeCalls > 0);
assertEquals(1, apiClient.homeCalls);
assertEquals(0, apiClient.conversationsCalls);
JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData");
assertEquals(1, conversationsData.length());
assertEquals("folder_archive", conversationsData.optJSONObject(0).optString("conversationType", ""));
assertEquals("mac-studio:boss", conversationsData.optJSONObject(0).optString("projectId", ""));
assertEquals(2, conversationsData.optJSONObject(0).optInt("threadCount", 0));
}
@Test
public void refreshConversationsData_groupsFlatFallbackFeedWhenHomeFeedFails() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
RecordingRejectedConversationSourceClient apiClient = new RecordingRejectedConversationSourceClient(
RecordingRejectedHomeConversationSourceClient apiClient = new RecordingRejectedHomeConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -328,7 +407,7 @@ public class MainActivityRealtimeTest {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
RecordingIOExceptionConversationSourceClient apiClient = new RecordingIOExceptionConversationSourceClient(
RecordingIOExceptionHomeConversationSourceClient apiClient = new RecordingIOExceptionHomeConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -347,7 +426,7 @@ public class MainActivityRealtimeTest {
}
@Test
public void refreshAllData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception {
public void refreshAllData_prefersGroupedHomeFeedOverFlatConversationFeed() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
@@ -363,18 +442,51 @@ public class MainActivityRealtimeTest {
"refreshAllData",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject())
);
waitFor(() -> apiClient.homeCalls > 0 || apiClient.conversationsCalls > 0);
waitFor(() -> apiClient.homeCalls > 0);
assertEquals(1, apiClient.homeCalls);
assertEquals(0, apiClient.conversationsCalls);
}
@Test
public void refreshAllData_prefersGroupedHomeFeedForRootList() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
SharedPreferences prefs = activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE);
prefs.edit()
.putString("session_cookie", "boss_session=test")
.putString("restore_token", "restore-test")
.apply();
RecordingConversationSourceClient apiClient = new RecordingConversationSourceClient(
prefs
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"refreshAllData",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject())
);
waitFor(() -> apiClient.homeCalls > 0);
assertEquals(1, apiClient.homeCalls);
assertEquals(0, apiClient.conversationsCalls);
JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData");
assertEquals(1, conversationsData.length());
assertEquals("folder_archive", conversationsData.optJSONObject(0).optString("conversationType", ""));
assertEquals("mac-studio:boss", conversationsData.optJSONObject(0).optString("projectId", ""));
assertEquals(2, conversationsData.optJSONObject(0).optInt("threadCount", 0));
}
@Test
public void refreshAllData_groupsFlatFallbackFeedWhenHomeFeedFails() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
RecordingRejectedConversationSourceClient apiClient = new RecordingRejectedConversationSourceClient(
RecordingRejectedHomeConversationSourceClient apiClient = new RecordingRejectedHomeConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -401,7 +513,7 @@ public class MainActivityRealtimeTest {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
RecordingIOExceptionConversationSourceClient apiClient = new RecordingIOExceptionConversationSourceClient(
RecordingIOExceptionHomeConversationSourceClient apiClient = new RecordingIOExceptionHomeConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -445,6 +557,13 @@ public class MainActivityRealtimeTest {
int deviceRefreshCount;
int meRefreshCount;
@Override
BossApiClient createApiClient() {
SharedPreferences prefs = getSharedPreferences("boss_native_client", Context.MODE_PRIVATE);
prefs.edit().clear().apply();
return new InertBootstrapApiClient(prefs);
}
@Override
void refreshConversationsData() {
conversationRefreshCount += 1;
@@ -464,7 +583,28 @@ public class MainActivityRealtimeTest {
}
}
private static final class RecordingRejectedConversationSourceClient extends BossApiClient {
private static final class InertBootstrapApiClient extends BossApiClient {
InertBootstrapApiClient(SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
}
@Override
public ApiResponse autoLogin() throws IOException, org.json.JSONException {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "TEST_BOOTSTRAP_DISABLED"));
}
@Override
public ApiResponse restoreSession() throws IOException, org.json.JSONException {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "TEST_BOOTSTRAP_DISABLED"));
}
@Override
public ApiResponse getSession() throws IOException, org.json.JSONException {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "TEST_BOOTSTRAP_DISABLED"));
}
}
private static final class RecordingRejectedHomeConversationSourceClient extends BossApiClient {
int homeCalls;
int conversationsCalls;
int sessionCalls;
@@ -472,7 +612,7 @@ public class MainActivityRealtimeTest {
int settingsCalls;
int otaCalls;
RecordingRejectedConversationSourceClient(android.content.SharedPreferences prefs) {
RecordingRejectedHomeConversationSourceClient(android.content.SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
}
@@ -489,7 +629,7 @@ public class MainActivityRealtimeTest {
conversationsCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("conversations", buildFlatConversations()));
.put("conversations", RecordingConversationSourceClient.buildFlatConversations()));
}
@Override
@@ -498,7 +638,7 @@ public class MainActivityRealtimeTest {
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("session", new JSONObject()
.put("account", "17600003315")
.put("account", "krisolo")
.put("displayName", "Boss 超级管理员")));
}
@@ -526,32 +666,6 @@ public class MainActivityRealtimeTest {
.put("ok", true)
.put("hasOta", false));
}
private static JSONArray buildFlatConversations() throws org.json.JSONException {
return new JSONArray()
.put(new JSONObject()
.put("projectId", "thread-revert")
.put("conversationType", "single_device")
.put("projectTitle", "发布回滚")
.put("threadTitle", "发布回滚")
.put("folderLabel", "Boss")
.put("folderKey", "mac-studio:boss")
.put("lastMessagePreview", "最近:发布回滚")
.put("latestReplyAt", "2026-04-06T10:00:00.000Z")
.put("latestReplyLabel", "11:00")
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 80).put("level", "watch")))
.put(new JSONObject()
.put("projectId", "thread-ui")
.put("conversationType", "single_device")
.put("projectTitle", "Android UI 收尾")
.put("threadTitle", "Android UI 收尾")
.put("folderLabel", "Boss")
.put("folderKey", "mac-studio:boss")
.put("lastMessagePreview", "最近Android UI 收尾")
.put("latestReplyAt", "2026-04-06T09:59:00.000Z")
.put("latestReplyLabel", "10:59")
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 95).put("level", "safe")));
}
}
private static final class RecordingConversationSourceClient extends BossApiClient {
@@ -588,7 +702,7 @@ public class MainActivityRealtimeTest {
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("session", new JSONObject()
.put("account", "17600003315")
.put("account", "krisolo")
.put("displayName", "Boss 超级管理员")));
}
@@ -619,13 +733,15 @@ public class MainActivityRealtimeTest {
private static JSONArray buildHomeConversations() throws org.json.JSONException {
return new JSONArray().put(new JSONObject()
.put("projectId", "folder-boss")
.put("projectId", "mac-studio:boss")
.put("conversationType", "folder_archive")
.put("folderKey", "mac-studio:boss")
.put("projectTitle", "Boss")
.put("threadTitle", "Boss")
.put("threadCount", 2)
.put("folderLabel", "2 个线程 · 最近:发布回滚")
.put("searchAliases", new JSONArray().put("发布回滚").put("Android UI 收尾"))
.put("searchTargetProjectIds", new JSONArray().put("thread-revert").put("thread-ui"))
.put("lastMessagePreview", "最近:发布回滚")
.put("latestReplyLabel", "11:00"));
}
@@ -657,7 +773,7 @@ public class MainActivityRealtimeTest {
}
}
private static final class RecordingIOExceptionConversationSourceClient extends BossApiClient {
private static final class RecordingIOExceptionHomeConversationSourceClient extends BossApiClient {
int homeCalls;
int conversationsCalls;
int sessionCalls;
@@ -665,7 +781,7 @@ public class MainActivityRealtimeTest {
int settingsCalls;
int otaCalls;
RecordingIOExceptionConversationSourceClient(android.content.SharedPreferences prefs) {
RecordingIOExceptionHomeConversationSourceClient(android.content.SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
}
@@ -680,7 +796,7 @@ public class MainActivityRealtimeTest {
conversationsCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("conversations", buildFlatConversations()));
.put("conversations", RecordingConversationSourceClient.buildFlatConversations()));
}
@Override
@@ -689,7 +805,7 @@ public class MainActivityRealtimeTest {
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("session", new JSONObject()
.put("account", "17600003315")
.put("account", "krisolo")
.put("displayName", "Boss 超级管理员")));
}
@@ -717,31 +833,5 @@ public class MainActivityRealtimeTest {
.put("ok", true)
.put("hasOta", false));
}
private static JSONArray buildFlatConversations() throws org.json.JSONException {
return new JSONArray()
.put(new JSONObject()
.put("projectId", "thread-revert")
.put("conversationType", "single_device")
.put("projectTitle", "发布回滚")
.put("threadTitle", "发布回滚")
.put("folderLabel", "Boss")
.put("folderKey", "mac-studio:boss")
.put("lastMessagePreview", "最近:发布回滚")
.put("latestReplyAt", "2026-04-06T10:00:00.000Z")
.put("latestReplyLabel", "11:00")
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 80).put("level", "watch")))
.put(new JSONObject()
.put("projectId", "thread-ui")
.put("conversationType", "single_device")
.put("projectTitle", "Android UI 收尾")
.put("threadTitle", "Android UI 收尾")
.put("folderLabel", "Boss")
.put("folderKey", "mac-studio:boss")
.put("lastMessagePreview", "最近Android UI 收尾")
.put("latestReplyAt", "2026-04-06T09:59:00.000Z")
.put("latestReplyLabel", "10:59")
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 95).put("level", "safe")));
}
}
}

View File

@@ -176,7 +176,7 @@ public class MasterAgentTakeoverActivityTest {
200,
new JSONObject()
.put("ok", true)
.put("session", new JSONObject().put("account", "17600003315"))
.put("session", new JSONObject().put("account", "krisolo"))
);
}

View File

@@ -59,6 +59,14 @@ public class ProjectChatUiStateTest {
assertTrue(ProjectChatUiState.canForwardSelection(next));
}
@Test
public void copySelectionRequiresAtLeastOneMessage() {
assertFalse(ProjectChatUiState.canCopySelection(ProjectChatUiState.emptySelection()));
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
assertTrue(ProjectChatUiState.canCopySelection(state));
}
@Test
public void selectionPreservesInsertionOrder() {
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m2");
@@ -104,6 +112,7 @@ public class ProjectChatUiStateTest {
assertTrue(chromeState.showMultiSelectBar);
assertFalse(chromeState.showRefresh);
assertFalse(chromeState.showHeaderAction);
assertTrue(chromeState.copyEnabled);
assertTrue(chromeState.forwardEnabled);
assertEquals("取消", chromeState.backLabel);
assertEquals("已选 2 条", chromeState.title);
@@ -120,6 +129,7 @@ public class ProjectChatUiStateTest {
assertFalse(chromeState.showMultiSelectBar);
assertFalse(chromeState.showRefresh);
assertTrue(chromeState.showHeaderAction);
assertFalse(chromeState.copyEnabled);
assertFalse(chromeState.forwardEnabled);
assertEquals("返回", chromeState.backLabel);
assertEquals("北区试产线回归", chromeState.title);
@@ -136,6 +146,7 @@ public class ProjectChatUiStateTest {
assertFalse(chromeState.showMultiSelectBar);
assertTrue(chromeState.showRefresh);
assertFalse(chromeState.showHeaderAction);
assertFalse(chromeState.copyEnabled);
assertFalse(chromeState.forwardEnabled);
assertEquals("返回", chromeState.backLabel);
assertEquals("北区试产线回归", chromeState.title);
@@ -196,9 +207,10 @@ public class ProjectChatUiStateTest {
}
@Test
public void queuedReplyTaskStartsReplyWaitFromRequestMessageId() throws Exception {
public void queuedReplyTaskStartsReplyWaitFromImmediateReplyWhenPresent() throws Exception {
JSONObject response = new JSONObject()
.put("message", new JSONObject().put("id", "msg-user-1"))
.put("replyMessage", new JSONObject().put("id", "msg-master-ack-1"))
.put("task", new JSONObject()
.put("taskId", "task-1")
.put("taskType", "conversation_reply")
@@ -207,7 +219,7 @@ public class ProjectChatUiStateTest {
ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterSend(response);
assertTrue(waitSpec.shouldWait);
assertEquals("msg-user-1", waitSpec.baselineMessageId);
assertEquals("msg-master-ack-1", waitSpec.baselineMessageId);
}
@Test
@@ -250,6 +262,318 @@ public class ProjectChatUiStateTest {
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, ""));
}
@Test
public void replyWaitIgnoresDuplicateBaselineMessages() throws Exception {
JSONObject project = new JSONObject()
.put("messages", new JSONArray()
.put(new JSONObject().put("id", "msg-user-1"))
.put(new JSONObject().put("id", "msg-user-1")));
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, "msg-user-1"));
}
@Test
public void timedOutMasterRelayKeepsConversationPollingEvenWhenRealtimeConnected() {
assertTrue(ProjectChatUiState.shouldAutoRefreshConversation(true, true, true));
assertTrue(ProjectChatUiState.shouldAutoRefreshConversation(true, false, false));
assertFalse(ProjectChatUiState.shouldAutoRefreshConversation(true, true, false));
assertFalse(ProjectChatUiState.shouldAutoRefreshConversation(false, true, true));
}
@Test
public void threadProcessMessagesAreCollapsedBeforeFinalResult() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "u1")
.put("sender", "user")
.put("body", "继续"))
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "我先看一下当前聊天渲染链路和消息结构。"))
.put(new JSONObject()
.put("id", "p2")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "接下来我会补一组单元测试,再把折叠 UI 接上。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "这轮已经接好过程折叠,最终结果现在直接显示在主消息流里。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(3, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
assertEquals("u1", items.get(0).message.optString("id"));
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
assertEquals(2, items.get(1).processMessages.size());
assertEquals("p1", items.get(1).processMessages.get(0).optString("id"));
assertEquals("p2", items.get(1).processMessages.get(1).optString("id"));
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
assertEquals("r1", items.get(2).message.optString("id"));
}
@Test
public void errorMessagesStayVisibleInsteadOfBeingCollapsed() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "e1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "当前执行失败,构建报错,需要先补依赖。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(1, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
assertEquals("e1", items.get(0).message.optString("id"));
}
@Test
public void processGroupPreviewUsesLatestProgressLine() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("body", "我先检查项目结构。"))
.put(new JSONObject()
.put("id", "p2")
.put("sender", "device")
.put("body", "接下来开始补聊天折叠按钮。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(1, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
assertEquals("接下来开始补聊天折叠按钮。", ProjectChatUiState.processGroupPreview(items.get(0)));
}
@Test
public void explicitThreadProcessKindIsCollapsedEvenWhenCopyLooksLikeACompletionUpdate() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "thread_process")
.put("body", "工程骨架已经建好了,我现在开始写核心代码。"))
.put(new JSONObject()
.put("id", "p2")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "thread_process")
.put("body", "编译错误已定位到导入问题,我已修复并正在重新构建确认。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "text")
.put("body", "已完成折叠修复,过程消息会收进按钮里,未读只增加一次。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(2, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
assertEquals(2, items.get(0).processMessages.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(1).type);
assertEquals("r1", items.get(1).message.optString("id"));
}
@Test
public void executionProgressCardsStayVisibleBetweenProcessGroups() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "thread_process")
.put("body", "我先检查当前执行链路。"))
.put(new JSONObject()
.put("id", "progress-1")
.put("sender", "master")
.put("senderLabel", "主 Agent")
.put("kind", "execution_progress")
.put("body", "执行进度")
.put("executionProgress", new JSONObject()
.put("status", "running")
.put("steps", new JSONArray()
.put(new JSONObject().put("text", "接收对话任务").put("status", "done"))
.put(new JSONObject().put("text", "等待目标线程回复").put("status", "running")))))
.put(new JSONObject()
.put("id", "p2")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "thread_process")
.put("body", "我继续执行验证。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(3, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(1).type);
assertEquals("progress-1", items.get(1).message.optString("id"));
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(2).type);
}
@Test
public void processGroupKeepsFinalResultVisibleWhenProcessMessagesCarryThreadProcessKind() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "u1")
.put("sender", "user")
.put("body", "继续推进"))
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "thread_process")
.put("body", "我先检查聊天折叠链路,确认过程消息不会直接展开。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "text")
.put("body", "这轮已经完成折叠修复,未读现在只会算最终结果。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(3, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
assertEquals(1, items.get(1).processMessages.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
assertEquals("r1", items.get(2).message.optString("id"));
}
@Test
public void numberedProgressUpdatesAreCollapsedWhenMarkedAsThreadProcess() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "u1")
.put("sender", "user")
.put("body", "继续处理"))
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "thread_process")
.put("body", "1. 先检查当前消息折叠链路。\\n2. 再确认 Android 端只把最终结果记成未读。\\n3. 处理完成后我会回你最终结果。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "text")
.put("body", "这轮已经处理完成,最终结果已回写。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(3, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
assertEquals(1, items.get(1).processMessages.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
assertEquals("r1", items.get(2).message.optString("id"));
}
@Test
public void numberedProgressUpdatesWithoutKindStillCollapseBeforeFinalResult() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "u1")
.put("sender", "user")
.put("body", "继续处理"))
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "1. 先检查当前消息折叠链路。\n2. 再确认 Android 端只把最终结果记成未读。\n3. 处理完成后我会回你最终结果。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "这轮已经处理完成,最终结果已回写。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(3, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
assertEquals(1, items.get(1).processMessages.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
assertEquals("r1", items.get(2).message.optString("id"));
}
@Test
public void progressUpdatesStartingWithWoZheBianYiJingStillCollapseIntoProcessGroup() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "u1")
.put("sender", "user")
.put("body", "继续"))
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "我这边已经查了adb 现在还只看到一台 USB 连着的 PHZ110PLB110 的无线目标还没有被发现出来。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "text")
.put("body", "无线调试已经接通,最新 debug 包也装好了。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(3, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
assertEquals(1, items.get(1).processMessages.size());
assertEquals("p1", items.get(1).processMessages.get(0).optString("id"));
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
assertEquals("r1", items.get(2).message.optString("id"));
}
@Test
public void realThreadPlanningCopyIsCollapsedButSavedResultStaysVisible() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Andorid")
.put("body", "我发现当前这个仓库快照里没有 ios/ 目录,所以这份报告会明确分成两层。"))
.put(new JSONObject()
.put("id", "p2")
.put("sender", "device")
.put("senderLabel", "Andorid")
.put("body", "我准备新增一份 doc/iOS实时转写开发交接报告_20260419.md。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Andorid")
.put("body", "报告已经落盘了。我再快速过一遍这份文档的结构和措辞。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(2, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
assertEquals(2, items.get(0).processMessages.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(1).type);
assertEquals("r1", items.get(1).message.optString("id"));
}
@Test
public void threadExecutionConflictCopyExplainsPreferredGuiModeAsProjectScoped() throws Exception {
JSONObject conflict = new JSONObject()

View File

@@ -19,6 +19,7 @@ import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowDialog;
import org.robolectric.util.ReflectionHelpers;
@@ -113,7 +114,7 @@ public class ProjectDetailActivityMasterAgentMenuTest {
}
@Test
public void normalConversationMoreMenuShowsInfoAndRefresh() {
public void normalConversationHeaderActionOpensConversationInfoDirectly() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss 移动控制台");
@@ -122,15 +123,11 @@ public class ProjectDetailActivityMasterAgentMenuTest {
.setup()
.get();
ReflectionHelpers.callInstanceMethod(activity, "showConversationMoreMenu");
ReflectionHelpers.callInstanceMethod(activity, "openConversationInfo");
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
AlertDialog actionDialog = (AlertDialog) latestDialog;
ListView listView = actionDialog.getListView();
assertMenuItem(listView, 0, "会话信息");
assertMenuItem(listView, 1, "刷新");
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
assertNotNull(nextIntent);
assertEquals(ConversationInfoActivity.class.getName(), nextIntent.getComponent().getClassName());
}
@Test

View File

@@ -1,11 +1,19 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.fail;
import static org.junit.Assert.assertTrue;
import android.app.Dialog;
import android.app.NotificationManager;
import android.content.Context;
import android.content.Intent;
import android.os.Looper;
import android.view.View;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -13,8 +21,12 @@ import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowApplication;
import org.robolectric.shadows.ShadowDialog;
import org.robolectric.shadows.ShadowNotificationManager;
import org.robolectric.util.ReflectionHelpers;
import java.util.concurrent.CountDownLatch;
@@ -43,7 +55,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(0, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
@@ -68,7 +80,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(0, activity.reloadCount);
}
@@ -92,7 +104,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-2"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(0, activity.reloadCount);
}
@@ -130,10 +142,10 @@ public class ProjectDetailActivityRealtimeTest {
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
assertEquals(0, activity.messageReloadCount);
}
@Test
@@ -158,7 +170,7 @@ public class ProjectDetailActivityRealtimeTest {
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount);
assertEquals(0, activity.messageReloadCount);
@@ -197,12 +209,162 @@ public class ProjectDetailActivityRealtimeTest {
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(0, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
}
@Test
public void dialogGuardInterventionRequiredShowsBlockedSafeActionDialog() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
TestRealtimeProjectDetailActivity activity = Robolectric
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
.setup()
.resume()
.get();
RecordingDialogGuardApiClient apiClient = new RecordingDialogGuardApiClient();
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent(
"desktop.dialog_guard.intervention_required",
new JSONObject()
.put("interventionId", "intervention-1")
.put("dialogId", "dialog-1")
.put("requestId", "request-1")
.put("taskId", "task-1")
.put("deviceId", "mac-studio")
.put("projectId", "project-1")
.put("appName", "微信")
.put("platform", "macos")
.put("risk", "blocked")
.put("summary", "微信正在请求读取敏感通讯录权限")
.put("recommendedAction", "handled_on_device")
.put("availableActions", new JSONArray()
.put("allow_once")
.put("allow_for_device_dialog")
.put("deny")
.put("handled_on_device")
.put("cancel_task"))
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
AlertDialog dialog = (AlertDialog) latestDialog;
assertTrue(dialog.isShowing());
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "微信"));
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "微信正在请求读取敏感通讯录权限"));
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "我已在电脑上处理"));
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "取消任务"));
assertFalse(viewTreeContainsText(dialog.getWindow().getDecorView(), "允许本次"));
assertFalse(viewTreeContainsText(dialog.getWindow().getDecorView(), "当前设备此弹窗允许"));
View handledButton = findClickableViewContainingText(dialog.getWindow().getDecorView(), "我已在电脑上处理");
assertNotNull(handledButton);
handledButton.performClick();
waitFor(() -> apiClient.decisionCallCount == 1);
assertEquals("intervention-1", apiClient.lastInterventionId);
assertEquals("handled_on_device", apiClient.lastDecision);
}
@Test
public void dialogGuardResolvedEventClosesMatchingDialog() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
TestRealtimeProjectDetailActivity activity = Robolectric
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
.setup()
.resume()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent(
"desktop.dialog_guard.intervention_required",
new JSONObject()
.put("interventionId", "intervention-2")
.put("projectId", "project-1")
.put("appName", "访达")
.put("risk", "safe")
.put("summary", "确认打开下载文件")
.put("availableActions", new JSONArray().put("allow_once").put("deny"))
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertTrue(dialog.isShowing());
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent(
"desktop.dialog_guard.intervention_resolved",
new JSONObject()
.put("interventionId", "intervention-2")
.put("projectId", "project-1")
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertFalse(dialog.isShowing());
}
@Test
public void openingMasterAgentConversationClearsPendingMasterAgentNotification() throws Exception {
Context context = RuntimeEnvironment.getApplication();
BossApplication application = (BossApplication) context.getApplicationContext();
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
ShadowNotificationManager notificationManager = Shadows.shadowOf(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
);
application.visibilityTracker().onAppBackgrounded();
JSONObject message = new JSONObject()
.put("id", "master-msg-1")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "主 Agent 后台回复");
JSONObject payload = new JSONObject()
.put("projectId", "master-agent")
.put("projectMessagesPayload", new JSONObject().put(
"project",
new JSONObject().put("messages", new JSONArray().put(message))
));
assertTrue(application.notificationRouter().maybeNotifyForRealtimeEvent(
new BossRealtimeEvent("project.messages.updated", payload)
));
assertEquals(1, notificationManager.size());
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
Robolectric.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
.setup()
.resume()
.get();
assertEquals(0, notificationManager.size());
}
@Test
public void burstRealtimeEventsWhileReloadingCoalesceIntoSingleFollowUpReload() throws Exception {
Intent intent = new Intent()
@@ -224,7 +386,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertTrue(activity.awaitFirstLoadStarted());
ReflectionHelpers.callInstanceMethod(
@@ -243,7 +405,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(0, activity.loadCallCount);
assertEquals(1, activity.messageLoadCallCount);
@@ -277,7 +439,7 @@ public class ProjectDetailActivityRealtimeTest {
"handleRealtimeConnectionChanged",
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount);
}
@@ -317,6 +479,49 @@ public class ProjectDetailActivityRealtimeTest {
fail("condition not met before timeout");
}
private static void drainRealtimeDebounce(TestRealtimeProjectDetailActivity activity) {
Shadows.shadowOf(activity.getMainLooper()).idleFor(350, TimeUnit.MILLISECONDS);
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof android.widget.TextView) {
CharSequence text = ((android.widget.TextView) root).getText();
if (expectedText.contentEquals(text)) {
return true;
}
}
if (!(root instanceof android.view.ViewGroup)) {
return false;
}
android.view.ViewGroup group = (android.view.ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static View findClickableViewContainingText(View root, String expectedText) {
if (root == null) {
return null;
}
if (viewTreeContainsText(root, expectedText) && root.isClickable()) {
return root;
}
if (!(root instanceof android.view.ViewGroup)) {
return null;
}
android.view.ViewGroup group = (android.view.ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
View match = findClickableViewContainingText(group.getChildAt(index), expectedText);
if (match != null) {
return match;
}
}
return null;
}
public static class TestRealtimeProjectDetailActivity extends ProjectDetailActivity {
int reloadCount;
int messageReloadCount;
@@ -397,4 +602,22 @@ public class ProjectDetailActivityRealtimeTest {
setRefreshing(false);
}
}
private static final class RecordingDialogGuardApiClient extends BossApiClient {
int decisionCallCount;
String lastInterventionId;
String lastDecision;
RecordingDialogGuardApiClient() {
super(RuntimeEnvironment.getApplication().getSharedPreferences("dialog_guard_test", Context.MODE_PRIVATE), "https://boss.hyzq.net");
}
@Override
public ApiResponse decideDialogGuardIntervention(String interventionId, String decision) throws org.json.JSONException {
decisionCallCount += 1;
lastInterventionId = interventionId;
lastDecision = decision;
return new ApiResponse(200, new JSONObject().put("ok", true));
}
}
}

View File

@@ -3,15 +3,21 @@ package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.drawable.ColorDrawable;
import android.content.res.ColorStateList;
import android.graphics.drawable.GradientDrawable;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.ListView;
@@ -26,6 +32,7 @@ import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.android.controller.ActivityController;
import org.robolectric.Shadows;
import org.robolectric.shadows.ShadowDialog;
import org.robolectric.util.ReflectionHelpers;
@@ -34,11 +41,112 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.function.BooleanSupplier;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class ProjectDetailActivityUiTest {
@Test
public void typingAtInComposerShowsAgentMentionSuggestions() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
EditText input = activity.findViewById(R.id.project_chat_input);
input.requestFocus();
input.setText("@");
input.setSelection(1);
Shadows.shadowOf(activity.getMainLooper()).idle();
View panel = activity.findViewById(R.id.project_chat_mention_panel);
assertEquals(View.VISIBLE, panel.getVisibility());
assertTrue(viewTreeContainsText(panel, "主Agent"));
assertTrue(viewTreeContainsText(panel, "审计Agent"));
}
@Test
public void tappingMentionSuggestionInsertsAgentMentionAndClosesPanel() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
EditText input = activity.findViewById(R.id.project_chat_input);
input.requestFocus();
input.setText("@");
input.setSelection(1);
Shadows.shadowOf(activity.getMainLooper()).idle();
View panel = activity.findViewById(R.id.project_chat_mention_panel);
View masterAgentRow = findClickableViewContainingText(panel, "主Agent");
assertNotNull(masterAgentRow);
masterAgentRow.performClick();
assertEquals("@主Agent ", input.getText().toString());
assertEquals(input.getText().length(), input.getSelectionStart());
assertEquals(View.GONE, panel.getVisibility());
}
@Test
public void tappingAuditMentionSuggestionInsertsAuditAgentMention() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
EditText input = activity.findViewById(R.id.project_chat_input);
input.requestFocus();
input.setText("请看 @审");
input.setSelection(input.getText().length());
Shadows.shadowOf(activity.getMainLooper()).idle();
View panel = activity.findViewById(R.id.project_chat_mention_panel);
View auditAgentRow = findClickableViewContainingText(panel, "审计Agent");
assertNotNull(auditAgentRow);
auditAgentRow.performClick();
assertEquals("请看 @审计Agent ", input.getText().toString());
assertEquals(View.GONE, panel.getVisibility());
}
@Test
public void formatMessageTimeConvertsUtcTimestampIntoLocalTimezoneClock() {
TimeZone original = TimeZone.getDefault();
try {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
String label = ReflectionHelpers.callInstanceMethod(
activity,
"formatMessageTime",
ReflectionHelpers.ClassParameter.from(String.class, "2026-04-20T09:01:00.000Z")
);
assertEquals("17:01", label);
} finally {
TimeZone.setDefault(original);
}
}
@Test
public void multiSelectModeUpdatesRealChatChrome() {
Intent intent = new Intent()
@@ -73,12 +181,14 @@ public class ProjectDetailActivityUiTest {
LinearLayout multiSelectActions = activity.findViewById(R.id.project_chat_multi_select_actions);
ImageButton backButton = activity.findViewById(R.id.screen_back_button);
ImageButton refreshButton = activity.findViewById(R.id.screen_refresh_button);
Button copyButton = activity.findViewById(R.id.project_chat_multi_copy);
Button forwardButton = activity.findViewById(R.id.project_chat_multi_forward);
assertEquals(View.GONE, composerRow.getVisibility());
assertEquals(View.VISIBLE, multiSelectActions.getVisibility());
assertEquals("取消", String.valueOf(backButton.getContentDescription()));
assertEquals(View.GONE, refreshButton.getVisibility());
assertTrue(copyButton.isEnabled());
assertEquals(false, forwardButton.isEnabled());
secondMessage.performClick();
@@ -92,6 +202,101 @@ public class ProjectDetailActivityUiTest {
assertEquals(View.GONE, refreshButton.getVisibility());
}
@Test
public void systemBackInMultiSelectModeExitsSelectionInsteadOfClosingConversation() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "北区试产线回归");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "归档确认");
ReflectionHelpers.callInstanceMethod(
activity,
"enterMultiSelectFromMessage",
ReflectionHelpers.ClassParameter.from(String.class, "m1")
);
assertEquals(View.GONE, activity.findViewById(R.id.project_chat_composer_row).getVisibility());
assertEquals(View.VISIBLE, activity.findViewById(R.id.project_chat_multi_select_actions).getVisibility());
activity.getOnBackPressedDispatcher().onBackPressed();
assertEquals(0, activity.finishCallCount);
assertEquals(View.VISIBLE, activity.findViewById(R.id.project_chat_composer_row).getVisibility());
assertEquals(View.GONE, activity.findViewById(R.id.project_chat_multi_select_actions).getVisibility());
assertEquals("返回", String.valueOf(((ImageButton) activity.findViewById(R.id.screen_back_button)).getContentDescription()));
}
@Test
public void multiSelectModeShowsCheckmarksBeforeMessagesAndCopiesTranscript() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
JSONObject payload = new JSONObject()
.put("project", new JSONObject()
.put("id", "project-1")
.put("name", "北区试产线回归")
.put("messages", new JSONArray()
.put(new JSONObject()
.put("id", "msg-user")
.put("sender", "user")
.put("senderLabel", "Boss 超级管理员")
.put("body", "请同步项目目标")
.put("kind", "text")
.put("sentAt", "2026-04-20T09:01:00+08:00"))
.put(new JSONObject()
.put("id", "msg-master")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "我会先核对目标,再更新版本记录。")
.put("kind", "text")
.put("sentAt", "2026-04-20T09:02:00+08:00"))));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
ReflectionHelpers.callInstanceMethod(
activity,
"enterMultiSelectFromMessage",
ReflectionHelpers.ClassParameter.from(String.class, "msg-user")
);
ReflectionHelpers.callInstanceMethod(
activity,
"toggleMultiSelectMessage",
ReflectionHelpers.ClassParameter.from(String.class, "msg-master")
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, ""));
assertTrue(viewTreeContainsText(content, "你 · 09:01"));
assertTrue(viewTreeContainsText(content, "主Agent · 09:02"));
Button copyButton = activity.findViewById(R.id.project_chat_multi_copy);
copyButton.performClick();
android.content.ClipData clipData = activity
.getSystemService(android.content.ClipboardManager.class)
.getPrimaryClip();
assertNotNull(clipData);
String copied = String.valueOf(clipData.getItemAt(0).coerceToText(activity));
assertTrue(copied.contains("09:01 你:请同步项目目标"));
assertTrue(copied.contains("09:02 主Agent我会先核对目标再更新版本记录。"));
}
@Test
public void composerFocus_scrollsChatToBottomToKeepLatestMessageVisible() {
Intent intent = new Intent()
@@ -148,6 +353,56 @@ public class ProjectDetailActivityUiTest {
assertNotNull(childScrollCallback);
}
@Test
public void renderProjectWithUnread_marksConversationReadOncePerVisibleSession() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
ActivityController<TestProjectDetailActivity> controller = Robolectric.buildActivity(TestProjectDetailActivity.class, intent);
TestProjectDetailActivity activity = controller.setup().get();
RecordingConversationActionApiClient apiClient = new RecordingConversationActionApiClient();
ReflectionHelpers.setField(activity, "apiClient", apiClient);
JSONObject payload = new JSONObject()
.put("project", new JSONObject()
.put("id", "project-1")
.put("name", "北区试产线回归")
.put("unreadCount", 3)
.put("messages", new JSONArray()));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
waitForUiCondition(activity, () -> apiClient.markConversationReadCount == 1);
assertEquals("project-1", apiClient.lastMarkedProjectId);
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
Thread.sleep(80L);
assertEquals(1, apiClient.markConversationReadCount);
controller.pause();
controller.resume();
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
waitForUiCondition(activity, () -> apiClient.markConversationReadCount == 2);
}
@Test
public void composerRowLayoutChangeWithFocusedInput_scrollsChatToBottomAgain() {
Intent intent = new Intent()
@@ -235,6 +490,110 @@ public class ProjectDetailActivityUiTest {
assertTrue(params.height >= BossUi.dp(activity, 46));
}
@Test
public void scrollBottomShortcutIsFloatingIconAboveComposerAndTriggersBottomScroll() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
int shortcutId = activity.getResources().getIdentifier(
"project_chat_scroll_bottom",
"id",
activity.getPackageName()
);
assertTrue("project_chat_scroll_bottom id should exist", shortcutId != 0);
View shortcutView = activity.findViewById(shortcutId);
assertNotNull(shortcutView);
assertTrue(shortcutView instanceof ImageButton);
assertEquals(View.GONE, shortcutView.getVisibility());
assertTrue(shortcutView.getLayoutParams() instanceof FrameLayout.LayoutParams);
FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) shortcutView.getLayoutParams();
assertTrue((params.gravity & Gravity.BOTTOM) == Gravity.BOTTOM);
assertTrue((params.gravity & Gravity.LEFT) == Gravity.LEFT || (params.gravity & Gravity.START) == Gravity.START);
assertEquals(BossUi.dp(activity, 12), params.leftMargin);
assertTrue(params.bottomMargin >= BossUi.dp(activity, 12));
assertEquals(BossUi.dp(activity, 48), params.width);
assertEquals(BossUi.dp(activity, 48), params.height);
int baselineScrollCount = activity.scrollChatToBottomCount;
shortcutView.performClick();
assertTrue(activity.scrollChatToBottomCount > baselineScrollCount);
}
@Test
public void scrollBottomShortcutVisibilityLogicMatchesObservedSwipeDirection() {
Boolean farFromBottom = ReflectionHelpers.callStaticMethod(
ProjectDetailActivity.class,
"shouldShowScrollBottomShortcut",
ReflectionHelpers.ClassParameter.from(int.class, 140),
ReflectionHelpers.ClassParameter.from(int.class, 96),
ReflectionHelpers.ClassParameter.from(int.class, 460),
ReflectionHelpers.ClassParameter.from(int.class, 400),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
Boolean oppositeDirection = ReflectionHelpers.callStaticMethod(
ProjectDetailActivity.class,
"shouldShowScrollBottomShortcut",
ReflectionHelpers.ClassParameter.from(int.class, 140),
ReflectionHelpers.ClassParameter.from(int.class, 96),
ReflectionHelpers.ClassParameter.from(int.class, 320),
ReflectionHelpers.ClassParameter.from(int.class, 400),
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
Boolean keepVisibleWhileStopped = ReflectionHelpers.callStaticMethod(
ProjectDetailActivity.class,
"shouldShowScrollBottomShortcut",
ReflectionHelpers.ClassParameter.from(int.class, 140),
ReflectionHelpers.ClassParameter.from(int.class, 96),
ReflectionHelpers.ClassParameter.from(int.class, 400),
ReflectionHelpers.ClassParameter.from(int.class, 400),
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
Boolean alreadyNearBottom = ReflectionHelpers.callStaticMethod(
ProjectDetailActivity.class,
"shouldShowScrollBottomShortcut",
ReflectionHelpers.ClassParameter.from(int.class, 80),
ReflectionHelpers.ClassParameter.from(int.class, 96),
ReflectionHelpers.ClassParameter.from(int.class, 320),
ReflectionHelpers.ClassParameter.from(int.class, 400),
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
assertTrue(farFromBottom);
assertFalse(oppositeDirection);
assertTrue(keepVisibleWhileStopped);
assertFalse(alreadyNearBottom);
}
@Test
public void normalConversationHeaderActionOpensConversationInfoDirectlyWithoutDialog() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss 移动控制台");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "Boss 移动控制台");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "归档确认");
ReflectionHelpers.callInstanceMethod(activity, "updateSelectionUi");
ImageButton headerAction = activity.findViewById(R.id.screen_header_action);
ShadowDialog.reset();
headerAction.performClick();
assertNull(ShadowDialog.getLatestDialog());
}
@Test
public void manualAnalysisAttachmentShowsActionChip() throws Exception {
Intent intent = new Intent()
@@ -358,7 +717,7 @@ public class ProjectDetailActivityUiTest {
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
prefs.edit()
.putString("account", "17600003315")
.putString("account", "krisolo")
.putString("display_name", "OpenAI 平台账号")
.apply();
ReflectionHelpers.setField(activity, "apiClient", new BossApiClient(prefs, "https://boss.hyzq.net"));
@@ -369,7 +728,7 @@ public class ProjectDetailActivityUiTest {
.put("senderLabel", "Boss 超级管理员")
.put("body", "请只回复一句:聊天链路自检正常。")
.put("kind", "text")
.put("sentAt", "2026-03-31T10:26:00.000Z");
.put("sentAt", "2026-03-31T10:26:00+08:00");
View messageView = ReflectionHelpers.callInstanceMethod(
activity,
@@ -377,10 +736,174 @@ public class ProjectDetailActivityUiTest {
ReflectionHelpers.ClassParameter.from(JSONObject.class, message)
);
assertTrue(viewTreeContainsText(messageView, "10:26"));
assertTrue(viewTreeContainsText(messageView, "你 · 10:26"));
assertFalse(viewTreeContainsText(messageView, "Boss 超级管理员 · 10:26"));
}
@Test
public void masterAgentMessageUsesStableSpeakerLabelAndLightBlueBubble() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
JSONObject message = new JSONObject()
.put("id", "msg-master-1")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "我会先核对目标,再同步到顶部入口。")
.put("kind", "text")
.put("sentAt", "2026-04-20T09:16:00+08:00");
View messageView = ReflectionHelpers.callInstanceMethod(
activity,
"buildMessageView",
ReflectionHelpers.ClassParameter.from(JSONObject.class, message)
);
assertTrue(viewTreeContainsText(messageView, "主Agent · 09:16"));
assertTrue(viewTreeHasGradientColor(messageView, 0xFFEAF5FF));
}
@Test
public void renderThreadMessageUsesBoundCodexDeviceAvatar() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "thread-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss开发主线程");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
JSONObject payload = new JSONObject()
.put("project", new JSONObject()
.put("id", "thread-1")
.put("name", "Boss开发主线程")
.put("deviceIds", new JSONArray().put("mac-studio"))
.put("messages", new JSONArray()
.put(new JSONObject()
.put("id", "msg-device-1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程 · Mac Studio")
.put("body", "已完成构建检查。")
.put("kind", "text")
.put("sentAt", "2026-05-09T09:10:00+08:00"))))
.put("devices", new JSONArray()
.put(new JSONObject()
.put("id", "mac-studio")
.put("name", "Mac Studio")
.put("avatar", "M")));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "M"));
assertTrue(viewTreeContainsContentDescription(content, "来自 Mac Studio"));
}
@Test
public void renderGroupThreadMessageMatchesAvatarByCodexDeviceName() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "group-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "协作群");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
JSONObject payload = new JSONObject()
.put("project", new JSONObject()
.put("id", "group-1")
.put("name", "协作群")
.put("isGroup", true)
.put("deviceIds", new JSONArray().put("mac-studio").put("windows-gpu"))
.put("messages", new JSONArray()
.put(new JSONObject()
.put("id", "msg-device-2")
.put("sender", "device")
.put("senderLabel", "购物车修复 · Windows GPU")
.put("body", "Windows 线程已回写结果。")
.put("kind", "text")
.put("sentAt", "2026-05-09T09:16:00+08:00"))))
.put("devices", new JSONArray()
.put(new JSONObject()
.put("id", "mac-studio")
.put("name", "Mac Studio")
.put("avatar", "M"))
.put(new JSONObject()
.put("id", "windows-gpu")
.put("name", "Windows GPU")
.put("avatar", "W")));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "W"));
assertTrue(viewTreeContainsContentDescription(content, "来自 Windows GPU"));
}
@Test
public void executionProgressMessageRendersAsStructuredCard() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "thread-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss开发主线程");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
JSONObject message = new JSONObject()
.put("id", "progress-1")
.put("sender", "master")
.put("senderLabel", "主 Agent")
.put("body", "执行进度")
.put("kind", "execution_progress")
.put("sentAt", "2026-05-08T10:16:00+08:00")
.put("executionProgress", new JSONObject()
.put("status", "completed")
.put("steps", new JSONArray()
.put(new JSONObject().put("text", "回读计划和 H5 商品支付链现状").put("status", "done"))
.put(new JSONObject().put("text", "运行 targeted/full test、typecheck 和 diff 检查").put("status", "done")))
.put("branch", new JSONObject()
.put("additions", 181500)
.put("deletions", 52)
.put("githubCliStatus", "unavailable"))
.put("artifacts", new JSONArray()
.put(new JSONObject().put("label", "development_version_log_20260508.md").put("kind", "file"))
.put(new JSONObject().put("label", "已生成图像 1").put("kind", "image")))
.put("agents", new JSONArray()
.put(new JSONObject().put("name", "Mendel").put("role", "explorer"))));
View messageView = ReflectionHelpers.callInstanceMethod(
activity,
"buildMessageView",
ReflectionHelpers.ClassParameter.from(JSONObject.class, message)
);
assertTrue(viewTreeContainsText(messageView, "进度"));
assertTrue(viewTreeContainsText(messageView, "回读计划和 H5 商品支付链现状"));
assertTrue(viewTreeContainsText(messageView, "+181,500"));
assertTrue(viewTreeContainsText(messageView, "-52"));
assertTrue(viewTreeContainsText(messageView, "GitHub CLI 不可用"));
assertTrue(viewTreeContainsText(messageView, "development_version_log_20260508.md"));
assertTrue(viewTreeContainsText(messageView, "Mendelexplorer"));
}
@Test
public void completedReplyResponseRendersImmediatelyWithoutReloadingProjectDetail() throws Exception {
Intent intent = new Intent()
@@ -473,6 +996,170 @@ public class ProjectDetailActivityUiTest {
assertEquals("更多", String.valueOf(headerAction.getContentDescription()));
}
@Test
public void completedBrowserControlResponseShowsControlSummaryInConversation() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "主 Agent");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话");
JSONObject initialPayload = new JSONObject()
.put("project", new JSONObject()
.put("id", "master-agent")
.put("name", "主 Agent")
.put("messages", new JSONArray()));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, initialPayload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
JSONObject userMessage = new JSONObject()
.put("id", "msg-user-browser")
.put("sender", "user")
.put("senderLabel", "Boss 超级管理员")
.put("body", "打开 https://example.com 看一下首页")
.put("kind", "text")
.put("sentAt", "2026-04-17T10:00:00.000Z");
JSONObject replyMessage = new JSONObject()
.put("id", "msg-master-browser")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "浏览器控制已完成:打开 https://example.com 看一下首页")
.put("kind", "text")
.put("sentAt", "2026-04-17T10:00:01.000Z");
JSONObject sendResponse = new JSONObject()
.put("ok", true)
.put("message", userMessage)
.put("replyMessage", replyMessage)
.put("masterReplyState", "completed")
.put("replyPresenter", "master")
.put("executionMode", "browser")
.put("riskLevel", "medium")
.put("requiresConfirmation", true)
.put("targetUrl", "https://example.com")
.put("task", JSONObject.NULL)
.put("dispatchPlan", JSONObject.NULL)
.put("collaborationGate", new JSONObject()
.put("isGroup", false)
.put("collaborationMode", "development")
.put("approvalState", "not_required"));
CompletedReplyApiClient fakeApiClient = new CompletedReplyApiClient(sendResponse);
ReflectionHelpers.setField(activity, "apiClient", fakeApiClient);
ReflectionHelpers.callInstanceMethod(
activity,
"sendProjectMessage",
ReflectionHelpers.ClassParameter.from(String.class, "text"),
ReflectionHelpers.ClassParameter.from(String.class, "打开 https://example.com 看一下首页")
);
Shadows.shadowOf(activity.getMainLooper()).idle();
JSONObject controlSummary = ReflectionHelpers.callInstanceMethod(
activity,
"buildControlSummaryMessageIfNeeded",
ReflectionHelpers.ClassParameter.from(JSONObject.class, sendResponse)
);
assertNotNull(controlSummary);
assertEquals("control_summary", controlSummary.optString("kind"));
assertEquals("https://example.com", controlSummary.optString("controlTarget"));
assertEquals("浏览器控制已完成:打开 https://example.com 看一下首页", controlSummary.optString("body"));
assertEquals(0, fakeApiClient.projectDetailCallCount);
}
@Test
public void completedDesktopControlResponseShowsControlSummaryInConversation() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "主 Agent");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话");
JSONObject initialPayload = new JSONObject()
.put("project", new JSONObject()
.put("id", "master-agent")
.put("name", "主 Agent")
.put("messages", new JSONArray()));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, initialPayload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
JSONObject userMessage = new JSONObject()
.put("id", "msg-user-desktop")
.put("sender", "user")
.put("senderLabel", "Boss 超级管理员")
.put("body", "打开微信并准备切到聊天窗口")
.put("kind", "text")
.put("sentAt", "2026-04-17T10:05:00.000Z");
JSONObject replyMessage = new JSONObject()
.put("id", "msg-master-desktop")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "桌面控制已完成:打开微信并准备切到聊天窗口")
.put("kind", "text")
.put("sentAt", "2026-04-17T10:05:01.000Z");
JSONObject sendResponse = new JSONObject()
.put("ok", true)
.put("message", userMessage)
.put("replyMessage", replyMessage)
.put("masterReplyState", "completed")
.put("replyPresenter", "master")
.put("executionMode", "desktop")
.put("riskLevel", "medium")
.put("requiresConfirmation", true)
.put("targetApp", "微信")
.put("task", JSONObject.NULL)
.put("dispatchPlan", JSONObject.NULL)
.put("collaborationGate", new JSONObject()
.put("isGroup", false)
.put("collaborationMode", "development")
.put("approvalState", "not_required"));
CompletedReplyApiClient fakeApiClient = new CompletedReplyApiClient(sendResponse);
ReflectionHelpers.setField(activity, "apiClient", fakeApiClient);
ReflectionHelpers.callInstanceMethod(
activity,
"sendProjectMessage",
ReflectionHelpers.ClassParameter.from(String.class, "text"),
ReflectionHelpers.ClassParameter.from(String.class, "打开微信并准备切到聊天窗口")
);
Shadows.shadowOf(activity.getMainLooper()).idle();
JSONObject controlSummary = ReflectionHelpers.callInstanceMethod(
activity,
"buildControlSummaryMessageIfNeeded",
ReflectionHelpers.ClassParameter.from(JSONObject.class, sendResponse)
);
assertNotNull(controlSummary);
assertEquals("control_summary", controlSummary.optString("kind"));
assertEquals("微信", controlSummary.optString("controlTarget"));
assertEquals("桌面控制已完成:打开微信并准备切到聊天窗口", controlSummary.optString("body"));
assertEquals(0, fakeApiClient.projectDetailCallCount);
}
@Test
public void normalConversationHeaderUsesWechatMoreMenuLabel() {
Intent intent = new Intent()
@@ -635,6 +1322,80 @@ public class ProjectDetailActivityUiTest {
assertEquals(null, ReflectionHelpers.getField(activity, "masterAgentReplyBaselineMessageId"));
}
@Test
public void startReplyWaitTracksMasterRelayInThreadConversation() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "thread-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "AI 眼镜线程");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "pendingReplyPresenter", "master");
JSONObject sendResponse = new JSONObject()
.put("message", new JSONObject().put("id", "msg-user-1"))
.put("task", new JSONObject()
.put("taskId", "task-1")
.put("taskType", "conversation_reply")
.put("status", "queued"));
ProjectChatUiState.ReplyWaitSpec waitSpec =
ProjectChatUiState.resolveReplyWaitAfterSend(sendResponse);
ReflectionHelpers.callInstanceMethod(
activity,
"startReplyWait",
ReflectionHelpers.ClassParameter.from(ProjectChatUiState.ReplyWaitSpec.class, waitSpec),
ReflectionHelpers.ClassParameter.from(boolean.class, false),
ReflectionHelpers.ClassParameter.from(String.class, "消息已发送,主 Agent 正在转述")
);
assertTrue(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyWaiting"));
assertFalse(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyTimedOut"));
assertEquals("msg-user-1", ReflectionHelpers.getField(activity, "masterAgentReplyBaselineMessageId"));
assertEquals(1, activity.replyWaitPollCount);
}
@Test
public void renderThreadProjectClearsMasterRelayWaitStateAfterNewReplyArrives() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "thread-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "AI 眼镜线程");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "AI 眼镜线程");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话");
ReflectionHelpers.setField(activity, "pendingReplyPresenter", "master");
ReflectionHelpers.setField(activity, "masterAgentReplyWaiting", false);
ReflectionHelpers.setField(activity, "masterAgentReplyTimedOut", true);
ReflectionHelpers.setField(activity, "masterAgentReplyBaselineMessageId", "msg-user-1");
JSONObject project = new JSONObject()
.put("project", new JSONObject()
.put("id", "thread-1")
.put("name", "AI 眼镜线程")
.put("messages", new JSONArray()
.put(new JSONObject().put("id", "msg-user-1").put("sender", "user"))
.put(new JSONObject().put("id", "msg-master-1").put("sender", "master"))));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, project),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
assertFalse(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyWaiting"));
assertFalse(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyTimedOut"));
assertEquals(null, ReflectionHelpers.getField(activity, "masterAgentReplyBaselineMessageId"));
assertEquals(null, ReflectionHelpers.getField(activity, "pendingReplyPresenter"));
}
@Test
public void outgoingAttachmentMetaPrefersTimeOnly() throws Exception {
Intent intent = new Intent()
@@ -667,8 +1428,7 @@ public class ProjectDetailActivityUiTest {
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
assertTrue(viewTreeContainsText(attachmentView, "09:26"));
assertFalse(viewTreeContainsText(attachmentView, "你 · 09:26"));
assertTrue(viewTreeContainsText(attachmentView, "你 · 09:26"));
}
@Test
@@ -917,6 +1677,67 @@ public class ProjectDetailActivityUiTest {
return false;
}
private static boolean viewTreeContainsContentDescription(View root, String expectedText) {
if (root == null) {
return false;
}
CharSequence description = root.getContentDescription();
if (description != null && expectedText.contentEquals(description)) {
return true;
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsContentDescription(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static boolean viewTreeHasBackgroundColor(View root, int expectedColor) {
if (root.getBackground() instanceof ColorDrawable) {
return ((ColorDrawable) root.getBackground()).getColor() == expectedColor;
}
if (root.getBackground() instanceof GradientDrawable) {
ColorStateList color = ((GradientDrawable) root.getBackground()).getColor();
if (color != null && color.getDefaultColor() == expectedColor) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeHasBackgroundColor(group.getChildAt(index), expectedColor)) {
return true;
}
}
return false;
}
private static boolean viewTreeHasGradientColor(View root, int expectedColor) {
if (root.getBackground() instanceof GradientDrawable) {
ColorStateList color = ((GradientDrawable) root.getBackground()).getColor();
if (color != null && color.getDefaultColor() == expectedColor) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeHasGradientColor(group.getChildAt(index), expectedColor)) {
return true;
}
}
return false;
}
private static View findClickableViewContainingText(View root, String expectedText) {
if (root == null) {
return null;
@@ -954,6 +1775,7 @@ public class ProjectDetailActivityUiTest {
String lastReplyWaitBaselineMessageId;
boolean lastReplyWaitIncludeDispatchPlans;
int scrollChatToBottomCount;
int finishCallCount;
@Override
boolean shouldLoadOnCreate() {
@@ -971,6 +1793,12 @@ public class ProjectDetailActivityUiTest {
void scrollChatToBottom() {
scrollChatToBottomCount += 1;
}
@Override
public void finish() {
finishCallCount += 1;
super.finish();
}
}
private static final class CompletedReplyApiClient extends BossApiClient {
@@ -1002,6 +1830,22 @@ public class ProjectDetailActivityUiTest {
}
}
private static final class RecordingConversationActionApiClient extends BossApiClient {
int markConversationReadCount;
String lastMarkedProjectId;
RecordingConversationActionApiClient() {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
}
@Override
public ApiResponse markConversationRead(String projectId) throws org.json.JSONException {
markConversationReadCount += 1;
lastMarkedProjectId = projectId;
return new ApiResponse(200, new JSONObject().put("ok", true));
}
}
private static final class InMemorySharedPreferences implements SharedPreferences {
private final Map<String, Object> values = new HashMap<>();

View File

@@ -0,0 +1,96 @@
package com.hyzq.boss;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.View;
import android.view.ViewGroup;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class TelegramIntegrationActivityTest {
@Test
public void populateShowsCurrentTelegramStatusBeforeEditableForm() throws Exception {
TestTelegramIntegrationActivity activity = Robolectric
.buildActivity(TestTelegramIntegrationActivity.class, new Intent())
.setup()
.get();
JSONObject telegram = new JSONObject()
.put("enabled", true)
.put("mode", "webhook")
.put("botTokenConfigured", true)
.put("webhookSecretConfigured", true)
.put("botUsername", "boss_demo_bot")
.put("defaultProjectId", "master-agent")
.put("processedUpdateCount", 3)
.put("lastError", "上次 webhook 同步失败")
.put("allowFrom", new JSONArray().put("123456"))
.put("groups", new JSONArray().put("-10001"))
.put(
"groupProjectRoutes",
new JSONArray().put(
new JSONObject()
.put("chatId", "-10001")
.put("threadId", 12)
.put("projectId", "audit-collab")
.put("label", "审计 Topic")
)
)
.put("dmPolicy", "allowlist")
.put("groupPolicy", "allowlist")
.put("requireMentionInGroups", true);
ReflectionHelpers.callInstanceMethod(
activity,
"populate",
ReflectionHelpers.ClassParameter.from(JSONObject.class, telegram)
);
ViewGroup content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content.getChildAt(0), "当前状态"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "接入:已开启"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "模式Webhook"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "Bot@boss_demo_bot"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "Token已配置"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "Webhook Secret已配置"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "已处理 update3"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "最近错误:上次 webhook 同步失败"));
assertTrue(viewTreeContainsText(content, "群 / Topic 路由"));
assertTrue(viewTreeContainsText(content, "-10001#12 audit-collab 审计 Topic"));
}
private static boolean viewTreeContainsText(View view, String text) {
if (view instanceof android.widget.TextView) {
CharSequence value = ((android.widget.TextView) view).getText();
if (value != null && value.toString().contains(text)) {
return true;
}
}
if (view instanceof ViewGroup) {
ViewGroup group = (ViewGroup) view;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), text)) {
return true;
}
}
}
return false;
}
public static class TestTelegramIntegrationActivity extends TelegramIntegrationActivity {
@Override
protected void reload() {
// Tests drive rendering directly through populate().
}
}
}

View File

@@ -137,13 +137,96 @@ public class WechatSurfaceMapperTest {
assertEquals("已导入线程", row.lastMessagePreview);
}
@Test
public void toConversationRow_sanitizesLeakedPromptTitleToFolderFallback() throws Exception {
JSONObject item = new JSONObject()
.put("conversationType", "single_device")
.put("projectTitle", "你当前接手的项目根目录是:")
.put("threadTitle", "你当前接手的项目根目录是:")
.put("folderLabel", "boss")
.put("latestReplyLabel", "17:35");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("boss", row.threadTitle);
}
@Test
public void toConversationRow_extractsWorkspaceFolderFromPromptLeakTitle() throws Exception {
JSONObject item = new JSONObject()
.put("conversationType", "single_device")
.put("projectTitle", "你现在接手的项目根目录是 /Users/kris/code/yuandi。")
.put("threadTitle", "你现在接手的项目根目录是 /Users/kris/code/yuandi。")
.put("latestReplyLabel", "17:36");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("yuandi", row.threadTitle);
}
@Test
public void toConversationRow_prefersStableMasterAgentProjectTitleOverOperationalThreadTitle() throws Exception {
JSONObject item = new JSONObject()
.put("projectId", "master-agent")
.put("projectTitle", "主 Agent")
.put("threadTitle", "主 Agent 汇总")
.put("lastMessagePreview", "同步已完成")
.put("latestReplyLabel", "10:18");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("主 Agent", row.threadTitle);
assertEquals("同步已完成", row.lastMessagePreview);
}
@Test
public void toConversationRow_prefersStableAuditProjectTitleOverOperationalThreadTitle() throws Exception {
JSONObject item = new JSONObject()
.put("projectId", "audit-collab")
.put("projectTitle", "硬件审计协作")
.put("threadTitle", "审计对话")
.put("lastMessagePreview", "审计结果已回写")
.put("latestReplyLabel", "10:20");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("硬件审计协作", row.threadTitle);
assertEquals("审计结果已回写", row.lastMessagePreview);
}
@Test
public void toConversationRow_hidesProcessLikePreviewFallback() throws Exception {
JSONObject item = new JSONObject()
.put("projectTitle", "Boss")
.put("threadTitle", "Boss开发主线程")
.put("lastMessagePreview", "我继续往下收,这一轮先检查折叠链路,再确认未读逻辑,随后回你结果。")
.put("latestReplyLabel", "10:20");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("", row.lastMessagePreview);
}
@Test
public void toConversationRow_keepsFinalSummaryPreviewVisible() throws Exception {
JSONObject item = new JSONObject()
.put("projectTitle", "Boss")
.put("threadTitle", "Boss开发主线程")
.put("lastMessagePreview", "折叠修复已部署,未读数现在只按最终结果计数。")
.put("latestReplyLabel", "10:22");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("折叠修复已部署,未读数现在只按最终结果计数。", row.lastMessagePreview);
}
@Test
public void toDeviceRow_mapsLegacyWechatThreeLineSummary() throws Exception {
JSONObject item = new StubJSONObject()
.withString("name", "Mac Studio")
.withString("avatar", "M")
.withString("status", "online")
.withString("account", "17600003315")
.withString("account", "krisolo")
.withStringArray("projects", "北区试产线回归", "容灾切换验证")
.withInt("quota5h", 8)
.withInt("quota7d", 22);
@@ -151,7 +234,7 @@ public class WechatSurfaceMapperTest {
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
assertEquals("Mac Studio", row.title);
assertEquals("账号: 17600003315 · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle);
assertEquals("账号: krisolo · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle);
assertEquals("额度: 5h 8% · 7d 22%", row.meta);
assertEquals("M", row.avatarLabel);
assertEquals("online", row.statusKey);
@@ -162,12 +245,12 @@ public class WechatSurfaceMapperTest {
JSONObject item = new StubJSONObject()
.withString("name", "Mac Studio")
.withString("status", "abnormal")
.withString("account", "17600003315");
.withString("account", "krisolo");
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
assertEquals("Mac Studio", row.title);
assertEquals("账号: 17600003315", row.subtitle);
assertEquals("账号: krisolo", row.subtitle);
assertEquals("额度: 暂无 · 状态异常", row.meta);
assertEquals("abnormal", row.statusKey);
}
@@ -177,7 +260,7 @@ public class WechatSurfaceMapperTest {
JSONObject item = new StubJSONObject()
.withString("name", "Mac Studio")
.withString("status", "online")
.withString("account", "17600003315")
.withString("account", "krisolo")
.withString("note", "书房主机")
.withString("endpoint", "https://boss.hyzq.net/device/mac-studio")
.withStringArray("projects", "master-agent", "android-app");
@@ -185,14 +268,14 @@ public class WechatSurfaceMapperTest {
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(item);
assertEquals("Mac Studio", summary.title);
assertEquals("账号: 17600003315 · 项目: master-agent / android-app", summary.subtitle);
assertEquals("账号: krisolo · 项目: master-agent / android-app", summary.subtitle);
assertEquals("额度: 暂无 · 书房主机 · https://boss.hyzq.net/device/mac-studio · 项目 master-agent, android-app", summary.meta);
}
@Test
public void rootMeMenuTitles_matchLegacyWechatMenuWithOpsEntry() throws Exception {
assertArrayEquals(
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitles()
);
}
@@ -208,7 +291,7 @@ public class WechatSurfaceMapperTest {
@Test
public void mainPage_keepsOpsEntryInStableWechatMenuOrder() throws Exception {
assertArrayEquals(
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitles()
);
}
@@ -292,7 +375,7 @@ public class WechatSurfaceMapperTest {
JSONArray devices = new StubObjectArray(
new StubJSONObject()
.withString("id", "device-b")
.withString("account", "17600003315"),
.withString("account", "krisolo"),
new StubJSONObject()
.withString("id", "device-c")
.withString("account", "other-account")
@@ -311,7 +394,7 @@ public class WechatSurfaceMapperTest {
null,
"stale-device-id",
"missing-bound-device",
"17600003315",
"krisolo",
devices
);
@@ -380,15 +463,20 @@ public class WechatSurfaceMapperTest {
public void meMenuItems_useStableKeysInsteadOfDisplayTitlesForRouting() throws Exception {
WechatSurfaceMapper.MeMenuItem[] items = WechatSurfaceMapper.rootMeMenuItems();
assertEquals(6, items.length);
assertEquals(9, items.length);
assertEquals("security", items[0].key);
assertEquals("账号与安全", items[0].title);
assertEquals("settings", items[1].key);
assertEquals("ops", items[2].key);
assertEquals("运维与修复", items[2].title);
assertEquals("ai_accounts", items[3].key);
assertEquals("skills", items[4].key);
assertEquals("about", items[5].key);
assertEquals("access", items[2].key);
assertEquals("用户与权限", items[2].title);
assertEquals("ops", items[3].key);
assertEquals("运维与修复", items[3].title);
assertEquals("ai_accounts", items[4].key);
assertEquals("storage", items[5].key);
assertEquals("附件与存储", items[5].title);
assertEquals("telegram", items[6].key);
assertEquals("skills", items[7].key);
assertEquals("about", items[8].key);
}
@Test

View File

@@ -40,6 +40,17 @@ public class WechatSurfaceMapperTopActionTest {
assertEquals("add_device", action.actionKey);
}
@Test
public void rootTopAction_hidesAddDeviceForSubAccounts() {
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("devices", false, false, "member");
assertEquals("刷新", action.label);
assertEquals("refresh", action.iconKey);
assertFalse(action.primaryStyle);
assertTrue(action.compactStyle);
assertEquals("refresh", action.actionKey);
}
@Test
public void rootTopAction_keepsRefreshOnMeTab() {
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("me", true);

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Boss 企业后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1762
apps/boss-admin-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
{
"name": "@boss/admin-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5174",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0 --port 4174",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"ant-design-vue": "^4.2.6",
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.3",
"vite": "^6.0.7",
"vue-tsc": "^2.2.0"
}
}

View File

@@ -0,0 +1,732 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { message } from "ant-design-vue";
import {
AppstoreOutlined,
AuditOutlined,
ClusterOutlined,
DashboardOutlined,
SafetyOutlined,
SettingOutlined,
TeamOutlined,
ToolOutlined,
UserOutlined,
} from "@ant-design/icons-vue";
import {
fetchBossAdminBackoffice,
postAdminAccess,
postRiskAction,
postSkillLifecycleRequest,
type BossAdminBackofficePayload,
} from "./api/bossAdmin";
type AdminRecord = Record<string, unknown>;
const loading = ref(true);
const mutating = ref(false);
const error = ref("");
const activeKey = ref("workbench");
const payload = ref<BossAdminBackofficePayload | null>(null);
const companyForm = reactive({
companyId: "",
name: "",
ownerAccount: "",
successOwnerAccount: "",
planTier: "enterprise",
contractExpiresAt: "",
note: "",
});
const accountForm = reactive({
account: "",
displayName: "",
role: "member",
password: "",
companyId: "",
});
const grantForm = reactive({
account: "",
scope: "device",
targetId: "",
templateId: "developer",
permissions: ["device.view"],
expiresAt: "",
note: "",
});
const riskForm = reactive({
ownerAccount: "",
slaDueAt: "",
note: "",
});
const skillRequestForm = reactive({
action: "update",
deviceId: "",
skillId: "",
sourceUrl: "",
trustedSourceId: "",
targetVersion: "",
rollbackToVersion: "",
lockedVersion: "",
checksum: "",
note: "",
});
const menuIconMap = {
workbench: DashboardOutlined,
tenant: ClusterOutlined,
user: UserOutlined,
role: SafetyOutlined,
resource: AppstoreOutlined,
skills: ToolOutlined,
risk: AuditOutlined,
audit: AuditOutlined,
system: SettingOutlined,
} as const;
const menuTree = computed(() => payload.value?.menuTree ?? []);
const summary = computed(() => payload.value?.workbench.summary ?? {});
const tenants = computed(() => payload.value?.tenants ?? []);
const users = computed(() => payload.value?.users ?? []);
const roles = computed(() => payload.value?.roles.builtInRoles ?? []);
const templates = computed(() => payload.value?.roles.permissionTemplates ?? []);
const devices = computed(() => payload.value?.resourceGroups.devices ?? []);
const projects = computed(() => payload.value?.resourceGroups.projects ?? []);
const skills = computed(() => payload.value?.resourceGroups.skills ?? []);
const risks = computed(() => payload.value?.audit.risks ?? []);
const auditLogs = computed(() => payload.value?.audit.permissionLogs ?? []);
const grants = computed(() => payload.value?.resourceGroups.grants ?? { devices: [], projects: [], skills: [] });
const grantRows = computed(() => [
...grants.value.devices.map((grant) => ({ ...grant, scopeLabel: "设备", targetLabel: text(grant.deviceId) })),
...grants.value.projects.map((grant) => ({ ...grant, scopeLabel: "项目", targetLabel: text(grant.projectId) })),
...grants.value.skills.map((grant) => ({ ...grant, scopeLabel: "Skill", targetLabel: text(grant.skillId) })),
]);
const selectedTemplate = computed(() =>
templates.value.find((item) => text(item.templateId) === grantForm.templateId),
);
const selectedScopePermissions = computed(() => {
if (grantForm.scope === "project") return selectedTemplate.value?.projectPermissions ?? ["project.view"];
if (grantForm.scope === "skill") return selectedTemplate.value?.skillPermissions ?? ["skill.view"];
return selectedTemplate.value?.devicePermissions ?? ["device.view"];
});
const selectedScopePermissionPlaceholder = computed(() =>
Array.isArray(selectedScopePermissions.value) ? selectedScopePermissions.value.join(" / ") : "device.view",
);
async function loadBackoffice() {
loading.value = true;
error.value = "";
try {
payload.value = await fetchBossAdminBackoffice();
} catch (err) {
error.value = err instanceof Error ? err.message : "后台数据加载失败";
} finally {
loading.value = false;
}
}
function selectMenu(key: string) {
activeKey.value = key;
}
function text(value: unknown, fallback = "-") {
if (value === null || value === undefined || value === "") return fallback;
return String(value);
}
function permissionText(value: unknown) {
return Array.isArray(value) ? value.join(" / ") : text(value);
}
function resetCompanyForm() {
Object.assign(companyForm, {
companyId: "",
name: "",
ownerAccount: "",
successOwnerAccount: "",
planTier: "enterprise",
contractExpiresAt: "",
note: "",
});
}
function resetAccountForm() {
Object.assign(accountForm, {
account: "",
displayName: "",
role: "member",
password: "",
companyId: "",
});
}
async function runMutation(label: string, task: () => Promise<unknown>) {
mutating.value = true;
const hide = message.loading(`${label}中...`, 0);
try {
await task();
hide();
message.success(`${label}完成`);
await loadBackoffice();
} catch (err) {
hide();
message.error(`${label}失败:${err instanceof Error ? err.message : "UNKNOWN_ERROR"}`);
} finally {
mutating.value = false;
}
}
async function saveCompany() {
await runMutation("新建租户", async () => {
await postAdminAccess({
action: "upsert_company",
...companyForm,
});
resetCompanyForm();
});
}
async function setCompanyStatus(record: AdminRecord, status: "active" | "disabled") {
await runMutation(status === "active" ? "启用租户" : "停用租户", () =>
postAdminAccess({
action: "set_company_status",
companyId: text(record.companyId),
status,
}),
);
}
async function saveAccount() {
await runMutation("新建账号", async () => {
await postAdminAccess({
action: "upsert_account",
...accountForm,
});
resetAccountForm();
});
}
async function setAccountStatus(record: AdminRecord, status: "active" | "disabled") {
await runMutation(status === "active" ? "启用账号" : "停用账号", () =>
postAdminAccess({
action: "set_account_status",
account: text(record.account),
status,
}),
);
}
async function resetPassword(record: AdminRecord) {
const password = window.prompt(`请输入 ${text(record.account)} 的新密码`);
if (!password) return;
await runMutation("重置密码", () =>
postAdminAccess({
action: "reset_account_password",
account: text(record.account),
password,
}),
);
}
async function reclaimAccount(record: AdminRecord) {
if (!window.confirm(`确认离职回收 ${text(record.account)}?这会停用账号并清理授权。`)) return;
await runMutation("离职回收", () =>
postAdminAccess({
action: "reclaim_account",
account: text(record.account),
reason: "enterprise-admin-web",
}),
);
}
async function submitGrant() {
const permissions = grantForm.permissions.length > 0 ? grantForm.permissions : selectedScopePermissions.value;
const base = {
account: grantForm.account,
permissions,
expiresAt: grantForm.expiresAt,
note: grantForm.note,
};
await runMutation("分配资源", async () => {
if (grantForm.scope === "project") {
await postAdminAccess({ action: "grant_project", ...base, projectId: grantForm.targetId });
} else if (grantForm.scope === "skill") {
await postAdminAccess({ action: "grant_skill", ...base, skillId: grantForm.targetId });
} else {
await postAdminAccess({ action: "grant_device", ...base, deviceId: grantForm.targetId });
}
});
}
async function applyPermissionTemplate() {
await runMutation("套用权限模板", () =>
postAdminAccess({
action: "apply_template",
account: grantForm.account,
templateId: grantForm.templateId,
deviceIds: grantForm.scope === "device" ? [grantForm.targetId] : [],
projectIds: grantForm.scope === "project" ? [grantForm.targetId] : [],
skillIds: grantForm.scope === "skill" ? [grantForm.targetId] : [],
}),
);
}
async function revokeGrant(record: AdminRecord) {
if (!window.confirm(`确认撤销授权 ${text(record.grantId)}`)) return;
await runMutation("撤销授权", () =>
postAdminAccess({
action: "revoke_grant",
grantId: text(record.grantId),
}),
);
}
async function handleRisk(record: AdminRecord, action: string) {
await runMutation(
action === "assign_owner"
? "指派负责人"
: action === "set_sla"
? "设置 SLA"
: action === "ack"
? "确认风险"
: action === "resolve"
? "关闭风险"
: "创建工单",
() =>
postRiskAction({
riskId: text(record.riskId),
action,
ownerAccount: riskForm.ownerAccount,
slaDueAt: riskForm.slaDueAt,
note: riskForm.note,
}),
);
}
async function createSkillRequest() {
await runMutation("创建 Skill 请求", () =>
postSkillLifecycleRequest({
...skillRequestForm,
}),
);
}
onMounted(loadBackoffice);
</script>
<template>
<a-config-provider
:theme="{
token: {
colorPrimary: '#10b981',
borderRadius: 16,
fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
},
}"
>
<div class="boss-admin-shell">
<aside class="boss-admin-sidebar">
<div class="boss-admin-brand">
<div class="boss-admin-brand-mark">B</div>
<div>
<h1>Boss 企业后台</h1>
<p>平台侧 To B 管理中心</p>
</div>
</div>
<nav class="boss-admin-menu" aria-label="企业后台菜单">
<button
v-for="item in menuTree"
:key="item.key"
class="boss-admin-menu-item"
:class="{ active: activeKey === item.key }"
type="button"
@click="selectMenu(item.key)"
>
<component :is="menuIconMap[item.key as keyof typeof menuIconMap] ?? TeamOutlined" />
<span>{{ item.label }}</span>
</button>
</nav>
</aside>
<main class="boss-admin-main">
<header class="boss-admin-header">
<div>
<p class="boss-admin-eyebrow">YuDao / Vben 信息架构 · Boss 数据契约</p>
<h2>{{ menuTree.find((item) => item.key === activeKey)?.label ?? "工作台" }}</h2>
</div>
<div class="boss-admin-header-actions">
<a-tag color="green">highest_admin</a-tag>
<a-button :loading="loading" @click="loadBackoffice">刷新</a-button>
</div>
</header>
<a-alert
v-if="error"
class="boss-admin-alert"
type="error"
show-icon
:message="`后台数据加载失败:${error}`"
/>
<a-spin :spinning="loading">
<section v-if="activeKey === 'workbench'" class="boss-admin-section-grid">
<a-card class="boss-admin-hero" :bordered="false">
<p class="boss-admin-eyebrow">总览统计</p>
<h3>公司账号电脑节点和风险一张图看清</h3>
<div class="boss-admin-metrics">
<a-statistic title="公司数" :value="summary.companies ?? 0" />
<a-statistic title="账号数" :value="summary.accounts ?? 0" />
<a-statistic title="在线设备" :value="summary.onlineDevices ?? 0" />
<a-statistic title="开放风险" :value="summary.openRisks ?? 0" />
</div>
</a-card>
<a-card title="关键风险" :bordered="false">
<a-table size="small" :pagination="false" :data-source="risks.slice(0, 5)" row-key="riskId">
<a-table-column title="风险" data-index="title" />
<a-table-column title="级别" data-index="severity" />
<a-table-column title="对象" data-index="deviceId" />
<a-table-column title="时间" data-index="lastSeenAt" />
</a-table>
</a-card>
<a-card title="客户健康排行" :bordered="false">
<a-table size="small" :pagination="false" :data-source="tenants.slice(0, 6)" row-key="companyId">
<a-table-column title="公司" data-index="name" />
<a-table-column title="账号" data-index="accountCount" />
<a-table-column title="设备" data-index="deviceCount" />
<a-table-column title="风险" data-index="openRiskCount" />
</a-table>
</a-card>
<a-card title="节点健康" :bordered="false">
<a-table size="small" :pagination="false" :data-source="devices.slice(0, 8)" row-key="id">
<a-table-column title="设备" data-index="name" />
<a-table-column title="状态" data-index="status" />
<a-table-column title="CLI" data-index="codexCliOnline" />
<a-table-column title="GUI" data-index="codexGuiOnline" />
</a-table>
</a-card>
</section>
<section v-else-if="activeKey === 'tenant'" class="boss-admin-section-grid">
<a-card title="新建租户" :bordered="false">
<a-form layout="vertical" class="boss-admin-form">
<a-form-item label="租户 ID">
<a-input v-model:value="companyForm.companyId" placeholder="acme" />
</a-form-item>
<a-form-item label="公司名称">
<a-input v-model:value="companyForm.name" placeholder="默认显示给平台运营人员" />
</a-form-item>
<a-form-item label="老板账号">
<a-input v-model:value="companyForm.ownerAccount" placeholder="owner@example.com" />
</a-form-item>
<a-form-item label="客户成功">
<a-input v-model:value="companyForm.successOwnerAccount" placeholder="cs@boss.com" />
</a-form-item>
<a-form-item label="套餐">
<a-select v-model:value="companyForm.planTier">
<a-select-option value="trial">trial</a-select-option>
<a-select-option value="standard">standard</a-select-option>
<a-select-option value="enterprise">enterprise</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="合同到期">
<a-input v-model:value="companyForm.contractExpiresAt" placeholder="2027-05-01T00:00:00+08:00" />
</a-form-item>
<a-button type="primary" block :loading="mutating" @click="saveCompany">新建租户</a-button>
</a-form>
</a-card>
<a-card title="租户管理" :bordered="false">
<a-table :data-source="tenants" row-key="companyId">
<a-table-column title="公司" data-index="name" />
<a-table-column title="套餐" data-index="planTier" />
<a-table-column title="老板账号" data-index="ownerAccount" />
<a-table-column title="账号数" data-index="accountCount" />
<a-table-column title="开放风险" data-index="openRiskCount" />
<a-table-column title="操作">
<template #default="{ record }">
<a-space>
<a-button size="small" @click="setCompanyStatus(record, 'active')">启用租户</a-button>
<a-button size="small" danger @click="setCompanyStatus(record, 'disabled')">停用租户</a-button>
</a-space>
</template>
</a-table-column>
</a-table>
</a-card>
</section>
<section v-else-if="activeKey === 'user'" class="boss-admin-section-grid">
<a-card title="新建账号" :bordered="false">
<a-form layout="vertical" class="boss-admin-form">
<a-form-item label="账号">
<a-input v-model:value="accountForm.account" placeholder="member@example.com" />
</a-form-item>
<a-form-item label="昵称">
<a-input v-model:value="accountForm.displayName" placeholder="成员姓名" />
</a-form-item>
<a-form-item label="角色">
<a-select v-model:value="accountForm.role">
<a-select-option value="member">member</a-select-option>
<a-select-option value="admin">admin</a-select-option>
<a-select-option value="highest_admin">highest_admin</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="所属租户">
<a-select v-model:value="accountForm.companyId" allow-clear>
<a-select-option v-for="company in tenants" :key="text(company.companyId)" :value="text(company.companyId)">
{{ text(company.name) }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="初始密码">
<a-input-password v-model:value="accountForm.password" placeholder="留空时只更新资料" />
</a-form-item>
<a-button type="primary" block :loading="mutating" @click="saveAccount">新建账号</a-button>
</a-form>
</a-card>
<a-card title="账号管理" :bordered="false">
<a-table :data-source="users" row-key="id">
<a-table-column title="账号" data-index="account" />
<a-table-column title="昵称" data-index="displayName" />
<a-table-column title="角色" data-index="role" />
<a-table-column title="公司" data-index="companyName" />
<a-table-column title="状态" data-index="status" />
<a-table-column title="操作">
<template #default="{ record }">
<a-space wrap>
<a-button size="small" @click="setAccountStatus(record, 'active')">启用账号</a-button>
<a-button size="small" @click="resetPassword(record)">重置密码</a-button>
<a-button size="small" danger @click="setAccountStatus(record, 'disabled')">停用账号</a-button>
<a-button size="small" danger @click="reclaimAccount(record)">离职回收</a-button>
</a-space>
</template>
</a-table-column>
</a-table>
</a-card>
</section>
<section v-else-if="activeKey === 'role'" class="boss-admin-section-grid">
<a-card title="角色权限" :bordered="false">
<a-list :data-source="roles">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="text(item.label)" :description="text(item.description)" />
<a-tag>{{ item.role }}</a-tag>
</a-list-item>
</template>
</a-list>
</a-card>
<a-card title="权限模板" :bordered="false">
<a-list :data-source="templates">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta :title="text(item.name)" :description="text(item.description)" />
<a-tag color="green">{{ item.templateId }}</a-tag>
</a-list-item>
</template>
</a-list>
</a-card>
</section>
<section v-else-if="activeKey === 'resource'" class="boss-admin-section-grid">
<div class="boss-admin-section-title">资源授权</div>
<a-card title="分配资源" :bordered="false">
<a-form layout="vertical" class="boss-admin-form">
<a-form-item label="账号">
<a-select v-model:value="grantForm.account" show-search>
<a-select-option v-for="user in users" :key="text(user.account)" :value="text(user.account)">
{{ text(user.displayName) }} · {{ text(user.account) }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="授权范围">
<a-segmented v-model:value="grantForm.scope" :options="['device', 'project', 'skill']" />
</a-form-item>
<a-form-item label="目标">
<a-select v-model:value="grantForm.targetId" show-search>
<a-select-option
v-for="device in devices"
v-if="grantForm.scope === 'device'"
:key="text(device.id)"
:value="text(device.id)"
>
{{ text(device.name) }}
</a-select-option>
<a-select-option
v-for="project in projects"
v-if="grantForm.scope === 'project'"
:key="text(project.id)"
:value="text(project.id)"
>
{{ text(project.name) }}
</a-select-option>
<a-select-option
v-for="skill in skills"
v-if="grantForm.scope === 'skill'"
:key="text(skill.skillId)"
:value="text(skill.skillId)"
>
{{ text(skill.name) }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="权限模板">
<a-select v-model:value="grantForm.templateId">
<a-select-option v-for="template in templates" :key="text(template.templateId)" :value="text(template.templateId)">
{{ text(template.name) }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="权限">
<a-select v-model:value="grantForm.permissions" mode="tags" :placeholder="selectedScopePermissionPlaceholder" />
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="grantForm.note" />
</a-form-item>
<a-space>
<a-button type="primary" :loading="mutating" @click="submitGrant">分配资源</a-button>
<a-button :loading="mutating" @click="applyPermissionTemplate">套用权限模板</a-button>
</a-space>
</a-form>
</a-card>
<a-card title="授权清单" :bordered="false">
<a-table :data-source="grantRows" row-key="grantId">
<a-table-column title="范围" data-index="scopeLabel" />
<a-table-column title="账号" data-index="account" />
<a-table-column title="目标" data-index="targetLabel" />
<a-table-column title="权限">
<template #default="{ record }">
{{ permissionText(record.permissions) }}
</template>
</a-table-column>
<a-table-column title="操作">
<template #default="{ record }">
<a-button size="small" danger @click="revokeGrant(record)">撤销授权</a-button>
</template>
</a-table-column>
</a-table>
</a-card>
</section>
<section v-else-if="activeKey === 'skills'" class="boss-admin-section-grid">
<a-card title="创建 Skill 请求" :bordered="false">
<a-form layout="vertical" class="boss-admin-form">
<a-form-item label="动作">
<a-select v-model:value="skillRequestForm.action">
<a-select-option value="install">install</a-select-option>
<a-select-option value="update">update</a-select-option>
<a-select-option value="uninstall">uninstall</a-select-option>
<a-select-option value="rollback">rollback</a-select-option>
<a-select-option value="version_lock">version_lock</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="设备">
<a-select v-model:value="skillRequestForm.deviceId" show-search>
<a-select-option v-for="device in devices" :key="text(device.id)" :value="text(device.id)">
{{ text(device.name) }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Skill">
<a-select v-model:value="skillRequestForm.skillId" allow-clear show-search>
<a-select-option v-for="skill in skills" :key="text(skill.skillId)" :value="text(skill.skillId)">
{{ text(skill.name) }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="来源 URL">
<a-input v-model:value="skillRequestForm.sourceUrl" placeholder="安装或更新远端来源" />
</a-form-item>
<a-form-item label="版本 / 回滚 / 锁定">
<a-input-group compact>
<a-input v-model:value="skillRequestForm.targetVersion" style="width: 33%" placeholder="targetVersion" />
<a-input v-model:value="skillRequestForm.rollbackToVersion" style="width: 33%" placeholder="rollbackToVersion" />
<a-input v-model:value="skillRequestForm.lockedVersion" style="width: 34%" placeholder="lockedVersion" />
</a-input-group>
</a-form-item>
<a-form-item label="校验和 / 备注">
<a-input v-model:value="skillRequestForm.checksum" placeholder="sha256 checksum" />
<a-input v-model:value="skillRequestForm.note" class="boss-admin-form-gap" placeholder="备注" />
</a-form-item>
<a-button type="primary" block :loading="mutating" @click="createSkillRequest">创建 Skill 请求</a-button>
</a-form>
</a-card>
<a-card title="Skill 中心" :bordered="false">
<a-table :data-source="skills" row-key="skillId">
<a-table-column title="Skill" data-index="name" />
<a-table-column title="说明" data-index="description" />
<a-table-column title="分类" data-index="category" />
<a-table-column title="设备数" data-index="deviceCount" />
<a-table-column title="更新时间" data-index="updatedAt" />
</a-table>
</a-card>
</section>
<section v-else-if="activeKey === 'risk'" class="boss-admin-section">
<a-card title="风险告警与处置" :bordered="false">
<div class="boss-admin-action-strip">
<a-input v-model:value="riskForm.ownerAccount" placeholder="负责人账号,用于指派负责人" />
<a-input v-model:value="riskForm.slaDueAt" placeholder="SLA 时间,如 2026-05-02T18:00:00+08:00" />
<a-input v-model:value="riskForm.note" placeholder="处理备注" />
</div>
<a-table :data-source="risks" row-key="riskId">
<a-table-column title="风险" data-index="title" />
<a-table-column title="级别" data-index="severity" />
<a-table-column title="公司" data-index="companyId" />
<a-table-column title="负责人" data-index="ownerAccount" />
<a-table-column title="SLA" data-index="slaDueAt" />
<a-table-column title="操作">
<template #default="{ record }">
<a-space wrap>
<a-button size="small" @click="handleRisk(record, 'assign_owner')">指派负责人</a-button>
<a-button size="small" @click="handleRisk(record, 'set_sla')">设置 SLA</a-button>
<a-button size="small" @click="handleRisk(record, 'ack')">确认风险</a-button>
<a-button size="small" danger @click="handleRisk(record, 'resolve')">关闭风险</a-button>
<a-button size="small" @click="handleRisk(record, 'create_repair_ticket')">创建工单</a-button>
</a-space>
</template>
</a-table-column>
</a-table>
</a-card>
</section>
<section v-else-if="activeKey === 'audit'" class="boss-admin-section">
<a-card title="审计日志" :bordered="false">
<a-table :data-source="auditLogs" row-key="auditId">
<a-table-column title="操作人" data-index="actorAccount" />
<a-table-column title="动作" data-index="action" />
<a-table-column title="对象账号" data-index="targetAccount" />
<a-table-column title="详情" data-index="detail" />
<a-table-column title="时间" data-index="createdAt" />
</a-table>
</a-card>
</section>
<section v-else class="boss-admin-section">
<a-card title="系统设置" :bordered="false">
<p>当前独立后台已接入 Boss Admin BFF并已具备租户账号授权风险和 Skill 的基础治理动作</p>
<a-descriptions bordered size="small" :column="1">
<a-descriptions-item v-for="(value, key) in payload?.yudaoMapping ?? {}" :key="key" :label="key">
{{ value }}
</a-descriptions-item>
</a-descriptions>
</a-card>
</section>
</a-spin>
</main>
</div>
</a-config-provider>
</template>

View File

@@ -0,0 +1,83 @@
export interface BossAdminMenuItem {
key: string;
label: string;
children?: BossAdminMenuItem[];
}
export interface BossAdminBackofficePayload {
ok: boolean;
menuTree: BossAdminMenuItem[];
workbench: {
summary: Record<string, number>;
companies: Array<Record<string, unknown>>;
devices: Array<Record<string, unknown>>;
risks: Array<Record<string, unknown>>;
notifications: Array<Record<string, unknown>>;
grantsSummary: Record<string, number>;
};
tenants: Array<Record<string, unknown>>;
users: Array<Record<string, unknown>>;
roles: {
builtInRoles: Array<Record<string, unknown>>;
permissionTemplates: Array<Record<string, unknown>>;
};
resourceGroups: {
devices: Array<Record<string, unknown>>;
projects: Array<Record<string, unknown>>;
skills: Array<Record<string, unknown>>;
grants: Record<string, Array<Record<string, unknown>>>;
};
audit: {
risks: Array<Record<string, unknown>>;
notifications: Array<Record<string, unknown>>;
riskTimeline: Array<Record<string, unknown>>;
permissionLogs: Array<Record<string, unknown>>;
};
yudaoMapping: Record<string, string>;
}
async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
credentials: "include",
...init,
headers: {
Accept: "application/json",
...(init.body ? { "Content-Type": "application/json" } : {}),
...(init.headers ?? {}),
},
});
if (response.status === 401) {
window.location.href = "/auth/login";
throw new Error("UNAUTHORIZED");
}
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.message ?? `HTTP_${response.status}`);
}
return response.json();
}
export async function fetchBossAdminBackoffice(): Promise<BossAdminBackofficePayload> {
return requestJson<BossAdminBackofficePayload>("/api/v1/admin/backoffice");
}
export async function postAdminAccess(payload: Record<string, unknown>) {
return requestJson<Record<string, unknown>>("/api/v1/admin/access", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function postRiskAction(payload: Record<string, unknown>) {
return requestJson<Record<string, unknown>>("/api/v1/admin/risks/actions", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function postSkillLifecycleRequest(payload: Record<string, unknown>) {
return requestJson<Record<string, unknown>>("/api/v1/admin/skills/requests", {
method: "POST",
body: JSON.stringify(payload),
});
}

View File

@@ -0,0 +1,7 @@
import { createApp } from "vue";
import Antd from "ant-design-vue";
import "ant-design-vue/dist/reset.css";
import App from "./App.vue";
import "./styles.css";
createApp(App).use(Antd).mount("#app");

View File

@@ -0,0 +1,174 @@
:root {
color: #102018;
background: #eef4ef;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
body {
margin: 0;
min-width: 1280px;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.16), transparent 30%),
linear-gradient(135deg, #f7fbf8 0%, #eef4ef 55%, #e8f1ed 100%);
}
.boss-admin-shell {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
}
.boss-admin-sidebar {
padding: 28px 20px;
background: rgba(255, 255, 255, 0.78);
border-right: 1px solid rgba(16, 32, 24, 0.08);
backdrop-filter: blur(20px);
}
.boss-admin-brand {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 32px;
}
.boss-admin-brand-mark {
display: grid;
place-items: center;
width: 44px;
height: 44px;
color: white;
font-weight: 800;
background: #10b981;
border-radius: 16px;
box-shadow: 0 14px 28px rgba(16, 185, 129, 0.24);
}
.boss-admin-brand h1 {
margin: 0;
font-size: 20px;
}
.boss-admin-brand p,
.boss-admin-eyebrow {
margin: 0;
color: #6b766f;
font-size: 13px;
}
.boss-admin-menu {
display: grid;
gap: 8px;
}
.boss-admin-menu-item {
display: flex;
gap: 12px;
align-items: center;
width: 100%;
padding: 12px 14px;
color: #56615a;
font: inherit;
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 14px;
}
.boss-admin-menu-item.active,
.boss-admin-menu-item:hover {
color: #0f7a55;
background: #e7f8ef;
}
.boss-admin-main {
padding: 28px 34px 44px;
}
.boss-admin-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.boss-admin-header h2 {
margin: 4px 0 0;
font-size: 28px;
}
.boss-admin-header-actions {
display: flex;
gap: 10px;
align-items: center;
}
.boss-admin-alert {
margin-bottom: 16px;
}
.boss-admin-section,
.boss-admin-section-grid {
display: grid;
gap: 18px;
}
.boss-admin-section-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.boss-admin-section-title {
grid-column: 1 / -1;
color: #25342b;
font-size: 18px;
font-weight: 700;
}
.boss-admin-hero {
grid-column: 1 / -1;
overflow: hidden;
background:
linear-gradient(135deg, rgba(16, 185, 129, 0.16), rgba(255, 255, 255, 0.92)),
white;
}
.boss-admin-hero h3 {
margin: 8px 0 20px;
font-size: 24px;
}
.boss-admin-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.boss-admin-metrics .ant-statistic {
padding: 18px;
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(16, 32, 24, 0.08);
border-radius: 18px;
}
.boss-admin-form {
max-width: 520px;
}
.boss-admin-form-gap {
margin-top: 10px;
}
.boss-admin-action-strip {
display: grid;
grid-template-columns: 220px 320px 1fr;
gap: 12px;
margin-bottom: 16px;
}
.ant-card {
box-shadow: 0 18px 50px rgba(16, 32, 24, 0.07);
}

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}

View File

@@ -0,0 +1,23 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
base: "/admin-web/",
plugins: [vue()],
build: {
outDir: "../../public/admin-web",
emptyOutDir: true,
},
server: {
proxy: {
"/api": {
target: process.env.BOSS_WEB_ORIGIN ?? "http://localhost:3000",
changeOrigin: true,
},
"/auth": {
target: process.env.BOSS_WEB_ORIGIN ?? "http://localhost:3000",
changeOrigin: true,
},
},
},
});

View File

@@ -10,6 +10,14 @@ boss.hyzq.net {
reverse_proxy 127.0.0.1:3000
}
admin.boss.hyzq.net {
encode zstd gzip
redir / /admin 308
reverse_proxy 127.0.0.1:3000
}
http://106.53.170.158 {
encode zstd gzip

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.hyzq.boss.codex-desktop-bridge</string>
<key>EnvironmentVariables</key>
<dict>
<key>BOSS_CODEX_DESKTOP_BRIDGE_HOST</key>
<string>127.0.0.1</string>
<key>BOSS_CODEX_DESKTOP_BRIDGE_PORT</key>
<string>4318</string>
</dict>
<key>ProgramArguments</key>
<array>
<string>/bin/zsh</string>
<string>-lc</string>
<string>cd /Users/kris/code/boss &amp;&amp; node scripts/codex-desktop-refresh-bridge-daemon.mjs</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/boss-codex-desktop-bridge.out</string>
<key>StandardErrorPath</key>
<string>/tmp/boss-codex-desktop-bridge.err</string>
</dict>
</plist>

View File

@@ -0,0 +1,150 @@
# Boss To B 管理后台接入记录
更新时间:`2026-04-27`
## 目标
为 Boss To B 场景新增系统管理后台,供平台侧查看和管理不同客户公司的账号、设备、权限和风险状态,重点支持快速发现客户电脑 Codex 节点掉线、主 Agent 任务失败、线程上下文风险和运维故障。
## 技术选型
- 使用 `@refinedev/core` 作为管理后台资源抽象层。
- 使用 `antd` 原生组件作为后台 UI 组件库。
- 不使用 `@refinedev/antd`,原因是当前版本会传递引入 `@ant-design/pro-layout -> path-to-regexp@8.2.0` 高危 audit 链。
- 不直接接入 `ant-design-pro` 工程,避免把 Umi/Max 路由和权限体系引入现有 `Next.js 16 + App Router` 主工程。
## 企业级后台独立化方向
2026-04-30 起Boss 后台进入独立 PC 管理后台阶段。调研 `YunaiV/yudao-cloud` 后,当前策略是借鉴它的租户、用户、角色、菜单、日志和工作台信息架构,但不直接接入 YuDao 的 Java 微服务后端,避免把现有 `Next.js + 文件状态账本 + local-agent` 运行时拆碎。
第一批新增:
- `apps/boss-admin-web`:独立 Vue + Vite + Ant Design Vue 后台工程,面向平台侧运营和客户成功人员。
- `/api/v1/admin/backoffice`:企业后台 BFF把 Boss 当前账本聚合成 YuDao 风格的菜单、工作台、租户、账号、角色权限、资源授权、Skill 中心、风险和审计数据。
- `/enterprise-admin`Next 主站内的受保护入口,只允许 `highest_admin` 访问,并跳转到独立后台静态产物 `/admin-web/index.html`
- `admin:web:dev` / `admin:web:build` / `admin:web:publish`:根工程脚本入口。`admin:web:publish` 会把 Vue 构建产物写入 `public/admin-web`,随 Next standalone 的 `public` 一起发布。
边界:
- 现有 `/admin` 不删除,继续作为主站内 fallback。
- 独立后台只消费 Admin BFF不直接读取 `boss-state.json`
- 独立后台当前复用 Boss Cookie 登录态,后续再绑定 `admin.boss.hyzq.net` 的独立部署。
- `/api/v1/admin/backoffice` 仍只允许 `highest_admin`,并过滤 `passwordHash``mfaSecret` 和 session token。
## 当前落地范围
- 新增 `/admin` 页面。
- 新增 `/api/v1/admin/overview` 聚合接口。
- 新增 `/api/v1/admin/backoffice` 独立企业后台聚合接口。
- 新增 `/api/v1/admin/risks/actions` 风险处理动作接口。
- 新增 `/api/v1/admin/notifications/dispatch` 风险通知派发接口。
- 新增 `buildAdminOverview(state)` 纯函数,负责从当前文件状态聚合后台数据。
- 新增显式 `adminCompanies` 租户账本,支持把账号和设备直接绑定到客户公司,不再只能依赖账号邮箱域名推断。
当前页面已在 `2026-04-30` 升级为 PC To B 总后台结构,不再是简单的 3 个表格页签。新结构包含 4 个一级区:
- `平台运营驾驶舱`:平台全局健康、待处理风险、客户健康、节点健康、最近事件。
- `客户与账号`:客户公司、账号列表、设备归属和客户开通任务流。
- `授权工作台`:复用既有账号 / 设备 / 项目 / Skill 授权能力,但放在更清晰的权限上下文里。
- `风险与治理`风险战情室、SLA、负责人、修复工单以及 Skill 生命周期治理。
### 平台运营驾驶舱
- 展示今日待处理:客户公司、账号、在线设备、开放风险和风险通知。
- 展示客户健康排行:按开放风险和设备在线情况优先排列。
- 展示关键风险队列:只展示最值得处理的风险,完整队列进入风险战情室。
- 展示节点健康集中查看客户电脑、Codex GUI / CLI 和最近心跳。
- 展示最近事件:风险通知和风险时间线,避免平台侧漏跟进。
### 客户与账号
- 展示客户公司列表、健康状态、账号数、在线设备、开放风险和客户成功负责人。
- 展示客户开通任务流:创建客户公司、开通老板账号、绑定客户电脑、分配项目与 Skill 权限。
- 展示账号列表:账号、角色、公司、状态和最近登录。
- 展示客户设备设备状态、GUI / CLI 在线状态、风险数和最近心跳。
### 授权工作台
- 继续复用 `/api/v1/admin/access`
- 支持创建 / 更新子账号、公司管理、批量导入、账号归属、设备归属、权限模板、设备 / 项目 / Skill 授权和离职回收。
- 高危动作继续保留二次确认和审计记录。
### 风险与治理
- 风险战情室按严重程度、客户影响、负责人和 SLA 组织风险。
- 风险处理不再使用浏览器 `window.prompt`,改成页面内处理面板。
- 处理面板支持指派负责人、设置 SLA、确认、关闭和创建修复工单。
- 对暂不支持动作的风险类型保持只读提示,不假装处置成功。
- Skill 生命周期治理作为同一区域的第二页签,继续复用 `/api/v1/admin/skills/requests`
## 旧版落地范围记录
以下是第一版落地内容,仍保留作为能力来源说明:
### 总览
- 总览统计:公司数、账号数、在线设备、开放风险。
- 风险通知:展示由 SLA 扫描生成的超时通知,避免平台侧只看到风险列表、漏掉需要主动跟进的客户事项。
- 风险时间线:展示风险通知生成、派发、确认、关闭、负责人和 SLA 调整等最近事件。
- 关键风险:展示最高优先级风险。
- 风险表:离线设备、未关闭运维故障、线程上下文告警、失败主 Agent 任务。
- 风险动作:`ops_fault` 支持指派负责人、设置 SLA、确认、关闭和创建修复工单`thread_context_alert` 支持指派负责人、设置 SLA、确认和关闭暂不支持的风险类型会显式失败不假成功。
- 设备表设备在线状态、CLI/GUI 连接状态、最近心跳和风险数量。
- 公司表:优先使用显式 `adminCompanies`,账号和设备未绑定公司时才回退到账号域名或默认公司。
- 公司表补齐 To B 运营字段:套餐等级、合同到期时间、客户负责人和客户成功负责人。
- 账号表:展示账号、角色、公司、状态、创建/更新时间,不暴露 `passwordHash`
### 账号与授权
- 复用 `/api/v1/admin/access`,支持创建 / 更新 `member``admin` 子账号。
- 支持查看账号状态,并对非主账号执行启用 / 停用;停用账号会同步撤销该账号当前活跃会话。
- 支持公司管理、账号归属、设备归属和公司列表。
- 支持按公司批量导入成员账号,并支持先预览新增 / 更新 / 异常数量,预览不会写入状态账本。
- 支持 CSV 文件导入账号清单,表头为 `account,displayName,role,password`
- 支持对子账号开启 / 关闭 MFA后台 GET 不返回 `mfaSecret`,仅开启时在本次响应返回一次 `mfaSetupSecret` 供初始化。
- 支持最高管理员重置子账号密码;重置后会撤销该账号所有活跃会话,响应不暴露 `passwordHash`
- 支持停用 / 启用客户公司;停用公司会同步禁用该租户下的普通子账号并撤销活跃会话,不会波及平台最高管理员。
- 支持离职回收:停用账号、撤销活跃会话,并清理设备 / 项目 / Skill 授权。
- 公司停用 / 授权撤销 / 密码重置 / 离职回收等高危动作均在 PC 后台做二次确认。
- 支持套用内置权限模板。
- 支持设备、项目、Skill 三类授权。
- 支持撤销单条授权。
- 支持查看最近权限审计记录。
### Skill 治理
- 复用 `/api/v1/admin/skills/requests`,支持创建 `install / update / uninstall / rollback / version_lock` 请求。
- 设备端仍由 `local-agent` 按既有 lifecycle 链路认领和完成。
- 管理后台只负责下发治理请求与查看请求状态,不绕过设备端 allowlist、checksum、备份和回滚约束。
- 2026-04-30 起PC 总后台的 Skill 治理入口改为 `Skill 中心`:先展示 Skill 目录、详情、授权对象和执行轨迹,再通过右侧安装向导创建生命周期请求。
- `Skill 中心` 会优先使用 `/api/v1/admin/access` 返回的 `skillCatalog`,没有聚合目录时再由设备 Skill 清单前端兜底聚合,避免最高管理员必须记住每台电脑的原始 `skillId`
- 创建请求仍提交到 `/api/v1/admin/skills/requests`,只是把 `sourceUrl / trustedSourceId / checksum` 等字段放入向导步骤中,降低误操作和填表成本。
## 权限边界
- `/admin` 页面要求登录。
-`highest_admin` 只看到“仅最高管理员可用”提示。
- `/api/v1/admin/overview` 未登录返回 `401`,非最高管理员返回 `403`
- `/api/v1/admin/risks/actions` 未登录返回 `401`,非最高管理员返回 `403`
- `/api/v1/admin/risks/scan` 未登录返回 `401`,非最高管理员返回 `403`
- `/api/v1/admin/notifications/dispatch` 未登录返回 `401`,非最高管理员返回 `403`
- 后台 mutation 路由和认证 mutation 路由会拒绝显式跨站浏览器请求;原生 APP 请求通过 `x-boss-native-app: 1` 豁免浏览器 CSRF 检查。
- 后台 mutation 路由会把 `x-forwarded-for / x-real-ip / user-agent / x-request-id` 写入 `permissionAuditLogs`;高危动作会额外写入安全化 `beforeJson / afterJson` 快照,便于企业客户追责和回放。
## 数据来源
- `authAccounts`:账号与角色。
- `adminCompanies`:客户公司 / 租户实体。
- `devices`:电脑与 Codex CLI/GUI 能力状态。
- `projects`:项目与设备关联。
- `opsFaults`:未关闭运维故障。
- `threadContextAlerts`:未解决线程上下文告警。
- `masterAgentTasks`:失败任务。
- `accountDeviceGrants``accountProjectGrants``accountSkillGrants`:授权汇总和过期授权统计。
- `adminNotifications`:风险 SLA 超时通知账本,由 `/api/v1/admin/risks/scan` 幂等生成,并由 `/api/v1/admin/notifications/dispatch` 派发。
- `adminRiskTimeline`:风险处理时间线,记录通知生成、派发和人工处置动作。
## 后续扩展
- 下一期应接入企业微信 / 飞书 / 短信等更多通知渠道;当前 `BOSS_ADMIN_NOTIFICATION_MODE=email` 可走服务器 sendmail默认 `disabled` 只记录派发状态。
- PostgreSQL 切换仍建议先用 `scripts/boss-state-store-maintenance.mjs` 做备份、dry-run 迁移和回滚演练,再设置 `BOSS_STATE_STORE=postgres`

View File

@@ -19,10 +19,12 @@
2. `docs/architecture/repo_map_cn.md`
3. `docs/architecture/current_runtime_and_deploy_status_cn.md`
4. `docs/architecture/api_and_service_inventory_cn.md`
5. `docs/architecture/boss_server_connection_and_deploy_cn.md`
6. `docs/architecture/wechat_project_conversation_mapping_cn.md`
7. `docs/architecture/thread_context_budget_and_handoff_protocol_cn.md`
8. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md`
5. `docs/architecture/rbac_skill_regression_matrix_cn.md`
6. `docs/architecture/boss_server_connection_and_deploy_cn.md`
7. `docs/architecture/wechat_project_conversation_mapping_cn.md`
8. `docs/architecture/thread_context_budget_and_handoff_protocol_cn.md`
9. `docs/architecture/dependency_security_audit_cn.md`
10. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md`
## 3. 当前有效实现边界
@@ -58,6 +60,7 @@
- `android/app/src/main/java/com/hyzq/boss/AttachmentComposerState.java`:原生附件发送确认规则与待上传附件模型
- `android/app/src/main/java/com/hyzq/boss/BossWindowInsets.java`:原生顶部安全区处理,负责把状态栏 / 刘海区让出来
- `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`:原生设备详情与技能入口
- `android/app/src/main/java/com/hyzq/boss/AccessManagementActivity.java`:原生最高管理员用户与权限管理页
- `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`:原生 AI 账号管理页
- `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`:原生运维 / 审计中心
- `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`:原生微信式 surface contract
@@ -97,6 +100,7 @@
- `POST /api/auth/login` 正常,会写入 `boss_session`
- `boss_session` 当前默认保持 30 天
- `GET /api/auth/session` 正常
- `GET/POST /api/v1/auth/sessions` 正常,已支持基础跨端会话治理和单会话撤销
- `POST /api/auth/restore` 正常,原生 Android 客户端可用 `restore token` 自动恢复登录态
- `GET /api/v1/app-logs` 正常,可按登录态分页读取 APP 日志
- `POST /api/v1/projects/master-agent/messages` 正常,已验证通过 `local-agent -> codex exec -> complete` 返回真实主 Agent 回复
@@ -137,6 +141,9 @@
- 线程改名当前遵循微信最新逻辑:从聊天页右上角进入会话信息页,再进行改名
- 当前已支持从单线程会话发起独立群聊:原会话保留,新群聊自动命名并可在群资料页改名
- 当前群聊编排主链已经补到第一阶段:群聊消息先进入主 Agent主 Agent 生成推荐下发方案,用户确认后再创建执行单;执行完成后线程原始结果会回群,主 Agent 再追加汇总
- 当前 Boss APP 按“Codex 同一线程客户端”同步桌面记录APP 直连线程和主 Agent 托管线程都会把用户原文镜像进目标 Codex rollout供 Codex 桌面版打开/刷新该线程时看到同一段沟通记录;内部 prompt、调度字段和系统约束不得写入桌面可见记录
- 当前桌面实时性新增轻量刷新桥:镜像成功后 `local-agent` 会优先调用本机常驻 `Codex Desktop Bridge` endpoint再由 bridge 打开 `codex://threads/{threadId}` 目标线程深链并发送一次应用刷新快捷键,让 Codex 桌面版切到目标线程后重新感知线程更新endpoint 不可用时会回退到原命令式刷新。这条桥只做打开/刷新提示,不承担消息输入,失败也不能阻断主链。默认配置会在短暂失败时重试 2 次、间隔 120ms并保留 deep link 与尝试次数方便排查桌面端是否收到刷新提示。bridge 还提供本机 SSE`GET /api/v1/codex-desktop/events`,只广播安全元数据;`scripts/codex-desktop-event-consumer.mjs` 已作为 Desktop 插件/IPC 的消费样例
- 当前还新增 `scripts/codex-desktop-integration-probe.mjs` 与 bridge `GET /api/v1/codex-desktop/capabilities`:用于自动探测当前 Codex Desktop 是否支持 `codex://threads/{threadId}` 这类稳定入口,并明确禁止把“修改 Codex.app 签名包体”作为支持能力
- 当前设备导入主链已经补到第一阶段:设备 heartbeat 可上报真实候选线程,系统会生成导入草稿;用户勾选后可生成导入决议,并把选中的线程真正落成聊天窗口
- 当前设备导入草稿不会再被旧 `projects` 字段绕过;只有 `apply` 之后,候选线程才会真正变成聊天窗口
- 当前设备导入 `review` 已经会留下 `device_import_resolution` master task 轨迹,但决议内容仍是服务端 heuristic 版,尚未真正交给 `local-agent -> codex exec`
@@ -150,17 +157,21 @@
- 移动端 UI 已去掉假的状态栏与桌面预览壳;底部一级导航固定在视口底部,返回逻辑不会再把 APP 根页直接弹回桌面
- `项目目标` 支持用户编辑、主 Agent 复核、完成项自动划线
- `版本迭代记录` 只读,由主 Agent 汇总
- `我的` 根页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于`
- `我的` 根页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 附件与存储 / Telegram 接入 / 技能 / 关于`,其中 `用户与权限` 仅最高管理员可见
- `我的 > 账号与安全` 已支持查看和撤销登录会话;最高管理员可管理全部活跃会话,子账号只能管理自己的会话
- `我的 > 用户与权限` 与 Web `/me/access` 共用 `/api/v1/admin/access`,可创建子账号、分配设备 / 项目 / Skill 权限,并查看同名 Skill 跨设备聚合PC `/admin` 已补公司停用、CSV/文本批量导入预览、重置密码、子账号 MFA、风险 SLA 通知派发、风险时间线和后台审计来源字段
- 多用户 / RBAC / Skill / 主 Agent 权限和多设备控制的集中状态、回归矩阵与缺口清单见 `docs/architecture/rbac_skill_regression_matrix_cn.md`
- `我的 > 主 Agent 提示词 / 记忆` 当前可编辑管理员全局主提示词、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 项目记忆
- `我的 > AI 账号` 必须可查看和切换 `主 GPT / 备用 GPT / API 容灾`
- `我的 > 技能` 必须按绑定设备展示 Skill并支持一键复制调用语句
- Skill 远程治理第一版已经接通最高管理员后端入口和设备端执行:`GET/POST /api/v1/admin/skills/requests` 可创建和查看 `install / update / uninstall / rollback / version_lock` 请求local-agent 通过 `claim / complete` 认领执行并回写最新 Skill 清单。当前设备端已增加 source allowlist / trusted source、`checksum / expectedChecksum` sha256 校验、更新 / 卸载 / 回滚前备份和失败恢复;仍未做签名校验和依赖安装沙箱
- `设备` 页当前只允许出现生产设备,旧演示脏数据不能回流到正式视图
- 登录后必须形成最小会话,受保护页面和核心 `/api/v1/*` 接口不能再裸奔
- 必须保留登录、注册、忘记密码和验证码入口
## 6. 当前技术路线
- Web`Next.js 16.2.1 + React 19`
- Web`Next.js 16.2.4 + React 19`
- 数据:当前是文件型持久化 `data/boss-state.json`
- 状态写入:串行事务队列 + 原子写入 + `.bak` 备份恢复
- device-agent原生 Node HTTP 服务
@@ -168,7 +179,7 @@
- 邮件:`Postfix + Dovecot`
- Android`AppCompatActivity + 原生 XML 布局 + HttpURLConnection`
- 原生登录恢复:`SharedPreferences + restore token`
- 当前最新原生 APK`2.5.4``versionCode=17`
- 当前最新原生 APK`2.5.11``versionCode=24`
当前不要误判成已经用了:
@@ -187,7 +198,7 @@ npm install
npm run build
npm run lint
curl -sS http://127.0.0.1:3000/api/health
curl -sS -H 'Content-Type: application/json' -d '{"account":"17600003315","password":"boss123456","method":"password"}' http://127.0.0.1:3000/api/auth/login
curl -sS -H 'Content-Type: application/json' -d '{"account":"krisolo","password":"<admin-password>","method":"password"}' http://127.0.0.1:3000/api/auth/login
curl -sS http://127.0.0.1:3000/api/auth/session
curl -sS http://127.0.0.1:3000/api/v1/conversations
curl -sS http://127.0.0.1:3000/api/v1/projects/master-agent
@@ -211,20 +222,20 @@ npm run apk:debug
## 8. 当前已知未完成项
- 认证仍是 MVP 级别:虽然已有最小会话 Cookie,但还没有刷新令牌、跨端会话治理和 CSRF 防护
- 认证仍是 MVP 级别但已收紧:已有最小会话 Cookie、restore token 轮换、浏览器 CSRF 基础防护、子账号 MFA、基础跨端会话治理和后台高危动作审计临时免验证登录默认关闭只能通过 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 显式开启
- 当前已补“原生 restore token 自动恢复”,但这仍不是完整的多端会话系统
- 当前默认最高管理员账号是 `17600003315`,默认密码 `boss123456`,并已绑定本机 Codex 节点
- 当前默认最高管理员账号是 `krisolo`,默认密码由线上初始化配置管理,并已绑定本机 Codex 节点
- 主 Agent 实时回复当前依赖被绑定设备的 `local-agent` 在线,并能在本机跑通 `codex exec`
- API 容灾当前由用户在 APP 的 `我的 > AI 账号` 中自行配置 `OpenAI API` 账号
- 服务器默认固定验证码仍是 `000000`
- 服务器默认验证码模式仍是 fixed但验证码登录也必须先申请验证码不允许只靠固定码直接登录
- 服务器邮件栈已部署完成,应用内也已经支持 email 模式,但默认开关还没切到 email
- OTA 版本中心、检查更新、执行升级和 APK 包下载已接通,但当前仍是文件型状态驱动的 MVP
- APP 实时日志同步、主 Agent 日志镜像、SSE 自动刷新和 Skill 同步页已经接通,但日志检索、告警和远程 Skill 管理仍未做
- 设备导入主链当前已经具备后端闭环和 Web/Android 前台接线,后续重点改成继续细化导入筛选规则和主 Agent 理解策略,而不是再从 0 接页面
- 数据库尚未替代文件存储
- APP 实时日志同步、主 Agent 日志镜像、SSE 自动刷新和 Skill 同步页已经接通日志检索已有基础分页,风险 SLA 通知账本已接入,外部通知渠道仍未做
- 设备导入主链当前已经具备后端闭环和 Web/Android 前台接线;主 Agent 理解同步已经避免未接管状态下主动问线程,后续重点继续细化导入筛选规则和用户主动同步体验
- 数据库尚未替代文件存储;当前已补 `BOSS_STATE_STORE=postgres` 单行 JSONB 适配层、schema 和 `scripts/boss-state-store-maintenance.mjs` 备份 / 迁移 / 回滚工具,但生产仍默认文件状态
- 域名入口的代理 / 分裂 DNS 结构仍未完全摸清
- 当前只支持服务器文件存储和阿里 OSS尚未接更多对象存储或更丰富的附件详情页
- 认证有真实 session 和令牌吊销
- 认证有真实 session、restore token 轮换、单会话撤销、CSRF 基础防护和 MFA 开关,但还没有企业 SSO / IdP
## 9. 继续开发时的工作原则

View File

@@ -16,7 +16,18 @@
- 当前原生恢复态:`restore token + SharedPreferences`
- 当前执行底座:`src/lib/execution/`,已包含 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现
### 1.2 boss-android-native
### 1.2 boss-admin-web
- 形态:独立 PC 企业后台前端
- 工程目录:`apps/boss-admin-web`
- 技术栈:`Vue 3 + Vite + Ant Design Vue`
- 本地开发脚本:`npm run admin:web:dev`
- 构建脚本:`npm run admin:web:build`
- 数据入口:`GET /api/v1/admin/backoffice`
- 登录态:复用 `boss_session` HttpOnly Cookie
- 当前定位:平台侧 To B 总后台面向公司、账号、设备、项目、Skill、风险与审计治理现有 `/admin` 继续作为主站内 fallback
### 1.3 boss-android-native
- 形态:原生 Android 客户端
- 原生入口:`android/app/src/main/java/com/hyzq/boss/MainActivity.java`
@@ -39,8 +50,11 @@
- `DeviceEnrollmentActivity`
- `SkillInventoryActivity`
- `SecurityActivity`
- `AccessManagementActivity`
- `SettingsActivity`
- `StorageSettingsActivity`
- `AiAccountsActivity`
- `TelegramIntegrationActivity`
- `OpenAiOnboardingActivity`
- `OpsCenterActivity`
- `AboutActivity`
@@ -53,11 +67,13 @@
- 单线程会话支持按微信最新逻辑改线程名
- 当前已经支持从单线程会话发起独立群聊,群聊创建后作为新会话保留,原会话不升级
- 当前单线程会话已经支持打开 `线程状态` 只读页,查看主 Agent 当前掌握的线程状态文档和最近进展事件
- 当前已经支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`
- 当前已经支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`,其中删除会调用服务端账本删除接口并刷新会话预览
- 当前多选模式会切换成微信式 `取消 + 已选数量 + 底部转发` 状态
- 当前统一使用 `ForwardTargetActivity` 选择目标会话,替换旧的备注转发主链
- 当前已支持聊天附件主链:输入框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;图片 / 视频先确认,文件直接发送
- 当前附件消息支持下载、原生打开、手动分析和自动分析状态展示
- 当前线程聊天消息会按该线程绑定的 Codex 电脑显示来源头像:单线程会话使用项目绑定设备头像,多设备 / 群聊消息会优先根据发送人里的设备名匹配对应电脑头像;主 Agent 总入口自身仍保留主 Agent 对话样式
- 当前已支持 `execution_progress` 执行进度卡:普通线程对话、主 Agent 托管线程和群聊目标线程执行时,会在对应聊天窗口显示“进度 / 分支详情 / 生成结果 / 后台智能体”结构化卡片;线程过程噪音仍走 `thread_process` 折叠
- `线程详情 / 运维调试` 仍保留对应原生活动页,但已退出主聊天面
- 当前已补上本地发送中气泡、发送按钮状态控制,以及“只有接近底部才自动滚到底”的消息流行为
- 当前根页导航:
@@ -74,17 +90,20 @@
- 保留版本与 OTA 操作
- 当前已补上 OTA 下载进度、失败重试、安装授权提示和返回关于页后的本地状态恢复
- 当前 `我的` 根页:
- 保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于`
- 已按登录角色过滤入口:`member` 只显示 `账号与安全 / 设置 / 技能 / 关于`
- `admin / highest_admin` 额外显示 `运维与修复 / AI 账号 / 附件与存储 / Telegram 接入`
- `用户与权限``highest_admin` 可见,用于创建子账号和分配设备 / 项目 / Skill 权限
- `运维与修复` 直接进入 `OpsCenterActivity`
- `技能` 入口会继续依赖服务端 Skill 授权过滤,不在客户端自行扩大可见范围
- 当前 `OpenAiOnboardingActivity`
- 会先自动打开 `OpenAI Platform` 登录页
- 支持继续打开 `API Keys` 页面
- 回 APP 后可直接粘贴 key并设为当前主控
- 登录成功后会直接给出 `测试主 Agent 对话` 入口
- 当前登录:临时免验证,点击登录直接创建最高管理员会话
- 当前登录:默认要求账号密码或验证码校验;临时开发兜底只允许通过显式环境变量开启
- 当前会话恢复:`SharedPreferences` 中保存 `boss_session / restore_token / account`
### 1.3 boss-local-agent
### 1.4 boss-local-agent
- 形态Node 原生 HTTP 服务
- 本地端口:默认 `4317`
@@ -94,21 +113,47 @@
- 当前新增职责:递归扫描本机 `~/.codex/skills` 并同步到设备 Skill 接口
- 当前完成回写:`conversation_reply / dispatch_execution` 会先标准化成统一远程执行结果,再调用 `/api/v1/master-agent/tasks/[taskId]/complete`
- 当前 `dispatch_execution` 会按 `orchestrationBackendId` 分流:默认走 `codex exec resume`,显式选择 `omx-team` 且本机配置可用时改走 `OMX Team Runtime` JSON 协议
- 当前 Codex 任务完成回写会附带 `executionProgress` 快照:包含 Git diff 简表、GitHub CLI 可用状态和从执行回复中提取的产物文件名,服务端更新同一张 `execution_progress` 卡片,不重复刷屏
- 当前 `RemoteRuntimeAdapter` 还负责拦截固定模式的线程内部环境提示;命中后会直接改写成失败,避免把只读/cwd 这类脏文本写进聊天记录
- 当前普通单线程 `conversation_reply` 在真正执行 `codex exec resume` 前,会先把 Boss 用户消息镜像进目标 Codex Desktop rollout定位优先走 `state_5.sqlite`,不可用时回退扫描 `~/.codex/sessions`,并按 `sourceMessageId` 去重
- 当前 Codex Desktop 同步新增常驻刷新桥:`scripts/codex-desktop-refresh-bridge-daemon.mjs` 通过 launchd 监听 `127.0.0.1:4318`,暴露 `POST /api/v1/codex-desktop/refresh``GET /api/v1/codex-desktop/events``GET /api/v1/codex-desktop/events/recent``GET /api/v1/codex-desktop/capabilities``local-agent` 会优先调用 refresh endpoint失败时回退到 `scripts/codex-desktop-refresh-hint.mjs` 命令式刷新。SSE 事件只包含线程引用、消息 ID、状态、deep link 等安全元数据,不包含用户正文或内部 prompt`scripts/codex-desktop-event-consumer.mjs` 可作为 Desktop 插件/IPC 接入前的订阅 smoke`scripts/codex-desktop-integration-probe.mjs` 负责只读探测 Codex.app 能力
- 当前 `local-agent` 还新增了两条统一电脑控制 runtime
- `local-agent/browser-control-task-runner.mjs`
- `local-agent/computer-use-task-runner.mjs`
- 当前 `browser_control / desktop_control` 任务已经可以被 `local-agent/server.mjs` 识别并分流;当本机配置了对应 runtime 命令时,会通过 JSON stdin/stdout 协议委托给外部进程执行,否则返回明确 runtime disabled 错误,不再回退占位成功结果
- 当前 `browser_control / desktop_control` 的完成回写已贯通 `targetUrl / targetApp -> RemoteRuntimeAdapter -> /api/v1/master-agent/tasks/[taskId]/complete -> boss-state.json`,服务端写入 `control_summary` 消息时会保留 `controlTarget`Android 会话页可直接渲染“目标URL/应用名”
- 相关配置项:
- `browserControlEnabled / browserControlCommand / browserControlArgs / browserControlWorkdir / browserControlTimeoutMs`
- `computerUseEnabled / computerUseCommand / computerUseArgs / computerUseWorkdir / computerUseTimeoutMs`
- 当前仓库已自带最小 smoke runtime
- `scripts/browser-control-smoke.mjs`
- `scripts/computer-use-smoke.mjs`
- `scripts/browser-control-smoke.mjs` 当前已支持两段式最小真实动作:
- 能从目标 URL 拉取 HTML 标题并回写到 `replyBody / executionSummary`
- 在显式配置 opener 命令时可实际执行打开 URL
- `scripts/computer-use-smoke.mjs` 当前已支持识别常见桌面应用名macOS 下默认用 `osascript` 激活目标应用,并支持把用户请求中的引号文本输入到当前前台应用、按需回车发送;同时保留 `open -a` 兜底,并会落盘结构化 artifact便于后续真实 Computer Use runtime 复用同一回写协议
- `config.example.json / config.cloud.json` 现默认把这两条 smoke runtime 作为 browser/desktop 控制的推荐起步配置
- `config.example.json / config.cloud.json` 现同时默认把 `browserAutomationConnected / computerUseConnected` 置为 `true`,让前台设备详情默认按“这台 Mac 已具备浏览器控制 / 桌面控制能力”展示
- 这两条 smoke runtime 当前还会返回结构化字段:
- browser`targetUrl / artifacts`
- desktop`targetApp / typedText / artifacts`
- 这样前台与后续真实 runtime 可以共用同一套结果形态,而不需要等接入 Playwright / Computer Use 后再改返回协议
- heartbeat 的 `browserAutomation / computerUse` 能力上报会同时参考静态 connected 标记和 runtime 配置状态
### 1.4 Caddy
### 1.5 Caddy
- 作用:反向代理和 HTTPS 自动续签
- 服务器服务名:`caddy.service`
- 配置文件:`deployment/Caddyfile`
- 当前站点:`boss.hyzq.net` 服务客户 Web / App API`admin.boss.hyzq.net` 服务平台总后台。独立后台第一批仍未替换线上 `/admin`,后续部署完成后再把该域名切到 `apps/boss-admin-web` 静态产物
### 1.5 boss-server-debug skill
### 1.6 boss-server-debug skill
- 作用:跨 Codex 窗口稳定连接 `106.53.170.158`
- 路径:`$HOME/.codex/skills/boss-server-debug/SKILL.md`
- 密码来源:优先读取 macOS Keychain
### 1.6 Postfix + Dovecot
### 1.7 Postfix + Dovecot
- 作用:服务器侧邮件发送 / 接收基础设施
- SMTP 端口:`25 / 465 / 587`
@@ -143,6 +188,7 @@
- `GET /me/security`
- `GET /me/about`
- `GET /me/storage`
- `GET /me/access`
- `GET /me/ai-accounts`
- `GET /me/ops`
- `GET /me/ops/audit`
@@ -163,9 +209,194 @@
#### `GET /api/state`
- 用途:读取当前完整状态
- 注意:这是内部 MVP 调试接口,会直接返回整个 `BossState`
- 当前行为最高管理员可读取完整状态非最高管理员会返回已按当前账号授权裁剪后的状态快照设备、项目、线程状态、进展事件、Skill、日志和任务都会尽量限制在可见范围内
- 注意:这是内部 MVP 调试接口,仍不建议作为普通业务页面的主数据源;业务页面应优先使用具体 `/api/v1/*` 投影接口
### 3.1.1 执行底座抽象层
### 3.1.1 多用户 RBAC 与 Skill 授权
- 权限模块:`src/lib/boss-permissions.ts`
- 状态字段:
- `accountDeviceGrants`
- `accountProjectGrants`
- `accountSkillGrants`
- `skillCatalog`
- `permissionAuditLogs`
- 当前规则:
- `highest_admin` 全局可见
- 非管理员必须通过设备、项目或 Skill 授权获得可见性
- `device.view` 只提供设备与关联项目只读可见性,不自动放大为聊天、接管、电脑控制或 Skill 使用权限
- `thread.chat / master_agent.ask / master_agent.takeover / computer.control / skill.use` 需要显式授权
- 当前已接入过滤的接口:
- `GET/POST /api/v1/admin/access`(仅最高管理员)
- `GET /api/v1/devices`
- `GET /api/v1/conversations`
- `GET /api/v1/conversations/home`
- `GET /api/v1/conversation-folders/[folderKey]`
- `GET /api/v1/projects/[projectId]`
- `GET/POST /api/v1/projects/[projectId]/messages`
- `GET /api/v1/devices/[deviceId]/skills`
- `GET /api/state`
- 当前主 Agent 行为:执行提示词使用授权快照生成,任务队列会记录 `authorizedDeviceIds / authorizedProjectIds / authorizedSkillIds / requiredPermissions`
- 当前前台入口Web `/me/access` 与原生 Android `AccessManagementActivity` 共用 `/api/v1/admin/access`,仅 `highest_admin` 可见;`admin/member` 不显示入口且直接请求会返回 `403`
#### `GET /api/v1/admin/access`
- 用途:最高管理员读取账号与授权管理台所需数据
- 权限:仅 `highest_admin`
- 返回:
- 脱敏 `accounts`,不包含 `passwordHash`
- `companies`:显式客户公司 / 租户列表
- `devices / projects / skills`
- 按同名 Skill 聚合的 `skillCatalog`
- 内置 `permissionTemplates`
- `grants.devices / grants.projects / grants.skills`
- `auditLogs`
#### `POST /api/v1/admin/access`
- 用途:最高管理员执行最小授权管理动作
- 权限:仅 `highest_admin`
- 支持动作:
- `upsert_company`:创建或更新客户公司 / 租户
- `set_company_status`:启用或停用客户公司;停用时同步禁用该租户普通子账号并撤销活跃会话
- `assign_account_company`:把账号绑定到指定客户公司
- `assign_device_company`:把设备绑定到指定客户公司
- `preview_bulk_import_accounts`:预览批量导入结果,返回新增 / 更新 / 异常数量,不写入状态
- `bulk_import_accounts`:按公司批量导入 `member/admin` 子账号
- `reset_account_password`:最高管理员重置子账号密码,重置后撤销该账号活跃会话且响应不返回 `passwordHash`
- `reclaim_account`:离职回收,停用账号、撤销活跃会话并清理设备 / 项目 / Skill 授权
- `upsert_account`:创建或更新子账号
- `set_account_status`:启用或停用子账号;停用时撤销该账号当前活跃会话,且禁止停用最高管理员账号
- `grant_device`:授予设备权限
- `grant_project`:授予项目权限
- `grant_skill`:授予 Skill 权限
- `apply_template`:对指定账号和目标设备 / 项目 / Skill 批量套用内置权限模板
- `revoke_grant`:撤销任意设备 / 项目 / Skill 授权
- 当前行为:所有变更类动作都会写入 `permissionAuditLogs`,用于后续审计和主 Agent 接手时判断权限来源;后台 mutation 会记录 `ipAddress / userAgent / requestId`,高危动作可记录安全化 `beforeJson / afterJson`
#### `GET /api/v1/admin/overview`
- 用途:最高管理员读取 To B 管理后台总览数据
- 权限:仅 `highest_admin`
- 返回:
- `summary`:公司、账号、设备、在线设备、开放风险、风险通知、严重风险数量
- `companies[]`:优先使用显式客户公司 / 租户,其次按账号域名或默认公司聚合
- `accounts[]`:脱敏账号列表,不包含 `passwordHash`
- `devices[]`设备在线状态、CLI/GUI 能力、项目数和风险数
- `risks[]`:离线设备、运维故障、线程上下文风险和失败主 Agent 任务;运维故障和线程上下文风险会带出负责人和 SLA
- `notifications[]`:开放中的风险 SLA 通知,当前由 `/api/v1/admin/risks/scan` 生成
- `grantsSummary`:设备 / 项目 / Skill 授权数量与过期授权数量
#### `GET /api/v1/admin/backoffice`
- 用途:独立 PC 企业后台读取 YuDao/Vben 风格的总后台契约数据
- 权限:仅 `highest_admin`
- 返回:
- `menuTree`工作台、租户管理、账号管理、角色权限、资源授权、Skill 中心、风险告警、审计日志、系统设置
- `workbench`:平台总览、客户健康、设备健康、风险、通知和授权摘要
- `tenants[]`:客户公司 / 租户列表,来自 `adminCompanies` 与现有聚合
- `users[]`:脱敏账号列表,不包含 `passwordHash / mfaSecret / authSessions`
- `roles`:内置角色与 `BOSS_PERMISSION_TEMPLATES`
- `resourceGroups`设备、项目线程、Skill 聚合目录和授权记录
- `audit`:风险、通知、风险时间线和 `permissionAuditLogs`
- `yudaoMapping`Boss 账本字段到后台概念的映射,用于后续数据库化或模块拆分
- 当前定位:供 `apps/boss-admin-web` 消费;现有 `/admin` 仍继续使用 `/api/v1/admin/overview``/api/v1/admin/access`
#### `POST /api/v1/admin/risks/scan`
- 用途:扫描当前风险 SLA幂等生成平台侧待跟进通知
- 权限:仅 `highest_admin`
- 当前行为:
- 扫描未关闭的 `opsFaults``threadContextAlerts`
-`slaDueAt` 已早于当前时间时,写入 `adminNotifications[]`
- 同一个 `riskId` 只生成一条 `risk_sla_overdue` 通知,重复扫描不会重复膨胀账本
- 生成新通知时发布 `project.context_risk.updated`
#### `POST /api/v1/admin/risks/actions`
- 用途:最高管理员在管理后台处理风险
- 权限:仅 `highest_admin`
- 输入:
- `riskId`:当前支持 `ops-fault:<faultId>``thread-alert:<alertId>`
- `action``assign_owner | set_sla | ack | resolve | create_repair_ticket`
- `ownerAccount``assign_owner` 必填
- `slaDueAt``set_sla` 必填
- `note`:可选处理备注
- 当前行为:
- `ops-fault` 支持指派负责人、设置 SLA、确认、关闭、创建或复用修复工单
- `thread-alert` 支持指派负责人、设置 SLA、确认和关闭关闭时写入 `resolvedAt`
- 离线设备、失败主 Agent 任务等暂不支持直接动作,会返回 `RISK_ACTION_UNSUPPORTED`
- 当前事件:成功动作会发布 `project.context_risk.updated`
#### `GET /api/v1/audits/permission-logs`
- 用途:查询 `permissionAuditLogs` 并返回第一版权限审计风险摘要
- 权限:仅 `highest_admin`;普通 `admin/member` 直接返回 `403`
- 查询参数:
- `action`
- `actorAccount`
- `targetAccount`
- `deviceId`
- `projectId`
- `skillId`
- `cursor`
- `limit`,默认 `50`,最大 `200`
- 返回:
- `logs[]`:按 `createdAt` 最新在前排序后的当前页审计日志
- `nextCursor`:下一页游标;没有更多数据时为 `null`
- `total`:匹配过滤条件的总数
- `riskSummary`:基于现有 `permissionAuditLogs` 和仍存在授权记录生成的 deterministic 摘要
- 当前风险规则:
- `rapid_permission_grants`:同一 actor / target 在 10 分钟内出现 5 条及以上授权类日志
- `skill_lifecycle_failed`Skill lifecycle 完成日志中可识别失败,或后续写入 `skill.lifecycle.failed`
- `expired_grant_present`:设备 / 项目 / Skill 授权记录已过期但仍留存在状态中
- `admin_route_denied`:已有 `task.denied` 日志能识别非最高管理员访问 admin route 被拒
- 当前限制:权限审计风险摘要仍是查询时实时计算;持久化通知账本只覆盖风险 SLA 超时场景。
#### `GET /api/v1/admin/skills/requests`
- 用途:最高管理员读取 Skill 远程治理请求队列
- 权限:仅 `highest_admin`;普通 `admin/member` 直接返回 `403`
- 返回:
- `requests[]`:当前保存在 `boss-state.json` 的 Skill lifecycle 请求
- 当前行为:按最新请求在前返回;设备端认领后状态会从 `pending` 变成 `running / completed / failed`
#### `POST /api/v1/admin/skills/requests`
- 用途:最高管理员创建 Skill 生命周期治理请求
- 权限:仅 `highest_admin`
- 支持动作:
- `install`
- `update`
- `uninstall`
- `rollback`
- `version_lock`
- 输入要求:
- 必须提供 `deviceId`
- 必须提供 `skillId``sourceUrl` 之一
- 可选 `targetVersion / rollbackToVersion / lockedVersion / checksum / expectedChecksum / trustedSource / note`
- 当前行为:请求以 `pending` 状态写入 `skillLifecycleRequests`local-agent 会按设备 token 认领执行,并把 `completed / failed` 与结果摘要写回
- 当前设备端安全策略:远程 `install` 或带 `sourceUrl` 的更新必须命中本机 `skillLifecycleAllowedSources``skillLifecycleTrustedSources`allowlist 为空时只允许既有本地 Skill 的 `update / rollback / uninstall / version_lock`。如果请求带 `checksum / expectedChecksum`local-agent 会对 `manifest.json``SKILL.md` 做 sha256 校验;校验失败会失败回写,并清理半安装目录或尽量从 `skillsDir/.boss-skill-backups` 恢复
- 当前限制:第一版仅支持 Git 安装 / 更新、本地目录卸载、Git checkout 回滚和 `.boss-skill-locks.json` 版本锁;尚未做签名校验、依赖安装沙箱或 per-run Skill 执行审计
#### `POST /api/v1/devices/[deviceId]/skill-requests/claim`
- 用途:设备端领取下一条属于自己的 Skill 生命周期请求
- 权限:设备 token 或具备 `device.manage` 的登录会话
- 返回:
- `request`:下一条请求;无待处理时为 `null`
- 当前行为:只领取当前设备 `pending` 请求,领取后改为 `running`
#### `POST /api/v1/devices/[deviceId]/skill-requests/[requestId]/complete`
- 用途:设备端回写 Skill 生命周期请求执行结果
- 权限:设备 token 或具备 `device.manage` 的登录会话
- 输入:
- `status``completed``failed`
- `resultSummary` / `error`
- 当前行为:写回 `completedAt / updatedAt / resultSummary / error`,并追加 `permissionAuditLogs`
### 3.1.2 执行底座抽象层
- 目录:`src/lib/execution/`
- 当前默认实现:
@@ -182,13 +413,14 @@
- 当前状态:
- 已在生产代码中被 `boss-master-agent.ts``local-agent/server.mjs``master-agent task complete route` 使用
- 当前仍服务 Boss 自身执行链
- 当前已补 `browser_control / desktop_control` 两个新的 execution tool并已纳入统一权限与风险分级判断
- 当前已最小接入 `ClawBackendAdapter`,但默认关闭,仅在显式配置且可用性探测通过时才参与执行
- 如果历史 `backendOverride=claw-runtime` 当前不可用,运行时会自动回退到默认后端,并把原因回给前台
- 当前仓库自带 `scripts/claw-runtime-smoke.mjs` 作为兼容 JSON 协议的 smoke runtime可用于本地和服务器验证 `ClawBackendAdapter`
- 当前已最小接入 `OmxTeamBackendAdapter`但默认关闭Web 群聊详情页和原生群资料页已经可以在 `Boss Native``OMX Team` 间切换编排后端OMX 不可用时会自动回退到默认后端并返回明确原因
- 当前仓库自带 `scripts/omx-team-smoke.mjs`,可用于本地和服务器验证 `OmxTeamBackendAdapter``dispatch_execution` JSON 协议
### 3.1.2 线程状态文档与进展事件
### 3.1.3 线程状态文档与进展事件
- 状态字段:
- `threadStatusDocuments`
@@ -198,7 +430,8 @@
- 让 Web / Android 前台能直接查看线程的当前目标、阶段、进度、架构、阻塞、建议下一步
- 当前同步策略:
- `heartbeat / thread reply` 平时优先写轻量进展事件
- 首次理解、状态变薄、长时间未刷新或主 Agent 真正接手时,才补排隐藏全量理解任务
- 只有单线程接管、全局接管或用户明确要求同步项目目标 / 版本记录时,才补排隐藏全量理解任务
- 关闭接管会同步清理仍在 queued/running 的项目理解同步任务,避免取消接管后继续主动打扰线程
### 3.2 认证相关
@@ -208,13 +441,13 @@
- 输入:
- `account`
- `purpose`: `login | register | forgot-password`
- 当前行为:在邮件验证码正式切换前,固定验证码为 `000000`
- 当前行为:在邮件验证码正式切换前,fixed 模式仍返回固定验证码,但所有验证码登录都必须先通过 `send-code` 生成有效记录
- 当前说明Web 侧已经支持 email 模式email 模式下会通过本机 `sendmail` 调用 `Postfix` 发信;服务器默认仍保持 fixed
- 当前保护60 秒冷却,同一账号 15 分钟窗口内超过 5 次会被限流
- 当前前置校验:
- `purpose=login | forgot-password` 时要求账号已存在
- `purpose=register` 时要求账号尚未注册
- 当前 fixed 模式:登录可直接输入 `000000`,不再依赖先申请验证码;注册和重置密码`send-code` 申请链路
- 当前 fixed 模式:登录注册和重置密码都必须先`send-code` 申请链路,再消费账本里的有效验证码
#### `POST /api/auth/login`
@@ -224,16 +457,16 @@
- `password`
- `code`
- 当前行为:
- 当前已临时切到免验证模式,点击登录会直接创建 `17600003315` 的最高管理员会话
- 默认不再允许临时免验证登录,只有显式配置 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 时才开启开发兜底
- 原生 Android 端登录后会持久化 `boss_session + restore token`,用于 30 天登录保持和 OTA / 覆盖安装后的会话恢复
- 当前阶段不会因为账号、密码或验证码为空而拒绝登录
- 正常模式要求 `password``code` 校验通过
- 校验通过后会写入 `boss_session` Cookie
- 当请求头带 `x-boss-native-app: 1` 时,还会额外返回 `restoreToken`
- 当前 `boss_session` 默认保持 30 天
- 连续失败 5 次后会锁定 10 分钟
- 当前密码存储:新注册 / 重置密码使用 `scrypt`;历史 `sha256` 会在下次密码登录时自动迁移
- 当前默认管理员账号:`17600003315`
- 当前默认测试密码`boss123456`
- 当前默认管理员账号:`krisolo`
- 当前默认测试密码由线上初始化配置管理,文档不再明文记录
#### `GET /api/auth/session`
@@ -248,6 +481,26 @@
- 当请求头带 `x-boss-native-app: 1` 时,还会返回:
- `restoreToken`
#### `GET /api/v1/auth/sessions`
- 用途:查看可管理的登录会话
- 当前行为:
- `highest_admin` 可查看全部活跃会话
- 其他账号只能查看自己的活跃会话
- 返回内容只包含 `sessionId / account / role / displayName / loginMethod / createdAt / expiresAt / lastSeenAt / current`
- 不返回 `sessionToken / restoreToken`
- 前台入口Web `/me/security` 与原生 Android `SecurityActivity`
#### `POST /api/v1/auth/sessions`
- 用途:撤销单个登录会话
- 输入:
- `action=revoke_session`
- `sessionId`
- 当前权限:
- `highest_admin` 可撤销任意活跃会话
- 其他账号只能撤销自己的会话
#### `POST /api/auth/restore`
- 用途:原生 APP 使用 `restore token` 恢复 `boss_session`
@@ -385,6 +638,7 @@
- 普通单线程项目当前会在写入用户消息后,继续创建 `taskType=conversation_reply` 的主 Agent 任务
- 返回体会附带 `task.taskId / taskType / status`,给 Web 和原生 Android 保持等待真实回写使用
- `projectId=master-agent``kind=text` 时,会先返回 `masterReplyState + task`,真实回复随后异步回写到账本
- Telegram Gateway 当前也复用这条主 Agent 链路Telegram 私聊文本会写入 `master-agent` 项目,快速回复直接返回,异步任务通过 `externalReplyTarget` 在完成后回推 Telegram
- 当前主链路优先走 `Master Codex Node``task queue -> local-agent -> codex exec -> complete`
- 如果当前主控是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 当前会优先尝试已配置的 `OpenAI API / 阿里百炼 Qwen` 账号,避免聊天直接只剩失败日志
- 如本机节点未接通,可切到 `OpenAI API``阿里百炼 Qwen` 备用账号
@@ -392,6 +646,16 @@
- 群聊文本消息当前还会返回 `dispatchPlan / dispatchRecommendation`,用于展示主 Agent 推荐的线程下发方案
- 如果群里已经有一条待确认推荐,接口会直接返回 `409`,要求先确认或拒绝当前推荐,避免审批消息叠加
#### `DELETE /api/v1/projects/[projectId]/messages`
- 用途:删除当前项目消息账本里的一条聊天消息
- 输入:
- `messageId`:优先从 query string 读取,也兼容 JSON body
- 当前行为:
- 删除成功后会刷新项目预览、更新时间和未读计数
- 会发布 `project.messages.updated / conversation.updated`
- Android 长按消息的“删除”菜单已接入该接口
#### `GET /api/v1/projects/[projectId]/agent-controls`
- 用途:读取当前对话级别的 `modelOverride / reasoningEffortOverride / backendOverride`
@@ -660,6 +924,7 @@
#### `GET /api/v1/storage/config`
- 用途:读取当前登录用户的附件存储配置
- 当前入口Web `我的 > 附件与存储` 与 Android `StorageSettingsActivity`
- 返回:
- `mode`: `server_file | oss`
- `ossProvider`
@@ -902,7 +1167,49 @@
- `taskType=conversation_reply` 时,会把目标 Codex 线程的原始回复写回普通单线程会话
- `taskType=dispatch_execution` 时,会把线程原始结果镜像回群聊,再追加一条主 Agent 汇总,并更新对应执行单状态
- `failed` 时写入 relay 失败消息,并更新 AI 账号健康状态
- 如果任务带有 `externalReplyTarget.provider=telegram`,完成后会尝试调用 Telegram Bot API 把 `replyBody` 回推到原始聊天
- 对群聊分发推荐失败的情况,消息入口当前会额外写入一条 `system_notice`,把“没有真实线程”或“成员引用失效”明确回显给用户
#### `GET /api/v1/integrations/telegram`
- 用途:读取 Telegram Bot 接入配置
- 当前保护:仅 `highest_admin` 可读
- 返回:脱敏后的 `enabled / mode / botTokenConfigured / webhookSecretConfigured / allowFrom / groups / defaultProjectId / groupProjectRoutes`
#### `POST /api/v1/integrations/telegram`
- 用途:保存 Telegram Bot 接入配置,并可选执行 `getMe` 探测
- 当前保护:仅 `highest_admin` 可写
- 输入:
- `enabled`
- `mode`: `webhook | polling`
- `botToken`
- `dmPolicy`: `allowlist | open | disabled`
- `allowFrom`: Telegram user id 字符串数组
- `groupPolicy`: `allowlist | open | disabled`
- `groups`: Telegram chat id 字符串数组
- `requireMentionInGroups`
- `defaultProjectId`
- `groupProjectRoutes`: 群 / Topic 到 Boss 项目的路由表,单项格式为 `{ chatId, threadId?, projectId, label? }`
- `webhookSecret`
- `webhookUrl`
- `testConnection`
- 当前行为:
- `mode=webhook` 且提供 `webhookUrl` 时,会自动调用 Telegram `setWebhook`
- `mode=polling` 或关闭接入时,会自动调用 Telegram `deleteWebhook`
- `testConnection=true` 时会额外调用 `getMe`,并把返回的 bot username 回写到配置视图
#### `POST /api/v1/integrations/telegram/webhook`
- 用途Telegram Bot webhook 入口
- 当前保护:优先校验 `x-telegram-bot-api-secret-token`,再执行 DM / group allowlist
- 当前行为:
- 私聊文本默认桥接到 `master-agent`
- 群聊文本需要命中 `groups` 白名单;开启 `requireMentionInGroups` 时,必须 `@Bot` 或直接回复当前 Bot 上一条消息;进入主 Agent 前会自动清洗 bot mention
- 如果配置了 `groupProjectRoutes`,会优先按 `chatId + threadId` 精确匹配,再按 `chatId` 匹配,把消息写入指定 Boss 项目;未命中时回到 `defaultProjectId`
- 本地 fast path 回复会立即调用 Telegram `sendMessage`
- 需要排队的主 Agent 任务会保存 `externalReplyTarget`,任务完成后从 `/api/v1/master-agent/tasks/[taskId]/complete` 自动回推 Telegram
- 已处理的 `update_id` 会保留最近 256 条用于幂等去重
- 当前保护:要求 `x-boss-device-token` 或匹配登录会话
#### `GET /api/v1/master-agent/prompt-policy`
@@ -1080,6 +1387,7 @@
- local-agent 会周期性请求 `POST /api/v1/master-agent/tasks/claim`
- 认领到任务后会执行本机 `codex exec`
- `conversation_reply` 当前会优先走 `codex exec resume <targetCodexThreadRef>`,把任务恢复到真实 Codex 线程;只有缺失真实线程引用时才退回 `--ephemeral`
- 对已绑定 `targetCodexThreadRef` 的普通单线程 `conversation_reply`local-agent 现在会在 `codex exec resume` 前先把 Boss 用户消息镜像写入目标 Codex Desktop 线程 rollout镜像按 `sourceMessageId` 去重不会因任务重试重复写入。rollout 定位优先走 `state_5.sqlite`,不可用时回退扫描 `~/.codex/sessions`;状态库可写且能命中 thread 时会同步刷新线程活跃时间
- `dispatch_execution` 当前默认也走 `codex exec resume`,但当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议
- `codex exec resume` 前当前还会做目标线程绑定预检若目标线程缺失、已归档、cwd 不匹配或为只读会话,会直接失败并返回标准化错误,不继续把任务派进错误线程
- 如果历史 `worker / explorer` 子线程需要转回可开发线程,除了数据库权限本身,还必须显式补发新的解锁指令覆盖其旧的“只读勘察 / 不改文件”上下文;否则前台看起来像可写,实际执行仍可能被旧上下文限制
@@ -1094,15 +1402,32 @@
- `data/boss-state.json`
状态文件当前带有迁移前置元数据:
- `schemaVersion`:当前 BossState schema 版本
- `migratedAt`:最近一次从旧 schema 迁移到当前 schema 的时间
读取状态时会先经过 `migrateBossState`,用于从无版本或旧版本 JSON 补齐当前结构,并规范化授权和 Skill 生命周期相关数组。这个机制只为后续正式 DB 迁移提供稳定 schema 边界,不表示数据库化已经完成。
关键对象:
- `schemaVersion`
- `migratedAt`
- `user`
- `devices`
- `projects`
- `verificationCodes`
- `verificationDispatches`
- `adminCompanies`
- `adminNotifications`
- `adminRiskTimeline`
- `authAccounts`
- `authSessions`
- `accountDeviceGrants`
- `accountProjectGrants`
- `accountSkillGrants`
- `skillCatalog`
- `skillLifecycleRequests`
- `aiAccounts`
- `aiAccountSwitchHistory`
- `userAttachmentStorageConfigs`
@@ -1130,8 +1455,8 @@
不要误以为已经存在:
- 正式数据库
- 正式鉴权中间件
- 已直接切换完成的正式数据库
- 企业 SSO / IdP
- 多家对象存储适配(当前只有服务器文件存储和阿里 OSS
- 完整的附件详情页与富预览器
- 完整的多端用户会话系统与刷新令牌体系
- 完整的多端会话风控平台(当前已有 restore token 轮换、CSRF 基础防护和 MFA 开关)

View File

@@ -0,0 +1,83 @@
# Codex Server 协议与 Boss 执行进度卡接入记录
更新时间:`2026-05-08`
## 1. Codex 最新开放协议结论
当前可作为 Boss 稳定集成入口的是 Codex CLI MCP server
- 启动命令:`codex mcp-server`
- Inspector 调试:`npx @modelcontextprotocol/inspector codex mcp-server`
- 官方 MCP 工具:
- `codex`:启动一个 Codex 会话,入参包含 `prompt / approval-policy / base-instructions / config / cwd / include-plan-tool / model / profile / sandbox`
- `codex-reply`:继续一个 Codex 会话,入参包含 `prompt / threadId``conversationId` 只是兼容别名
- 线程续写应使用 `tools/call` 返回里的 `structuredContent.threadId`
- 现代 MCP 客户端主要读取 `structuredContent``content` 只作为旧客户端兼容输出
本机当前检测结果:
- 本机 `codex --version``codex-cli 0.114.0`
- npm 最新稳定包:`@openai/codex 0.129.0`
- npm alpha`0.130.0-alpha.5`
- 本机 `0.114.0` 已支持 `codex mcp-server --help`,但落后于当前 `0.129.0` 的 app-server / protocol 拆分、ThreadStore、MCP turn metadata、plugin sharing 等新能力
## 2. Boss 当前采用的接入策略
短期不直接依赖 Codex Desktop 私有 UI 结构,也不把 Codex CLI 原始 stderr/stdout 泄露给 APP。
当前实现采用 Boss 自有结构化消息:
- 新消息类型:`execution_progress`
- 服务端字段:`Message.executionProgress`
- 触发范围:
- 普通单线程对话:用户在 Boss APP 指定线程里发消息
- 主 Agent 托管线程:托管消息实际派到目标 Codex 线程时
- 群聊确认下发:后续目标线程执行单会复用同一张卡
- 生命周期:
- 任务入队:创建进度卡
- local-agent 认领:更新为 running
- local-agent 完成:更新同一张卡为 completed / failed
APP 展示结构对齐截图:
- `进度`:步骤列表,显示已完成 / 进行中 / 待处理 / 失败
- `分支详情`变更行、Git 操作、GitHub CLI 可用状态
- `生成结果`从执行结果里提取文件、图片、APK、文档等产物名
- `后台智能体`:预留 OMX / Hermes / explorer 等多智能体来源展示
## 3. 安全边界
进度卡只允许展示用户可见摘要:
- 不展示系统提示词
- 不展示完整执行 prompt
- 不展示设备 token、账号密钥、内部工作目录调度说明
- 不展示 Codex CLI 启动 envelope、sandbox、approval、session id、MCP 启动日志
- `RemoteRuntimeAdapter` 仍会先拦截只读环境提示和 Codex envelope 泄漏,再进入消息账本
## 4. 历史引用项目最新状态
本次按 GitHub 最新元数据核对过的项目:
| 项目 | 最新状态 | 对 Boss 的可借鉴点 |
| --- | --- | --- |
| `openai/codex` | `rust-v0.129.0`2026-05-07 发布main 在 2026-05-08 仍有提交 | 后续优先补 `codex mcp-server` 长驻适配器;参考 ThreadStore、turn metadata、app-server protocol v3 方向,不再只靠 `codex exec resume` |
| `Yeachan-Heo/oh-my-codex` | `v0.16.2`2026-05-08 发布 | `$ultragoal` 聚合目标、commit-shared wiki / compaction、state/session isolation、Codex native hook setup 值得同步到 Boss 的任务目标与进度卡 |
| `ultraworkers/claw-code` | main 最新提交 2026-05-06`instructkr/claw-code` 已指向该仓库;暂无 GitHub release | 继续保留抽象后端,不写死版本;重点观察 skills help routing、push_output_block、Rust harness 更新 |
| `NousResearch/hermes-agent` | `v2026.5.7 / v0.13.0`Tenacity Release | Durable Multi-Agent Kanban、heartbeat / reclaim / zombie detection、goal lock、checkpoints v2 可作为 Boss 主 Agent 长任务可靠性升级参考 |
| `iflytek/skillhub` | `v0.2.6`2026-04-29 发布main 2026-05-08 仍更新 | Skill 订阅通知、OIDC 登录、S3 IAM、namespace CSV 批量成员导入,适合 Boss 企业 Skill 治理后台后续吸收 |
| `openclaw/openclaw` | `v2026.5.7`2026-05-07 发布main 2026-05-08 仍更新 | Telegram allowlist、polling watchdog、deliverySucceeded、Codex approval 去重、provider/model callback 修复,可用于 Boss Telegram 网关和远程审批 |
| `goldmar/openclaw-code-agent` | `v4.2.3`2026-05-08 发布 | OpenClaw + Codex coding agent 的 session lifecycle、wake routing、worktree/PR policy可作为 Boss “聊天控制桌面 Codex 开发”的旁路参考 |
## 5. 下一步建议
第一阶段已经落地:
- Boss 消息账本新增 `execution_progress`
- Android 原生聊天页新增结构化进度卡
- local-agent 完成回写会补 Git diff、GitHub CLI 状态和产物名
后续建议按两步继续:
1. 新增 `CodexMcpBackendAdapter`:让 `codex mcp-server` 成为 `ExecutionBackend` 的可选实现,先 feature flag 默认关闭,保留 `codex exec resume` 作为生产主链。
2. 增加任务级 live progress API`POST /api/v1/master-agent/tasks/[taskId]/progress`,让本地 agent 在执行中也能实时刷新进度卡,而不是只在 claim / complete 两个节点更新。

View File

@@ -1,6 +1,6 @@
# Boss 当前运行与部署状态
更新时间:`2026-04-03`
更新时间:`2026-04-27`
## 1. 本地状态
@@ -20,7 +20,12 @@
- 登录接口:`POST http://127.0.0.1:3000/api/auth/login`
- 登录态接口:`GET http://127.0.0.1:3000/api/auth/session`
- 登录恢复接口:`POST http://127.0.0.1:3000/api/auth/restore`
- 登录会话治理接口:`GET/POST http://127.0.0.1:3000/api/v1/auth/sessions`
- 登出接口:`POST http://127.0.0.1:3000/api/auth/logout`
- 管理后台总览接口:`GET http://127.0.0.1:3000/api/v1/admin/overview`
- 独立企业后台 BFF`GET http://127.0.0.1:3000/api/v1/admin/backoffice`
- 管理后台授权接口:`GET/POST http://127.0.0.1:3000/api/v1/admin/access`
- 管理后台风险 SLA 扫描接口:`POST http://127.0.0.1:3000/api/v1/admin/risks/scan`
- OTA 包下载接口:`GET http://127.0.0.1:3000/api/v1/user/ota/package`
- 本地 agent 健康检查:`http://127.0.0.1:4317/health`。当前这台开发机的 `launchd` 常驻已经恢复,`/health` 可在数十毫秒内返回,并且在手动 heartbeat 执行期间也不会再被 Codex 线程扫描卡死
- 本地 Skill 扫描接口:`http://127.0.0.1:4317/api/v1/skills`
@@ -28,7 +33,9 @@
- `launchd` 已安装:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist`
- 当前执行底座抽象层已落地在 `src/lib/execution/`,并已补齐 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现
- 当前生产主链仍然沿用 `local-agent -> codex exec resume -> /api/v1/master-agent/tasks/[taskId]/complete`,执行底座重构以“先抽象、不改行为”为准
- 当前 Codex server 调研结论已记录在 `docs/architecture/codex_server_progress_card_cn.md`:官方稳定入口是 `codex mcp-server` 的 MCP 协议,本机 `codex-cli 0.114.0` 已支持该命令但落后于 npm 最新 `0.129.0`Boss 当前先保留 `codex exec resume` 主链,并新增 `execution_progress` 结构化进度卡作为 APP 可见执行态
- 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有显式配置 `BOSS_CLAW_*` 且可用性探测通过时,`master-agent` 当前对话中才会出现并允许选择 `claw-runtime`
- 当前已新增最小 `Telegram Gateway`Boss 当前可直接暴露 Telegram webhook把 Telegram 私聊或受控群聊文本桥接进 `master-agent` 或按群 / Topic 路由到指定 Boss 项目,并在主 Agent 异步任务完成后自动回推 Telegram配置入口已接到 Web `/me/telegram` 和原生 Android `我的 > Telegram 接入`
- 如果历史上已经保存过 `backendOverride=claw-runtime`,但当前 `Claw Runtime` 不可用,运行时会自动回退到默认后端,并在 Web/Android 前台给出明确原因
- 当前仓库已自带 `scripts/claw-runtime-smoke.mjs` 作为本地 smoke runtime在没有真实 `claw-code` 可执行文件时,可先用 `BOSS_CLAW_COMMAND=node``BOSS_CLAW_ARGS=scripts/claw-runtime-smoke.mjs` 验证整条链
- 当前 `oh-my-codex` 已以最小 `OmxTeamBackendAdapter` 形式接入执行底座,但默认关闭;当前已经接到 Web 群聊详情页 / 原生群资料页的编排后端选择卡,可在 `Boss Native``OMX Team` 间切换OMX 不可用时会自动回退到默认后端并明确提示原因
@@ -40,6 +47,10 @@
- 当前 `conversation_reply / dispatch_execution` 的线程执行结果会先经过 `RemoteRuntimeAdapter` 标准化;如果线程返回的是固定模式的内部环境提示(如“当前会话环境只读 / cwd …”),会直接转成失败,不再把原文写回会话消息
- 当前设备模型已支持同一台 Mac / Windows 同时接入 Codex `GUI + CLI` 双能力Web / Android 设备详情页都会展示两种能力状态,并允许切换默认执行模式
- 当前同项目 `GUI / CLI` 并行写入风险已接入项目/文件夹级冲突控制:默认阻断,用户只能对当前异常项目/文件夹选择 `禁止 / 允许本次 / 永久放行`
- 当前已补上“Boss 统一电脑控制中枢”第二批本地 runtime主 Agent 已能把聊天请求识别为 `discussion_only / project_development / browser_control / desktop_control``browser_control / desktop_control` 已能作为正式 `MasterAgentTask` 入队,并返回 `executionMode / riskLevel / requiresConfirmation` 元数据给前台;本机 `local-agent` 现已把 `browser-control-task-runner.mjs / computer-use-task-runner.mjs` 升级成外部 runtime 桥,并默认带上 `scripts/browser-control-smoke.mjs / scripts/computer-use-smoke.mjs` 作为 smoke 执行器,后续只需要替换配置就能接真实 browser automation 与 computer use runtime
- 当前这两条控制链的 `control_summary` 已能回写结构化目标信息browser 会保留 `targetUrl`desktop 会保留 `targetApp`Android 聊天窗口会在控制结果卡片里直接显示执行目标
- 当前 `scripts/browser-control-smoke.mjs` 已提升到“最小真实浏览器探测”:如果目标 URL 可访问,会抓取页面 `<title>` 并回写结果;`scripts/computer-use-smoke.mjs` 也已升级为 macOS 默认 `osascript` 激活应用、引号文本输入、按需回车发送、`open -a` 兜底和 artifact 回写,因此 Boss App 里的 browser/desktop 控制消息都已开始返回真实执行结果而不是固定 smoke 文案
- 当前本机 `local-agent` 默认 heartbeat 已把 `browserAutomation / computerUse` 两项能力视为“已接通起步版 runtime”因此 Boss 前台设备能力会直接显示这两条链路在线;如果后续需要临时关闭,可在 `local-agent/config.cloud.json` 里单独下掉对应 connected 标记或 runtime 命令
本地已知运行方式:
@@ -90,19 +101,31 @@ cd /Users/kris/code/boss
- `npm start`、服务器 `systemd` 与远端 `npm run build` 当前都显式设置了 `BOSS_RUNTIME_ROOT`,避免 `process.cwd()` 在 standalone / 服务器构建阶段误扫描整个仓库
- `next.config.ts` 当前已把 `deployment / docs / design / local-agent / prompts / scripts / android` 等目录排除出 standalone tracing服务器端构建不会再把非运行时资产卷进 `.next/standalone`
- `data/boss-state.json` 的写入已经改成串行事务队列、原子替换和 `.bak` 备份恢复,`heartbeat` 与 APP 日志并发写入已复核通过
- `BossState` 当前新增 `schemaVersion / migratedAt` 元数据和 `migrateBossState` 迁移入口;读取旧的无版本状态时会补齐当前 schema并规范化 `accountDeviceGrants / accountProjectGrants / accountSkillGrants / skillLifecycleRequests / permissionAuditLogs`
- 这只是正式数据库迁移前置层,当前生产读写仍然是 `data/boss-state.json`,尚未完成 PostgreSQL / Redis / 其他 DB 落地
- 当前登录成功后会写入 `boss_session` Cookie`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 路由都要求有效会话
- 当前 `boss_session` 默认保持 30 天,`Set-Cookie` 已验证为 `Max-Age=2592000`
- 原生 Android 客户端当前会把登录返回的 `boss_session / restore token / account` 落到 `SharedPreferences`,并在 APP 启动时通过 `/api/auth/restore` 自动补回会话;已本地验证“登录 -> 取 restore token -> restore 接口恢复”链路
- 当前多用户 / RBAC 第一阶段已落地:状态文件新增 `accountDeviceGrants / accountProjectGrants / accountSkillGrants / skillCatalog / skillLifecycleRequests / permissionAuditLogs`,非最高管理员访问 `devices / conversations / projects / messages / device skills / state` 时都会先走 `src/lib/boss-permissions.ts` 和 session-aware projections 过滤
- 当前最高管理员授权管理接口已落地:`GET/POST /api/v1/admin/access` 可以查看脱敏账号、公司、设备、项目、Skill、授权、权限模板和审计日志并支持公司管理、公司启用/停用、账号/设备归属、批量导入预览、批量导入子账号、重置子账号密码、离职回收、创建/更新子账号、启用/停用子账号、授予设备/项目/Skill 权限、套用权限模板、撤销授权;停用公司会禁用该租户普通子账号并撤销会话,停用 / 回收 / 重置账号也会撤销该账号当前活跃会话,普通账号访问返回 `403`
- 当前 To B 管理后台第一版可操作面已经落地Web `/admin``highest_admin` 可进,包含 `总览 / 账号与授权 / Skill 治理` 三个页签;总览使用 `/api/v1/admin/overview`,账号与授权复用 `/api/v1/admin/access`Skill 治理复用 `/api/v1/admin/skills/requests`;公司聚合优先使用显式 `adminCompanies`,未绑定时才回退账号域名。
- 当前企业级后台独立化第一批已开始落地:新增 `apps/boss-admin-web` 作为 Vue + Vite + Ant Design Vue 独立 PC 后台骨架,新增 `/api/v1/admin/backoffice` 作为 YuDao/Vben 风格 BFF现有 `/admin` 暂保留为主站 fallback`admin.boss.hyzq.net` 后续再切到独立后台静态产物。
- 当前后台风险处理接口已落地:`POST /api/v1/admin/risks/actions``highest_admin` 可用,支持对 `ops_fault` 指派负责人、设置 SLA、确认、关闭、创建或复用修复工单`thread_context_alert` 指派负责人、设置 SLA、确认和关闭`POST /api/v1/admin/risks/scan` 会扫描超时 SLA 并幂等写入 `adminNotifications`,管理后台总览会展示开放风险通知;不支持的风险类型会明确返回 `RISK_ACTION_UNSUPPORTED`
- 当前权限审计查询第一版已落地:`GET /api/v1/audits/permission-logs``highest_admin` 可读,支持按 `action / actorAccount / targetAccount / deviceId / projectId / skillId / cursor / limit` 查询 `permissionAuditLogs`并实时返回短时间大量授权、Skill lifecycle 失败、过期授权仍存在、admin route 拒绝访问等 deterministic 风险摘要;后台 mutation 审计已支持 `ipAddress / userAgent / requestId / beforeJson / afterJson`其中重置密码会记录安全化前后快照Web `/me/ops/audit` 会向最高管理员展示最近权限审计和风险摘要
- 当前 Skill 远程治理第一版可执行链路已落地:`GET/POST /api/v1/admin/skills/requests` 仅允许 `highest_admin` 创建和查看 `install / update / uninstall / rollback / version_lock` 请求;设备端通过 `/api/v1/devices/[deviceId]/skill-requests/claim``/complete` 认领回写local-agent 默认每 5 秒执行本机 Skill 安装 / 更新 / 卸载 / 回滚 / 版本锁,并同步最新 Skill 清单。远程安装或带 `sourceUrl` 的更新必须命中本机 `skillLifecycleAllowedSources``skillLifecycleTrustedSources`;配置为空时不允许远程新来源安装,但保留既有本地 Skill 的更新 / 回滚 / 卸载 / 版本锁。携带 `checksum / expectedChecksum` 的请求会校验 `manifest.json``SKILL.md` 的 sha256更新 / 卸载 / 回滚前会写入 `skillsDir/.boss-skill-backups` 并在失败时尽量恢复
- 当前授权管理前台已接入Web `/me/access` 与原生 Android `我的 > 用户与权限` 仅最高管理员可见,可创建子账号、授权设备/项目/Skill、套用 `只读观察员 / 项目开发者 / 设备操作者` 模板、查看同名 Skill 跨设备聚合并撤销单条授权
- 当前权限继承规则:显式 `device.view` 可带来绑定该设备项目的只读可见性,但不会自动获得 `thread.chat / master_agent.ask / master_agent.takeover / computer.control / skill.use`;这些动作必须来自项目或 Skill 显式授权
- 当前主 Agent 执行链已经使用授权快照:`boss-master-agent.ts` 会先按请求账号裁出可见设备、项目、线程状态、进展事件和 Skill再生成执行提示词排入 `MasterAgentTask` 时会记录本次授权范围,供后续审计和执行器收敛
- 登录成功后的客户端跳转当前已做稳态兜底:会先确认 `/api/auth/session` 已可读,再 `replace``/conversations`,并补一次 `window.location.replace` 防止真机 WebView 偶发卡在登录提示页
- `POST /api/auth/send-code` 当前已增加 60 秒冷却和 15 分钟窗口限流
- `POST /api/auth/send-code` 当前还会先按用途校验账号状态:登录 / 忘记密码必须是已存在账号,注册必须是未注册账号
- 当前账号连续登录失败 5 次后会锁定 10 分钟
- 当前登录页已临时切到免验证模式;点击“登录”会直接创建最高管理员会话,不再校验账号密码或验证码
- 当前登录页默认要求账号密码或验证码校验;临时开发兜底只有显式设置 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 时才会开启
- 新注册和重置密码当前已切到 `scrypt` 哈希;历史 `sha256` 密码会在下一次密码登录时自动迁移
- `launchd` 会保持 `com.hyzq.boss.local-agent` 常驻,所以本地 agent 被手动结束后会自动重启
- `launchd` 默认加载 `local-agent/config.cloud.json`,控制面指向 `https://boss.hyzq.net`
- `local-agent/config.example.json` 仍保留给本地 `127.0.0.1:3000` 回环开发
- 本地 `launchd` 当前已把 `mac-studio` 作为 `17600003315` 的绑定 Codex 节点上报
- 本地 `launchd` 当前已把 `mac-studio` 作为 `krisolo` 的绑定 Codex 节点上报
- 本地 agent 当前会递归扫描 `~/.codex/skills`,并把本机 Skill 同步到云端设备维度
- 根布局当前会挂载 APP 日志桥,路由切换、运行时错误、消息发送和 OTA 操作会通过 `/api/v1/app-logs` 实时同步到服务器;日志绑定已改成按当前登录会话解析设备
- 根布局当前还会挂载原生运行时桥:维护 APP 内导航历史、拦截 Android 返回键、防止根页直接退回桌面,并在 OTA / 同签名覆盖安装后自动尝试恢复登录态
@@ -123,6 +146,9 @@ cd /Users/kris/code/boss
- 当前原生聊天页已把待审批推荐前移到主消息流:`ProjectDetailActivity` 会直接显示 `确认下发 / 拒绝` 操作,且刷新后仍能恢复最近一条待确认推荐
- 当前 `approval_required` 群聊在已经存在一条 `pending_user_confirmation` 推荐时,会拒绝继续创建新的推荐并返回 `409`,前台会提示用户先确认或拒绝当前推荐
- 当前普通单线程聊天也已补上真实执行链:`POST /api/v1/projects/[projectId]/messages` 不再只写用户消息,而是会追加 `conversation_reply` 任务;绑定设备上的 `local-agent` 认领后会继续恢复到真实 Codex 线程,再把线程原始回复回写到该聊天窗口
- 当前 Boss APP 到 Codex 桌面版的记录同步以数据层镜像为主:普通单线程消息和托管模式消息都会把 APP 用户原文作为干净 `user_message` 写入目标 Codex 线程 rollout并同步刷新 Codex thread 的 `updated_at / updated_at_ms`;托管链路不会把主 Agent 内部调度 prompt、系统提示词或权限字段镜像成桌面可见聊天记录
- 当前 `local-agent` 已补 `Codex Desktop Refresh Bridge`rollout 镜像完成后会优先 POST 到本机常驻 `http://127.0.0.1:4318/api/v1/codex-desktop/refresh`,由 `scripts/codex-desktop-refresh-bridge-daemon.mjs` 给 Codex 桌面版发安全刷新提示daemon 不可用时回退到 `scripts/codex-desktop-refresh-hint.mjs` 命令式刷新。默认 `deeplink-reload` 模式会打开 `codex://threads/{threadId}` 目标线程深链,并在短延迟后发送一次应用刷新快捷键;它仍不模拟聊天输入、不点击、不发送。刷新桥默认会对短暂失败重试 2 次、每次间隔 120ms并把 deep link 与尝试次数作为结果返回;失败只记 `local_agent.codex_desktop_refresh_failed`,不会回滚已经写入的线程消息。当前 bridge 还暴露 `GET /api/v1/codex-desktop/events` SSE 和 `GET /api/v1/codex-desktop/events/recent`,每次刷新 hint 都会广播不含消息正文、不含内部 prompt 的 `codex_desktop_refresh` 事件;`scripts/codex-desktop-event-consumer.mjs` 是后续 Codex Desktop 插件/IPC 的订阅样例,可用 `BOSS_CODEX_DESKTOP_EVENTS_ONCE=true` 做一次性 smoke
- 当前 bridge 还暴露 `GET /api/v1/codex-desktop/capabilities`,内部复用 `scripts/codex-desktop-integration-probe.mjs` 探测当前 Codex Desktop读取 `Info.plist`、确认 `codex` URL scheme、扫描 `app.asar` 中是否存在 `codex://threads/`,并明确返回 `packagePatch.supported=false`,避免后续误走修改签名 app 包体的路线
- 当前 Web 群聊详情页也已补上待确认推荐的刷新恢复:服务端会在页面渲染时读取最近一条 `pending_user_confirmation` 的 dispatch plan聊天输入区会继续显示“等待你确认主 Agent 推荐”,不再因刷新丢失确认入口
- 当前 `AI 账号` 页面已分成三条显式接入链:`登录 OpenAI 平台账号API Key``接入阿里百炼备用账号``绑定 Master Codex Node`OpenAI API 登录成功后会立即切成当前主控,阿里百炼账号会作为备用链路保存
- 当前 `登录 OpenAI 平台账号` 已升级成浏览器辅助登录流:原生 Android 会先进入 `OpenAiOnboardingActivity`,自动打开 `OpenAI Platform` 登录页;用户登录后可直接跳到 `API Keys` 页面,回 APP 粘贴 key 完成接入
@@ -138,8 +164,8 @@ cd /Users/kris/code/boss
- 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口
- Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链
- 当前设备导入前台的状态表达已经统一为:`等待候选线程 / 等待勾选 / 建议生成中 / 建议已生成 / 已导入`,并会回显最终导入的线程名
- 当前已导入设备也支持自动同步项目理解:绑定设备 heartbeat 发现活跃线程有新活动、或线程回写了新的执行结果时,系统会直接为这台设备上已导入的线程排隐藏的 `conversation_reply` 主 Agent 任务,回写最新的项目目标、当前进度、技术架构和下一步建议
- 当前自动同步链路已经拆成两层heartbeat / thread reply 默认只追加轻量 `threadProgressEvent`;只有在线程首次理解、文档信息过薄、距离上次全量刷新太久或主 Agent 真的要接手时,才补排隐藏的全量理解任务并更新 `ThreadStatusDocument`
- 当前已导入设备的项目理解同步已经收窄到“显式接管 / 用户主动要求同步”边界:绑定设备 heartbeat 或线程回写默认只追加轻量 `threadProgressEvent`,不会在未接管状态下主动向 Codex 线程发起隐藏对话
- 当前全量理解链路只在单线程接管有效、全局接管有效,或用户明确要求“同步/核对项目目标和版本记录”时排 `conversation_reply` 任务;关闭接管会同步清理仍在 queued/running 的项目理解同步任务,避免取消接管后继续主动打扰线程
- 当前群资料页已补上“修复群成员”入口:当群里存在失效线程引用、`master-agent` 这类不可下发成员,或真实线程成员少于 2 个时,前台会明确提示并允许重新选择真实线程成员
- 当前原生聊天页也已前移“修复群成员”入口:脏群会在消息流上方直接显示 `去修复` 按钮,并跳转到群资料页完成成员替换
- 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `deviceImportDraft`,不再绕过勾选/审核阶段直接自动导入聊天窗口
@@ -148,7 +174,8 @@ cd /Users/kris/code/boss
- 当前设备导入 `review` 已补 owner/admin 鉴权,并已切成真实异步审核:`review` 会先排队 `device_import_resolution` master task前台进入“主 Agent 审核中”并自动刷新;导入草稿在 `apply` 后再次 heartbeat 也不会从 `applied` 回退成 `resolved`
- 原生会话页当前的刷新失败策略已改成按当前 tab 独立判错:`会话` 不会再因为 `设备 / OTA / 设置` 的旁路请求失败而整体提示“刷新失败”
- 会话页、设备页、技能页和项目详情页当前都通过 `/api/v1/events` 的 SSE 自动刷新
- 我的页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于` 六个一级入口;`AI 账号` 支持查看 `主 GPT / 备用 GPT / API 容灾`,并明确主链路优先走已经在绑定电脑上登录 `ChatGPT Plus / Codex` `Master Codex Node`
- 我的页当前保留角色感知入口:`member` 只显示 `账号与安全 / 设置 / 技能 / 关于`,其中 Skill 列表继续由服务端按授权过滤;`admin / highest_admin` 额外显示 `运维与修复 / AI 账号 / 附件与存储 / Telegram 接入``用户与权限` 只给 `highest_admin`
- `AI 账号` 支持查看 `主 GPT / 备用 GPT / API 容灾`,并明确主链路优先走已经在绑定电脑上登录 `ChatGPT Plus / Codex``Master Codex Node`
- `AI 账号` 页当前已补上显式 `登录指引`:手机端不会直接弹出 ChatGPT OAuth`主 GPT` 需要先在绑定电脑上的 Codex / ChatGPT Plus 会话里登录,再回手机端点“测试连接 / 校验连接”
- `AI 账号` 页当前已升级成双入口:首页会显式展示 `登录 OpenAI 平台账号``绑定电脑上的 Codex 节点`
- `登录 OpenAI 平台账号` 当前通过填写 `OpenAI API Key` 完成;校验成功后会立即设为当前主控
@@ -157,6 +184,8 @@ cd /Users/kris/code/boss
- 因此 `POST /api/v1/accounts/onboard/openai-api` 在公网环境下已经能返回明确中文网络错误,但在服务器出网恢复前,还不能完成真实 OpenAI 平台账号探针与调用
- `POST /api/v1/accounts/[accountId]/validate` 当前对 `master_codex_node` 不再只看 `nodeId`,还会同时校验绑定设备是否在线;设备离线时返回 `degraded` 和清晰的人类可读提示
- 主 Agent 当前真实对话链路已验证通过:`Boss Web -> /api/v1/projects/master-agent/messages -> master-agent task queue -> local-agent -> codex exec -> /complete -> 项目消息账本`
- Telegram 当前真实对话链路已接通:`Telegram Bot webhook -> /api/v1/integrations/telegram/webhook -> master-agent -> /api/v1/master-agent/tasks/[taskId]/complete -> Telegram Bot sendMessage`
- Telegram 配置保存当前也会自动做 webhook 同步webhook 模式自动 `setWebhook`polling/关闭时自动 `deleteWebhook`
- 主 Agent 单聊当前已改成“快速入队 + 异步回流”:`POST /api/v1/projects/master-agent/messages` 会先返回 `masterReplyState + task`,真实回复随后再回写消息账本
- 当前对话级 `agentControls` 已经生效:`master-agent` 会话支持 `modelOverride / reasoningEffortOverride`,并会优先作用到实际 OpenAI 回复和 Master Codex Node 执行 prompt
- 当前对话级 `agentControls` 也已支持 `backendOverride``master-agent` 会话可在 `Claw Runtime` 可用时显式选择 `claw-runtime`,由 `ExecutionBackendSelector` 在当前对话里优先尝试对应后端;不可用时保存接口会直接拒绝,并返回人类可读原因
@@ -170,19 +199,19 @@ cd /Users/kris/code/boss
- `npm run aab:release` 当前会先准备本机 release keystore再构建 signed release AAB 并发布到 `public/downloads/boss-android-latest.aab`
- AAB 发布脚本当前还会额外保留带版本号的归档包:`public/downloads/boss-android-v{versionName}-{flavor}.aab`
- AAB 归档元数据会写入 `public/downloads/boss-android-latest-aab.json`
- 当前默认管理员账号:`17600003315`
- 当前默认测试密码`boss123456`
- 登录页当前是临时免验证入口;Web 登录页和原生 Android 登录页都会直接创建会话
- 当前默认管理员账号:`krisolo`
- 当前默认测试密码由线上初始化配置管理,文档不再明文记录
- Web 登录页和原生 Android 登录页默认都必须通过账号密码或验证码校验后才会创建会话
- 当前已生成 Android debug APK`android/app/build/outputs/apk/debug/app-debug.apk`
- 当前已生成 Android signed release APK`android/app/build/outputs/apk/release/app-release.apk`
- 当前 release 构建还会额外生成带版本号的 APK`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
- 当前最新 release 构建版本:`2.5.11``versionCode=24`
- 当前 release keystore 位于本机 `android/keystores/boss-release.keystore`,签名参数位于 `android/signing/release-signing.properties`
- 真机开发约束:除非用户明确要求切换设备,后续 Android 开发、ADB 安装、交互回归与问题复现统一使用 `PLB110`;如果 `PLB110` 当前不在线,应先恢复这台设备连接,不自动切到其他手机
- 真机开发约束:用户已明确切换到当前连接的 OPPO `PHZ110`ADB serial `U84XJRIB7D65ZH45`除非用户再次要求切换设备,后续 Android 开发、ADB 安装、交互回归与问题复现统一使用这台 OPPO不再回退到原 `PLB110`
- Android 真机无线调试当前可恢复使用但系统层面没有“永久保持无线调试开启”的官方稳定开关重启、切网、ADB server 重启或重新切换 USB 调试后,都可能自动失效
- 如果要尽量稳定,当前推荐做法是:同一局域网下先走 USB 启用,再执行 `adb tcpip 5555``adb connect <phone-ip>:5555`;同时固定同一 SSID、避免切热点/VPN、开启“保持唤醒”并保留 USB 作为长时间调试兜底
- `2.0.1` 已在本机连接的华为真机上复核通过,修复了 `Theme.SplashScreen` 导致的 `AppCompatActivity` 启动闪退
- `2.1.0` 已把 Web 一级页和主要二级页全部补成原生活动页:`MainActivity / ProjectDetailActivity / ProjectGoalsActivity / ProjectVersionsActivity / ProjectForwardActivity / ThreadDetailActivity / DeviceDetailActivity / DeviceEnrollmentActivity / SkillInventoryActivity / SecurityActivity / SettingsActivity / AiAccountsActivity / OpsCenterActivity / AboutActivity`
- `2.1.0` 已把 Web 一级页和主要二级页全部补成原生活动页:`MainActivity / ProjectDetailActivity / ProjectGoalsActivity / ProjectVersionsActivity / ProjectForwardActivity / ThreadDetailActivity / DeviceDetailActivity / DeviceEnrollmentActivity / SkillInventoryActivity / SecurityActivity / AccessManagementActivity / SettingsActivity / AiAccountsActivity / OpsCenterActivity / AboutActivity`
- `2.1.0` 已完成签名包覆盖安装到本机连接的华为真机,并确认 `com.hyzq.boss` 可以成功拉起进程
- `2.1.1` 已补上原生 OTA 下载安装引导、`REQUEST_INSTALL_PACKAGES` 权限声明,以及根页默认入口/返回逻辑收口
- `2.2.0` 已把原生 UI 回退到微信式交互:会话首页改为简单聊天列表,项目详情页改为聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口,设备页和我的页根面改为简单列表
@@ -201,16 +230,24 @@ cd /Users/kris/code/boss
- `2.5.5` 已给 `approval_required` 群聊补齐“确认 / 拒绝”两条审批动作;拒绝后会把群审批状态写成 `rejected`,并追加系统提示,不再继续下发到线程
- `2.5.11` 对应这一轮的主链收口Android 会话首页改为直接读取 `/api/v1/conversations`,会把这台 Mac 上已导入的 Codex 线程对话直接平铺出来;`master-agent` 对“操作真实线程”的请求会先生成推荐下发方案,确认后再把任务派到真实线程执行;线程无绑定或设备离线时,确认接口会给清晰失败原因,避免假成功状态
- 当前附件分析任务已带受控 `task token` 下载链接和文本摘录:本地开发环境会跟随请求 origin 生成链接,生产环境默认走 `https://boss.hyzq.net`
- `2.5.11` 当前补齐了消息删除闭环:`DELETE /api/v1/projects/[projectId]/messages?messageId=...` 会删除账本消息、刷新会话预览并推送实时事件Android 长按消息的“删除”已接入该接口
- `2.5.11` 当前补齐了原生 `我的 > 附件与存储` 入口Android 可直接查看当前存储方式,切换服务器文件存储 / 阿里 OSS并支持保存或测试并保存
- `2.5.11` 当前后台通知已扩展到所有会话里的主 Agent 回复:只要 APP 不在前台,线程会话内的主 Agent 接管回复也会触发 Android 系统通知
- `2.5.x` 当前已补上会话首页独立建群入口:可以不从单线程聊天内部出发,直接在会话首页右上角 `+` 建立新群聊;同时已把多个原生自定义 top bar 页面统一纳入状态栏安全区处理
- 当前 `local-agent` 已能回写带 `dispatchExecutionId / targetProjectId / targetThreadId / rawThreadReply` 的任务完成载荷,群聊分发执行结果不再只停留在主 Agent 队列
- 当前 `local-agent``conversation_reply` 任务会优先使用 `codex exec resume <targetCodexThreadRef>`,只有缺失真实线程引用时才退回 `--ephemeral`
- 当前已绑定真实 `codexThreadRef` 的普通单线程聊天,会在 `local-agent` 执行 `codex exec resume` 前,先把 Boss 用户消息镜像写入对应 Codex Desktop rollout这样 APP 发起的消息也能进入桌面版同一线程历史,并按 `sourceMessageId` 去重。rollout 定位优先使用 `state_5.sqlite`,状态库不可用或索引缺失时回退扫描 `~/.codex/sessions`;写入后会尽量刷新 `threads.updated_at / updated_at_ms / has_user_event`,再通过 `codex://threads/{threadId}` 深链提示桌面版打开目标线程
- 当前 `local-agent``dispatch_execution` 任务会按 `orchestrationBackendId` 分流:默认走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行并回写 `rawThreadReply / replyBody`
- 当前 `local-agent` 会在 Codex 任务完成时回传 `executionProgress`:服务端把同一任务的进度卡从 queued / running 更新到 completed / failedAndroid 原生聊天页会显示“进度 / 分支详情 / 生成结果 / 后台智能体”,其中 Git diff、GitHub CLI 可用性和产物名由本地 agent 补齐
- 当前 `local-agent``browser_control / desktop_control` 已从占位骨架升级成外部 runtime 桥:当本机配置了 `browserControlEnabled + browserControlCommand``computerUseEnabled + computerUseCommand` 时,会把标准化 JSON 请求透传给外部进程,并解析单行 JSON 结果;未启用时会 fail closed返回明确的 runtime disabled 错误,不再假装执行成功
- 当前历史脏群如果不再包含真实线程成员,群聊消息不会再表现成“无响应”;服务端会在群内追加明确 `system_notice`,提示先重新添加线程成员
- 当前设备导入决议已经升级成真正通过 `local-agent -> codex exec -> /complete` 回写的主 Agent 决议链Web 和 Android 前台都会在 `pending_resolution` 阶段显示审核任务状态,并在任务完成后自动刷新出正式导入建议
- 当前 `local-agent` 已改成先启动本地 `4317` 健康监听,再异步跑首次 heartbeat 和 task poll避免控制面短时阻塞时本地健康探针不可用
- 当前 heartbeat 上报 `browserAutomation / computerUse` 能力时,不再只看静态 `browserAutomationConnected / computerUseConnected` 布尔值;如果本机已经配置可执行的 browser/computer runtime也会自动把对应能力标记成 connected
- Codex 项目/线程扫描当前已搬到 worker 线程执行,避免 `.codex/logs_1.sqlite``state_5.sqlite` 的同步扫描阻塞主线程健康接口
- 当前 `local-agent` 的任务完成回写已通过 `RemoteRuntimeAdapter` 标准化,`conversation_reply / dispatch_execution` 的完成载荷会先做统一归一化,再进入主 Agent 完成路由
- 原生 Android 当前对 `master-agent` 聊天不再依赖长时间同步等待;发送后会先显示“主 Agent 思考中”,右上角改成微信式 `...` 菜单,菜单项包含 `模型 / 推理强度 / 会话信息 / 刷新`
- 原生 Android 当前已新增 `TelegramIntegrationActivity`:可从 `我的 > Telegram 接入` 查看当前 Bot 状态、配置 Bot Token / Webhook Secret / Webhook URL、私聊白名单、群聊白名单、群聊触发策略和群 / Topic 到 Boss 项目的路由;群聊可配置为只接受 `@Bot` 或直接回复当前 Bot 的消息,并可直接测试连接或保存配置
## 2. 服务器状态
@@ -237,7 +274,8 @@ cd /Users/kris/code/boss
- `boss-web` 当前通过 `npm start` 启动
- 实际监听端口为 `3000`
- `boss-web.service` 显式设置了 `BOSS_STATE_FILE=/opt/boss/data/boss-state.json`
- `Caddy` 反代 `127.0.0.1:3000`
- `Caddy` 反代 `127.0.0.1:3000``boss.hyzq.net` 服务客户 Web / App API`admin.boss.hyzq.net` 作为平台总后台独立 PC 入口并把根路径跳转到 `/admin`
- 服务器上存在 `gptpluscontrol-boss-caddy-reconcile.timer`,会周期性用 `/home/ubuntu/build/gptpluscontrol/deploy/server/caddy.boss_hyzq_net.gptpluscontrol.conf` 重写 `/etc/caddy/Caddyfile``/opt/boss/deployment/Caddyfile`;以后改 Caddy 入口必须同步更新这份 canonical否则会重新生成重复站点块并导致 Caddy reload 失败
- `Postfix` 监听 `25 / 465 / 587`
- `Dovecot` 监听 `993`
- 当前部署脚本在远端重启服务后会自动执行一遍本机 health check
@@ -257,10 +295,12 @@ cd /Users/kris/code/boss
- 服务器本机 `dig +short boss.hyzq.net` 返回 `106.53.170.158`
- 服务器本机访问 `http://boss.hyzq.net` 会被 `308` 跳转到 `https://boss.hyzq.net`
- 服务器本机执行 `curl --resolve boss.hyzq.net:443:127.0.0.1 https://boss.hyzq.net -I` 返回 `307` 并跳转到 `/auth/login`
- 当前 `admin.boss.hyzq.net` 用于平台总后台,应用根路由会在该 host 下把已登录用户送到 `/admin`,未登录用户送到 `/auth/login`
同时也确认了这些事实:
- 当前本机网络 `dig +short boss.hyzq.net` 仍返回 `198.18.1.188`
- 当前本机网络 `dig +short admin.boss.hyzq.net` 暂无 A 记录;需要在 DNSPod 增加 `admin -> 106.53.170.158`
- 当前本机网络 `curl -I http://boss.hyzq.net` 返回 `308`
- 当前本机网络 `curl -I https://boss.hyzq.net` 返回 `HTTP/2 307`,并跳转到 `/auth/login`
- 当前本机网络 `curl https://boss.hyzq.net/api/health` 返回 `{"ok":true,"service":"boss-web",...}`
@@ -283,13 +323,13 @@ cd /Users/kris/code/boss
## 4. 当前未完成或仅为 MVP 的部分
- 当前服务器默认仍是 `fixed`,验证码`000000`
- 当前服务器默认仍是 `fixed`验证码登录必须先通过 `send-code` 生成账本记录;不能只靠固定码直接登录
- 当前虽然已经补齐 OTA 版本中心、检查更新、执行升级和 APK 包下载链路,但仍是文件型状态驱动的 MVP不是原生增量更新基础设施
- 当前“OTA / 重装后不掉登录”覆盖原生 Android 客户端的 `SharedPreferences` 恢复与同签名覆盖安装;如果用户先卸载 APP 再全新安装,仍可能丢失本地原生存储
- 数据存储仍是文件型,而不是数据库
- 数据存储默认仍是文件型,但已经有 PostgreSQL store adapter、schema 和维护脚本生产切换前需先执行备份、dry-run 迁移和回滚演练
- 设备发现、项目扫描和额度采集仍是静态配置驱动的 MVP
- APP 实时日志当前已能同步到主 Agent 会话,但还没有单独的日志检索、分页和告警升级规则
- Skill 清单当前按设备同步和展示已经可用,但还没有“安装 / 卸载 Skill”这种远程管理能力
- Skill 清单当前按设备同步和展示已经可用;远程治理目前只有最高管理员创建 lifecycle 请求和 list 状态,尚未真正下发到设备端执行安装 / 更新 / 卸载 / 回滚
- 服务器侧主 Agent 实时回复依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;如果设备离线,只能保留任务或走 API 容灾账号
- 设备导入主链的后端状态机已经跑通,并且已经分成两条:
- 新接入设备继续走 `import draft -> 勾选 -> review -> apply`
@@ -304,7 +344,9 @@ cd /Users/kris/code/boss
- 原生 Android 的二级深层页虽然仍保留 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等能力,但它们已经退出主 UI 正面;后续如再加入口,需继续遵守“一级微信式,复杂能力下沉”的规则
- Android 本地 Gradle 验证当前必须串行执行;如果并发跑 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug`,会导致中间产物互踩并出现假失败
- 聊天附件当前已经支持真实上传、消息落账本、受保护下载和原生打开;默认后端为服务器文件存储,可按用户切到阿里 OSS 私有桶
- 认证虽然已有最小会话 Cookie但还没有刷新令牌、跨端会话治理、CSRF 防护和更细的风控策略
- 企业认证默认值已收紧:`POST /api/auth/login` 默认不再允许临时免验证登录,只有显式设置 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 才会开启开发兜底。
- 状态存储现在通过 `src/lib/boss-state-store.ts` 抽象,默认继续使用 `data/boss-state.json`;设置 `BOSS_STATE_STORE=postgres` 时必须同时配置 `BOSS_DATABASE_URL`schema 见 `scripts/postgres-state-schema.sql`
- 认证已补 CSRF 基础防护、restore token 轮换、账号锁定和子账号 MFA 开关;后续仍可继续补更完整的企业 IdP / SSO
- 邮件对外正式投递仍缺少 DNS / 信誉相关的最终收口,例如 SPF、DKIM、DMARC、MX 与退信策略
- 外部真实邮箱的 end-to-end 收件链路还没有在生产账号上完成最终验收
@@ -314,11 +356,13 @@ cd /Users/kris/code/boss
```bash
curl -sS http://127.0.0.1:3000/api/health
curl -sS -H 'Content-Type: application/json' -d '{"account":"17600003315","password":"boss123456","method":"password"}' http://127.0.0.1:3000/api/auth/login
curl -sS -H 'Content-Type: application/json' -d '{"account":"krisolo","password":"<admin-password>","method":"password"}' http://127.0.0.1:3000/api/auth/login
curl -sS http://127.0.0.1:3000/api/auth/session
curl -sS http://127.0.0.1:3000/api/v1/conversations
curl -sS http://127.0.0.1:3000/api/v1/projects/master-agent
curl -sS http://127.0.0.1:3000/api/v1/devices/mac-studio/skills
node scripts/boss-state-store-maintenance.mjs backup-file --dry-run
node scripts/boss-state-store-maintenance.mjs migrate-file-to-postgres --dry-run
curl -I http://127.0.0.1:3000/api/v1/user/ota/package
curl -sS http://127.0.0.1:4317/health
curl -sS http://127.0.0.1:4317/api/v1/skills

View File

@@ -0,0 +1,88 @@
# Boss 依赖漏洞治理记录
更新时间:`2026-04-27`
## 本次治理范围
- 处理 Web/npm 依赖:`package.json``package-lock.json`
- 处理应用源码中与漏洞依赖绑定的附件存储实现:`src/lib/boss-storage-aliyun-oss.ts`
- 处理构建追踪 warning`src/lib/boss-mail.ts`
- 未改 Android 工程、`local-agent` 或部署脚本。
- 修复策略:先运行 `npm audit --json` 定位来源;不使用 `npm audit fix --force`;对没有安全小版本升级路径的依赖链,改为移除依赖并用项目内最小实现替换。
## 漏洞统计
治理前 `npm audit --json`
- total`14`
- high`6`
- moderate`2`
- low`6`
- critical`0`
第一轮治理后 `npm audit --json`
- total`11`
- high`3`
- moderate`2`
- low`6`
- critical`0`
最终治理后 `npm audit`
- `found 0 vulnerabilities`
## 已应用的安全修复与替换
- `npm audit fix` 自动更新传递依赖:
- `@xmldom/xmldom``0.8.11 -> 0.8.13`
- `brace-expansion``1.1.12 -> 1.1.14`
- `lodash``4.17.23 -> 4.18.1`
- 根级 `postcss``8.5.8 -> 8.5.12`
- 手动把 Next patch 版本升级到安全版本:
- `next``16.2.1 -> 16.2.4`
- `eslint-config-next``16.2.1 -> 16.2.4`
- 使用 npm `overrides` 将 Next 内部 `postcss` 收敛到安全版本:
- `postcss``8.5.12`
- 移除旧 OSS SDK 与代理链:
- 移除 `ali-oss`
- 移除 `@types/ali-oss`
- 移除 `proxy-agent`
- 将阿里云 OSS 附件存储改为项目内原生 REST 客户端:
- 使用 Node `fetch` 发起 `PUT / GET / bucketInfo`
- 使用 `crypto.createHmac("sha1")` 生成 OSS V1 Authorization 与签名下载 URL。
- 保持现有外部调用接口:上传附件、签名下载、读取对象、配置校验。
- 将验证码邮件投递的 sendmail 启动器固定为 `/usr/bin/env` 字面量,避免 Turbopack 把动态 sendmail 路径追踪成大范围文件模式。
## 不采用的方案
- 未采用 `npm audit fix --force`
- npm 给出的部分修复路径包含 Next 降级,破坏当前 `Next.js 16 + React 19` 运行线。
- 未采用 `proxy-agent@8.0.1` override
-`urllib@2` 通过 CommonJS lazy require 使用 `proxy-agent@5`,强制替换为 ESM 版本存在运行时破坏风险。
- 未采用 `ali-oss@6.19.0-audit.1`
- 实测会把漏洞转移到 `urllib@3 -> undici@5` 链,`npm audit` 仍剩 `3` 条漏洞。
- 未等待 Next 官方 patch
- 当前可以用 `overrides.postcss=8.5.12` 通过 lint/build 回归,风险可控。
## 已执行命令
```bash
npm audit --json
npm audit fix
npm install next@16.2.4 eslint-config-next@16.2.4 --save-exact
npm install
npm audit
npm ls ali-oss proxy-agent urllib undici postcss next --all
npx tsx --test tests/boss-mail.test.ts tests/aliyun-oss-storage.test.ts
npm run lint
npm run build
```
最终验证结果:
- `npm audit`:通过,`found 0 vulnerabilities`
- `npm ls ali-oss proxy-agent urllib undici postcss next --all`:通过,漏洞依赖链已不存在;`next` 使用 `postcss@8.5.12`
- `npx tsx --test tests/boss-mail.test.ts tests/aliyun-oss-storage.test.ts``5/5` 通过。
- `npm run lint`:通过。
- `npm run build`:通过;未再出现 `boss-mail.ts` Turbopack broad file pattern warning。

View File

@@ -0,0 +1,197 @@
# RBAC / Skill / 主 Agent 权限与回归矩阵
更新时间:`2026-04-27`
这份文档只梳理当前已经落地或明确未生产化的多用户、RBAC、Skill、主 Agent 权限和多设备控制链路。当前运行真相仍以 `current_runtime_and_deploy_status_cn.md``api_and_service_inventory_cn.md` 为准。
## 1. 当前开发状态
### 1.1 已落地
- 多用户 / RBAC 第一阶段已经落地到文件状态:`accountDeviceGrants / accountProjectGrants / accountSkillGrants / skillCatalog / permissionAuditLogs` 已进入 `BossState`
- 最高管理员授权台已经可用:`GET/POST /api/v1/admin/access``highest_admin` 可访问,支持创建 / 更新子账号、授予设备 / 项目 / Skill 权限、套用模板和撤销授权。
- Web `/me/access` 与 Android `AccessManagementActivity` 已接入授权管理;`member` 不显示入口,直接请求也应返回 `403`
- 会话、设备、项目详情、消息读写、设备 Skill、`/api/state` 已按当前登录账号裁剪;最高管理员保持全局可见。
- 主 Agent prompt 与任务队列已接入授权快照:生成提示词时只带当前账号可见设备、项目、线程状态文档、进展事件和 Skill`MasterAgentTask` 会记录 `authorizedDeviceIds / authorizedProjectIds / authorizedSkillIds / requiredPermissions`
- 本地 `local-agent` 已能扫描 `~/.codex/skills` 并同步到云端设备 Skill 接口Web / Android Skill 页按授权后的设备和 Skill 展示。
- 普通线程单聊、群聊下发、设备导入审核、browser/desktop 控制都已经进入 `master-agent task queue -> local-agent -> complete` 主链。
- browser/desktop 控制已从占位结果升级为外部 runtime 桥:未配置时 fail closed配置 smoke runtime 时可回写结构化 `control_summary`
### 1.2 部分落地但仍属 MVP
- 登录会话已有 `boss_session`、原生 `restore token` 和单会话撤销但还没有独立刷新令牌、完整吊销审计、CSRF 防护和更细风控策略。
- `permissionAuditLogs` 已有第一版最高管理员查询入口和 deterministic 风险摘要;仍不是后台持久告警、归档和独立审计存储系统。
- Skill 当前已支持“扫描、展示、授权、复制调用语句、进入主 Agent 授权上下文”,并新增最高管理员发起的远程安装 / 更新 / 卸载 / 回滚 / 版本锁请求local-agent 会认领并执行本机 Skill 文件或 Git 操作,但还不是带签名校验、依赖沙箱和执行审计的完整 Skill 平台。
- 主 Agent 可以携带授权快照并派任务,但审批流仍是局部场景:群聊 `approval_required` 已有确认 / 拒绝;高风险 Skill、远程安装、跨设备接管还没有统一审批引擎。
- 多设备控制当前以设备在线状态、设备绑定、项目线程绑定和 runtime 配置为准,尚未形成租约、抢占、并发冲突仲裁的完整生产级控制面。
## 2. 权限模型
### 2.1 角色
| 角色 | 当前含义 | 当前边界 |
| --- | --- | --- |
| `highest_admin` | 最高管理员,默认账号 `krisolo` | 全局可见可管理账号、授权、AI 账号、Telegram、运维入口和所有活跃会话 |
| `admin` | 管理员 / 可信协作者 | 可见更多“我的”入口,但当前不是全局授权管理员;不能访问 `/api/v1/admin/access` |
| `member` | 子账号 / 普通成员 | 只看被授权设备、项目和 Skill`我的` 入口限制为个人安全、设置、技能、关于 |
### 2.2 权限点
| 权限 | 作用对象 | 当前作用 |
| --- | --- | --- |
| `device.view` | 设备 | 查看设备;可带来该设备关联项目的只读可见性 |
| `device.manage` | 设备 | 预留给设备管理动作,当前不是主要前台能力 |
| `project.view` | 项目 / 线程 / 群聊 | 查看项目详情、会话列表、线程状态和项目投影 |
| `thread.chat` | 项目 / 线程 / 群聊 | 向普通项目或线程发送消息并创建 `conversation_reply` |
| `master_agent.ask` | 项目 / 主 Agent | 向主 Agent 提问或让主 Agent 生成推荐 / 任务 |
| `master_agent.takeover` | 项目 / 线程 | 允许主 Agent 接管或代表用户推动线程执行 |
| `computer.control` | 设备 / 项目 | 允许 browser/desktop 控制类任务进入执行链 |
| `skill.view` | Skill | 查看已授权 Skill |
| `skill.use` | Skill | 把 Skill 作为可用能力放入授权上下文或调用语句 |
| `skill.manage` | Skill | 预留给细粒度 Skill 管理;当前远程安装 / 更新仍由 `highest_admin` 入口硬限制 |
| `account.manage` | 账号 | 预留;当前账号授权管理仍以 `highest_admin` 角色硬限制为准 |
| `audit.view` | 审计 | 预留;当前权限审计查询入口仍以 `highest_admin` 硬限制为准 |
### 2.3 继承与显式授权
- `highest_admin` 绕过设备、项目和 Skill 授权检查。
- 非最高管理员如果拥有某台设备或被授予 `device.view`,可以只读看到该设备关联的项目。
- `device.view` 不会自动放大为 `thread.chat / master_agent.ask / master_agent.takeover / computer.control / skill.use`
- 聊天、主 Agent 接管、电脑控制和 Skill 使用必须来自项目授权、设备授权或 Skill 授权中的显式权限。
- Skill 授权可带 `deviceId / projectId` scope同名 Skill 会聚合进 `skillCatalog`,但实际可见 / 可用仍要按设备和项目 scope 判断。
- 过期授权通过 `expiresAt` 失效;当前应在回归中覆盖过期授权不再生效。
### 2.4 内置模板
| 模板 | 设备权限 | 项目权限 | Skill 权限 | 适用场景 |
| --- | --- | --- | --- | --- |
| 只读观察员 | `device.view` | `project.view` | `skill.view` | 只看设备、项目和 Skill不允许聊天或执行 |
| 项目开发者 | `device.view` | `project.view / thread.chat / master_agent.ask` | `skill.view / skill.use` | 参与项目开发,可问主 Agent 和调用已分配 Skill |
| 设备操作者 | `device.view / computer.control` | `project.view / thread.chat / master_agent.ask / master_agent.takeover / computer.control` | `skill.view / skill.use` | 可信协作者,可触发接管和电脑控制 |
## 3. 控制链路权限边界
### 3.1 主 Agent 单聊
- 入口:`POST /api/v1/projects/master-agent/messages`
- 权限:当前需要 `master_agent.ask`;最高管理员全局通过。
- 执行链:写入消息后创建 `MasterAgentTask`,优先走 `Master Codex Node`,设备离线或立即失败时可退到已配置 API / 阿里备用链。
- 授权快照:任务保存 `authorizedDeviceIds / authorizedProjectIds / authorizedSkillIds / requiredPermissions`,用于执行器和后续审计判断。
### 3.2 普通线程单聊
- 入口:`POST /api/v1/projects/[projectId]/messages`
- 权限:普通项目需要 `thread.chat`;只读 `project.view` 不能发送消息。
- 执行链:创建 `conversation_reply`,由绑定设备 `local-agent``codex exec resume <targetCodexThreadRef>` 回到真实 Codex 线程。
- 重要边界如果线程缺失、设备离线、cwd 不匹配或历史只读线程上下文未解除,应失败并给出明确原因,不应假成功。
### 3.3 群聊下发
- 入口:群聊消息进入主 Agent 推荐;用户确认后调用 dispatch plan confirm。
- 权限:需要项目聊天 / 主 Agent 推荐能力;最终下发目标必须是真实可执行线程成员。
- 当前审批:`approval_required` 群聊支持确认 / 拒绝,一次只保留一个待确认推荐,避免叠加。
- 回写:`dispatch_execution` 完成后线程原始结果回群,再追加主 Agent 汇总;重复完成应幂等。
### 3.4 Skill
- 采集:`local-agent` 扫描本机 `~/.codex/skills`,上报 `/api/v1/devices/[deviceId]/skills`
- 展示:`GET /api/v1/devices/[deviceId]/skills` 和 Web / Android Skill 页按账号授权过滤。
- 使用:`skill.use` 决定 Skill 是否能进入当前账号的主 Agent 授权上下文或被用户复制调用。
- 已落地第一版远程安装、远程更新、远程卸载、Git checkout 回滚和版本锁请求会被 local-agent 认领执行并同步最新清单。
- 未生产化:远程 Skill 执行、签名校验、沙箱隔离、撤销后本地禁用、来源信任和依赖安装策略尚未完成。
### 3.5 多设备与电脑控制
- 设备能力来自 heartbeatCodex GUI / CLI、browserAutomation、computerUse 等。
- browser/desktop 控制任务要求 `computer.control`,并通过 `MasterAgentTask` 进入 `local-agent` 外部 runtime 桥。
- smoke runtime 当前能做最小真实动作和 artifact 回写,但还不是完整 Playwright / Computer Use 生产运行时。
- 同项目 GUI / CLI 并行写入已有冲突控制:默认阻断,用户可对异常项目选择本次 / 永久放行。
## 4. 回归矩阵
### 4.1 Web / API
| 场景 | 要测什么 | 命令 |
| --- | --- | --- |
| 基础构建 | Next.js 构建和 lint 无退化 | `npm run build && npm run lint` |
| 健康检查 | Web 服务可启动、健康探针正常 | `npm start` 后执行 `curl -sS http://127.0.0.1:3000/api/health` |
| 登录态 | 登录、session、restore、logout 链路 | `curl -sS -H 'Content-Type: application/json' -d '{"account":"krisolo","password":"<admin-password>","method":"password"}' http://127.0.0.1:3000/api/auth/login` |
| 会话治理 | 最高管理员可看全部会话,子账号只能看自己,会话 token 不泄露 | `curl -sS http://127.0.0.1:3000/api/v1/auth/sessions` |
| 授权台保护 | 未登录返回 `401`,非最高管理员返回 `403`,最高管理员可读脱敏数据 | `curl -i http://127.0.0.1:3000/api/v1/admin/access` |
| 授权动作 | `upsert_account / grant_device / grant_project / grant_skill / apply_template / revoke_grant` 都写入 `permissionAuditLogs` | 用最高管理员 Cookie 对 `/api/v1/admin/access` 发 JSON POST |
| 权限审计查询 | 最高管理员可按 action / actor / target / device / project / skill / cursor / limit 查询,普通账号 `403` | `npx tsx --test tests/audit-permission-logs-route.test.ts` |
| 权限审计风险 | 能识别短时间大量授权、Skill lifecycle 失败、过期授权仍存在、admin route 拒绝访问 | `npx tsx --test tests/audit-permission-logs-route.test.ts` |
| 账号裁剪 | 子账号只看到被授权设备 / 项目 / Skill | 用子账号 Cookie 分别请求 `/api/v1/devices``/api/v1/conversations``/api/v1/devices/mac-studio/skills` |
| 项目写权限 | 只有 `thread.chat` 可发普通线程消息,只读账号应 `403` | `curl -i -H 'Content-Type: application/json' -d '{"kind":"text","body":"权限回归"}' http://127.0.0.1:3000/api/v1/projects/<projectId>/messages` |
| 主 Agent 权限 | `master_agent.ask` 才能进入主 Agent 任务链,任务包含授权快照 | `curl -sS -H 'Content-Type: application/json' -d '{"kind":"text","body":"列出我可见的设备和 Skill"}' http://127.0.0.1:3000/api/v1/projects/master-agent/messages` |
| Skill 过滤 | Skill 列表按设备和账号授权过滤 | `curl -sS http://127.0.0.1:3000/api/v1/devices/mac-studio/skills` |
| Skill 治理请求 | 最高管理员可创建请求,设备可认领和回写 | `npx tsx --test tests/skill-lifecycle-route.test.ts` |
| browser/desktop fail closed | 未配置 runtime 时返回明确 disabled不写假成功 | 关闭 `browserControl* / computerUse*` 后发控制类主 Agent 消息 |
| 群聊审批 | `approval_required` 只能有一条待确认推荐,确认 / 拒绝状态正确 | 用群聊项目消息接口触发 dispatch plan再测 confirm / reject |
### 4.2 local-agent / 本机设备
| 场景 | 要测什么 | 命令 |
| --- | --- | --- |
| 健康探针 | `launchd` 常驻不被首次 heartbeat 阻塞 | `curl -sS http://127.0.0.1:4317/health` |
| Skill 扫描 | 本机 Skill 能递归扫描并返回 | `curl -sS http://127.0.0.1:4317/api/v1/skills` |
| Skill lifecycle | local-agent 能认领请求、执行版本锁/卸载等安全本机操作并同步清单 | `node --test tests/local-agent-skill-lifecycle-runner.test.mjs` |
| 心跳上报 | 设备、能力、Skill、thread-context 能上报 | `curl -sS -X POST http://127.0.0.1:4317/api/v1/heartbeat` |
| 任务认领 | `conversation_reply / dispatch_execution / browser_control / desktop_control` 能被认领或明确失败 | 触发对应 Web/API 消息后观察项目消息账本和 local-agent 日志 |
| Codex 线程恢复 | 有 `targetCodexThreadRef` 时走 `codex exec resume`,缺失时才退 `--ephemeral` | 发送普通线程消息并检查回写内容 |
| rollout 镜像 | Boss 用户消息先镜像进 Codex Desktop 同线程,重试不重复写入 | 对已绑定真实线程的项目发送消息后检查 Desktop 线程历史 |
### 4.3 Android
| 场景 | 要测什么 | 命令 |
| --- | --- | --- |
| 构建 | Debug APK 可构建并发布到 downloads | `npm run apk:debug` |
| Release | signed release APK 可构建 | `npm run apk:release` |
| 单元测试 | Android 本地测试串行通过,避免 Gradle 中间产物互踩 | `cd android && ./gradlew testDebugUnitTest` |
| 真机安装 | OPPO `PHZ110` 安装并可启动 | `adb -s U84XJRIB7D65ZH45 install -r android/app/build/outputs/apk/debug/app-debug.apk` |
| 角色入口 | `member / admin / highest_admin` 的“我的”入口差异正确 | 真机登录不同账号,检查 `用户与权限 / 技能 / 运维 / AI 账号 / Telegram` 可见性 |
| 权限管理页 | 最高管理员可创建子账号、套模板、撤销授权 | 真机打开 `我的 > 用户与权限` |
| Skill 页 | 子账号只看到授权 Skill可复制调用语句 | 真机打开 `我的 > 技能` |
| 聊天等待态 | 主 Agent、普通线程、群聊下发都显示等待直到真实回写或超时 | 真机分别发送三类消息 |
| 控制结果卡 | browser/desktop 控制结果以目标 URL / 应用名卡片展示 | 真机向主 Agent 发送控制类请求 |
### 4.4 真机 / 多设备控制
| 场景 | 要测什么 | 命令 |
| --- | --- | --- |
| ADB 目标 | 默认只使用 OPPO `PHZ110`,不自动回退旧设备 | `adb devices` |
| 无线调试 | 同一局域网 + USB 初次启用后可切无线 | `adb -s U84XJRIB7D65ZH45 tcpip 5555 && adb connect <phone-ip>:5555` |
| 设备在线 | Boss 设备页显示 `mac-studio` 在线及 browser/computer 能力 | `curl -sS http://127.0.0.1:4317/health && curl -sS -X POST http://127.0.0.1:4317/api/v1/heartbeat` |
| GUI / CLI 冲突 | 同项目双执行模式默认阻断,允许本次 / 永久放行生效 | 在设备详情或项目冲突提示中切换策略后再次派发 |
| 设备离线 | 主 Agent / 项目确认接口返回明确离线原因,不假成功 | 停掉 local-agent 后发送线程或控制任务 |
### 4.5 服务器部署
| 场景 | 要测什么 | 命令 |
| --- | --- | --- |
| SSH 健康 | 固定服务器 `106.53.170.158` 可连 | `"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" health` |
| Web 服务 | `boss-web.service` 运行且本机 API 正常 | `"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "systemctl status boss-web --no-pager && curl -sS http://127.0.0.1:3000/api/health"` |
| Caddy / HTTPS | Caddy 运行,域名跳转正常 | `"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "systemctl status caddy --no-pager && curl -I --resolve boss.hyzq.net:443:127.0.0.1 https://boss.hyzq.net"` |
| 外网域名 | 当前网络能访问公网 HTTPS API | `curl -I https://boss.hyzq.net && curl -sS https://boss.hyzq.net/api/health` |
| 状态文件 | 部署不覆盖服务器 `data/boss-state.json` | 部署前后在服务器检查 `/opt/boss/data/boss-state.json` mtime 和关键账号数据 |
| 邮件端口 | Postfix / Dovecot 在线 | `"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "systemctl status postfix --no-pager && systemctl status dovecot --no-pager"` |
## 5. 剩余缺口
### 5.1 需要生产化的权限能力
- Skill 远程安装 / 更新 / 卸载:第一版设备端安装器和版本锁已落地;仍需要签名校验、来源信任、安装审计增强和失败自动回滚。
- Skill 远程执行:需要明确输入输出协议、沙箱边界、资源限制、敏感权限提示和 per-run 审计。
- 统一审批流:高风险 Skill、电脑控制、跨设备接管、生产部署、账号和存储配置变更应进入同一审批模型而不是分散在群聊确认里。
- 多级组织:当前只有角色 + 单账号授权;还没有组织、团队、项目组、继承授权、批量授权、离职回收和委派管理员。
- 审计告警:授权日志已有账本,但缺少审计检索、异常检测、告警通道、不可篡改归档和审计报表。
- 风险分级执行:`requiresConfirmation / riskLevel` 已开始进入任务元数据,但还没有统一策略中心约束哪些风险必须二次确认。
### 5.2 需要生产化的运行能力
- 数据库:当前仍是 `data/boss-state.json` 文件存储RBAC、审计、会话和任务队列生产化前需要迁移到数据库并补索引和事务边界。
- 会话安全需要独立刷新令牌、完整吊销审计、CSRF 防护、设备绑定策略、登录风险检测和异常会话告警。
- 多设备控制租约:需要 device lease、抢占、超时释放、只读观察、独占输入和并发写入审计。
- 真实 Browser / Computer Use runtime当前 smoke runtime 只能作为过渡层;生产需要接入真实浏览器自动化和桌面控制执行器。
- 服务器出网:公网服务器当前对 `api.openai.com` 直接出网仍未完全打通OpenAI API 线上探针和调用依赖网络恢复。
- 生产邮件验收Postfix / Dovecot 已部署,但 SPF、DKIM、DMARC、MX、退信策略和真实外部收件链路仍需最终验收。

View File

@@ -0,0 +1,75 @@
# Telegram Gateway Integration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为 Boss 增加可用的 Telegram 对话入口,让 Telegram 用户能安全地与主 Agent 对话,并在主 Agent 异步完成任务后把结果回推回 Telegram。
**Architecture:**`src/lib` 新增一个轻量 Telegram gateway负责 update 归一化、访问控制、消息分流和 Telegram Bot API 调用Next.js 暴露 webhook 与管理员配置接口,仍然复用现有 `boss-master-agent``boss-data` 主链,不复制对话业务。异步回复依赖现有 `/api/v1/master-agent/tasks/[taskId]/complete` 完成回调,在任务落盘后立即尝试发回 Telegram。
**Tech Stack:** Next.js App Router、TypeScript、文件型状态 `data/boss-state.json`、原生 `fetch`、Node test runner + `tsx --test`
---
### Task 1: 定义 Telegram 状态与配置模型
**Files:**
- Create: `src/lib/telegram-gateway.ts`
- Modify: `src/lib/boss-data.ts`
- Test: `tests/telegram-gateway.test.ts`
- [ ] **Step 1: 写 Telegram 配置/状态的失败测试**
- [ ] **Step 2: 运行测试确认因接口缺失而失败**
- [ ] **Step 3: 在 `boss-data.ts` 增加 Telegram 配置与任务外部回推字段**
- [ ] **Step 4: 在 `telegram-gateway.ts` 增加归一化、mask、session key、chunk 等纯函数**
- [ ] **Step 5: 重新运行测试确认通过**
### Task 2: 打通 webhook 与主 Agent 桥接
**Files:**
- Create: `src/app/api/v1/integrations/telegram/webhook/route.ts`
- Modify: `src/lib/telegram-gateway.ts`
- Modify: `src/lib/boss-master-agent.ts`
- Test: `tests/telegram-gateway.test.ts`
- [ ] **Step 1: 写 webhook 接收、secret 校验、allowlist、主 Agent 快速回复 的失败测试**
- [ ] **Step 2: 跑测试确认 RED**
- [ ] **Step 3: 实现 webhook handler把 Telegram 文本桥接到 `master-agent`**
- [ ] **Step 4: 对快速回复直接回 Telegram对排队任务保存外部回推目标**
- [ ] **Step 5: 跑测试确认 GREEN**
### Task 3: 打通任务完成后的 Telegram 异步回推
**Files:**
- Modify: `src/lib/boss-data.ts`
- Modify: `src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts`
- Modify: `src/lib/telegram-gateway.ts`
- Test: `tests/telegram-gateway.test.ts`
- [ ] **Step 1: 写任务完成后自动回推 Telegram 的失败测试**
- [ ] **Step 2: 跑测试确认 RED**
- [ ] **Step 3: 在任务模型中加入 `externalReplyTarget` 并在 complete route 中触发 Telegram 发信**
- [ ] **Step 4: 补充发送成功/失败去重保护**
- [ ] **Step 5: 跑测试确认 GREEN**
### Task 4: 增加管理员配置接口
**Files:**
- Create: `src/app/api/v1/integrations/telegram/route.ts`
- Modify: `src/lib/telegram-gateway.ts`
- Test: `tests/telegram-integration-route.test.ts`
- [ ] **Step 1: 写 GET/POST 配置接口失败测试**
- [ ] **Step 2: 跑测试确认 RED**
- [ ] **Step 3: 实现管理员鉴权、配置读取、保存、token 掩码与 getMe 探测**
- [ ] **Step 4: 跑测试确认 GREEN**
### Task 5: 文档与回归验证
**Files:**
- Modify: `README.md`
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
- [ ] **Step 1: 更新 Boss 当前能力文档,写清 Telegram 接入方式、能力边界与部署方式**
- [ ] **Step 2: 运行 `tsx --test`、`npm run lint`、`npm run build`**
- [ ] **Step 3: 记录未完成项与后续扩展点群聊策略、pairing、Feishu/Telegram 复用层)**

View File

@@ -0,0 +1,274 @@
# Codex Desktop Thread Sync Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让 Boss App 发往单线程 Codex 会话的用户消息,在继续现有 `conversation_reply -> codex exec resume` 主链前,同步镜像进本机 Codex Desktop 的同一个线程历史。
**Architecture:** 服务端继续以 Boss 项目账本为主真相,但给普通单线程 `conversation_reply` 任务补齐 `sourceMessage*` 元数据和显式镜像开关。`local-agent` 在执行 `codex exec resume` 前,按 `targetCodexThreadRef` 解析目标 rollout 文件,先做一次本地 user_message append + 去重再继续原有执行链。rollout 定位优先使用 `state_5.sqlite`,若本机 Codex CLI/Desktop 版本导致状态库不可用,则回退扫描 `~/.codex/sessions`rollout 写入后尽量刷新 `threads.updated_at / updated_at_ms / has_user_event`,但不依赖 GUI 自动化。
**Tech Stack:** Next.js App Router, TypeScript, Node.js, sqlite, local-agent Node runtime, `tsx --test`, Node test runner
---
### Task 1: 给 conversation task 补齐 Desktop 镜像元数据
**Files:**
- Modify: `src/lib/boss-data.ts`
- Modify: `src/lib/boss-master-agent.ts`
- Modify: `src/app/api/v1/projects/[projectId]/messages/route.ts`
- Test: `tests/single-thread-message-execution.test.ts`
- [x] **Step 1: 写失败测试,要求普通单线程消息返回的任务带 sourceMessage 元数据**
```ts
assert.equal(task?.sourceMessageId, message.id);
assert.equal(task?.sourceMessageBody, "请同步一下当前阻塞情况");
assert.equal(task?.sourceMessageSentAt, message.sentAt);
assert.equal(task?.mirrorBossUserMessageToCodexDesktop, true);
```
- [x] **Step 2: 运行测试确认失败**
Run: `npx tsx --test tests/single-thread-message-execution.test.ts`
Expected: FAIL提示 `sourceMessageBody/sourceMessageSentAt/mirrorBossUserMessageToCodexDesktop` 不存在或断言失败。
- [x] **Step 3: 写最小实现**
`MasterAgentTask` 和状态序列化/反序列化里补字段:
```ts
sourceMessageId?: string;
sourceMessageBody?: string;
sourceMessageSentAt?: string;
mirrorBossUserMessageToCodexDesktop?: boolean;
```
`queueThreadConversationReplyTask` 中透传:
```ts
sourceMessageId: params.sourceMessageId,
sourceMessageBody: params.sourceMessageBody,
sourceMessageSentAt: params.sourceMessageSentAt,
mirrorBossUserMessageToCodexDesktop:
params.relayViaMasterAgent ? undefined : true,
```
在消息 route 调用时补:
```ts
const queuedTask = await queueThreadConversationReplyTask({
projectId,
requestMessageId: message.id,
requestText: message.body,
requestedBy: session.displayName || session.account,
requestedByAccount: session.account,
sourceMessageId: message.id,
sourceMessageBody: message.body,
sourceMessageSentAt: message.sentAt,
});
```
- [x] **Step 4: 运行测试确认通过**
Run: `npx tsx --test tests/single-thread-message-execution.test.ts`
Expected: PASS
### Task 2: 新增 rollout writer并确保重复任务不重复写 Desktop 线程
**Files:**
- Create: `local-agent/codex-thread-rollout-writer.mjs`
- Test: `tests/local-agent-codex-rollout-writer.test.mjs`
- [x] **Step 1: 写失败测试,约束 writer 会写入 user_message 且按 sourceMessageId 去重**
```js
test("appendBossUserMessageToCodexThreadRollout writes one user_message event and dedupes by source message id", async () => {
const first = await appendBossUserMessageToCodexThreadRollout({ ... });
const second = await appendBossUserMessageToCodexThreadRollout({ ... });
assert.equal(first.status, "written");
assert.equal(second.status, "duplicate");
});
```
- [x] **Step 2: 运行测试确认失败**
Run: `node --test tests/local-agent-codex-rollout-writer.test.mjs`
Expected: FAIL提示模块不存在或导出函数不存在。
- [x] **Step 3: 写最小实现**
实现 writer 逻辑:
```js
export async function appendBossUserMessageToCodexThreadRollout(params) {
const rolloutPath = await resolveThreadRolloutPath(params);
const duplicate = await hasBossSourceMessageInRolloutTail(rolloutPath, params.sourceMessageId);
if (duplicate) return { status: "duplicate", rolloutPath };
const responseItem = JSON.stringify({
timestamp: params.sentAt,
type: "response_item",
payload: {
type: "message",
role: "user",
content: [{ type: "input_text", text: params.message }],
},
});
const event = JSON.stringify({
timestamp: params.sentAt,
type: "event_msg",
payload: {
type: "user_message",
message: params.message,
images: [],
local_images: [],
text_elements: [],
metadata: {
bossSourceMessageId: params.sourceMessageId,
bossMirroredFrom: "boss-app",
},
},
});
await appendFile(rolloutPath, `${responseItem}\n${event}\n`, "utf8");
return { status: "written", rolloutPath };
}
```
- [x] **Step 4: 运行测试确认通过**
Run: `node --test tests/local-agent-codex-rollout-writer.test.mjs`
Expected: PASS
### Task 3: 在 local-agent 执行 resume 前追加 Desktop 线程镜像
**Files:**
- Modify: `local-agent/codex-task-runner.mjs`
- Modify: `tests/local-agent-codex-task-runner.test.mjs`
- [x] **Step 1: 写失败测试,要求 prepare 阶段保留镜像计划,且 relay task 不启用**
```js
assert.deepEqual(result.execution.desktopMirror, {
enabled: true,
sourceMessageId: "msg-1",
sourceMessageBody: "请继续推进",
});
```
- [x] **Step 2: 运行测试确认失败**
Run: `node --test tests/local-agent-codex-task-runner.test.mjs`
Expected: FAIL提示 `desktopMirror` 不存在。
- [x] **Step 3: 写最小实现**
`buildCodexTaskExecution` 返回值中增加:
```js
desktopMirror: shouldMirrorBossUserMessageToDesktop(task)
? {
enabled: true,
sourceMessageId: task.sourceMessageId,
sourceMessageBody: task.sourceMessageBody,
sourceMessageSentAt: task.sourceMessageSentAt,
targetThreadRef,
}
: { enabled: false }
```
其中 `shouldMirrorBossUserMessageToDesktop(task)` 需要保证:
- `task.taskType === "conversation_reply"`
- `task.mirrorBossUserMessageToCodexDesktop === true`
- `task.relayViaMasterAgent !== true`
- `targetThreadRef/sourceMessageId/sourceMessageBody` 全部存在
- [x] **Step 4: 运行测试确认通过**
Run: `node --test tests/local-agent-codex-task-runner.test.mjs`
Expected: PASS
### Task 4: 在实际任务执行前调用 rollout writer并保持现有 resume/complete 主链不回归
**Files:**
- Modify: `local-agent/server.mjs`
- Modify: `tests/local-agent-codex-task-runner.test.mjs`
- Modify: `tests/single-thread-message-execution.test.ts`
- [x] **Step 1: 写失败测试,要求 server 在 spawn codex 前先执行 rollout 镜像**
```js
assert.equal(writerCalls.length, 1);
assert.equal(writerCalls[0].sourceMessageId, "msg-1");
assert.equal(writerCalls[0].message, "请继续推进");
```
- [x] **Step 2: 运行测试确认失败**
Run: `node --test tests/local-agent-codex-task-runner.test.mjs`
Expected: FAIL提示 writer 未被调用。
- [x] **Step 3: 写最小实现**
`runMasterAgentTask``spawn("codex", ...)` 前增加:
```js
if (codexExecution.desktopMirror?.enabled) {
await appendBossUserMessageToCodexThreadRollout({
stateDbPath: config.codexStateDbPath,
targetThreadRef: codexExecution.desktopMirror.targetThreadRef,
sourceMessageId: codexExecution.desktopMirror.sourceMessageId,
message: codexExecution.desktopMirror.sourceMessageBody,
sentAt: codexExecution.desktopMirror.sourceMessageSentAt ?? new Date().toISOString(),
});
}
```
如果镜像失败:
- 不吞掉错误
- 直接按任务失败返回,让链路保持 fail-closed
- [x] **Step 4: 运行定向测试确认通过**
Run:
```bash
node --test tests/local-agent-codex-rollout-writer.test.mjs
node --test tests/local-agent-codex-task-runner.test.mjs
npx tsx --test tests/single-thread-message-execution.test.ts
```
Expected: PASS
### Task 5: 回归验证与文档同步
**Files:**
- Modify: `README.md`(如需补一句运行时说明)
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
- [x] **Step 1: 跑仓库要求的基线验证**
Run:
```bash
npm run lint
npm run build
```
Expected: PASS
- [x] **Step 2: 补文档**
在运行时/服务清单文档补一句:
- Boss 普通线程单聊现在会在 local-agent 执行 `codex exec resume` 前,把 Boss 用户消息镜像进目标 Codex Desktop 线程 rollout
- 该能力仅针对已绑定 `codexThreadRef` 的单线程会话
- [x] **Step 3: 完成最终自检**
检查:
- 没有把主 Agent 会话或 takeover relay 错写进 Desktop 子线程
- 没有重复写 rollout
- 现有 heartbeat 读取 recent desktop replies 仍可工作

View File

@@ -0,0 +1,176 @@
# Boss 统一电脑控制中枢 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 让 Boss 聊天成为统一电脑控制入口。用户在主 Agent 或线程聊天里提出需求后,系统能在“普通讨论 / Codex 开发 / 浏览器自动化 / 桌面控制”之间自动选路,并通过本机 `local-agent` 执行。
**Architecture:** 继续复用 Boss 现有消息账本、`MasterAgentTask` 队列、执行底座和 `local-agent`。本次新增 `browser_control / desktop_control` 两类正式任务与 runtime并让主 Agent 先做执行意图判断,再把请求路由到 Codex 线程、browser automation、computer use 或直接回复。
**Tech Stack:** Next.js App Router, TypeScript, Node.js local-agent, existing execution abstraction, `tsx --test`, Node test runner
---
### Task 1: 扩展执行类型与任务模型
**Files:**
- Modify: `src/lib/execution/types.ts`
- Modify: `src/lib/execution/tool-registry.ts`
- Modify: `src/lib/boss-data.ts`
- Test: `tests/computer-control-task-model.test.ts`
- [ ] **Step 1: 先写失败测试,要求任务模型支持 browser_control / desktop_control**
- [ ] **Step 2: 运行测试确认失败**
Run:
```bash
npx tsx --test tests/computer-control-task-model.test.ts
```
- [ ] **Step 3: 实现最小模型扩展**
补齐:
- `ExecutionRequestKind`
- `ExecutionToolName`
- `MasterAgentTaskType`
- `MasterAgentTask``intentCategory / runtimeKind / riskLevel / confirmationPolicy` 等字段
- [ ] **Step 4: 再跑测试确认通过**
### Task 2: 给主 Agent 增加控制意图分类
**Files:**
- Modify: `src/lib/boss-master-agent.ts`
- Modify: `src/app/api/v1/projects/[projectId]/messages/route.ts`
- Test: `tests/master-agent-control-intent-routing.test.ts`
- [ ] **Step 1: 先写失败测试,覆盖讨论 / 开发 / 浏览器 / 桌面四类消息**
- [ ] **Step 2: 运行测试确认失败**
Run:
```bash
npx tsx --test tests/master-agent-control-intent-routing.test.ts
```
- [ ] **Step 3: 实现最小路由逻辑**
要求:
- 讨论类继续直接回复
- 开发类仍优先走现有 `conversation_reply`
- 浏览器类排 `browser_control`
- 桌面类排 `desktop_control`
- [ ] **Step 4: 再跑测试确认通过**
### Task 3: 给 local-agent 增加 browser/computer use runtime 分流骨架
**Files:**
- Create: `local-agent/browser-control-task-runner.mjs`
- Create: `local-agent/computer-use-task-runner.mjs`
- Modify: `local-agent/server.mjs`
- Test: `tests/local-agent-browser-control-runner.test.mjs`
- Test: `tests/local-agent-computer-use-runner.test.mjs`
- [ ] **Step 1: 先写失败测试,要求 local-agent 能识别新任务类型并分流**
- [ ] **Step 2: 运行测试确认失败**
Run:
```bash
node --test tests/local-agent-browser-control-runner.test.mjs
node --test tests/local-agent-computer-use-runner.test.mjs
```
- [ ] **Step 3: 实现最小分流骨架**
第一版先做:
- browser_control: 返回标准化占位结果或接入现有 browser runtime
- desktop_control: 返回标准化占位结果或接入 computer-use runtime
要求:
- 不能影响现有 `conversation_reply / dispatch_execution`
- [ ] **Step 4: 再跑测试确认通过**
### Task 4: 增加风险分级与确认策略骨架
**Files:**
- Modify: `src/lib/execution/permission-policy.ts`
- Modify: `src/app/api/v1/projects/[projectId]/messages/route.ts`
- Test: `tests/computer-control-permission-policy.test.ts`
- [ ] **Step 1: 写失败测试,验证 low/medium/high 三档行为**
- [ ] **Step 2: 运行测试确认失败**
Run:
```bash
npx tsx --test tests/computer-control-permission-policy.test.ts
```
- [ ] **Step 3: 实现最小确认策略**
要求:
- `low` 默认可执行
- `medium` 标记需轻确认
- `high` 标记需强确认
- [ ] **Step 4: 再跑测试确认通过**
### Task 5: 前台返回执行模式元数据并做回归
**Files:**
- Modify: `src/app/api/v1/projects/[projectId]/messages/route.ts`
- Modify: `src/lib/boss-data.ts`
- Test: `tests/project-message-execution-mode.test.ts`
- [ ] **Step 1: 写失败测试,要求消息返回 executionMode/riskLevel**
- [ ] **Step 2: 运行测试确认失败**
- [ ] **Step 3: 实现最小返回结构**
- [ ] **Step 4: 再跑测试确认通过**
### Task 6: 基线验证与文档同步
**Files:**
- Modify: `README.md`
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
- [ ] **Step 1: 同步运行时文档**
- [ ] **Step 2: 跑仓库基线验证**
Run:
```bash
npm run lint
npm run build
```
- [ ] **Step 3: 跑新增与相关回归测试**
Run:
```bash
npx tsx --test tests/computer-control-task-model.test.ts
npx tsx --test tests/master-agent-control-intent-routing.test.ts
npx tsx --test tests/computer-control-permission-policy.test.ts
npx tsx --test tests/project-message-execution-mode.test.ts
node --test tests/local-agent-browser-control-runner.test.mjs
node --test tests/local-agent-computer-use-runner.test.mjs
```
- [ ] **Step 4: 如涉及 Android 前台行为,再补真机复核**

View File

@@ -0,0 +1,168 @@
# Boss Enterprise Hardening Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把 Boss 从 MVP 管理后台推进到 To B 可交付的第一批企业化硬化版本。
**Architecture:** 保留当前 `BossState` 文件账本作为兼容层,同时新增可切换的状态存储适配层、企业认证默认值、租户隔离守卫、风险 SLA 扫描和增强审计字段。所有高危行为先用测试锁住,再通过 `/admin``/api/v1/*` 渐进暴露。
**Tech Stack:** Next.js App Router、TypeScript、Node `node:test`、现有文件型状态、可选 PostgreSQL JSONB 单行快照、Ant Design 管理后台。
---
## 执行状态
- 2026-04-27已补齐认证安全默认值、状态存储适配层、租户强隔离、风险 SLA 通知账本、后台高危操作、增强审计字段和文档更新。
- 待最终收口:全量 `tests/*.test.ts``npm run lint``npm run build``npm audit` 和服务器 smoke。
### Task 1: 认证安全默认值
**Files:**
- Modify: `src/app/api/auth/login/route.ts`
- Modify: `src/app/auth/login/page.tsx`
- Modify: `src/components/app-ui.tsx`
- Test: `tests/auth-login-hardening-route.test.ts`
- [x] **Step 1: Write failing tests**
覆盖默认关闭免验证登录、显式 `BOSS_AUTH_AUTO_LOGIN=1` 才允许免验证、账号密码登录仍可用。
- [ ] **Step 2: Implement minimal auth hardening**
`shouldAllowTemporaryAutoLogin()` 改成只接受 `1 / true / yes`;登录页文案从“临时免验证”改成企业登录;固定验证码提示只在 fixed delivery 模式下展示。
- [ ] **Step 3: Verify**
Run: `npx tsx --test tests/auth-login-hardening-route.test.ts tests/auth-session-governance.test.ts`
### Task 2: 状态存储适配层
**Files:**
- Create: `src/lib/boss-state-store.ts`
- Modify: `src/lib/boss-data.ts`
- Create: `scripts/postgres-state-schema.sql`
- Test: `tests/boss-state-store.test.ts`
- [ ] **Step 1: Write failing tests**
覆盖默认 `file` 模式、`BOSS_STATE_STORE=postgres` 但无 `BOSS_DATABASE_URL` 时 fail closed、PostgreSQL SQL schema 包含 `boss_state_snapshots``jsonb`
- [ ] **Step 2: Implement adapter**
新增 `createBossStateStore()`默认文件读写PostgreSQL 模式先通过动态 `pg` 依赖实现单行 JSONB 快照,未安装或未配置时给出明确错误。
- [ ] **Step 3: Wire boss-data**
`readState/writeState/loadPersistedStateRaw` 通过 store 读写,保持 `mutateState` 事务队列不变。
### Task 3: 租户强隔离
**Files:**
- Modify: `src/lib/boss-permissions.ts`
- Modify: `src/lib/boss-admin-overview.ts`
- Test: `tests/rbac-tenant-isolation.test.ts`
- [ ] **Step 1: Write failing tests**
同公司账号能访问授权设备 / 项目;不同公司账号即使误授 grant 也不能访问;未绑定公司历史数据继续按 owner/grant 兼容。
- [ ] **Step 2: Implement tenant guard**
在非 `highest_admin` 路径中加入 `companyId` 比对。设备优先读 `device.companyId`,账号读 `authAccount.companyId`,项目通过绑定设备推导公司集合。
### Task 4: 风险 SLA 和通知账本
**Files:**
- Modify: `src/lib/boss-data.ts`
- Create: `src/lib/boss-risk-notifications.ts`
- Create: `src/app/api/v1/admin/risks/scan/route.ts`
- Modify: `src/components/admin/boss-admin-app.tsx`
- Test: `tests/admin-risk-sla-notifications-route.test.ts`
- [ ] **Step 1: Write failing tests**
设置过期 SLA 后扫描会生成通知;重复扫描不重复生成同一风险通知;通知会进入管理后台总览。
- [x] **Step 2: Implement notification model**
新增 `adminNotifications`,字段包含 `notificationId / kind / severity / companyId / riskId / title / body / status / createdAt / acknowledgedAt`
- [x] **Step 3: Implement scanner**
- [x] **Step 4: Implement dispatch and timeline**
新增 `/api/v1/admin/notifications/dispatch`,支持 sendmail 邮件通道或 disabled 模式状态落账;新增 `adminRiskTimeline` 记录通知生成、派发和人工处置。
扫描 `opsFaults``threadContextAlerts`,对 `slaDueAt < now` 且未关闭的风险生成 `risk_sla_overdue` 通知。
### Task 5: 后台高危操作补齐
**Files:**
- Modify: `src/lib/boss-data.ts`
- Modify: `src/app/api/v1/admin/access/route.ts`
- Modify: `src/components/admin/admin-access-panel.tsx`
- Test: `tests/admin-access-enterprise-ops-route.test.ts`
- [x] **Step 1: Write failing tests**
最高管理员可停用公司、重置子账号密码、批量导入预检;停用公司会禁用该公司普通子账号并撤销会话;最高管理员账号不可被公司停用波及。
- [x] **Step 2: Implement actions**
新增 `set_company_status / reset_account_password / preview_bulk_import_accounts` 三个 action并接到 PC 管理后台。
- [x] **Step 3: Implement enterprise UX polish**
补齐公司套餐、合同到期、客户成功、CSV 导入、危险操作确认和子账号 MFA 开关。
### Task 6: 增强审计字段
**Files:**
- Modify: `src/lib/boss-data.ts`
- Modify: admin action routes under `src/app/api/v1/admin/*`
- Test: `tests/admin-audit-compliance.test.ts`
- [x] **Step 1: Write failing tests**
高危动作写入 `ipAddress / userAgent / beforeJson / afterJson / requestId`API 响应不泄露 `passwordHash / apiKey / sessionToken / restoreToken`
- [x] **Step 2: Implement audit metadata**
扩展 `PermissionAuditLog`,新增 `buildRequestAuditMeta(request)`,所有 admin mutation route 传入审计上下文。
- [x] **Step 3: Implement auth hardening**
补齐浏览器 CSRF 基础防护、restore token 轮换和子账号 MFA 校验。
### Task 7: Docs, regression, deploy
**Files:**
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
- Modify: `docs/architecture/admin_refine_backoffice_cn.md`
- [x] **Step 1: Update docs**
记录企业登录默认值、PostgreSQL 切换方式、租户隔离规则、风险 SLA 扫描和后台高危操作。
- [ ] **Step 2: Full verification**
Run:
```bash
npx tsx --test tests/*.test.ts
npm run lint
npm run build
npm audit
```
- [ ] **Step 3: Deploy and smoke**
Run:
```bash
./scripts/deploy-server.sh
curl -fsS https://boss.hyzq.net/api/health
```
Post-deploy verify `/admin``/api/v1/admin/access``/api/v1/admin/overview``/api/v1/admin/risks/scan`

View File

@@ -0,0 +1,158 @@
# Boss Admin Backoffice Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rebuild `/admin` into a PC To B operations backoffice with a dashboard, customer workspace, permission workspace, and risk/governance command center.
**Architecture:** Keep the existing Next.js App Router route and existing admin APIs. Refactor the current client shell into focused React components under `src/components/admin/`, reusing `/api/v1/admin/overview`, `/api/v1/admin/access`, `/api/v1/admin/risks/actions`, and `/api/v1/admin/skills/requests`.
**Tech Stack:** Next.js 16 App Router, React 19, Ant Design, `@refinedev/core`, TypeScript source tests with `node:test`.
---
### Task 1: Source Tests For New Admin Structure
**Files:**
- Modify: `tests/admin-refine-page.test.ts`
- [ ] **Step 1: Update the admin structure assertions**
Replace the old page shell expectations with assertions for these strings:
```ts
for (const title of ["平台运营驾驶舱", "客户与账号", "授权工作台", "风险与治理"]) {
assert.match(source, new RegExp(title));
}
for (const title of ["今日待处理", "客户健康排行", "关键风险队列", "节点健康"]) {
assert.match(source, new RegExp(title));
}
assert.doesNotMatch(source, /window\.prompt/);
```
- [ ] **Step 2: Run the focused test**
Run:
```bash
npx tsx --test tests/admin-refine-page.test.ts
```
Expected: fail until the component is refactored.
### Task 2: Admin Shell And Navigation
**Files:**
- Modify: `src/components/admin/boss-admin-app.tsx`
- [ ] **Step 1: Replace top tabs with PC backoffice navigation**
Implement a left-side navigation with four keys: `dashboard`, `customers`, `permissions`, `governance`.
- [ ] **Step 2: Keep Refine data provider mounted**
Keep:
```tsx
<Refine dataProvider={createBossAdminDataProvider(initialOverview ?? undefined)} resources={resources}>
```
Expected: existing data provider tests continue to pass.
### Task 3: Dashboard
**Files:**
- Modify: `src/components/admin/boss-admin-app.tsx`
- [ ] **Step 1: Build metric cards**
Use existing `summary`, `companies`, `devices`, `risks`, `notifications`.
- [ ] **Step 2: Build key queues**
Dashboard must include:
```tsx
"今日待处理"
"客户健康排行"
"关键风险队列"
"节点健康"
"最近事件"
```
Expected: no big full-width table as the first visual object.
### Task 4: Customer And Permission Workspaces
**Files:**
- Modify: `src/components/admin/boss-admin-app.tsx`
- Reuse: `src/components/admin/admin-access-panel.tsx`
- [ ] **Step 1: Add customer overview section**
Show company table, account table, and customer onboarding hints.
- [ ] **Step 2: Mount `AdminAccessPanel` under 授权工作台**
Keep the existing working mutation path, but wrap it in clearer page copy and narrower visual hierarchy.
### Task 5: Risk Command Center
**Files:**
- Modify: `src/components/admin/boss-admin-app.tsx`
- [ ] **Step 1: Replace prompt-based actions**
Remove `window.prompt` for assigning owner and SLA. Use controlled inline inputs and buttons.
- [ ] **Step 2: Keep existing risk actions**
Continue posting:
```ts
{ riskId, action: "assign_owner", ownerAccount }
{ riskId, action: "set_sla", slaDueAt }
{ riskId, action: "ack" }
{ riskId, action: "resolve" }
{ riskId, action: "create_repair_ticket" }
```
### Task 6: Skill Governance
**Files:**
- Modify: `src/components/admin/boss-admin-app.tsx`
- Reuse: `src/components/admin/admin-skill-lifecycle-panel.tsx`
- [ ] **Step 1: Move Skill lifecycle panel under 风险与治理**
Use a nested Ant Design `Tabs` with `风险战情室` and `Skill 生命周期`.
### Task 7: Verification
**Files:**
- Test: `tests/admin-refine-page.test.ts`
- Test: `tests/admin-overview-route.test.ts`
- Test: `tests/admin-risk-actions-route.test.ts`
- Test: `tests/admin-skill-lifecycle-panel-source.test.ts`
- [ ] **Step 1: Run focused admin tests**
```bash
npx tsx --test tests/admin-refine-page.test.ts tests/admin-overview-route.test.ts tests/admin-risk-actions-route.test.ts tests/admin-skill-lifecycle-panel-source.test.ts
```
Expected: all pass.
- [ ] **Step 2: Run lint/build**
```bash
npm run lint
npm run build
```
Expected: both pass.
## Self-review
- Spec coverage: covers dashboard, customer workspace, permission workspace, risk command center, Skill governance, testing.
- Placeholder scan: no TBD/TODO language.
- Type consistency: component names and existing endpoints match current code.

View File

@@ -0,0 +1,24 @@
# YuDao 风格企业后台独立化实施计划
日期2026-04-30
## 执行批次
第一批只做独立后台骨架和 BFF 契约,确保可以继续迭代而不影响现有 `/admin`
## 步骤
1. 新增 `/api/v1/admin/backoffice` 的测试,覆盖 `highest_admin` 鉴权、YuDao 风格菜单、租户/账号/角色/资源/风险/审计数据,以及敏感字段不泄露。
2. 新增 `apps/boss-admin-web` 源码测试,覆盖 Vue/Vite/Ant Design Vue 工程骨架、API 地址、登录态携带、核心页面文案和根工程隔离。
3. 实现 Admin BFF`buildAdminOverview(state)``BOSS_PERMISSION_TEMPLATES`、设备、项目、Skill、审计记录聚合为独立后台契约。
4. 搭建独立 Vue 后台工程提供工作台、租户、账号、角色权限、资源授权、Skill 中心、风险告警和审计日志页面骨架。
5. 修改根工程 `tsconfig.json``eslint.config.mjs`,避免尚未安装 Vue 依赖时影响 Next 主站构建。
6. 更新架构文档,说明独立后台、现有 `/admin` fallback、BFF 契约和后续部署方向。
7. 运行专项测试、lint 和 build如失败修到通过再交付。
## 成功标准
- `/api/v1/admin/backoffice` 可被测试调用并返回稳定结构。
- `apps/boss-admin-web` 具备可独立安装运行的 Vue/Vite 工程文件。
- 根工程 lint/build 不受新独立前端影响。
- 文档能说明为什么不整套引入 YuDao 后端,以及后续如何独立部署。

View File

@@ -0,0 +1,405 @@
# Desktop Dialog Guard Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a cross-platform dialog guard foundation so Boss desktop control can classify macOS and Windows popups, auto-handle safe prompts, and pause for user confirmation on risky prompts.
**Architecture:** Add a local-agent dialog policy engine with platform-neutral snapshots and signatures, expose `needs_user_action` from the computer-use runner, and wire the smoke runtime through the guard before executing desktop actions. macOS and Windows share policy/consent semantics while platform adapters provide snapshots through the same JSON shape.
**Tech Stack:** Node.js ESM, `node:test`, local-agent runtime scripts, Boss `desktop_control` payloads.
---
### Task 1: Policy Engine
**Files:**
- Create: `local-agent/desktop-dialog-guard.mjs`
- Test: `tests/local-agent-desktop-dialog-guard.test.mjs`
- [ ] **Step 1: Write failing tests**
```js
import test from "node:test";
import assert from "node:assert/strict";
import {
buildDialogInterventionResult,
createDialogSignature,
evaluateDialogSnapshot,
normalizeDialogSnapshot,
} from "../local-agent/desktop-dialog-guard.mjs";
test("dialog guard auto-handles safe welcome prompts on macOS and Windows", () => {
for (const platform of ["darwin", "win32"]) {
const decision = evaluateDialogSnapshot({
platform,
appName: platform === "darwin" ? "Google Chrome" : "Microsoft Edge",
title: "Welcome",
text: "Welcome. Not now",
buttons: ["Get started", "Not now"],
});
assert.equal(decision.disposition, "auto_action");
assert.equal(decision.action, "click_button");
assert.equal(decision.button, "Not now");
assert.equal(decision.risk, "safe");
}
});
test("dialog guard pauses for sensitive permission prompts", () => {
const decision = evaluateDialogSnapshot({
platform: "darwin",
appName: "System Settings",
title: "Screen Recording",
text: "BossComputerUseHelper would like to record this computer's screen",
buttons: ["Allow", "Don't Allow"],
});
assert.equal(decision.disposition, "needs_user_action");
assert.equal(decision.risk, "blocked");
assert.equal(decision.kind, "permission_required");
});
test("dialog guard generates stable signatures from normalized content", () => {
const a = createDialogSignature({
platform: "darwin",
deviceId: "macbook-air",
appBundleId: "com.google.Chrome",
title: " Welcome ",
text: "Not now",
buttons: ["Not now", "OK"],
});
const b = createDialogSignature({
platform: "darwin",
deviceId: "macbook-air",
appBundleId: "com.google.Chrome",
title: "Welcome",
text: " Not now ",
buttons: ["Not now", "OK"],
});
assert.equal(a.id, b.id);
assert.equal(a.scopeKey, "darwin:macbook-air:com.google.Chrome");
});
test("dialog guard emits app-safe intervention payload", () => {
const snapshot = normalizeDialogSnapshot({
platform: "win32",
deviceId: "win-node",
appName: "Installer",
title: "User Account Control",
text: "Do you want to allow this app to make changes to your device?",
buttons: ["Yes", "No"],
});
const decision = evaluateDialogSnapshot(snapshot);
const result = buildDialogInterventionResult({
requestId: "desktop-task-1",
snapshot,
decision,
});
assert.equal(result.status, "needs_user_action");
assert.equal(result.kind, "dialog_intervention_required");
assert.equal(result.risk, "blocked");
assert.deepEqual(result.availableActions, ["handled_on_device", "cancel_task"]);
assert.match(result.summary, /Installer/);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test tests/local-agent-desktop-dialog-guard.test.mjs`
Expected: FAIL because `local-agent/desktop-dialog-guard.mjs` does not exist.
- [ ] **Step 3: Implement minimal policy engine**
Create `local-agent/desktop-dialog-guard.mjs` with:
```js
import { createHash } from "node:crypto";
const SAFE_DISMISS_BUTTONS = ["稍后", "跳过", "以后再说", "Not now", "Skip", "Later", "Cancel"];
const BLOCKED_TEXT_PATTERNS = [
/screen recording/i,
/accessibility/i,
/input monitoring/i,
/full disk access/i,
/keychain/i,
/administrator/i,
/apple id/i,
/user account control/i,
/make changes to your device/i,
/屏幕录制/,
/辅助功能/,
/输入监控/,
/完整磁盘访问/,
/钥匙串/,
/管理员密码/,
];
export function normalizeDialogText(value) {
return String(value || "").replace(/\s+/g, " ").trim();
}
export function normalizeDialogSnapshot(input = {}) {
const buttons = Array.isArray(input.buttons)
? input.buttons.map(normalizeDialogText).filter(Boolean)
: [];
return {
platform: normalizeDialogText(input.platform || process.platform || "unknown"),
deviceId: normalizeDialogText(input.deviceId || "unknown-device"),
appName: normalizeDialogText(input.appName || input.app || "Unknown App"),
appBundleId: normalizeDialogText(input.appBundleId || input.appId || input.appName || input.app || "unknown-app"),
title: normalizeDialogText(input.title),
text: normalizeDialogText(input.text),
buttons,
};
}
function hash(value) {
return createHash("sha256").update(value).digest("hex").slice(0, 16);
}
export function createDialogSignature(snapshotInput = {}) {
const snapshot = normalizeDialogSnapshot(snapshotInput);
const titleHash = hash(snapshot.title.toLowerCase());
const textHash = hash(snapshot.text.toLowerCase());
const buttonHash = hash(snapshot.buttons.join("|").toLowerCase());
return {
id: hash([snapshot.platform, snapshot.deviceId, snapshot.appBundleId, titleHash, textHash, buttonHash].join("|")),
scopeKey: [snapshot.platform, snapshot.deviceId, snapshot.appBundleId].join(":"),
platform: snapshot.platform,
deviceId: snapshot.deviceId,
appBundleId: snapshot.appBundleId,
titleHash,
textHash,
buttonHash,
};
}
function findSafeDismissButton(buttons) {
return buttons.find((button) =>
SAFE_DISMISS_BUTTONS.some((candidate) => candidate.toLowerCase() === button.toLowerCase()),
);
}
function isBlockedPrompt(snapshot) {
const combined = `${snapshot.title} ${snapshot.text}`;
return BLOCKED_TEXT_PATTERNS.some((pattern) => pattern.test(combined));
}
export function evaluateDialogSnapshot(snapshotInput = {}) {
const snapshot = normalizeDialogSnapshot(snapshotInput);
const signature = createDialogSignature(snapshot);
if (isBlockedPrompt(snapshot)) {
return {
disposition: "needs_user_action",
kind: "permission_required",
risk: "blocked",
action: "pause_for_user",
signature,
};
}
const safeButton = findSafeDismissButton(snapshot.buttons);
if (safeButton) {
return {
disposition: "auto_action",
kind: "safe_dismiss",
risk: "safe",
action: "click_button",
button: safeButton,
signature,
};
}
return {
disposition: "needs_user_action",
kind: "unknown_dialog",
risk: "medium",
action: "pause_for_user",
signature,
};
}
export function buildDialogInterventionResult({ requestId, snapshot: snapshotInput, decision }) {
const snapshot = normalizeDialogSnapshot(snapshotInput);
const signature = decision?.signature || createDialogSignature(snapshot);
const blocked = decision?.risk === "blocked";
return {
status: "needs_user_action",
requestId: requestId || undefined,
kind: "dialog_intervention_required",
dialogId: signature.id,
risk: decision?.risk || "medium",
summary: `${snapshot.appName} 弹窗需要确认:${snapshot.title || snapshot.text || "未知弹窗"}`,
recommendedAction: blocked ? "handle_on_device" : "review",
availableActions: blocked
? ["handled_on_device", "cancel_task"]
: ["allow_once", "allow_for_device_dialog", "deny"],
platform: snapshot.platform,
appName: snapshot.appName,
};
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test tests/local-agent-desktop-dialog-guard.test.mjs`
Expected: PASS.
### Task 2: Runner Result Support
**Files:**
- Modify: `local-agent/computer-use-task-runner.mjs`
- Test: `tests/local-agent-computer-use-runner.test.mjs`
- [ ] **Step 1: Write failing parser test**
Append this test:
```js
test("computer use runner parses dialog intervention runtime payload", () => {
const result = parseComputerUseTaskResult(
JSON.stringify({
status: "needs_user_action",
requestId: "desktop-task-dialog",
kind: "dialog_intervention_required",
dialogId: "dialog-1",
risk: "medium",
summary: "QQ 弹窗需要确认",
recommendedAction: "review",
availableActions: ["allow_once", "deny"],
platform: "darwin",
appName: "QQ",
}),
);
assert.equal(result.status, "needs_user_action");
assert.equal(result.requestId, "desktop-task-dialog");
assert.equal(result.kind, "dialog_intervention_required");
assert.equal(result.dialogId, "dialog-1");
assert.equal(result.risk, "medium");
assert.equal(result.summary, "QQ 弹窗需要确认");
assert.deepEqual(result.availableActions, ["allow_once", "deny"]);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test tests/local-agent-computer-use-runner.test.mjs`
Expected: FAIL because `needs_user_action` is not parsed.
- [ ] **Step 3: Implement parser branch**
Update `parseComputerUseTaskResult` so `status === "needs_user_action"` returns a structured object containing requestId, kind, dialogId, risk, summary, recommendedAction, availableActions, platform and appName.
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test tests/local-agent-computer-use-runner.test.mjs`
Expected: PASS.
### Task 3: Runtime Guard Integration
**Files:**
- Modify: `scripts/computer-use-smoke.mjs`
- Test: `tests/browser-desktop-smoke-runtime-scripts.test.mjs`
- [ ] **Step 1: Write failing runtime tests**
Add tests that run `scripts/computer-use-smoke.mjs` with `BOSS_DIALOG_GUARD_ENABLED=true` and `BOSS_DIALOG_GUARD_SNAPSHOT_JSON`.
The safe test should assert the runtime completes and includes a `dialogGuard` artifact entry. The blocked test should assert the runtime returns `needs_user_action` before executing the desktop action.
- [ ] **Step 2: Run tests to verify failure**
Run: `node --test tests/browser-desktop-smoke-runtime-scripts.test.mjs`
Expected: FAIL because the smoke runtime ignores dialog guard env vars.
- [ ] **Step 3: Implement runtime preflight**
Import the dialog guard module, parse `BOSS_DIALOG_GUARD_ENABLED`, parse `BOSS_DIALOG_GUARD_SNAPSHOT_JSON`, evaluate it before desktop automation, return `needs_user_action` for pause decisions, and include auto-action audit info in artifacts for safe decisions.
- [ ] **Step 4: Run tests to verify pass**
Run: `node --test tests/browser-desktop-smoke-runtime-scripts.test.mjs`
Expected: PASS.
### Task 4: Config Defaults
**Files:**
- Modify: `local-agent/config.example.json`
- Modify: `local-agent/config.cloud.json`
- Test: `tests/browser-desktop-runtime-config-defaults.test.mjs`
- [ ] **Step 1: Write failing config test**
Extend the default config test to assert `dialogGuardEnabled`, `dialogGuardPlatformAdapters`, and `dialogGuardConsentRequired` are present.
- [ ] **Step 2: Run test to verify failure**
Run: `node --test tests/browser-desktop-runtime-config-defaults.test.mjs`
Expected: FAIL because the config keys are absent.
- [ ] **Step 3: Add defaults**
Add:
```json
"dialogGuardEnabled": true,
"dialogGuardConsentRequired": true,
"dialogGuardPlatformAdapters": ["darwin", "win32"]
```
- [ ] **Step 4: Run test to verify pass**
Run: `node --test tests/browser-desktop-runtime-config-defaults.test.mjs`
Expected: PASS.
### Task 5: Focused Verification
**Files:**
- No production files.
- [ ] **Step 1: Run focused tests**
Run:
```bash
node --test tests/local-agent-desktop-dialog-guard.test.mjs \
tests/local-agent-computer-use-runner.test.mjs \
tests/browser-desktop-smoke-runtime-scripts.test.mjs \
tests/browser-desktop-runtime-config-defaults.test.mjs
```
Expected: PASS.
- [ ] **Step 2: Run project checks**
Run:
```bash
npm run lint
npm run build
```
Expected: both PASS.
## Self-review
Spec coverage:
1. macOS and Windows are represented by platform-neutral snapshots and adapter-ready config keys.
2. Safe auto handling, sensitive pause, signatures, APP-friendly intervention payloads and audit artifacts are covered.
3. Full native AX/UIA helpers are intentionally deferred behind the adapter interface because this batch establishes the runtime contract first.
Placeholder scan: no unresolved placeholders.
Type consistency: `needs_user_action`, `dialogId`, `risk`, `summary`, `availableActions`, `platform`, and `appName` are consistent across plan tasks.

View File

@@ -0,0 +1,75 @@
# Ruflo Governance And Dialog Guard Completion Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Finish the next Boss control-plane layer: Desktop Dialog Guard user confirmation/audit, plus Ruflo-inspired task ownership, capability grouping, and device trust foundations.
**Architecture:** Keep Ruflo as a reference only, not a runtime dependency. Add small Boss-native modules and route contracts that fit the current file-backed state store, Android realtime channel, local-agent desktop runtime, and existing RBAC/audit patterns.
**Tech Stack:** Next.js route handlers, TypeScript state helpers, Node test runner/tsx tests, Android Java/Robolectric, Boss SSE events, local-agent runtime payloads.
---
## Task A: Dialog Guard Backend Completion
**Files:**
- Modify: `src/lib/boss-data.ts`
- Modify: `src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts`
- Create: `src/app/api/v1/dialog-guard/interventions/[interventionId]/decision/route.ts`
- Test: `tests/dialog-guard-interventions-route.test.ts`
**Steps:**
- [ ] Add a failing route test proving `status: "needs_user_action"` with `kind: "dialog_intervention_required"` creates a pending intervention and publishes `desktop.dialog_guard.intervention_required`.
- [ ] Add a failing route test proving a user decision updates the intervention, writes a permission audit log, and publishes `desktop.dialog_guard.intervention_resolved`.
- [ ] Add `DialogGuardIntervention` state shape and migration default.
- [ ] Extend the master-agent completion route to preserve `needs_user_action` instead of normalizing it to completed.
- [ ] Implement decision route with allowed decisions: `allow_once`, `allow_for_device_dialog`, `deny`, `handled_on_device`, `cancel_task`.
- [ ] Run the focused backend tests.
## Task B: Android Dialog Guard Confirmation UI
**Files:**
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/BossNotificationRouter.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
- Test: `android/app/src/test/java/com/hyzq/boss/DialogGuardInterventionUiTest.java`
**Steps:**
- [ ] Add failing Robolectric test for handling `desktop.dialog_guard.intervention_required`.
- [ ] Render a compact confirmation card/dialog using the current Boss/微信-style visual system.
- [ ] For `blocked` risk, show only `我已在电脑上处理` and `取消任务`.
- [ ] For medium/safe risk, show `允许本次`, `当前设备此弹窗允许`, `拒绝` based on `availableActions`.
- [ ] Call `POST /api/v1/dialog-guard/interventions/{interventionId}/decision`.
- [ ] Remove/refresh the card on `desktop.dialog_guard.intervention_resolved`.
- [ ] Run the focused Android test.
## Task C: Ruflo-Inspired Governance Foundation
**Files:**
- Create: `src/lib/boss-work-claims.ts`
- Create: `src/lib/boss-capability-groups.ts`
- Create: `src/lib/boss-device-trust.ts`
- Test: `tests/ruflo-governance-foundation.test.ts`
**Steps:**
- [ ] Add failing tests for claim, handoff, stale claim detection, and stealable work.
- [ ] Add failing tests for grouped capabilities: computer control, Codex development, browser automation, skill operations, admin ops.
- [ ] Add failing tests for device trust tiers and budget/hop limit checks.
- [ ] Implement pure Boss-native modules without importing Ruflo.
- [ ] Keep the API persistence-ready but not UI-bound.
- [ ] Run the focused governance test.
## Integration Verification
- [ ] Run focused backend tests.
- [ ] Run focused Android test if local Gradle supports it.
- [ ] Run `npm run lint`.
- [ ] Run `npm run build`.
## Notes
Ruflo is used as architecture reference only. The Boss implementation must stay deterministic, auditable, RBAC-aware, and safe for multi-tenant enterprise deployment.

View File

@@ -0,0 +1,181 @@
# Codex Desktop 同线程消息镜像设计
目标:当用户在 Boss App 里对一个已绑定 `codexThreadRef` 的单线程会话发消息时,这条用户消息不仅进入 Boss 自己的项目账本和 `conversation_reply` 执行队列,也要被镜像进本机 Codex Desktop 的同一个线程历史里。这样用户稍后回到 Codex Desktop看见的是同一个线程下连续的聊天记录而不是 Boss 与 Desktop 两套割裂历史。
## 背景与现状
当前 Boss 的普通线程单聊主链是:
- Web / Android 调 `POST /api/v1/projects/[projectId]/messages`
- 服务端写入 Boss 项目消息账本
- 服务端排一个 `conversation_reply` 任务
- 本机 `local-agent` 认领任务后调用 `codex exec resume <targetCodexThreadRef>`
- Codex 线程完成后,再把线程回复回写到 Boss 项目账本
这条链现在已经能做到“Desktop 回复被 Boss 看见”,因为 heartbeat 扫描 `~/.codex/sessions/.../rollout-*.jsonl` 时,会把最近桌面 assistant 回复镜像回 Boss。缺口在反方向
- Boss App 发起的用户消息只存在于 Boss 项目账本
- `codex exec resume` 虽然会把 prompt 交给目标线程继续执行,但 Boss 发起的这条消息并不会先出现在 Desktop 线程历史
- 结果就是用户在 APP 和 Desktop 里看到的“同一个线程”并不是同一份完整聊天记录
## 方案对比
### 方案 1直接操控 Codex Desktop GUI 输入并发送
优点:
- 理论上最贴近“像用户在桌面端亲自发了一条消息”
缺点:
- 依赖窗口前台、焦点、输入法、系统权限
- 极易被 Codex Desktop UI 更新打断
- 无法稳定支持后台运行和多线程并发
不推荐作为主方案。
### 方案 2直接把 Boss 用户消息写入对应 Codex rollout JSONL再继续现有 `codex exec resume`
优点:
- 与当前 Desktop/CLI 共用的真实线程存储一致
- 不需要操控 GUI
- 可以保持现有 `local-agent -> codex exec resume` 主链不变
- 能与现有 heartbeat 读取 rollout 的能力形成闭环
缺点:
- 需要谨慎贴合 Codex rollout 事件格式
- 需要处理重复写入和 Desktop 刷新感知
这是本次推荐方案。
### 方案 3单独给 Desktop 再建一条镜像线程
优点:
- 对现有线程文件侵入最小
缺点:
- 用户要的是“同一个线程”,不是“另一个镜像线程”
- 历史会继续分叉
不满足目标。
## 本次设计
### 1. 保持 Boss 账本为移动端/UI 主真相
Boss 的项目消息账本、会话排序、未读数、主 Agent 协同逻辑继续基于现有 `boss-state.json`。这次不把 Boss UI 改成直接读取 `~/.codex`
### 2. 对单线程 `conversation_reply` 任务增加“写入 Desktop 线程历史”的镜像步骤
当任务满足以下条件时,在 `local-agent` 侧做一次 rollout 镜像:
- `task.taskType === "conversation_reply"`
- 存在 `targetCodexThreadRef`
- 存在用户原始消息文本
- 不属于 `relayViaMasterAgent === true` 的接管中转任务
镜像行为:
- 优先通过 `state_5.sqlite``threads.rollout_path` 定位目标 rollout 文件
- 如果本机 Codex 因版本/迁移差异无法稳定解析 `state_5.sqlite`,则回退扫描 `~/.codex/sessions/**/rollout-*-<threadId>.jsonl`
- 向该 rollout 文件追加一组 Codex 用户消息记录:`response_item / message(role=user)``event_msg / user_message`
- 事件内容使用 Boss 原始用户消息文本,而不是执行 prompt
- 事件时间优先使用 Boss 消息的 `sentAt`
- 事件写入成功后,再继续现有 `codex exec resume`
### 3. 任务负载补齐“Boss 原始消息”字段
现在任务里只有:
- `requestMessageId`
- `requestText`
- `executionPrompt`
这还不够稳,因为后续去重和 Desktop 镜像需要区分:
- 哪条 Boss 用户消息已经镜像过
- 这次镜像的真实显示文本是什么
- 这条消息的原始时间戳是什么
因此为 `MasterAgentTask` 增加:
- `sourceMessageId?: string`
- `sourceMessageBody?: string`
- `sourceMessageSentAt?: string`
- `mirrorBossUserMessageToCodexDesktop?: boolean`
对普通线程单聊:
- `sourceMessageId = message.id`
- `sourceMessageBody = message.body`
- `sourceMessageSentAt = message.sentAt`
- `mirrorBossUserMessageToCodexDesktop = true`
对主 Agent 直聊、`@主Agent`、托管中转等不应写进子线程 Desktop 历史的场景,不开启这个标记。
### 4. 去重策略
同一条 Boss 消息可能因为:
- 任务重试
- local-agent 重启
- claim / complete 重放
而被多次处理。为避免在 Desktop 线程里重复写入同一条用户消息本次采用“rollout 末尾去重”:
- 生成稳定镜像 key`boss-user:<threadRef>:<sourceMessageId>`
- 写入的 `event_msg` 中带上 `payload.metadata.bossSourceMessageId`
- 写入前读取 rollout 尾部固定窗口,检查最近是否已经存在同一 `bossSourceMessageId`
- 若存在,则跳过写入,仅继续 `codex exec resume`
这样不需要引入新的状态库,也能与 Codex 原始线程文件保持局部自洽。
### 5. 刷新感知
第一版只写 rollout 还不够稳,因为 Desktop 的线程列表排序和“最近活跃”判断通常还依赖 `threads.updated_at / updated_at_ms / has_user_event`。因此本次实现改为:
- rollout append 成功后,若 `state_5.sqlite` 可写且能命中该 thread则同步刷新
- `updated_at`
- `updated_at_ms`
- `has_user_event = 1`
- 如果当前机器上的 Codex 状态库不可用、字段不兼容或压根没有这条 thread 记录,则只保留 rollout 写入,不把整条消息链路判成失败
这样做的取舍是:
- 先保证 Boss -> Codex Desktop 同线程历史不丢
- 再尽可能提升 Desktop 侧的列表刷新和最近活跃感知
- 不引入 GUI 自动化,不依赖桌面窗口前台
## 涉及文件
- 新增 `local-agent/codex-thread-rollout-writer.mjs`
- 修改 `local-agent/codex-task-runner.mjs`
- 修改 `local-agent/server.mjs`
- 修改 `src/lib/boss-data.ts`
- 修改 `src/app/api/v1/projects/[projectId]/messages/route.ts`
- 修改 `src/lib/boss-master-agent.ts`(如果当前普通线程任务创建逻辑在这里有共用 helper也一起补齐
- 新增测试 `tests/local-agent-codex-rollout-writer.test.mjs`
- 修改测试 `tests/local-agent-codex-task-runner.test.mjs`
- 修改测试 `tests/single-thread-message-execution.test.ts`
## 边界
- 本次只处理“Boss App -> 已绑定 Codex Desktop 同线程”的用户消息镜像
- 不处理群聊镜像到 Desktop
- 不处理主 Agent 自己的回复写入 Desktop 子线程
- 不做 Codex Desktop GUI 自动输入
- 不把 Boss 会话列表直接改成读取 Desktop 原始线程文件
## 验收标准
- 普通单线程会话发消息后,生成的 `conversation_reply` 任务带有完整 `sourceMessage*` 字段
- local-agent 在执行 `codex exec resume` 前,能把这条 Boss 用户消息写进目标 rollout
- 同一 `sourceMessageId` 重试时不会重复写入 rollout
- 若状态库可用,镜像后会同步刷新 thread 的活跃时间和 `has_user_event`
- 若状态库不可用或这台机器上的线程索引不完整,仍可通过 `sessions` 回退找到 rollout 并完成消息镜像
- 现有普通线程回复链不回归Boss 仍能收到 Codex 线程回复
- 若目标线程缺失、只读或 cwd 不合法,仍保持现有 fail-closed 行为

View File

@@ -0,0 +1,399 @@
# Boss 聊天统一电脑控制中枢设计
目标:让用户在 Boss App 里,无论是和 `主 Agent` 对话,还是和某个具体线程对话,都可以稳定驱动这台 Mac/Windows 设备完成三类事情:
- 项目开发与代码执行
- 浏览器/桌面 GUI 操作
- 普通产品讨论、调研和任务协同
并且这三类能力不再割裂成几条旁路,而是统一挂在 Boss 现有的聊天、执行底座和设备心跳体系下面。
## 背景与现状
当前 Boss 已经具备几条关键基础链路:
- `master-agent` 单聊可以通过 `local-agent -> codex exec` 真实产出回复
- 普通单线程聊天已经可以排 `conversation_reply` 任务,并恢复到真实 Codex 线程执行
- 群聊已有 `group_dispatch_plan -> dispatch_execution` 的编排链
- 设备模型已经支持同一台机器的 `GUI + CLI` 双能力声明与默认执行模式切换
- 本机 `local-agent` 已能做 Codex 线程发现、task claim、task complete、Desktop rollout 镜像
但目前缺的不是“再加一个按钮”,而是统一控制中枢:
- `conversation_reply` 只适合“把消息转给 Codex 线程继续聊”
- `dispatch_execution` 主要面向群聊下发和线程编排
- 还没有正式的“桌面控制 / 浏览器控制”任务类型
- 主 Agent 也没有显式的能力路由模型,无法稳定判断当前消息应该走:
- Codex 开发
- Browser automation
- Computer Use
- 单纯讨论/总结
这会导致现在的体验像“能做一些事”,但还不是“可以靠 Boss 聊天控制电脑做事”。
## 目标边界
### 本次要达到的能力
1. 主 Agent 能把用户消息识别成四类执行意图:
- `project_development`
- `thread_collaboration`
- `browser_control`
- `desktop_control`
2. Boss 执行底座能显式表达这四类请求,并把它们路由到正确 runtime。
3. `local-agent` 增加两条新 runtime
- `browser-automation-runtime`
- `computer-use-runtime`
4. Web / Android 前台至少能拿到任务执行方式和当前状态,知道这条消息是:
- 交给 Codex 线程
- 交给浏览器自动化
- 交给桌面控制
- 仅由主 Agent 直接回复
5. 对高风险桌面动作建立最小确认机制,避免“发一句话就直接在电脑上乱点/乱删”。
### 本次不做的事情
- 不做完整远控桌面产品
- 不做视频流式屏幕回传
- 不做跨设备键鼠镜像
- 不把 Codex Desktop 自己纳入 Computer Use 自动点击目标
- 不依赖 GUI 自动化去操控 Codex 自己的窗口
## 方案原则
### 原则 1复用 Boss 现有执行底座,不另起一套“远控系统”
如果我们再单独造一层 `remote-control service`,会把:
- 会话账本
- 任务队列
- 权限与确认
- 前台状态展示
- 设备能力发现
全部再复制一遍。成本高,而且会和当前 Boss 的“聊天即控制入口”相冲突。
所以本次明确采用:
- 用户入口仍然是 Boss 聊天
- 任务记录仍然是 `MasterAgentTask`
- 路由仍然收敛进 `src/lib/execution`
- 执行仍然由绑定设备上的 `local-agent` 落地
### 原则 2先把“控制判断”标准化再扩 runtime
现在最大的问题不是工具不够,而是没有统一的“这条消息该怎么执行”判断结果。
因此要先补一层执行意图:
- `discussion_only`
- `thread_reply`
- `browser_control`
- `desktop_control`
- `development_execution`
然后让不同后端只关心自己该执行哪一种。
### 原则 3危险动作永远要显式分级
Boss 最终要能“做任何事”,但不能把“任何事”理解成“任何时候都自动执行”。
所以本次引入最小风险分级:
- `low`
- 打开页面
- 搜索信息
- 读取项目文件
- 运行只读检查
- `medium`
- 登录态网页操作
- 浏览器表单提交
- 桌面应用点击导航
- 修改非代码业务内容
- `high`
- 删除/覆盖文件
- 系统设置改动
- 批量提交/发布
- 不可逆外部操作
策略:
- `low`:默认直接执行
- `medium`:默认轻确认,可在项目/会话级放行
- `high`:必须明确确认
## 控制中枢设计
## 1. 新的执行意图模型
在当前 `ExecutionRequestKind` 基础上新增:
- `browser_control`
- `desktop_control`
并补充一个统一意图字段,供主 Agent 和前台共用:
- `intentCategory`
- `discussion_only`
- `project_development`
- `thread_collaboration`
- `browser_control`
- `desktop_control`
其中:
- `project_development` 继续优先走现有 Codex 线程 / CLI 执行链
- `thread_collaboration` 继续走 `conversation_reply`
- `browser_control` 新增浏览器自动化 runtime
- `desktop_control` 新增 Computer Use runtime
## 2. 新的 runtime 层
### 2.1 browser-automation-runtime
用途:
- 打开网页
- 登录指定后台
- 提交表单
- 抓取页面信息
- 复现 Web bug
第一版实现直接复用现有 Playwright 能力,不重新造驱动协议。
建议协议:
- 输入:
- `taskId`
- `projectId`
- `requestText`
- `executionPrompt`
- `targetUrl?`
- `riskLevel`
- 输出:
- `status`
- `replyBody`
- `structuredResult?`
- `artifacts?`
落地约束:
- `local-agent/browser-control-task-runner.mjs` 先收口成外部 runtime 桥,不把 Playwright 逻辑硬编码进 `server.mjs`
- 通过 `browserControlEnabled / browserControlCommand / browserControlArgs / browserControlWorkdir / browserControlTimeoutMs` 配置启用
- runtime 进程只需要遵守单行 JSON stdout 协议,后续可以平滑替换成真实 Playwright/OpenClaw/browser adapter
### 2.2 computer-use-runtime
用途:
- 打开本机应用
- 在桌面 GUI 上点击、输入、切换
- 配合浏览器外的桌面软件完成操作
第一版实现直接对接 Codex App 现有的 Computer Use 能力约束:
- 只能操作普通桌面应用
- 需要系统 Screen Recording + Accessibility
- 不把终端/Codex 自己当作自动点击目标
这意味着:
- 项目开发仍然优先走 Codex CLI/线程
- Computer Use 负责 GUI 世界
- 两者由主 Agent 在同一条聊天链里自动选择
落地约束:
- `local-agent/computer-use-task-runner.mjs` 同样先做成外部 runtime 桥
- 通过 `computerUseEnabled / computerUseCommand / computerUseArgs / computerUseWorkdir / computerUseTimeoutMs` 配置启用
- 先统一 Boss 与 runtime 的协议,再按设备情况接 Codex App Computer Use、OpenClaw 或其他 GUI runtime
## 3. MasterAgentTask 扩展
当前 `MasterAgentTaskType` 只有:
- `conversation_reply`
- `attachment_analysis`
- `group_dispatch_plan`
- `dispatch_execution`
- `device_import_resolution`
本次新增:
- `browser_control`
- `desktop_control`
新增字段:
- `intentCategory?`
- `runtimeKind?`
- `riskLevel?`
- `confirmationPolicy?`
- `requiresUserConfirmation?`
- `confirmationScopeKey?`
目的:
- 前台能展示“当前这条消息要走哪条执行链”
- 服务端能统一处理确认/拦截
- `local-agent` 能按 runtimeKind 正确分流
## 4. 主 Agent 路由逻辑
主 Agent 不再简单分成“自己答”或“排 conversation_reply”而是多一步意图判断。
推荐判断顺序:
1. 如果是明显的项目讨论、总结、目标/版本记录、普通问答
- `discussion_only`
2. 如果是“继续开发 / 改代码 / 跑测试 / 看项目状态 / 接手某线程”
- `project_development``thread_collaboration`
3. 如果是“打开网站 / 点网页 / 查后台 / 提交表单 / 看页面”
- `browser_control`
4. 如果是“打开电脑软件 / 操作桌面 / 系统 GUI / 非浏览器界面”
- `desktop_control`
路由结果:
- `discussion_only`
- 主 Agent 直接回复
- `thread_collaboration`
- 继续 `conversation_reply`
- `project_development`
- 优先真实 Codex 线程 / CLI
- `browser_control`
-`browser_control` 任务
- `desktop_control`
-`desktop_control` 任务
## 5. 设备能力模型
当前设备只有:
- `gui`
- `cli`
这对“统一控制电脑”不够精确,所以建议在设备 heartbeat 能力里细化为:
- `cli`
- `gui`
- `browserAutomation`
- `computerUse`
其中:
- `browserAutomation` 可由本机 Playwright/runtime 探测
- `computerUse` 由本机配置和权限状态探测
这样前台与主 Agent 都能知道:
- 当前机器只能写代码
- 还是也能控浏览器
- 还是能做完整桌面 GUI 操作
## 6. 前台产品表现
### 会话页 / 聊天页
每条“触发执行”的用户消息,服务端返回时增加:
- `executionMode`
- `discussion`
- `thread`
- `development`
- `browser`
- `desktop`
- `riskLevel`
- `requiresConfirmation`
前台展示原则:
- 不做厚重控制台 UI
- 保持当前微信式聊天界面
- 只在消息下方补一条轻状态:
- `已交给主 Agent`
- `正在调用浏览器自动化`
- `正在调用桌面控制`
- `等待你确认后执行`
### 会话信息 / 设备详情
补一个轻量能力区:
- `默认开发模式CLI / GUI`
- `浏览器自动化:可用 / 不可用`
- `桌面控制:可用 / 不可用`
不把这些塞回聊天主界面。
## 7. 风险确认设计
### 会话级别
如果当前会话在某个项目下已经对中风险动作做过一次确认,则可以对这个项目保留:
- `禁止`
- `允许本次`
- `当前项目永久放行`
这和现有 GUI/CLI 并行冲突的项目级策略一致,避免用户多学一套规则。
### 任务级别
当主 Agent 判断为高风险时:
- 不直接执行
- 先在聊天里给出极简确认卡
- 用户点确认后再排任务
## 8. 本次实施顺序
### 第一批
- 写设计与计划文档
- 扩展任务类型、执行请求类型、设备能力类型
- 接入 `browser_control / desktop_control` 两类任务基础骨架
- `local-agent` 增加 runtime 分流占位
- 前台返回 `executionMode/riskLevel` 元数据
### 第二批
- 接入 browser automation 真执行
- 接入 computer use 真执行
- 完成确认链
### 第三批
- Android/Web 前台补状态展示
- 真机回归
- 文档回写
## 涉及文件
- 修改 `src/lib/execution/types.ts`
- 修改 `src/lib/execution/tool-registry.ts`
- 修改 `src/lib/execution/permission-policy.ts`
- 修改 `src/lib/boss-data.ts`
- 修改 `src/lib/boss-master-agent.ts`
- 修改 `src/app/api/v1/projects/[projectId]/messages/route.ts`
- 修改 `local-agent/codex-task-runner.mjs`
- 修改 `local-agent/server.mjs`
- 新增 `local-agent/browser-control-task-runner.mjs`
- 新增 `local-agent/computer-use-task-runner.mjs`
- 新增对应 tests
## 验收标准
- 主 Agent 能把聊天输入稳定区分成讨论、开发、浏览器控制、桌面控制四类
- `browser_control / desktop_control` 能以正式任务进入 Boss 队列
- `local-agent` 能识别并分流这两类任务
- 前台能看到当前消息是走哪条执行链
- 中高风险动作不会静默直接执行
- 现有 `conversation_reply / dispatch_execution` 主链不回归

View File

@@ -0,0 +1,151 @@
# Boss To B 总后台重构设计
日期2026-04-30
## 背景
当前 `/admin` 已经具备最高管理员访问控制、总览聚合、账号授权、风险处理和 Skill 生命周期治理能力,但页面仍像“几个数据表拼在一起”。对于 To B 交付场景,平台侧需要的是一套能服务客户成功、运维值守和权限开通的 PC 总后台,而不是一个调试看板。
本次重构不新增大业务边界,优先重组现有 `/api/v1/admin/overview``/api/v1/admin/access``/api/v1/admin/risks/actions``/api/v1/admin/skills/requests` 数据和动作,把后台做成可用、可读、可处置的运营控制台。
## 目标
1. 最高管理员进入后台后,能在 10 秒内看出哪些客户、设备、主 Agent 任务或线程风险需要处理。
2. 客户开通从“多个分散表单”收口成可理解的工作台公司、老板账号、子账号、设备、项目、Skill 授权有清晰入口和状态。
3. 风险处理从“表格按钮”升级为战情室按严重程度、客户影响、负责人、SLA 和下一步动作组织。
4. Skill 治理保留安全约束,但展示成可追踪的生命周期队列。
5. UI 风格从移动端微信效率风改为 PC To B 管理后台:高密度、强层级、清晰状态、少装饰。
## 非目标
- 不引入新的 Umi / Ant Design Pro 工程。
- 不切换 PostgreSQL 或重写状态存储。
- 不改 Android APP 端交互。
- 不绕过 local-agent 的 Skill allowlist、checksum、备份和回滚约束。
- 不把客户侧 Web 控制台和平台总后台混成一个产品。
## 信息架构
后台改为 4 个一级区:
1. `驾驶舱`:平台全局健康、关键风险、客户影响、在线设备、主 Agent 失败、待处理通知。
2. `客户与账号`:公司列表、客户详情、账号开通、角色状态、登录与安全概览。
3. `授权工作台`设备、项目、Skill 授权,权限模板,过期授权,离职回收和审计。
4. `风险与治理`风险战情室、SLA、负责人、修复工单、风险时间线、Skill 生命周期请求。
现有 `账号与授权``Skill 治理` 不是删除,而是拆到更合理的上下文里:账号归客户,授权归权限,风险和 Skill 请求归治理。
## 页面设计
### 驾驶舱
顶部保留平台身份和刷新动作但标题从“Boss 管理后台”升级为“平台运营驾驶舱”。主区域按优先级展示:
- `今日待处理`:关键风险数、超 SLA 通知、离线设备、主 Agent 失败。
- `客户健康排行`:按开放风险、在线设备比例、合同/套餐状态排序。
- `关键风险队列`只展示最值得处理的风险提供负责人、SLA、确认、关闭、工单动作。
- `设备与节点健康`GUI/CLI、Browser、Computer Use 能力状态集中展示。
- `最近事件`:风险时间线和权限审计摘要。
驾驶舱默认不展示大分页表,避免用户一打开就被表格淹没。
### 客户与账号
采用左侧客户列表 + 右侧详情的结构:
- 客户列表显示公司名、套餐、账号数、设备数、开放风险、客户成功负责人。
- 右侧详情显示老板账号、子账号、绑定设备、项目数量和最近风险。
- 新建客户流程拆成三步:创建公司、创建老板账号、绑定设备/项目。
- 子账号管理支持启用/停用、重置密码、MFA 状态和登录会话摘要。
这部分复用现有 `/api/v1/admin/access`,但前台从表单堆叠改成任务流。
### 授权工作台
授权页面按“给谁授权”而不是“授权类型”组织:
- 先选择账号或客户。
- 再选择设备、项目、Skill。
- 最后套用权限模板或手动勾选权限。
页面底部保留最近授权审计和过期授权提醒。高危动作继续二次确认。
### 风险与治理
风险页面采用“战情室”结构:
- 左侧风险队列:按 `critical / warning / info`、客户、负责人、SLA 筛选。
- 中间风险详情:影响对象、错误摘要、最近时间线、建议动作。
- 右侧处理面板:指派、设置 SLA、确认、关闭、创建工单。
Skill 生命周期治理放在同一区域的第二页签,展示为请求队列:
- 待认领、执行中、成功、失败分栏。
- 每条请求展示设备、Skill、动作、来源、checksum、结果摘要。
- 创建请求表单保留,但根据动作动态收敛字段。
## 组件边界
建议拆出以下组件,降低当前 `boss-admin-app.tsx` 的复杂度:
- `AdminShell`PC 后台壳、顶部栏、一级导航。
- `AdminDashboard`:驾驶舱。
- `AdminCustomerWorkspace`:客户与账号工作台。
- `AdminPermissionWorkspace`:授权工作台。
- `AdminRiskCommandCenter`:风险战情室。
- `AdminSkillGovernance`Skill 生命周期治理,可复用并改造当前组件。
- `AdminStatusBadge``AdminMetricCard``AdminActionRail`:统一状态、指标和动作区。
数据请求先继续使用现有 fetch不强制引入新的客户端状态库。
## 数据和接口
第一批不要求新增后端字段,但前台应完整使用现有字段:
- `summary`
- `companies`
- `accounts`
- `devices`
- `risks`
- `notifications`
- `riskTimeline`
- `grantsSummary`
如果发现页面需要客户健康分数,可先在前端由 `openRiskCount / onlineDeviceCount / deviceCount / status` 计算,不改状态 schema。
## 错误处理
- 后台总览读取失败时展示一张明确的恢复卡,提供重试按钮。
- 风险动作失败时保留原行状态,不做乐观关闭。
- 指派负责人和 SLA 不再使用 `window.prompt`,改成右侧处理面板或弹窗表单。
- 空状态要表达下一步,例如“暂无风险,可以查看设备在线情况”,不要只写“暂无数据”。
## 测试策略
- 保留并更新 `tests/admin-refine-page.test.ts`,验证新的一级区和关键文案。
- 增加组件 source 测试,确认不再使用 `window.prompt` 做风险指派和 SLA。
- 复跑 `tests/admin-overview-route.test.ts``tests/admin-risk-actions-route.test.ts``tests/admin-skill-lifecycle-panel-source.test.ts`
- 最后跑 `npm run lint` 和相关 Node 测试。
## 分批落地
第一批直接做到可用:
1. 重构 `BossAdminApp` 外壳和一级导航。
2. 做新版驾驶舱。
3. 做风险战情室,替换 `window.prompt`
4. 账号授权和 Skill 治理先迁入新结构,并压缩视觉层级。
第二批再增强:
1. 客户详情抽屉。
2. 新建客户三步流程。
3. 风险筛选和搜索。
4. 客户健康分数和趋势。
## 自检
- 无 TBD / TODO。
- 范围聚焦 `/admin` PC 总后台,不触碰 APP。
- 没有要求新增大后端能力,优先复用现有接口。
- 关键交互从数据表改成工作台与战情室,解决“后台管理不太好”的主要问题。

View File

@@ -0,0 +1,75 @@
# YuDao 风格企业后台独立化设计
日期2026-04-30
## 背景
Boss 需要从“客户也能用的 Web 页面”升级为平台侧 To B 总后台。这个后台用于平台运营人员管理公司、老板账号、子账号、电脑节点、Skill 授权、风险告警和审计记录。现有 `/admin` 已能展示核心数据,但仍运行在 Next 主站内,信息架构不够像成熟企业后台,后续不适合承载更复杂的租户、权限和治理能力。
调研 `YunaiV/yudao-cloud` 后,结论是:不直接引入它的 Spring Cloud 微服务后端;借鉴它的租户、用户、角色、菜单、日志、工作台和独立前端思路。前端形态参考 YuDao 的 Vben/Vue 管理后台,数据仍由 Boss 现有状态账本和 Admin BFF 提供。
## 目标
第一批目标是完成企业后台独立化的可运行骨架:
- 新增独立 PC 后台工程 `apps/boss-admin-web`,使用 Vue + Vite + Ant Design Vue。
- 新增 `/api/v1/admin/backoffice` 聚合接口,输出 YuDao 风格的菜单、工作台、租户、账号、角色权限、资源授权、风险和审计数据。
- 保留现有 `/admin`,作为 Boss 主站内 fallback不和独立后台互相替代。
- 后台权限继续只允许 `highest_admin` 访问不暴露密码哈希、MFA 密钥和会话令牌。
- 新后台先复用 Boss Cookie 登录态,后续再接独立域名 `admin.boss.hyzq.net`
## 非目标
- 不引入 YuDao Java 后端、MySQL 表结构或微服务网关。
- 不在第一批替换所有现有后台 mutation 页面。
- 不重新设计 Android APP。
- 不改变当前 Boss 文件存储运行时。
## 架构
```mermaid
flowchart LR
A["Boss Admin Web\nVue + Ant Design Vue"] --> B["/api/v1/admin/backoffice\nAdmin BFF"]
B --> C["boss-state.json\n当前状态账本"]
B --> D["buildAdminOverview\n现有后台聚合"]
B --> E["BOSS_PERMISSION_TEMPLATES\n权限模板"]
F["现有 /admin\nNext fallback"] --> G["/api/v1/admin/overview"]
```
`apps/boss-admin-web` 是独立前端工程。它只消费 BFF不直接读取本地文件也不复制业务规则。`/api/v1/admin/backoffice` 是企业后台的新契约层,负责把 Boss 当前状态翻译为更稳定的后台管理模型。
## 数据模型
第一批 BFF 返回:
- `menuTree`工作台、租户管理、账号管理、角色权限、资源授权、Skill 中心、风险告警、审计日志、系统设置。
- `workbench`:总览指标、客户健康、设备健康、风险摘要。
- `tenants`:公司列表、套餐、负责人、账号数、设备数、风险数。
- `users`:账号、昵称、角色、状态、公司、最近登录。
- `roles`:内置角色和权限模板。
- `resourceGroups`:设备、项目线程和 Skill 目录。
- `audit`:风险、风险时间线和权限审计。
- `yudaoMapping`Boss 账本字段到后台概念的映射,便于后续迁移数据库或接 YuDao 风格模块。
## UI 方向
第一批 UI 只做高保真骨架,不新增业务动作:
- 左侧固定菜单,右侧工作区。
- 顶部展示当前账号、后台说明和刷新入口。
- 工作台使用指标卡、风险横幅、客户健康和节点表。
- 租户、账号、角色、资源、风险、审计分别使用独立区块或表格。
- Skill 中心聚合展示 Skill 目录、来源、设备数和治理状态,后续再接完整安装向导。
## 权限与安全
- 未登录返回 `401`
-`highest_admin` 返回 `403`
- BFF 只返回安全账号字段,不返回 `passwordHash``mfaSecret``authSessions` 或任何 session token。
- 所有返回头使用 `private, no-store`,避免后台数据被缓存。
## 验证
- 新增 BFF 路由测试,验证鉴权、菜单结构、数据聚合和敏感字段过滤。
- 新增独立前端源代码测试验证工程骨架、API 契约、核心页面模块和根工程隔离。
-`npm run lint``npm run build`,确认不会破坏现有 Next 主站。

View File

@@ -14,6 +14,8 @@ const eslintConfig = defineConfig([
"main-*.js",
"android/.gradle/**",
"android/**/build/**",
"apps/boss-admin-web/**",
"public/admin-web/**",
"next-env.d.ts",
]),
]);

View File

@@ -0,0 +1,208 @@
import { spawn } from "node:child_process";
import path from "node:path";
function parseBoolean(value) {
return String(value || "").trim().toLowerCase() === "true";
}
function parseArgs(value) {
return String(value || "")
.trim()
.split(/\s+/)
.filter(Boolean);
}
function parseTimeoutMs(value) {
const parsed = Number.parseInt(String(value || ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 45000;
}
function pickConfigValue(config, key, fallback) {
if (config && config[key] !== undefined && config[key] !== null && `${config[key]}`.trim() !== "") {
return config[key];
}
return fallback;
}
function resolveCommandArgs(command, args, cwd) {
const runtimeName = path.basename(command || "").toLowerCase();
const scriptRuntimes = new Set([
"node",
"node.exe",
"tsx",
"tsx.cmd",
"bun",
"bun.exe",
"deno",
"deno.exe",
]);
if (!scriptRuntimes.has(runtimeName) || args.length === 0) {
return args;
}
const [first, ...rest] = args;
if (!first || first.startsWith("-")) {
return args;
}
return [path.isAbsolute(first) ? first : path.resolve(cwd || process.cwd(), first), ...rest];
}
function parseJsonLine(rawOutput) {
const lines = String(rawOutput || "")
.trim()
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
return JSON.parse(lines.at(-1) || "");
}
export function getBrowserControlTaskRunnerConfig(env = process.env, config = {}) {
const enabled = parseBoolean(pickConfigValue(config, "browserControlEnabled", env.BOSS_BROWSER_CONTROL_ENABLED));
const command = String(pickConfigValue(config, "browserControlCommand", env.BOSS_BROWSER_CONTROL_COMMAND) || "").trim() || undefined;
const args = Array.isArray(config?.browserControlArgs)
? config.browserControlArgs.map((item) => String(item)).filter(Boolean)
: parseArgs(pickConfigValue(config, "browserControlArgs", env.BOSS_BROWSER_CONTROL_ARGS));
const cwd = String(pickConfigValue(config, "browserControlWorkdir", env.BOSS_BROWSER_CONTROL_WORKDIR) || "").trim() || undefined;
const timeoutMs = parseTimeoutMs(pickConfigValue(config, "browserControlTimeoutMs", env.BOSS_BROWSER_CONTROL_TIMEOUT_MS));
return {
enabled,
command,
args,
cwd,
timeoutMs,
};
}
export function canHandleBrowserControlTask(task) {
return String(task?.taskType || "").trim() === "browser_control";
}
export function buildBrowserControlTaskExecution(config, task) {
if (!config?.enabled) {
throw new Error("BROWSER_CONTROL_RUNTIME_DISABLED");
}
if (!config?.command) {
throw new Error("BROWSER_CONTROL_COMMAND_REQUIRED");
}
const cwd = config.cwd || process.cwd();
return {
command: config.command,
args: resolveCommandArgs(config.command, config.args || [], cwd),
cwd,
timeoutMs: config.timeoutMs || 45000,
stdinPayload: {
requestKind: "browser_control",
requestId: String(task?.taskId || "").trim(),
objective: String(task?.requestText || task?.executionPrompt || "").trim(),
context: {
projectId: String(task?.projectId || "").trim() || undefined,
threadId: String(task?.threadId || task?.targetThreadId || "").trim() || undefined,
requestedBy: String(task?.requestedByAccount || task?.requestedBy || "").trim() || undefined,
requestedAt: String(task?.requestedAt || "").trim() || undefined,
confirmationScopeKey: String(task?.confirmationScopeKey || "").trim() || undefined,
riskLevel: String(task?.riskLevel || "").trim() || undefined,
},
},
};
}
export function parseBrowserControlTaskResult(rawOutput) {
const parsed = parseJsonLine(rawOutput);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("INVALID_BROWSER_CONTROL_RUNTIME_PAYLOAD");
}
if (parsed.status === "failed") {
return {
status: "failed",
requestId: typeof parsed.requestId === "string" ? parsed.requestId.trim() || undefined : undefined,
errorMessage:
typeof parsed.error === "string" && parsed.error.trim()
? parsed.error.trim()
: "BROWSER_CONTROL_FAILED",
};
}
const replyBody =
typeof parsed.replyBody === "string" && parsed.replyBody.trim()
? parsed.replyBody.trim()
: typeof parsed.summary === "string" && parsed.summary.trim()
? parsed.summary.trim()
: "";
if (!replyBody) {
throw new Error("INVALID_BROWSER_CONTROL_RUNTIME_PAYLOAD");
}
return {
status: "completed",
requestId: typeof parsed.requestId === "string" ? parsed.requestId.trim() || undefined : undefined,
replyBody,
targetUrl:
typeof parsed.targetUrl === "string" && parsed.targetUrl.trim()
? parsed.targetUrl.trim()
: undefined,
executionSummary:
typeof parsed.executionSummary === "string" && parsed.executionSummary.trim()
? parsed.executionSummary.trim()
: undefined,
};
}
export async function executeBrowserControlTask(task, config = {}) {
const runnerConfig = getBrowserControlTaskRunnerConfig(process.env, config);
if (!runnerConfig.enabled) {
return {
status: "failed",
errorMessage: "BROWSER_CONTROL_RUNTIME_DISABLED",
};
}
const execution = buildBrowserControlTaskExecution(runnerConfig, task);
return new Promise((resolve, reject) => {
const child = spawn(execution.command, execution.args, {
cwd: execution.cwd,
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, execution.timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
clearTimeout(timer);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timer);
if (timedOut) {
reject(new Error("BROWSER_CONTROL_TIMEOUT"));
return;
}
if (code !== 0) {
reject(new Error(stderr.trim() || `browser control exit code ${code}`));
return;
}
try {
resolve(parseBrowserControlTaskResult(stdout));
} catch (error) {
reject(error);
}
});
child.stdin.write(JSON.stringify(execution.stdinPayload));
child.stdin.end();
});
}

View File

@@ -0,0 +1,335 @@
import { spawn } from "node:child_process";
import path from "node:path";
function parseBoolean(value) {
return String(value || "").trim().toLowerCase() === "true";
}
function parseArgs(value) {
return String(value || "")
.trim()
.split(/\s+/)
.filter(Boolean);
}
function parseTimeoutMs(value) {
const parsed = Number.parseInt(String(value || ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 3000;
}
function parseNonNegativeInteger(value, fallback) {
const parsed = Number.parseInt(String(value ?? ""), 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function trimToDefined(value) {
const trimmed = String(value ?? "").trim();
return trimmed ? trimmed : undefined;
}
function pickConfigValue(config, key, fallback) {
if (config && config[key] !== undefined && config[key] !== null && `${config[key]}`.trim() !== "") {
return config[key];
}
return fallback;
}
function resolveCommandArgs(command, args, cwd) {
const runtimeName = path.basename(command || "").toLowerCase();
const scriptRuntimes = new Set([
"node",
"node.exe",
"tsx",
"tsx.cmd",
"bun",
"bun.exe",
"deno",
"deno.exe",
]);
if (!scriptRuntimes.has(runtimeName) || args.length === 0) {
return args;
}
const [first, ...rest] = args;
if (!first || first.startsWith("-")) {
return args;
}
return [path.isAbsolute(first) ? first : path.resolve(cwd || process.cwd(), first), ...rest];
}
function parseJsonLine(rawOutput) {
const lines = String(rawOutput || "")
.trim()
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
return JSON.parse(lines.at(-1) || "");
}
export function getCodexDesktopRefreshBridgeConfig(env = process.env, config = {}) {
const enabled = parseBoolean(
pickConfigValue(config, "codexDesktopRefreshEnabled", env.BOSS_CODEX_DESKTOP_REFRESH_ENABLED),
);
const command =
trimToDefined(
pickConfigValue(config, "codexDesktopRefreshCommand", env.BOSS_CODEX_DESKTOP_REFRESH_COMMAND),
) || undefined;
const endpoint =
trimToDefined(
pickConfigValue(config, "codexDesktopRefreshEndpoint", env.BOSS_CODEX_DESKTOP_REFRESH_ENDPOINT),
) || undefined;
const args = Array.isArray(config?.codexDesktopRefreshArgs)
? config.codexDesktopRefreshArgs.map((item) => String(item)).filter(Boolean)
: parseArgs(pickConfigValue(config, "codexDesktopRefreshArgs", env.BOSS_CODEX_DESKTOP_REFRESH_ARGS));
const cwd =
trimToDefined(
pickConfigValue(config, "codexDesktopRefreshWorkdir", env.BOSS_CODEX_DESKTOP_REFRESH_WORKDIR),
) || undefined;
const timeoutMs = parseTimeoutMs(
pickConfigValue(config, "codexDesktopRefreshTimeoutMs", env.BOSS_CODEX_DESKTOP_REFRESH_TIMEOUT_MS),
);
const appName =
trimToDefined(pickConfigValue(config, "codexDesktopRefreshAppName", env.BOSS_CODEX_DESKTOP_APP_NAME)) ||
"Codex";
const refreshMode =
trimToDefined(
pickConfigValue(config, "codexDesktopRefreshMode", env.BOSS_CODEX_DESKTOP_REFRESH_MODE),
) || "deeplink-reload";
const retryCount = parseNonNegativeInteger(
pickConfigValue(config, "codexDesktopRefreshRetryCount", env.BOSS_CODEX_DESKTOP_REFRESH_RETRY_COUNT),
2,
);
const retryDelayMs = parseNonNegativeInteger(
pickConfigValue(config, "codexDesktopRefreshRetryDelayMs", env.BOSS_CODEX_DESKTOP_REFRESH_RETRY_DELAY_MS),
120,
);
return {
enabled,
endpoint,
command,
args,
cwd,
timeoutMs,
appName,
refreshMode,
retryCount,
retryDelayMs,
};
}
function buildCodexDesktopRefreshPayload(config, mirrorHint) {
if (!config?.enabled) {
throw new Error("CODEX_DESKTOP_REFRESH_DISABLED");
}
const targetThreadRef = trimToDefined(mirrorHint?.targetThreadRef);
const sourceMessageId = trimToDefined(mirrorHint?.sourceMessageId);
if (!targetThreadRef || !sourceMessageId) {
throw new Error("CODEX_DESKTOP_REFRESH_HINT_REQUIRED");
}
return {
kind: "codex_desktop_refresh_hint",
targetThreadRef,
sourceMessageId,
rolloutPath: trimToDefined(mirrorHint?.rolloutPath),
threadTouchStatus: trimToDefined(mirrorHint?.threadTouchStatus),
appName: trimToDefined(config.appName) || "Codex",
refreshMode: trimToDefined(config.refreshMode) || "deeplink-reload",
requestedAt: new Date().toISOString(),
};
}
export function buildCodexDesktopRefreshExecution(config, mirrorHint) {
if (!config?.enabled) {
throw new Error("CODEX_DESKTOP_REFRESH_DISABLED");
}
if (!config?.command) {
throw new Error("CODEX_DESKTOP_REFRESH_COMMAND_REQUIRED");
}
const cwd = config.cwd || process.cwd();
return {
command: config.command,
args: resolveCommandArgs(config.command, config.args || [], cwd),
cwd,
timeoutMs: config.timeoutMs || 3000,
stdinPayload: buildCodexDesktopRefreshPayload(config, mirrorHint),
};
}
export function parseCodexDesktopRefreshResult(rawOutput) {
const parsed = parseJsonLine(rawOutput);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("INVALID_CODEX_DESKTOP_REFRESH_PAYLOAD");
}
const attemptCount = parseNonNegativeInteger(parsed.attemptCount, undefined);
const baseResult = {
targetThreadRef: trimToDefined(parsed.targetThreadRef),
appName: trimToDefined(parsed.appName),
deepLink: trimToDefined(parsed.deepLink),
attemptCount,
};
if (parsed.status === "failed") {
return {
status: "failed",
...baseResult,
detail: trimToDefined(parsed.error) || "CODEX_DESKTOP_REFRESH_FAILED",
};
}
if (parsed.status !== "completed" && parsed.status !== "skipped") {
throw new Error("INVALID_CODEX_DESKTOP_REFRESH_PAYLOAD");
}
return {
status: parsed.status,
...baseResult,
detail: trimToDefined(parsed.detail),
};
}
function compactUndefinedFields(result) {
return Object.fromEntries(Object.entries(result).filter(([, value]) => value !== undefined));
}
function attachBridgeAttemptCount(result, attemptIndex) {
if (result.attemptCount !== undefined || attemptIndex > 1) {
return compactUndefinedFields({
...result,
attemptCount: result.attemptCount ?? attemptIndex,
});
}
return compactUndefinedFields(result);
}
function runCodexDesktopRefreshExecution(execution) {
return new Promise((resolve, reject) => {
const child = spawn(execution.command, execution.args, {
cwd: execution.cwd,
env: process.env,
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, execution.timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
clearTimeout(timer);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timer);
if (timedOut) {
reject(new Error("CODEX_DESKTOP_REFRESH_TIMEOUT"));
return;
}
if (code !== 0) {
reject(new Error(stderr.trim() || `codex desktop refresh exit code ${code}`));
return;
}
try {
resolve(parseCodexDesktopRefreshResult(stdout));
} catch (error) {
reject(error);
}
});
child.stdin.write(JSON.stringify(execution.stdinPayload));
child.stdin.end();
});
}
async function runCodexDesktopRefreshEndpoint(config, payload) {
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort();
}, config.timeoutMs || 3000);
try {
const response = await fetch(config.endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
signal: controller.signal,
});
const body = await response.text();
if (!response.ok) {
throw new Error(body.trim() || `codex desktop refresh endpoint status ${response.status}`);
}
return parseCodexDesktopRefreshResult(body);
} finally {
clearTimeout(timer);
}
}
async function executeWithRetries(operation, runnerConfig) {
const maxAttempts = Math.max(1, parseNonNegativeInteger(runnerConfig.retryCount, 2) + 1);
const retryDelayMs = parseNonNegativeInteger(runnerConfig.retryDelayMs, 120);
let lastError;
let lastFailedResult;
for (let attemptIndex = 1; attemptIndex <= maxAttempts; attemptIndex += 1) {
try {
const result = attachBridgeAttemptCount(await operation(), attemptIndex);
if (result.status !== "failed") {
return result;
}
lastFailedResult = result;
} catch (error) {
lastError = error;
}
if (attemptIndex < maxAttempts) {
await sleep(retryDelayMs);
}
}
if (lastFailedResult) {
return attachBridgeAttemptCount(lastFailedResult, maxAttempts);
}
const message = lastError instanceof Error ? lastError.message : String(lastError || "CODEX_DESKTOP_REFRESH_FAILED");
throw new Error(`${message}; attempts=${maxAttempts}`);
}
export async function executeCodexDesktopRefreshBridge(mirrorHint, config = {}) {
const runnerConfig =
config && Object.prototype.hasOwnProperty.call(config, "enabled")
? config
: getCodexDesktopRefreshBridgeConfig(process.env, config);
if (!runnerConfig.enabled) {
return {
status: "skipped",
reason: "disabled",
};
}
if (runnerConfig.endpoint) {
const endpointPayload = buildCodexDesktopRefreshPayload(runnerConfig, mirrorHint);
try {
return await executeWithRetries(() => runCodexDesktopRefreshEndpoint(runnerConfig, endpointPayload), runnerConfig);
} catch (error) {
if (!runnerConfig.command) {
throw error;
}
}
}
const execution = buildCodexDesktopRefreshExecution(runnerConfig, mirrorHint);
return executeWithRetries(() => runCodexDesktopRefreshExecution(execution), runnerConfig);
}

View File

@@ -1,26 +1,130 @@
import { createHash } from "node:crypto";
import os from "node:os";
import { basename, resolve } from "node:path";
import { basename, dirname, resolve } from "node:path";
import { readFileSync } from "node:fs";
import { readFile, readdir } from "node:fs/promises";
import { open, readFile, readdir } from "node:fs/promises";
import { DatabaseSync } from "node:sqlite";
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
const MAX_ROLLOUT_TAIL_BYTES = 768 * 1024;
const MAX_RECENT_ASSISTANT_MESSAGES = 6;
const ASSISTANT_DUPLICATE_TURN_WINDOW_MS = 2_000;
const LEAKED_TITLE_PREFIXES = [
"你当前接手的项目根目录是",
"你现在接手的项目根目录是",
"你现在以目标线程身份直接回复用户",
"你正在向主 Agent 同步当前项目状态",
"只回复对用户真正有用的内容",
"只输出 JSON",
];
const LEAKED_TITLE_CONTAINS = [
"不要发送内部字段",
"不要自称主 Agent",
"不要解释系统如何分发",
"不要输出 JSON",
"项目名称:",
"线程名称:",
"文件夹:",
"同步原因:",
"当前消息:",
"用户当前消息:",
];
function toIsoFromUnixSeconds(value) {
if (!Number.isFinite(value) || value <= 0) return null;
return new Date(value * 1000).toISOString();
}
function sanitizeDisplayName(raw, fallback) {
function normalizeDisplayName(raw) {
const source = typeof raw === "string" ? raw : "";
const firstLine = source
.replace(/\u0000/g, "")
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
if (!firstLine) return fallback;
const compact = firstLine.replace(/\s+/g, " ").trim();
if (!compact) return fallback;
return compact.length > 48 ? `${compact.slice(0, 45)}...` : compact;
if (!firstLine) return "";
return firstLine.replace(/\s+/g, " ").trim();
}
function trimWorkspacePrefix(value) {
const normalized = normalizeDisplayName(value).replaceAll("\\", "/");
if (!normalized) {
return "";
}
return normalized
.replace(/^\/Users\/[^/]+\/code\//i, "")
.replace(/^\/home\/[^/]+\/code\//i, "")
.replace(/^[A-Za-z]:\/Users\/[^/]+\/code\//i, "");
}
function stripTrailingDisplayNameNoise(value) {
return value.replace(/['"}\]]{2,}$/g, "").trimEnd();
}
function looksLikeLeakedDisplayName(value) {
const normalized = normalizeDisplayName(value);
if (!normalized) {
return false;
}
return (
LEAKED_TITLE_PREFIXES.some((marker) => normalized.startsWith(marker)) ||
LEAKED_TITLE_CONTAINS.some((marker) => normalized.includes(marker))
);
}
function extractWorkspaceProjectName(value) {
const normalized = normalizeDisplayName(value).replaceAll("\\", "/");
if (!normalized) {
return "";
}
const patterns = [
/\/Users\/[^/]+\/code\/([^/\s"'`,。;!?]+)/i,
/\/home\/[^/]+\/code\/([^/\s"'`,。;!?]+)/i,
/[A-Za-z]:\/Users\/[^/]+\/code\/([^/\s"'`,。;!?]+)/i,
];
for (const pattern of patterns) {
const match = normalized.match(pattern);
if (match?.[1]) {
return match[1].split("/")[0]?.trim() ?? "";
}
}
return "";
}
function pickDisplayNameFallback(candidates) {
for (const candidate of candidates) {
const extracted = extractWorkspaceProjectName(candidate);
if (extracted && !looksLikeLeakedDisplayName(extracted)) {
return extracted;
}
const normalized = stripTrailingDisplayNameNoise(trimWorkspacePrefix(candidate));
if (normalized && !looksLikeLeakedDisplayName(normalized)) {
return normalized;
}
}
return "";
}
function sanitizeDisplayName(raw, fallback, options = {}) {
const compact = stripTrailingDisplayNameNoise(trimWorkspacePrefix(raw));
if (compact && !looksLikeLeakedDisplayName(raw) && !looksLikeLeakedDisplayName(compact)) {
return compact.length > 48 ? `${compact.slice(0, 45)}...` : compact;
}
const extractedProject = extractWorkspaceProjectName(raw);
if (extractedProject && !looksLikeLeakedDisplayName(extractedProject)) {
return extractedProject;
}
const safeFallback = pickDisplayNameFallback([
options.folderName,
options.folderPath,
fallback,
]);
if (safeFallback) {
return safeFallback;
}
return fallback;
}
function fallbackDisplayName(thread, folderName) {
@@ -119,7 +223,7 @@ function loadThreadsFromStateDb(stateDbPath) {
try {
return db
.prepare(
"SELECT id, cwd, updated_at, archived, title, sandbox_policy, agent_nickname, agent_role FROM threads WHERE archived = 0 ORDER BY updated_at DESC",
"SELECT id, cwd, updated_at, archived, title, sandbox_policy, agent_nickname, agent_role, rollout_path, source FROM threads WHERE archived = 0 ORDER BY updated_at DESC",
)
.all()
.map((row) => ({
@@ -131,6 +235,8 @@ function loadThreadsFromStateDb(stateDbPath) {
sandboxPolicy: typeof row.sandbox_policy === "string" ? row.sandbox_policy : "",
agentNickname: typeof row.agent_nickname === "string" ? row.agent_nickname : "",
agentRole: typeof row.agent_role === "string" ? row.agent_role : "",
rolloutPath: typeof row.rollout_path === "string" ? row.rollout_path : "",
source: typeof row.source === "string" ? row.source : "",
}));
} finally {
db.close();
@@ -154,16 +260,45 @@ function parseSessionMeta(line) {
title: "",
agentNickname: typeof parsed.payload.agent_nickname === "string" ? parsed.payload.agent_nickname : "",
agentRole: typeof parsed.payload.agent_role === "string" ? parsed.payload.agent_role : "",
rolloutPath: "",
source: "sessions",
};
} catch {
return null;
}
}
async function loadThreadsFromSessions(sessionsDir) {
function parseRolloutFilenameTimestampSeconds(fileName) {
const match = fileName.match(
/^rollout-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-/,
);
if (!match) {
return null;
}
const [, datePart, hour, minute, second] = match;
const parsed = new Date(`${datePart}T${hour}:${minute}:${second}`);
if (Number.isNaN(parsed.getTime())) {
return null;
}
return Math.floor(parsed.getTime() / 1000);
}
function shouldReadSessionFile(fileName, cutoffSeconds) {
if (!Number.isFinite(cutoffSeconds)) {
return true;
}
const filenameSeconds = parseRolloutFilenameTimestampSeconds(fileName);
if (filenameSeconds === null) {
return true;
}
return filenameSeconds >= cutoffSeconds;
}
async function loadThreadsFromSessions(sessionsDir, options = {}) {
if (!sessionsDir) return [];
const pending = [resolve(sessionsDir)];
const threads = [];
const cutoffSeconds = Number(options.cutoffSeconds);
while (pending.length > 0) {
const dir = pending.pop();
if (!dir) continue;
@@ -180,11 +315,12 @@ async function loadThreadsFromSessions(sessionsDir) {
continue;
}
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
if (!shouldReadSessionFile(entry.name, cutoffSeconds)) continue;
try {
const raw = await readFile(fullPath, "utf8");
const firstLine = raw.split(/\r?\n/, 1)[0];
const parsed = parseSessionMeta(firstLine);
if (parsed) threads.push(parsed);
if (parsed) threads.push({ ...parsed, rolloutPath: fullPath });
} catch {
continue;
}
@@ -193,10 +329,165 @@ async function loadThreadsFromSessions(sessionsDir) {
return threads;
}
function normalizeEventTimestamp(value) {
if (typeof value !== "string" || !value.trim()) return null;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return null;
return parsed.toISOString();
}
function buildAssistantMessageId(threadId, sentAt, body) {
const digest = createHash("sha1").update(body).digest("hex").slice(0, 12);
return `codex-thread:${threadId}:${sentAt}:${digest}`;
}
function assistantMessageTimeValue(value) {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
function assistantPhasePriority(value) {
const phase = normalizeAssistantMessagePhase(value);
if (!phase) return 0;
if (phase === "final_answer" || phase === "final" || phase === "answer") return 3;
if (phase === "commentary" || phase === "process" || phase === "thinking") return 1;
return 2;
}
function normalizeAssistantMessagePhase(value) {
const phase = trimToDefined(value);
return phase || undefined;
}
async function readRolloutTail(rolloutPath) {
if (!rolloutPath) return "";
let handle;
try {
handle = await open(resolve(rolloutPath), "r");
const stats = await handle.stat();
const start = Math.max(0, stats.size - MAX_ROLLOUT_TAIL_BYTES);
const length = Math.max(0, stats.size - start);
if (length === 0) {
return "";
}
const buffer = Buffer.alloc(length);
await handle.read(buffer, 0, length, start);
let text = buffer.toString("utf8");
if (start > 0) {
const firstNewline = text.indexOf("\n");
text = firstNewline >= 0 ? text.slice(firstNewline + 1) : "";
}
return text;
} catch {
return "";
} finally {
await handle?.close().catch(() => {});
}
}
function parseRecentAssistantMessage(line, threadId) {
if (!line.trim()) return null;
try {
const parsed = JSON.parse(line);
let body = "";
let phase;
if (parsed?.type === "event_msg" && parsed?.payload?.type === "agent_message") {
body = typeof parsed.payload.message === "string" ? parsed.payload.message.trim() : "";
phase = normalizeAssistantMessagePhase(parsed.payload.phase);
} else if (
parsed?.type === "response_item" &&
parsed?.payload?.type === "message" &&
parsed?.payload?.role === "assistant"
) {
const content = Array.isArray(parsed.payload.content) ? parsed.payload.content : [];
body = content
.map((item) => (typeof item?.text === "string" ? item.text.trim() : ""))
.filter(Boolean)
.join("\n")
.trim();
phase = normalizeAssistantMessagePhase(parsed.payload.phase);
} else {
return null;
}
const sentAt = normalizeEventTimestamp(parsed.timestamp ?? parsed.payload.timestamp);
if (!body || !sentAt) {
return null;
}
const message = {
messageId: buildAssistantMessageId(threadId, sentAt, body),
body,
sentAt,
};
return phase ? { ...message, phase } : message;
} catch {
return null;
}
}
function mergeRecentAssistantMessage(existing, incoming) {
if (!existing) return incoming;
const existingPriority = assistantPhasePriority(existing.phase);
const incomingPriority = assistantPhasePriority(incoming.phase);
if (!incoming.phase || existingPriority >= incomingPriority) return existing;
return {
...existing,
phase: incoming.phase,
};
}
function isDuplicateAssistantTurn(existing, incoming) {
if (existing.body !== incoming.body) return false;
const existingTime = assistantMessageTimeValue(existing.sentAt);
const incomingTime = assistantMessageTimeValue(incoming.sentAt);
if (!existingTime || !incomingTime) return false;
return Math.abs(existingTime - incomingTime) <= ASSISTANT_DUPLICATE_TURN_WINDOW_MS;
}
function isGuiCodexThreadSource(value) {
const source = trimToDefined(value);
if (!source) return false;
if (source === "cli" || source === "exec" || source === "sessions") {
return false;
}
return true;
}
async function loadRecentAssistantMessages(rolloutPath, threadId) {
const tail = await readRolloutTail(rolloutPath);
if (!tail) return [];
const messagesById = new Map();
for (const line of tail.split(/\r?\n/)) {
const message = parseRecentAssistantMessage(line, threadId);
if (!message) continue;
const duplicateEntry = [...messagesById.entries()].find(([, existing]) =>
isDuplicateAssistantTurn(existing, message),
);
if (duplicateEntry) {
const [duplicateId, existing] = duplicateEntry;
messagesById.set(duplicateId, mergeRecentAssistantMessage(existing, message));
continue;
}
messagesById.set(message.messageId, mergeRecentAssistantMessage(messagesById.get(message.messageId), message));
}
return [...messagesById.values()]
.sort((left, right) => left.sentAt.localeCompare(right.sentAt))
.slice(-MAX_RECENT_ASSISTANT_MESSAGES);
}
function requireText(filePath) {
return readFileSync(resolve(filePath), "utf8");
}
function resolveDefaultSessionsDir(options = {}) {
if (options.sessionsDir) {
return options.sessionsDir;
}
if (options.stateDbPath) {
return resolve(dirname(resolve(options.stateDbPath)), "sessions");
}
return resolve(os.homedir(), ".codex/sessions");
}
export async function discoverCodexProjectCandidates(options = {}) {
const now = options.now instanceof Date ? options.now : new Date();
const lookbackHours = Number.isFinite(options.lookbackHours) ? Number(options.lookbackHours) : 24;
@@ -209,17 +500,17 @@ export async function discoverCodexProjectCandidates(options = {}) {
options.logsDbPath ?? resolve(os.homedir(), ".codex/logs_1.sqlite"),
);
let threads = loadThreadsFromStateDb(
const stateDbThreads = loadThreadsFromStateDb(
options.stateDbPath ?? resolve(os.homedir(), ".codex/state_5.sqlite"),
);
if (threads.length === 0) {
threads = await loadThreadsFromSessions(
options.sessionsDir ?? resolve(os.homedir(), ".codex/sessions"),
);
}
const sessionThreads = await loadThreadsFromSessions(resolveDefaultSessionsDir(options), {
cutoffSeconds,
});
const threads = [...stateDbThreads, ...sessionThreads];
const seenThreadIds = new Set();
const groupedCandidates = new Map();
let guiConnected = false;
for (const thread of threads) {
if (!thread?.id || seenThreadIds.has(thread.id)) continue;
if (isReadOnlySandboxPolicy(thread.sandboxPolicy)) {
@@ -231,6 +522,7 @@ export async function discoverCodexProjectCandidates(options = {}) {
}
seenThreadIds.add(thread.id);
guiConnected = guiConnected || isGuiCodexThreadSource(thread.source);
const hintedPath = workspaceHints.get(thread.id);
const folderPath = resolve(hintedPath || thread.cwd || "");
const folderName = basename(folderPath);
@@ -239,8 +531,16 @@ export async function discoverCodexProjectCandidates(options = {}) {
const sessionName = sessionNames.get(thread.id)?.threadName;
const displayName = sanitizeDisplayName(
sessionName,
sanitizeDisplayName(thread.title, fallbackDisplayName(thread, folderName)),
sanitizeDisplayName(thread.title, fallbackDisplayName(thread, folderName), {
folderName,
folderPath,
}),
{
folderName,
folderPath,
},
);
const recentAssistantMessages = await loadRecentAssistantMessages(thread.rolloutPath, thread.id);
const candidate = {
folderName,
@@ -251,6 +551,7 @@ export async function discoverCodexProjectCandidates(options = {}) {
codexThreadRef: thread.id,
lastActiveAt: toIsoFromUnixSeconds(latestActivitySeconds) ?? now.toISOString(),
suggestedImport: true,
...(recentAssistantMessages.length > 0 ? { recentAssistantMessages } : {}),
};
const folderKey = folderPath || folderName;
const bucket = groupedCandidates.get(folderKey) ?? [];
@@ -277,6 +578,7 @@ export async function discoverCodexProjectCandidates(options = {}) {
return {
projects,
projectCandidates: candidates,
guiConnected,
};
}

View File

@@ -1,9 +1,9 @@
import os from "node:os";
import { readFileSync } from "node:fs";
import { constants } from "node:fs";
import { access, stat } from "node:fs/promises";
import { access, readFile, readdir, stat } from "node:fs/promises";
import { DatabaseSync } from "node:sqlite";
import { resolve } from "node:path";
import { dirname, resolve } from "node:path";
function trimToDefined(value) {
const trimmed = String(value ?? "").trim();
@@ -45,6 +45,14 @@ function defaultCodexPath(relativePath) {
return resolve(os.homedir(), ".codex", relativePath);
}
function defaultSessionsDirForStateDb(stateDbPath) {
const resolvedStateDbPath = trimToDefined(stateDbPath);
if (resolvedStateDbPath) {
return resolve(dirname(resolve(resolvedStateDbPath)), "sessions");
}
return defaultCodexPath("sessions");
}
function loadThreadWorkspaceHints(globalStatePath) {
try {
const raw = readFileSync(resolve(globalStatePath), "utf8");
@@ -72,6 +80,29 @@ function shouldPreflightResumeTask(task) {
);
}
function buildDesktopMirrorPlan(task, targetThreadRef) {
if (task?.taskType !== "conversation_reply") {
return { enabled: false };
}
if (task?.mirrorBossUserMessageToCodexDesktop !== true) {
return { enabled: false };
}
const sourceMessageId = trimToDefined(task?.sourceMessageId || task?.requestMessageId);
const sourceMessageBody = trimToDefined(task?.sourceMessageBody || task?.requestText);
if (!targetThreadRef || !sourceMessageId || !sourceMessageBody) {
return { enabled: false };
}
return {
enabled: true,
targetThreadRef,
sourceMessageId,
sourceMessageBody,
sourceMessageSentAt: trimToDefined(task?.sourceMessageSentAt),
};
}
function buildStructuredTaskBindingError(code, message, details) {
return {
code,
@@ -80,7 +111,71 @@ function buildStructuredTaskBindingError(code, message, details) {
};
}
function inspectCodexThreadBinding(config, targetThreadRef, targetFolderRef) {
function parseSessionMetaLine(line) {
try {
const parsed = JSON.parse(line);
if (parsed?.type !== "session_meta" || !parsed?.payload?.id || !parsed?.payload?.cwd) {
return null;
}
return {
id: String(parsed.payload.id),
cwd: String(parsed.payload.cwd),
};
} catch {
return null;
}
}
async function findSessionThreadBinding(config, targetThreadRef) {
const root = trimToDefined(
config?.codexSessionsDir || defaultSessionsDirForStateDb(config?.codexStateDbPath),
);
if (!root) {
return {
status: "missing",
};
}
const stack = [resolve(root)];
const suffix = `-${targetThreadRef}.jsonl`;
while (stack.length > 0) {
const current = stack.pop();
if (!current) continue;
let entries = [];
try {
entries = await readdir(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const entryPath = resolve(current, entry.name);
if (entry.isDirectory()) {
stack.push(entryPath);
continue;
}
if (!entry.isFile() || !entry.name.endsWith(suffix)) {
continue;
}
try {
const raw = await readFile(entryPath, "utf8");
const meta = parseSessionMetaLine(raw.split(/\r?\n/, 1)[0] ?? "");
if (meta?.id === targetThreadRef) {
return {
status: "ok",
threadCwd: trimToDefined(meta.cwd) || "",
};
}
} catch {
continue;
}
}
}
return {
status: "missing",
};
}
async function inspectCodexThreadBinding(config, targetThreadRef, targetFolderRef) {
const stateDbPath = trimToDefined(config?.codexStateDbPath || defaultCodexPath("state_5.sqlite"));
if (!stateDbPath) {
return {
@@ -94,7 +189,10 @@ function inspectCodexThreadBinding(config, targetThreadRef, targetFolderRef) {
const row = db
.prepare("SELECT id, cwd, archived, sandbox_policy FROM threads WHERE id = ? LIMIT 1")
.get(targetThreadRef);
if (!row || row.archived) {
if (!row) {
return await findSessionThreadBinding(config, targetThreadRef);
}
if (row.archived) {
return {
status: "missing",
};
@@ -127,9 +225,7 @@ function inspectCodexThreadBinding(config, targetThreadRef, targetFolderRef) {
db.close();
}
} catch {
return {
status: "unavailable",
};
return await findSessionThreadBinding(config, targetThreadRef);
}
}
@@ -159,7 +255,7 @@ export async function prepareCodexTaskExecution(config, task, outputFile) {
}
const resumeTarget = resolveResumeTarget(config, task);
const bindingInspection = inspectCodexThreadBinding(config, targetThreadRef, resumeTarget.cwd);
const bindingInspection = await inspectCodexThreadBinding(config, targetThreadRef, resumeTarget.cwd);
if (bindingInspection.status === "missing") {
return {
ok: false,
@@ -255,6 +351,7 @@ export function buildCodexTaskExecution(config, task, outputFile) {
mode: "resume",
cwd,
args,
desktopMirror: buildDesktopMirrorPlan(task, targetThreadRef),
};
}
@@ -277,5 +374,6 @@ export function buildCodexTaskExecution(config, task, outputFile) {
mode: "ephemeral",
cwd: config.masterAgentWorkdir || process.cwd(),
args,
desktopMirror: { enabled: false },
};
}

View File

@@ -0,0 +1,279 @@
import os from "node:os";
import { appendFile, open, readdir } from "node:fs/promises";
import { DatabaseSync } from "node:sqlite";
import { resolve } from "node:path";
const MAX_ROLLOUT_TAIL_BYTES = 256 * 1024;
function trimToDefined(value) {
const trimmed = String(value ?? "").trim();
return trimmed ? trimmed : undefined;
}
function defaultCodexPath(relativePath) {
return resolve(os.homedir(), ".codex", relativePath);
}
function resolveThreadRolloutPath({ stateDbPath, targetThreadRef }) {
const resolvedStateDbPath = trimToDefined(stateDbPath || defaultCodexPath("state_5.sqlite"));
if (!resolvedStateDbPath) {
throw new Error("CODEX_STATE_DB_MISSING");
}
const db = new DatabaseSync(resolvedStateDbPath, { readonly: true });
try {
const row = db
.prepare("SELECT rollout_path, archived FROM threads WHERE id = ? LIMIT 1")
.get(targetThreadRef);
if (!row) {
throw new Error("CODEX_THREAD_NOT_FOUND");
}
if (row.archived) {
throw new Error("CODEX_THREAD_ARCHIVED");
}
const rolloutPath = trimToDefined(row.rollout_path);
if (!rolloutPath) {
throw new Error("CODEX_ROLLOUT_PATH_MISSING");
}
return resolve(rolloutPath);
} finally {
db.close();
}
}
function defaultCodexSessionsDir() {
return defaultCodexPath("sessions");
}
async function findRolloutPathInSessionsDir({ sessionsDir, targetThreadRef }) {
const root = trimToDefined(sessionsDir || defaultCodexSessionsDir());
if (!root) {
throw new Error("CODEX_SESSIONS_DIR_MISSING");
}
const stack = [resolve(root)];
const suffix = `-${targetThreadRef}.jsonl`;
while (stack.length > 0) {
const current = stack.pop();
if (!current) {
continue;
}
let entries = [];
try {
entries = await readdir(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const entryPath = resolve(current, entry.name);
if (entry.isDirectory()) {
stack.push(entryPath);
continue;
}
if (entry.isFile() && entry.name.endsWith(suffix)) {
return entryPath;
}
}
}
throw new Error("CODEX_ROLLOUT_PATH_FALLBACK_NOT_FOUND");
}
async function resolveThreadRolloutPathWithFallback({ stateDbPath, sessionsDir, targetThreadRef }) {
try {
return resolveThreadRolloutPath({ stateDbPath, targetThreadRef });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message === "CODEX_THREAD_ARCHIVED") {
throw error;
}
}
return findRolloutPathInSessionsDir({
sessionsDir,
targetThreadRef,
});
}
function resolveThreadTouchTimestamps(sentAt) {
const parsed = Date.parse(sentAt);
const updatedAtMs = Number.isFinite(parsed) ? parsed : Date.now();
return {
updatedAtMs,
updatedAt: Math.floor(updatedAtMs / 1000),
};
}
function touchThreadActivity({ stateDbPath, targetThreadRef, sentAt }) {
const resolvedStateDbPath = trimToDefined(stateDbPath || defaultCodexPath("state_5.sqlite"));
if (!resolvedStateDbPath) {
throw new Error("CODEX_STATE_DB_MISSING");
}
const { updatedAt, updatedAtMs } = resolveThreadTouchTimestamps(sentAt);
const db = new DatabaseSync(resolvedStateDbPath);
try {
try {
const result = db.prepare(
`
UPDATE threads
SET updated_at = ?,
updated_at_ms = ?,
has_user_event = 1
WHERE id = ?
`,
).run(updatedAt, updatedAtMs, targetThreadRef);
if (Number(result?.changes ?? 0) <= 0) {
return {
status: "skipped",
reason: "thread-not-found",
};
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("updated_at_ms")) {
throw error;
}
const result = db.prepare(
`
UPDATE threads
SET updated_at = ?,
has_user_event = 1
WHERE id = ?
`,
).run(updatedAt, targetThreadRef);
if (Number(result?.changes ?? 0) <= 0) {
return {
status: "skipped",
reason: "thread-not-found",
};
}
}
return {
status: "updated",
updatedAt,
updatedAtMs,
};
} finally {
db.close();
}
}
async function readRolloutTail(rolloutPath) {
let handle;
try {
handle = await open(rolloutPath, "r");
const stats = await handle.stat();
const start = Math.max(0, stats.size - MAX_ROLLOUT_TAIL_BYTES);
const length = Math.max(0, stats.size - start);
if (length === 0) {
return "";
}
const buffer = Buffer.alloc(length);
await handle.read(buffer, 0, length, start);
let text = buffer.toString("utf8");
if (start > 0) {
const firstNewline = text.indexOf("\n");
text = firstNewline >= 0 ? text.slice(firstNewline + 1) : "";
}
return text;
} finally {
await handle?.close().catch(() => {});
}
}
async function rolloutAlreadyHasSourceMessage(rolloutPath, sourceMessageId) {
const tail = await readRolloutTail(rolloutPath);
if (!tail.trim()) {
return false;
}
for (const line of tail.split(/\r?\n/)) {
if (!line.trim()) {
continue;
}
try {
const parsed = JSON.parse(line);
if (parsed?.payload?.metadata?.bossSourceMessageId === sourceMessageId) {
return true;
}
} catch {
continue;
}
}
return false;
}
export async function appendBossUserMessageToCodexThreadRollout(params) {
const targetThreadRef = trimToDefined(params?.targetThreadRef);
const sourceMessageId = trimToDefined(params?.sourceMessageId);
const message = trimToDefined(params?.message);
const sentAt = trimToDefined(params?.sentAt) ?? new Date().toISOString();
if (!targetThreadRef) {
throw new Error("CODEX_THREAD_REF_REQUIRED");
}
if (!sourceMessageId) {
throw new Error("CODEX_SOURCE_MESSAGE_ID_REQUIRED");
}
if (!message) {
throw new Error("CODEX_SOURCE_MESSAGE_BODY_REQUIRED");
}
const rolloutPath = await resolveThreadRolloutPathWithFallback({
stateDbPath: params?.stateDbPath,
sessionsDir: params?.sessionsDir,
targetThreadRef,
});
if (await rolloutAlreadyHasSourceMessage(rolloutPath, sourceMessageId)) {
return {
status: "duplicate",
rolloutPath,
};
}
const responseItem = {
timestamp: sentAt,
type: "response_item",
payload: {
type: "message",
role: "user",
content: [
{
type: "input_text",
text: message,
},
],
},
};
const event = {
timestamp: sentAt,
type: "event_msg",
payload: {
type: "user_message",
message,
images: [],
local_images: [],
text_elements: [],
metadata: {
bossSourceMessageId: sourceMessageId,
bossMirroredFrom: "boss-app",
},
},
};
await appendFile(rolloutPath, `${JSON.stringify(responseItem)}\n${JSON.stringify(event)}\n`, "utf8");
let threadTouch = { status: "skipped" };
try {
threadTouch = touchThreadActivity({
stateDbPath: params?.stateDbPath,
targetThreadRef,
sentAt,
});
} catch {
threadTouch = { status: "skipped" };
}
return {
status: "written",
rolloutPath,
threadTouch,
};
}

View File

@@ -0,0 +1,275 @@
import { spawn } from "node:child_process";
import path from "node:path";
function parseBoolean(value) {
return String(value || "").trim().toLowerCase() === "true";
}
function parseArgs(value) {
return String(value || "")
.trim()
.split(/\s+/)
.filter(Boolean);
}
function parseCsv(value) {
return String(value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function parseTimeoutMs(value) {
const parsed = Number.parseInt(String(value || ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 45000;
}
function pickConfigValue(config, key, fallback) {
if (config && config[key] !== undefined && config[key] !== null && `${config[key]}`.trim() !== "") {
return config[key];
}
return fallback;
}
function resolveCommandArgs(command, args, cwd) {
const runtimeName = path.basename(command || "").toLowerCase();
const scriptRuntimes = new Set([
"node",
"node.exe",
"tsx",
"tsx.cmd",
"bun",
"bun.exe",
"deno",
"deno.exe",
]);
if (!scriptRuntimes.has(runtimeName) || args.length === 0) {
return args;
}
const [first, ...rest] = args;
if (!first || first.startsWith("-")) {
return args;
}
return [path.isAbsolute(first) ? first : path.resolve(cwd || process.cwd(), first), ...rest];
}
function parseJsonLine(rawOutput) {
const lines = String(rawOutput || "")
.trim()
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
return JSON.parse(lines.at(-1) || "");
}
export function getComputerUseTaskRunnerConfig(env = process.env, config = {}) {
const enabled = parseBoolean(pickConfigValue(config, "computerUseEnabled", env.BOSS_COMPUTER_USE_ENABLED));
const command = String(pickConfigValue(config, "computerUseCommand", env.BOSS_COMPUTER_USE_COMMAND) || "").trim() || undefined;
const args = Array.isArray(config?.computerUseArgs)
? config.computerUseArgs.map((item) => String(item)).filter(Boolean)
: parseArgs(pickConfigValue(config, "computerUseArgs", env.BOSS_COMPUTER_USE_ARGS));
const cwd = String(pickConfigValue(config, "computerUseWorkdir", env.BOSS_COMPUTER_USE_WORKDIR) || "").trim() || undefined;
const timeoutMs = parseTimeoutMs(pickConfigValue(config, "computerUseTimeoutMs", env.BOSS_COMPUTER_USE_TIMEOUT_MS));
const dialogGuardEnabled = parseBoolean(pickConfigValue(config, "dialogGuardEnabled", env.BOSS_DIALOG_GUARD_ENABLED));
const dialogGuardConsentRequired = parseBoolean(
pickConfigValue(config, "dialogGuardConsentRequired", env.BOSS_DIALOG_GUARD_CONSENT_REQUIRED),
);
const dialogGuardPlatformAdapters = Array.isArray(config?.dialogGuardPlatformAdapters)
? config.dialogGuardPlatformAdapters.map((item) => String(item).trim()).filter(Boolean)
: parseCsv(pickConfigValue(config, "dialogGuardPlatformAdapters", env.BOSS_DIALOG_GUARD_PLATFORM_ADAPTERS));
const dialogGuardMacActionCommand = String(
pickConfigValue(config, "dialogGuardMacActionCommand", env.BOSS_MAC_DIALOG_GUARD_ACTION_COMMAND) || "",
).trim();
const dialogGuardMacActionArgs = Array.isArray(config?.dialogGuardMacActionArgs)
? config.dialogGuardMacActionArgs.map((item) => String(item)).filter(Boolean)
: parseArgs(pickConfigValue(config, "dialogGuardMacActionArgs", env.BOSS_MAC_DIALOG_GUARD_ACTION_ARGS));
const dialogGuardWindowsActionCommand = String(
pickConfigValue(config, "dialogGuardWindowsActionCommand", env.BOSS_WINDOWS_DIALOG_GUARD_ACTION_COMMAND) || "",
).trim();
const dialogGuardWindowsActionArgs = Array.isArray(config?.dialogGuardWindowsActionArgs)
? config.dialogGuardWindowsActionArgs.map((item) => String(item)).filter(Boolean)
: parseArgs(pickConfigValue(config, "dialogGuardWindowsActionArgs", env.BOSS_WINDOWS_DIALOG_GUARD_ACTION_ARGS));
return {
enabled,
command,
args,
cwd,
timeoutMs,
dialogGuardEnabled,
dialogGuardConsentRequired,
dialogGuardPlatformAdapters,
dialogGuardMacActionCommand,
dialogGuardMacActionArgs,
dialogGuardWindowsActionCommand,
dialogGuardWindowsActionArgs,
};
}
export function canHandleComputerUseTask(task) {
return String(task?.taskType || "").trim() === "desktop_control";
}
export function buildComputerUseTaskExecution(config, task) {
if (!config?.enabled) {
throw new Error("COMPUTER_USE_RUNTIME_DISABLED");
}
if (!config?.command) {
throw new Error("COMPUTER_USE_COMMAND_REQUIRED");
}
const cwd = config.cwd || process.cwd();
return {
command: config.command,
args: resolveCommandArgs(config.command, config.args || [], cwd),
cwd,
timeoutMs: config.timeoutMs || 45000,
env: {
BOSS_DIALOG_GUARD_ENABLED: config.dialogGuardEnabled ? "true" : "false",
BOSS_DIALOG_GUARD_CONSENT_REQUIRED: config.dialogGuardConsentRequired ? "true" : "false",
BOSS_DIALOG_GUARD_PLATFORM_ADAPTERS: Array.isArray(config.dialogGuardPlatformAdapters)
? config.dialogGuardPlatformAdapters.join(",")
: "",
BOSS_MAC_DIALOG_GUARD_ACTION_COMMAND: config.dialogGuardMacActionCommand || "",
BOSS_MAC_DIALOG_GUARD_ACTION_ARGS_JSON: JSON.stringify(config.dialogGuardMacActionArgs || []),
BOSS_WINDOWS_DIALOG_GUARD_ACTION_COMMAND: config.dialogGuardWindowsActionCommand || "",
BOSS_WINDOWS_DIALOG_GUARD_ACTION_ARGS_JSON: JSON.stringify(config.dialogGuardWindowsActionArgs || []),
},
stdinPayload: {
requestKind: "desktop_control",
requestId: String(task?.taskId || "").trim(),
objective: String(task?.requestText || task?.executionPrompt || "").trim(),
context: {
projectId: String(task?.projectId || "").trim() || undefined,
threadId: String(task?.threadId || task?.targetThreadId || "").trim() || undefined,
requestedBy: String(task?.requestedByAccount || task?.requestedBy || "").trim() || undefined,
requestedAt: String(task?.requestedAt || "").trim() || undefined,
confirmationScopeKey: String(task?.confirmationScopeKey || "").trim() || undefined,
riskLevel: String(task?.riskLevel || "").trim() || undefined,
},
},
};
}
export function parseComputerUseTaskResult(rawOutput) {
const parsed = parseJsonLine(rawOutput);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("INVALID_COMPUTER_USE_RUNTIME_PAYLOAD");
}
if (parsed.status === "failed") {
return {
status: "failed",
requestId: typeof parsed.requestId === "string" ? parsed.requestId.trim() || undefined : undefined,
errorMessage:
typeof parsed.error === "string" && parsed.error.trim()
? parsed.error.trim()
: "COMPUTER_USE_FAILED",
};
}
if (parsed.status === "needs_user_action") {
return {
status: "needs_user_action",
requestId: typeof parsed.requestId === "string" ? parsed.requestId.trim() || undefined : undefined,
kind: typeof parsed.kind === "string" && parsed.kind.trim() ? parsed.kind.trim() : "user_action_required",
dialogId: typeof parsed.dialogId === "string" && parsed.dialogId.trim() ? parsed.dialogId.trim() : undefined,
risk: typeof parsed.risk === "string" && parsed.risk.trim() ? parsed.risk.trim() : "medium",
summary: typeof parsed.summary === "string" && parsed.summary.trim() ? parsed.summary.trim() : "",
recommendedAction:
typeof parsed.recommendedAction === "string" && parsed.recommendedAction.trim()
? parsed.recommendedAction.trim()
: undefined,
availableActions: Array.isArray(parsed.availableActions)
? parsed.availableActions.map((item) => String(item).trim()).filter(Boolean)
: [],
platform: typeof parsed.platform === "string" && parsed.platform.trim() ? parsed.platform.trim() : undefined,
appName: typeof parsed.appName === "string" && parsed.appName.trim() ? parsed.appName.trim() : undefined,
};
}
const replyBody =
typeof parsed.replyBody === "string" && parsed.replyBody.trim()
? parsed.replyBody.trim()
: typeof parsed.summary === "string" && parsed.summary.trim()
? parsed.summary.trim()
: "";
if (!replyBody) {
throw new Error("INVALID_COMPUTER_USE_RUNTIME_PAYLOAD");
}
return {
status: "completed",
requestId: typeof parsed.requestId === "string" ? parsed.requestId.trim() || undefined : undefined,
replyBody,
targetApp:
typeof parsed.targetApp === "string" && parsed.targetApp.trim()
? parsed.targetApp.trim()
: undefined,
executionSummary:
typeof parsed.executionSummary === "string" && parsed.executionSummary.trim()
? parsed.executionSummary.trim()
: undefined,
};
}
export async function executeComputerUseTask(task, config = {}) {
const runnerConfig = getComputerUseTaskRunnerConfig(process.env, config);
if (!runnerConfig.enabled) {
return {
status: "failed",
errorMessage: "COMPUTER_USE_RUNTIME_DISABLED",
};
}
const execution = buildComputerUseTaskExecution(runnerConfig, task);
return new Promise((resolve, reject) => {
const child = spawn(execution.command, execution.args, {
cwd: execution.cwd,
env: {
...process.env,
...(execution.env || {}),
},
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, execution.timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", (error) => {
clearTimeout(timer);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timer);
if (timedOut) {
reject(new Error("COMPUTER_USE_TIMEOUT"));
return;
}
if (code !== 0) {
reject(new Error(stderr.trim() || `computer use exit code ${code}`));
return;
}
try {
resolve(parseComputerUseTaskResult(stdout));
} catch (error) {
reject(error);
}
});
child.stdin.write(JSON.stringify(execution.stdinPayload));
child.stdin.end();
});
}

View File

@@ -2,13 +2,56 @@
"bindHost": "127.0.0.1",
"port": 4317,
"heartbeatIntervalMs": 15000,
"masterAgentPollIntervalMs": 3000,
"masterAgentPollIntervalMs": 1000,
"skillLifecycleEnabled": true,
"skillLifecyclePollIntervalMs": 5000,
"skillLifecycleTimeoutMs": 120000,
"skillLifecycleAllowedSources": [],
"skillLifecycleTrustedSources": {},
"controlPlaneUrl": "https://boss.hyzq.net",
"skillsDir": "/Users/kris/.codex/skills",
"masterAgentEnabled": true,
"masterAgentWorkdir": "/Users/kris/code/boss",
"masterAgentSandbox": "workspace-write",
"masterAgentModel": "gpt-5.4",
"browserAutomationConnected": true,
"computerUseConnected": true,
"browserControlEnabled": true,
"browserControlCommand": "node",
"browserControlArgs": [
"scripts/browser-control-smoke.mjs"
],
"browserControlWorkdir": "/Users/kris/code/boss",
"browserControlTimeoutMs": 45000,
"computerUseEnabled": true,
"computerUseCommand": "node",
"computerUseArgs": [
"scripts/computer-use-smoke.mjs"
],
"computerUseWorkdir": "/Users/kris/code/boss",
"computerUseTimeoutMs": 45000,
"dialogGuardEnabled": true,
"dialogGuardConsentRequired": true,
"dialogGuardPlatformAdapters": [
"darwin",
"win32"
],
"dialogGuardMacActionCommand": "",
"dialogGuardMacActionArgs": [],
"dialogGuardWindowsActionCommand": "",
"dialogGuardWindowsActionArgs": [],
"codexDesktopRefreshEnabled": true,
"codexDesktopRefreshCommand": "node",
"codexDesktopRefreshEndpoint": "http://127.0.0.1:4318/api/v1/codex-desktop/refresh",
"codexDesktopRefreshArgs": [
"scripts/codex-desktop-refresh-hint.mjs"
],
"codexDesktopRefreshWorkdir": "/Users/kris/code/boss",
"codexDesktopRefreshTimeoutMs": 3000,
"codexDesktopRefreshAppName": "Codex",
"codexDesktopRefreshMode": "deeplink-reload",
"codexDesktopRefreshRetryCount": 2,
"codexDesktopRefreshRetryDelayMs": 120,
"omxEnabled": false,
"omxCommand": "",
"omxArgs": [],
@@ -20,7 +63,7 @@
"token": "boss-mac-studio-token",
"name": "Mac Studio",
"avatar": "M",
"account": "17600003315",
"account": "krisolo",
"status": "online",
"quota5h": 68,
"quota7d": 81,

View File

@@ -2,7 +2,12 @@
"bindHost": "127.0.0.1",
"port": 4317,
"heartbeatIntervalMs": 15000,
"masterAgentPollIntervalMs": 3000,
"masterAgentPollIntervalMs": 1000,
"skillLifecycleEnabled": true,
"skillLifecyclePollIntervalMs": 5000,
"skillLifecycleTimeoutMs": 120000,
"skillLifecycleAllowedSources": [],
"skillLifecycleTrustedSources": {},
"controlPlaneUrl": "http://127.0.0.1:3000",
"skillsDir": "/Users/kris/.codex/skills",
"masterAgentEnabled": true,
@@ -11,6 +16,44 @@
"masterAgentModel": "gpt-5.4",
"preferredExecutionMode": "cli",
"guiConnected": false,
"browserAutomationConnected": true,
"computerUseConnected": true,
"browserControlEnabled": true,
"browserControlCommand": "node",
"browserControlArgs": [
"scripts/browser-control-smoke.mjs"
],
"browserControlWorkdir": "/Users/kris/code/boss",
"browserControlTimeoutMs": 45000,
"computerUseEnabled": true,
"computerUseCommand": "node",
"computerUseArgs": [
"scripts/computer-use-smoke.mjs"
],
"computerUseWorkdir": "/Users/kris/code/boss",
"computerUseTimeoutMs": 45000,
"dialogGuardEnabled": true,
"dialogGuardConsentRequired": true,
"dialogGuardPlatformAdapters": [
"darwin",
"win32"
],
"dialogGuardMacActionCommand": "",
"dialogGuardMacActionArgs": [],
"dialogGuardWindowsActionCommand": "",
"dialogGuardWindowsActionArgs": [],
"codexDesktopRefreshEnabled": true,
"codexDesktopRefreshCommand": "node",
"codexDesktopRefreshEndpoint": "http://127.0.0.1:4318/api/v1/codex-desktop/refresh",
"codexDesktopRefreshArgs": [
"scripts/codex-desktop-refresh-hint.mjs"
],
"codexDesktopRefreshWorkdir": "/Users/kris/code/boss",
"codexDesktopRefreshTimeoutMs": 3000,
"codexDesktopRefreshAppName": "Codex",
"codexDesktopRefreshMode": "deeplink-reload",
"codexDesktopRefreshRetryCount": 2,
"codexDesktopRefreshRetryDelayMs": 120,
"omxEnabled": false,
"omxCommand": "",
"omxArgs": [],
@@ -22,7 +65,7 @@
"token": "boss-mac-studio-token",
"name": "Mac Studio",
"avatar": "M",
"account": "17600003315",
"account": "krisolo",
"status": "online",
"quota5h": 68,
"quota7d": 81,

View File

@@ -0,0 +1,202 @@
import { createHash } from "node:crypto";
const SAFE_DISMISS_BUTTONS = [
"稍后",
"跳过",
"以后再说",
"不,谢谢",
"Not now",
"Skip",
"Later",
"Cancel",
"Maybe later",
"No thanks",
];
const BLOCKED_TEXT_PATTERNS = [
/screen recording/i,
/accessibility/i,
/input monitoring/i,
/full disk access/i,
/keychain/i,
/administrator/i,
/apple id/i,
/user account control/i,
/make changes to your device/i,
/屏幕录制/,
/辅助功能/,
/输入监控/,
/完整磁盘访问/,
/钥匙串/,
/管理员密码/,
/用户帐户控制/,
/用户账户控制/,
];
export function normalizeDialogText(value) {
return String(value || "").replace(/\s+/g, " ").trim();
}
export function normalizeDialogSnapshot(input = {}) {
const buttons = Array.isArray(input.buttons)
? input.buttons.map(normalizeDialogText).filter(Boolean)
: [];
const appName = normalizeDialogText(input.appName || input.app || "Unknown App");
return {
platform: normalizeDialogText(input.platform || process.platform || "unknown"),
deviceId: normalizeDialogText(input.deviceId || "unknown-device"),
appName,
appBundleId: normalizeDialogText(input.appBundleId || input.appId || appName || "unknown-app"),
title: normalizeDialogText(input.title),
text: normalizeDialogText(input.text),
buttons,
raw: input.raw,
};
}
function parseSnapshotJson(raw, sourceName) {
const value = String(raw || "").trim();
if (!value) {
return undefined;
}
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`INVALID_DIALOG_GUARD_SNAPSHOT:${sourceName}`);
}
return parsed;
}
export function readDialogSnapshotFromEnv(env = process.env, platform = process.platform) {
const normalizedPlatform = normalizeDialogText(platform || process.platform || "unknown");
const platformSnapshotKey =
normalizedPlatform === "darwin"
? "BOSS_MAC_DIALOG_GUARD_SNAPSHOT_JSON"
: normalizedPlatform === "win32"
? "BOSS_WINDOWS_DIALOG_GUARD_SNAPSHOT_JSON"
: "";
const parsed =
parseSnapshotJson(env.BOSS_DIALOG_GUARD_SNAPSHOT_JSON, "BOSS_DIALOG_GUARD_SNAPSHOT_JSON") ||
(platformSnapshotKey ? parseSnapshotJson(env[platformSnapshotKey], platformSnapshotKey) : undefined);
if (!parsed) {
return undefined;
}
return normalizeDialogSnapshot({
...parsed,
platform: parsed.platform || normalizedPlatform,
});
}
function hash(value) {
return createHash("sha256").update(String(value || "")).digest("hex").slice(0, 16);
}
export function createDialogSignature(snapshotInput = {}) {
const snapshot = normalizeDialogSnapshot(snapshotInput);
const titleHash = hash(snapshot.title.toLowerCase());
const textHash = hash(snapshot.text.toLowerCase());
const buttonHash = hash(snapshot.buttons.join("|").toLowerCase());
return {
id: hash([
snapshot.platform,
snapshot.deviceId,
snapshot.appBundleId,
titleHash,
textHash,
buttonHash,
].join("|")),
scopeKey: [snapshot.platform, snapshot.deviceId, snapshot.appBundleId].join(":"),
platform: snapshot.platform,
deviceId: snapshot.deviceId,
appBundleId: snapshot.appBundleId,
titleHash,
textHash,
buttonHash,
};
}
function textMatchesAny(text, patterns) {
return patterns.some((pattern) => pattern.test(text));
}
function findSafeDismissButton(buttons) {
return buttons.find((button) =>
SAFE_DISMISS_BUTTONS.some((candidate) => candidate.toLowerCase() === button.toLowerCase()),
);
}
function isBlockedPrompt(snapshot) {
const combined = `${snapshot.title} ${snapshot.text}`;
return textMatchesAny(combined, BLOCKED_TEXT_PATTERNS);
}
export function evaluateDialogSnapshot(snapshotInput = {}) {
const snapshot = normalizeDialogSnapshot(snapshotInput);
const signature = createDialogSignature(snapshot);
if (isBlockedPrompt(snapshot)) {
return {
disposition: "needs_user_action",
kind: "permission_required",
risk: "high",
action: "pause_for_user",
signature,
};
}
const safeButton = findSafeDismissButton(snapshot.buttons);
if (safeButton) {
return {
disposition: "auto_action",
kind: "safe_dismiss",
risk: "low",
action: "click_button",
button: safeButton,
signature,
};
}
return {
disposition: "needs_user_action",
kind: "unknown_dialog",
risk: "medium",
action: "pause_for_user",
signature,
};
}
export function buildDialogAuditEntry({ requestId, snapshot: snapshotInput, decision, handledAt = new Date().toISOString() }) {
const snapshot = normalizeDialogSnapshot(snapshotInput);
const signature = decision?.signature || createDialogSignature(snapshot);
return {
kind: "desktop_dialog_guard",
requestId: requestId || undefined,
handledAt,
platform: snapshot.platform,
appName: snapshot.appName,
dialogId: signature.id,
risk: decision?.risk || "medium",
disposition: decision?.disposition || "unknown",
action: decision?.action || "pause_for_user",
button: decision?.button || undefined,
policyKind: decision?.kind || "unknown_dialog",
};
}
export function buildDialogInterventionResult({ requestId, snapshot: snapshotInput, decision }) {
const snapshot = normalizeDialogSnapshot(snapshotInput);
const signature = decision?.signature || createDialogSignature(snapshot);
const blocked = decision?.risk === "high";
return {
status: "needs_user_action",
requestId: requestId || undefined,
kind: "dialog_intervention_required",
dialogId: signature.id,
risk: decision?.risk || "medium",
summary: `${snapshot.appName} 弹窗需要确认:${snapshot.title || snapshot.text || "未知弹窗"}`,
recommendedAction: blocked ? "handled_on_device" : "allow_once",
availableActions: blocked
? ["handled_on_device", "cancel_task"]
: ["allow_once", "allow_for_device_dialog", "deny"],
platform: snapshot.platform,
appName: snapshot.appName,
};
}

View File

@@ -0,0 +1,71 @@
export const MASTER_CODEX_NODE_OUTPUT_LEAKED = "MASTER_CODEX_NODE_OUTPUT_LEAKED";
const EXECUTION_PROMPT_SECTION_LABELS = [
"管理员全局主提示词:",
"用户私有主提示词:",
"当前对话附加提示词:",
"当前消息:",
"项目记忆:",
"用户通用记忆:",
];
function trimToDefined(value) {
const trimmed = String(value ?? "").trim();
return trimmed ? trimmed : undefined;
}
export function looksLikeCodexCliEnvelopeLeak(value) {
const text = trimToDefined(value);
if (!text) {
return false;
}
const hasCodexHeader = /OpenAI Codex v[\d.]+/i.test(text);
const hasExecutionMetadata =
/^workdir:\s+/m.test(text) &&
/^model:\s+/m.test(text) &&
/^provider:\s+/m.test(text);
const hasRuntimePolicy = /^approval:\s+/m.test(text) || /^sandbox:\s+/m.test(text);
const hasSessionOrMcp = /^session id:\s+/m.test(text) || /^mcp:\s+/m.test(text);
return hasCodexHeader && hasExecutionMetadata && hasRuntimePolicy && hasSessionOrMcp;
}
export function looksLikeExecutionPromptLeak(value) {
const text = trimToDefined(value);
if (!text) {
return false;
}
const sectionHitCount = EXECUTION_PROMPT_SECTION_LABELS.filter((label) => text.includes(label)).length;
if (sectionHitCount >= 2) {
return true;
}
return (
text.includes("管理员全局主提示词") &&
text.includes("系统级最高约束") &&
text.includes("不可被用户私有提示词")
);
}
export function shouldBlockSensitiveMasterAgentOutput(value) {
return looksLikeCodexCliEnvelopeLeak(value) || looksLikeExecutionPromptLeak(value);
}
export function sanitizeSensitiveTaskFailureDetailForTransport(value) {
const text = trimToDefined(value);
if (!text) {
return undefined;
}
return shouldBlockSensitiveMasterAgentOutput(text) ? MASTER_CODEX_NODE_OUTPUT_LEAKED : text;
}
export function sanitizeSensitiveTaskFailureDetailForLog(value) {
const text = trimToDefined(value);
if (!text) {
return undefined;
}
if (!shouldBlockSensitiveMasterAgentOutput(text)) {
return text;
}
return "已拦截内部执行日志,原始内容不再展示。";
}

View File

@@ -0,0 +1,68 @@
const DEFAULT_PROJECT_UNDERSTANDING_TIMEOUT_MS = 8 * 60 * 1000;
const DEFAULT_MASTER_REPLY_TIMEOUT_MS = 15 * 60 * 1000;
const DEFAULT_THREAD_EXECUTION_TIMEOUT_MS = 30 * 60 * 1000;
function normalizeTimeoutMs(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return Math.round(parsed);
}
export function resolveMasterAgentTaskTimeoutMs(config = {}, task = {}) {
if (task?.projectUnderstandingTargetProjectId) {
return (
normalizeTimeoutMs(config.projectUnderstandingTaskTimeoutMs) ??
DEFAULT_PROJECT_UNDERSTANDING_TIMEOUT_MS
);
}
if (
task?.taskType === "dispatch_execution" ||
(task?.taskType === "conversation_reply" && task?.projectId !== "master-agent")
) {
return (
normalizeTimeoutMs(config.threadExecutionTaskTimeoutMs) ??
DEFAULT_THREAD_EXECUTION_TIMEOUT_MS
);
}
return normalizeTimeoutMs(config.masterAgentReplyTimeoutMs) ?? DEFAULT_MASTER_REPLY_TIMEOUT_MS;
}
export async function runWithTaskTimeout({ timeoutMs, label, onTimeout }, operation) {
const normalizedTimeoutMs = normalizeTimeoutMs(timeoutMs);
if (!normalizedTimeoutMs) {
return await operation();
}
return await new Promise((resolve, reject) => {
let settled = false;
const finish = (callback, value) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
callback(value);
};
const timer = setTimeout(async () => {
try {
await onTimeout?.();
} catch {
// Timeout cleanup is best-effort. The caller still gets a timeout error.
}
finish(
reject,
new Error(`${label || "master_agent_task"} exceeded timeout after ${normalizedTimeoutMs}ms`),
);
}, normalizedTimeoutMs);
Promise.resolve()
.then(operation)
.then((value) => finish(resolve, value))
.catch((error) => finish(reject, error));
});
}

View File

@@ -0,0 +1,77 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveMasterAgentTaskTimeoutMs, runWithTaskTimeout } from "./master-task-timeout.mjs";
test("resolveMasterAgentTaskTimeoutMs prefers short timeout for project understanding sync", () => {
const timeoutMs = resolveMasterAgentTaskTimeoutMs(
{},
{
taskType: "conversation_reply",
projectId: "master-agent",
projectUnderstandingTargetProjectId: "project-1",
},
);
assert.equal(timeoutMs, 8 * 60 * 1000);
});
test("resolveMasterAgentTaskTimeoutMs uses thread execution timeout for child thread work", () => {
const timeoutMs = resolveMasterAgentTaskTimeoutMs(
{},
{
taskType: "conversation_reply",
projectId: "project-1",
},
);
assert.equal(timeoutMs, 30 * 60 * 1000);
});
test("resolveMasterAgentTaskTimeoutMs honors config overrides", () => {
const timeoutMs = resolveMasterAgentTaskTimeoutMs(
{
projectUnderstandingTaskTimeoutMs: 1234,
masterAgentReplyTimeoutMs: 2345,
threadExecutionTaskTimeoutMs: 3456,
},
{
taskType: "conversation_reply",
projectId: "master-agent",
projectUnderstandingTargetProjectId: "project-1",
},
);
assert.equal(timeoutMs, 1234);
});
test("runWithTaskTimeout resolves before timeout when operation finishes", async () => {
const result = await runWithTaskTimeout(
{
timeoutMs: 100,
label: "quick-task",
},
async () => "ok",
);
assert.equal(result, "ok");
});
test("runWithTaskTimeout rejects and invokes timeout cleanup when operation hangs", async () => {
let timeoutCleanupCalled = 0;
await assert.rejects(
runWithTaskTimeout(
{
timeoutMs: 20,
label: "hung-task",
onTimeout: async () => {
timeoutCleanupCalled += 1;
},
},
async () => await new Promise(() => {}),
),
/hung-task exceeded timeout after 20ms/,
);
assert.equal(timeoutCleanupCalled, 1);
});

View File

@@ -7,11 +7,37 @@ import os from "node:os";
import { join, resolve } from "node:path";
import { discoverCodexProjectCandidatesInWorker } from "./codex-session-discovery.mjs";
import { prepareCodexTaskExecution } from "./codex-task-runner.mjs";
import { appendBossUserMessageToCodexThreadRollout } from "./codex-thread-rollout-writer.mjs";
import {
executeOmxTeamTask,
getOmxTeamTaskRunnerConfig,
shouldUseOmxTeamTaskRunner,
} from "./omx-team-task-runner.mjs";
import {
canHandleBrowserControlTask,
executeBrowserControlTask,
getBrowserControlTaskRunnerConfig,
} from "./browser-control-task-runner.mjs";
import {
canHandleComputerUseTask,
executeComputerUseTask,
getComputerUseTaskRunnerConfig,
} from "./computer-use-task-runner.mjs";
import {
executeCodexDesktopRefreshBridge,
} from "./codex-desktop-refresh-bridge.mjs";
import {
executeSkillLifecycleRequest,
getSkillLifecycleRunnerConfig,
} from "./skill-lifecycle-runner.mjs";
import {
sanitizeSensitiveTaskFailureDetailForLog,
sanitizeSensitiveTaskFailureDetailForTransport,
} from "./master-task-output-sanitizer.mjs";
import {
resolveMasterAgentTaskTimeoutMs,
runWithTaskTimeout,
} from "./master-task-timeout.mjs";
import { createSerializedRunner } from "./serialized-runner.mjs";
async function loadConfig(configPath) {
@@ -47,9 +73,11 @@ async function resolveHeartbeatProjects(config, runtime) {
runtime.lastProjectDiscoveryAt = new Date().toISOString();
runtime.lastProjectDiscoveryOk = true;
runtime.lastProjectDiscoverySummary = `${mergedCandidates.length} threads / ${mergedProjects.length} folders`;
runtime.lastCodexGuiConnected = discovered.guiConnected === true;
return {
projects: mergedProjects,
projectCandidates: mergedCandidates,
guiConnected: discovered.guiConnected === true,
};
} catch (error) {
runtime.lastProjectDiscoveryAt = new Date().toISOString();
@@ -65,6 +93,7 @@ async function resolveHeartbeatProjects(config, runtime) {
return {
projects: staticProjects,
projectCandidates: staticCandidates,
guiConnected: false,
};
}
}
@@ -75,6 +104,11 @@ async function postHeartbeat(config, runtime, heartbeatProjects) {
config.preferredExecutionMode === "gui" || config.preferredExecutionMode === "cli"
? config.preferredExecutionMode
: undefined;
const browserControlRuntime = getBrowserControlTaskRunnerConfig(process.env, config);
const computerUseRuntime = getComputerUseTaskRunnerConfig(process.env, config);
const guiConnected =
config.guiConnected === true ||
(config.guiConnected !== false && heartbeatProjects.guiConnected === true);
const response = await fetch(`${config.controlPlaneUrl.replace(/\/$/, "")}/api/device-heartbeat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -90,7 +124,7 @@ async function postHeartbeat(config, runtime, heartbeatProjects) {
quota7d: config.quota7d,
capabilities: {
gui: {
connected: Boolean(config.guiConnected),
connected: guiConnected,
lastSeenAt: now,
lastActiveProjectId: "",
},
@@ -99,6 +133,16 @@ async function postHeartbeat(config, runtime, heartbeatProjects) {
lastSeenAt: now,
lastActiveProjectId: "",
},
browserAutomation: {
connected: config.browserAutomationConnected !== false || Boolean(browserControlRuntime.enabled && browserControlRuntime.command),
lastSeenAt: now,
lastActiveProjectId: "",
},
computerUse: {
connected: Boolean(config.computerUseConnected) || Boolean(computerUseRuntime.enabled && computerUseRuntime.command),
lastSeenAt: now,
lastActiveProjectId: "",
},
},
preferredExecutionMode,
projects: heartbeatProjects.projects,
@@ -311,7 +355,54 @@ async function completeMasterAgentTask(config, runtime, payload) {
dispatchExecutionId: payload.dispatchExecutionId,
targetProjectId: payload.targetProjectId,
targetThreadId: payload.targetThreadId,
targetUrl: payload.targetUrl,
targetApp: payload.targetApp,
rawThreadReply: payload.rawThreadReply,
executionProgress: payload.executionProgress,
}),
},
);
return {
ok: response.ok,
status: response.status,
body: await response.text(),
};
}
async function claimSkillLifecycleRequest(config, runtime) {
const response = await fetch(
`${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/devices/${config.deviceId}/skill-requests/claim`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
...deviceTokenHeaders(config, runtime),
},
body: JSON.stringify({ deviceId: config.deviceId }),
},
);
return {
ok: response.ok,
status: response.status,
body: await response.text(),
};
}
async function completeSkillLifecycleRequest(config, runtime, request, result) {
const response = await fetch(
`${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/devices/${config.deviceId}/skill-requests/${request.requestId}/complete`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
...deviceTokenHeaders(config, runtime),
},
body: JSON.stringify({
status: result.status === "failed" ? "failed" : "completed",
resultSummary: result.resultSummary,
error: result.error,
}),
},
);
@@ -372,14 +463,108 @@ function buildRemoteExecutionCompletionPayload(task, payload) {
typeof payload.targetProjectId === "string" ? payload.targetProjectId.trim() || undefined : undefined,
targetThreadId:
typeof payload.targetThreadId === "string" ? payload.targetThreadId.trim() || undefined : undefined,
targetUrl:
typeof payload.targetUrl === "string" ? payload.targetUrl.trim() || undefined : undefined,
targetApp:
typeof payload.targetApp === "string" ? payload.targetApp.trim() || undefined : undefined,
rawThreadReply:
typeof payload.rawThreadReply === "string" ? payload.rawThreadReply.trim() || undefined : undefined,
executionProgress:
payload.executionProgress && typeof payload.executionProgress === "object"
? payload.executionProgress
: undefined,
};
}
function runShortCommand(command, args, options = {}) {
return new Promise((resolve) => {
const child = spawn(command, args, {
cwd: options.cwd || process.cwd(),
env: process.env,
});
let stdout = "";
let stderr = "";
const timeout = setTimeout(() => {
if (!child.killed) {
child.kill("SIGKILL");
}
}, options.timeoutMs || 1500);
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", (error) => {
clearTimeout(timeout);
resolve({ ok: false, stdout, stderr: error.message });
});
child.on("close", (code) => {
clearTimeout(timeout);
resolve({
ok: code === 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
});
});
});
}
function parseGitShortstat(shortstat) {
const text = String(shortstat || "");
const changedFiles = Number((text.match(/(\d+)\s+files?\s+changed/) || [])[1]);
const additions = Number((text.match(/(\d+)\s+insertions?\(\+\)/) || [])[1]);
const deletions = Number((text.match(/(\d+)\s+deletions?\(-\)/) || [])[1]);
return {
changedFiles: Number.isFinite(changedFiles) ? changedFiles : undefined,
additions: Number.isFinite(additions) ? additions : undefined,
deletions: Number.isFinite(deletions) ? deletions : undefined,
};
}
function collectArtifactsFromReply(text) {
const matches = new Set();
const source = String(text || "");
const pattern = /(?:[\w.-]+\/)*[\w.-]+\.(?:md|txt|ts|tsx|js|mjs|java|kt|json|png|jpe?g|webp|svg|apk|aab)\b/gi;
let match;
while ((match = pattern.exec(source)) && matches.size < 12) {
const label = match[0].split("/").filter(Boolean).pop();
if (label) {
matches.add(label);
}
}
return Array.from(matches).map((label) => ({
label,
kind: /\.(png|jpe?g|webp|svg)$/i.test(label) ? "image" : "file",
}));
}
async function collectLocalExecutionProgress(cwd, replyBody) {
const [diffShortstat, statusShort, ghVersion] = await Promise.all([
runShortCommand("git", ["diff", "--shortstat"], { cwd }),
runShortCommand("git", ["status", "--short"], { cwd }),
runShortCommand("gh", ["--version"], { cwd }),
]);
const parsedDiff = diffShortstat.ok ? parseGitShortstat(diffShortstat.stdout) : {};
const hasGitState = diffShortstat.ok || statusShort.ok;
return {
branch: hasGitState
? {
...parsedDiff,
gitStatus: statusShort.ok && statusShort.stdout ? "有未提交变更" : "工作区干净",
githubCliStatus: ghVersion.ok ? "available" : "unavailable",
}
: {
githubCliStatus: ghVersion.ok ? "available" : "unavailable",
},
artifacts: collectArtifactsFromReply(replyBody),
};
}
async function runMasterAgentTask(config, runtime, task) {
const outputFile = join(os.tmpdir(), `${task.taskId}.reply.txt`);
const stderrChunks = [];
const taskTimeoutMs = resolveMasterAgentTaskTimeoutMs(config, task);
runtime.activeMasterTask = {
taskId: task.taskId,
status: "running",
@@ -387,51 +572,142 @@ async function runMasterAgentTask(config, runtime, task) {
};
try {
let replyBody;
let dispatchExecutionCompletion = null;
if (shouldUseOmxTeamTaskRunner(task)) {
const omxResult = await executeOmxTeamTask(getOmxTeamTaskRunnerConfig(process.env, config), task);
if (omxResult.status === "failed") {
throw new Error(omxResult.errorMessage || "OMX_EXECUTION_FAILED");
let activeChild = null;
const executionResult = await (async () => {
if (canHandleBrowserControlTask(task)) {
const browserResult = await executeBrowserControlTask(task, config);
if (browserResult.status === "failed") {
throw new Error(browserResult.errorMessage || "BROWSER_CONTROL_FAILED");
}
return {
replyBody: browserResult.replyBody,
dispatchExecutionCompletion: {
targetUrl: browserResult.targetUrl,
},
};
}
replyBody = omxResult.replyBody ?? omxResult.rawThreadReply;
dispatchExecutionCompletion = {
rawThreadReply: omxResult.rawThreadReply,
replyBody: omxResult.replyBody,
};
} else {
if (canHandleComputerUseTask(task)) {
const computerUseResult = await executeComputerUseTask(task, config);
if (computerUseResult.status === "failed") {
throw new Error(computerUseResult.errorMessage || "COMPUTER_USE_FAILED");
}
return {
replyBody: computerUseResult.replyBody,
dispatchExecutionCompletion: {
targetApp: computerUseResult.targetApp,
},
};
}
if (shouldUseOmxTeamTaskRunner(task)) {
const omxResult = await executeOmxTeamTask(getOmxTeamTaskRunnerConfig(process.env, config), task);
if (omxResult.status === "failed") {
throw new Error(omxResult.errorMessage || "OMX_EXECUTION_FAILED");
}
return {
replyBody: omxResult.replyBody ?? omxResult.rawThreadReply,
dispatchExecutionCompletion: {
rawThreadReply: omxResult.rawThreadReply,
replyBody: omxResult.replyBody,
},
};
}
const codexPreparation = await prepareCodexTaskExecution(config, task, outputFile);
if (!codexPreparation.ok) {
throw new Error(codexPreparation.error.message);
}
const codexExecution = codexPreparation.execution;
await new Promise((resolveTask, rejectTask) => {
const child = spawn("codex", codexExecution.args, {
cwd: codexExecution.cwd,
env: process.env,
if (codexExecution.desktopMirror?.enabled) {
const mirrorResult = await appendBossUserMessageToCodexThreadRollout({
stateDbPath: config.codexStateDbPath,
sessionsDir: config.codexSessionsDir,
targetThreadRef: codexExecution.desktopMirror.targetThreadRef,
sourceMessageId: codexExecution.desktopMirror.sourceMessageId,
message: codexExecution.desktopMirror.sourceMessageBody,
sentAt: codexExecution.desktopMirror.sourceMessageSentAt,
});
child.stderr.on("data", (chunk) => {
stderrChunks.push(String(chunk));
});
child.on("error", rejectTask);
child.on("close", (code) => {
if (code === 0) {
resolveTask();
return;
try {
const refreshResult = await executeCodexDesktopRefreshBridge(
{
targetThreadRef: codexExecution.desktopMirror.targetThreadRef,
sourceMessageId: codexExecution.desktopMirror.sourceMessageId,
rolloutPath: mirrorResult.rolloutPath,
threadTouchStatus: mirrorResult.threadTouch?.status,
},
config,
);
if (refreshResult.status === "failed") {
await postAppLog(config, runtime, {
projectId: task.projectId,
level: "warn",
category: "local_agent.codex_desktop_refresh_failed",
message: "Codex 桌面刷新提示未完成,消息已写入线程记录。",
detail: refreshResult.detail,
mirrorToMaster: false,
});
}
rejectTask(new Error(stderrChunks.join("").trim() || `codex exit code ${code}`));
});
});
} catch (error) {
await postAppLog(config, runtime, {
projectId: task.projectId,
level: "warn",
category: "local_agent.codex_desktop_refresh_failed",
message: "Codex 桌面刷新提示执行失败,消息已写入线程记录。",
detail: error instanceof Error ? error.message : String(error),
mirrorToMaster: false,
});
}
}
await runWithTaskTimeout(
{
timeoutMs: taskTimeoutMs,
label: `master task ${task.taskId}`,
onTimeout: async () => {
if (activeChild && !activeChild.killed) {
activeChild.kill("SIGKILL");
}
},
},
async () =>
await new Promise((resolveTask, rejectTask) => {
const child = spawn("codex", codexExecution.args, {
cwd: codexExecution.cwd,
env: process.env,
});
activeChild = child;
replyBody = (await readFile(outputFile, "utf8")).trim();
dispatchExecutionCompletion =
task.taskType === "dispatch_execution"
? parseDispatchExecutionCompletion(replyBody)
: null;
}
child.stderr.on("data", (chunk) => {
stderrChunks.push(String(chunk));
});
child.on("error", (error) => {
activeChild = null;
rejectTask(error);
});
child.on("close", (code) => {
activeChild = null;
if (code === 0) {
resolveTask();
return;
}
rejectTask(new Error(stderrChunks.join("").trim() || `codex exit code ${code}`));
});
}),
);
const replyBody = (await readFile(outputFile, "utf8")).trim();
const executionProgress = await collectLocalExecutionProgress(codexExecution.cwd, replyBody);
return {
replyBody,
executionProgress,
dispatchExecutionCompletion:
task.taskType === "dispatch_execution"
? parseDispatchExecutionCompletion(replyBody)
: null,
};
})();
const { replyBody, dispatchExecutionCompletion, executionProgress } = executionResult;
const completion = await completeMasterAgentTask(
config,
@@ -442,7 +718,10 @@ async function runMasterAgentTask(config, runtime, task) {
dispatchExecutionId: task.dispatchExecutionId,
targetProjectId: task.targetProjectId,
targetThreadId: task.targetThreadId,
targetUrl: dispatchExecutionCompletion?.targetUrl,
targetApp: dispatchExecutionCompletion?.targetApp,
rawThreadReply: dispatchExecutionCompletion?.rawThreadReply,
executionProgress,
}),
);
runtime.activeMasterTask = {
@@ -461,18 +740,20 @@ async function runMasterAgentTask(config, runtime, task) {
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
const transportDetail = sanitizeSensitiveTaskFailureDetailForTransport(detail);
const logDetail = sanitizeSensitiveTaskFailureDetailForLog(detail);
runtime.activeMasterTask = {
taskId: task.taskId,
status: "failed",
completedAt: new Date().toISOString(),
detail,
detail: logDetail ?? transportDetail ?? "MASTER_AGENT_TASK_FAILED",
};
await completeMasterAgentTask(
config,
runtime,
buildRemoteExecutionCompletionPayload(task, {
status: "failed",
errorMessage: detail,
errorMessage: transportDetail,
dispatchExecutionId: task.dispatchExecutionId,
targetProjectId: task.targetProjectId,
targetThreadId: task.targetThreadId,
@@ -483,7 +764,7 @@ async function runMasterAgentTask(config, runtime, task) {
level: "error",
category: "local_agent.master_agent_task_failed",
message: `Master Codex Node 执行主 Agent 任务失败:${task.taskId}`,
detail,
detail: logDetail,
mirrorToMaster: true,
});
} finally {
@@ -532,6 +813,82 @@ async function pollMasterAgentTasks(config, runtime) {
}
}
async function pollSkillLifecycleRequests(config, runtime) {
const runnerConfig = getSkillLifecycleRunnerConfig(process.env, config);
if (!runnerConfig.enabled || runtime.skillLifecycleBusy) {
return;
}
try {
const claim = await claimSkillLifecycleRequest(config, runtime);
runtime.lastSkillLifecyclePoll = {
at: new Date().toISOString(),
ok: claim.ok,
status: claim.status,
body: claim.body,
};
if (!claim.ok) {
return;
}
const parsed = JSON.parse(claim.body);
if (!parsed.request) {
return;
}
runtime.skillLifecycleBusy = true;
runtime.activeSkillLifecycleRequest = {
requestId: parsed.request.requestId,
action: parsed.request.action,
status: "running",
startedAt: new Date().toISOString(),
};
let result = await executeSkillLifecycleRequest(parsed.request, config, runtime);
if (result.status !== "failed") {
const skills = await discoverSkills(config);
runtime.lastSkills = skills;
const skillSyncResult = await postSkills(config, runtime, skills);
runtime.lastSkillSyncAt = new Date().toISOString();
runtime.lastSkillSyncOk = skillSyncResult.ok;
runtime.lastSkillSyncStatus = skillSyncResult.status;
runtime.lastSkillSyncBody = skillSyncResult.body;
if (!skillSyncResult.ok) {
result = {
status: "failed",
error: `SKILL_SYNC_FAILED:${skillSyncResult.status}:${skillSyncResult.body}`,
};
}
}
const completion = await completeSkillLifecycleRequest(config, runtime, parsed.request, result);
runtime.activeSkillLifecycleRequest = {
requestId: parsed.request.requestId,
action: parsed.request.action,
status: completion.ok ? result.status : "complete_failed",
completedAt: new Date().toISOString(),
detail: completion.body,
};
await postAppLog(config, runtime, {
level: result.status === "failed" ? "error" : "info",
category: result.status === "failed"
? "local_agent.skill_lifecycle_failed"
: "local_agent.skill_lifecycle_completed",
message: `Skill 远程治理任务${result.status === "failed" ? "失败" : "完成"}${parsed.request.action}`,
detail: result.resultSummary ?? result.error,
mirrorToMaster: result.status === "failed",
});
} catch (error) {
runtime.lastSkillLifecyclePoll = {
at: new Date().toISOString(),
ok: false,
status: 0,
body: error instanceof Error ? error.message : String(error),
};
} finally {
runtime.skillLifecycleBusy = false;
}
}
const configPath = process.argv[2];
if (!configPath) {
console.error("Usage: node local-agent/server.mjs <config.json>");
@@ -556,6 +913,9 @@ const runtime = {
masterTaskBusy: false,
activeMasterTask: null,
lastMasterTaskPoll: null,
skillLifecycleBusy: false,
activeSkillLifecycleRequest: null,
lastSkillLifecyclePoll: null,
lastProjectDiscoveryAt: null,
lastProjectDiscoveryOk: false,
lastProjectDiscoverySummary: null,
@@ -636,6 +996,12 @@ async function performHeartbeat() {
}
const heartbeat = createSerializedRunner(performHeartbeat);
const masterTaskPoll = createSerializedRunner(async () => {
await pollMasterAgentTasks(config, runtime);
});
const skillLifecyclePoll = createSerializedRunner(async () => {
await pollSkillLifecycleRequests(config, runtime);
});
const server = createServer(async (request, response) => {
if (request.url === "/health") {
@@ -699,7 +1065,8 @@ server.listen(config.port, config.bindHost, () => {
void (async () => {
await heartbeat();
await pollMasterAgentTasks(config, runtime);
await masterTaskPoll();
await skillLifecyclePoll();
})();
setInterval(() => {
@@ -707,5 +1074,9 @@ setInterval(() => {
}, config.heartbeatIntervalMs ?? 15000);
setInterval(() => {
void pollMasterAgentTasks(config, runtime);
}, config.masterAgentPollIntervalMs ?? 3000);
void masterTaskPoll();
}, config.masterAgentPollIntervalMs ?? 1000);
setInterval(() => {
void skillLifecyclePoll();
}, config.skillLifecyclePollIntervalMs ?? 5000);

View File

@@ -0,0 +1,425 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import { basename, dirname, join, relative, resolve } from "node:path";
function trimToDefined(value) {
const trimmed = String(value ?? "").trim();
return trimmed ? trimmed : undefined;
}
function parseSourceList(value) {
if (Array.isArray(value)) {
return value.map(trimToDefined).filter(Boolean);
}
if (typeof value === "string") {
return value.split(",").map(trimToDefined).filter(Boolean);
}
return [];
}
function parseTrustedSources(value) {
if (!value) {
return {};
}
if (typeof value === "object" && !Array.isArray(value)) {
return Object.fromEntries(
Object.entries(value)
.map(([key, source]) => [trimToDefined(key), trimToDefined(source)])
.filter(([key, source]) => key && source),
);
}
if (typeof value === "string") {
try {
return parseTrustedSources(JSON.parse(value));
} catch {
return {};
}
}
return {};
}
export function getSkillLifecycleRunnerConfig(env = process.env, config = {}) {
const enabledValue = config.skillLifecycleEnabled ?? env.BOSS_SKILL_LIFECYCLE_ENABLED;
const enabled = enabledValue === undefined ? true : enabledValue !== false && enabledValue !== "false";
const skillsDir = resolve(
trimToDefined(config.skillsDir || env.BOSS_SKILLS_DIR) ?? join(os.homedir(), ".codex", "skills"),
);
const timeoutMs = Number(config.skillLifecycleTimeoutMs ?? env.BOSS_SKILL_LIFECYCLE_TIMEOUT_MS ?? 120_000);
const allowedSources = parseSourceList(
config.skillLifecycleAllowedSources ?? env.BOSS_SKILL_LIFECYCLE_ALLOWED_SOURCES,
);
const trustedSources = parseTrustedSources(
config.skillLifecycleTrustedSources ?? env.BOSS_SKILL_LIFECYCLE_TRUSTED_SOURCES,
);
return {
enabled,
skillsDir,
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 120_000,
allowedSources,
trustedSources,
};
}
export function slugifySkillName(value) {
return String(value || "")
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48) || "skill";
}
function isInside(parent, child) {
const diff = relative(resolve(parent), resolve(child));
return diff === "" || (!diff.startsWith("..") && !diff.startsWith("/"));
}
function assertInsideSkillsDir(skillsDir, targetPath) {
if (!isInside(skillsDir, targetPath)) {
throw new Error("SKILL_PATH_OUTSIDE_SKILLS_DIR");
}
}
function sourceName(sourceUrl) {
try {
const parsed = new URL(sourceUrl);
return basename(parsed.pathname).replace(/\.git$/i, "") || "remote-skill";
} catch {
return basename(sourceUrl).replace(/\.git$/i, "") || "remote-skill";
}
}
function resolveRequestSourceUrl(request, runnerConfig) {
const sourceUrl = trimToDefined(request.sourceUrl);
if (sourceUrl) {
return sourceUrl;
}
const trustedSource = trimToDefined(request.trustedSource ?? request.trustedSourceId);
return trustedSource ? runnerConfig.trustedSources[trustedSource] : undefined;
}
function parseUrlOrNull(value) {
try {
return new URL(value);
} catch {
return null;
}
}
function normalizeTrailingSlashes(value) {
return String(value ?? "").replace(/[\\/]+$/g, "");
}
function urlMatchesSourceBoundary(sourceUrl, allowedSource) {
const source = parseUrlOrNull(sourceUrl);
const allowed = parseUrlOrNull(allowedSource);
if (!source || !allowed || source.origin !== allowed.origin) {
return false;
}
if (allowed.search || allowed.hash) {
return source.href === allowed.href;
}
const sourcePath = normalizeTrailingSlashes(source.pathname);
const allowedPath = normalizeTrailingSlashes(allowed.pathname);
return sourcePath === allowedPath || sourcePath.startsWith(`${allowedPath}/`);
}
function sourceLooksLikePath(value) {
return value.startsWith("/") || value.startsWith("./") || value.startsWith("../") || value.startsWith("~");
}
function sourceMatchesAllowedBoundary(sourceUrl, allowedSource) {
const source = trimToDefined(sourceUrl);
const allowed = trimToDefined(allowedSource);
if (!source || !allowed) {
return false;
}
if (source === allowed) {
return true;
}
if (parseUrlOrNull(source) || parseUrlOrNull(allowed)) {
return urlMatchesSourceBoundary(source, allowed);
}
if (sourceLooksLikePath(source) || sourceLooksLikePath(allowed)) {
return isInside(resolve(allowed), resolve(source));
}
const normalizedAllowed = normalizeTrailingSlashes(allowed);
return source === normalizedAllowed
|| source.startsWith(`${normalizedAllowed}/`)
|| source.startsWith(`${normalizedAllowed}\\`);
}
function isAllowedSource(sourceUrl, runnerConfig) {
if (!sourceUrl) {
return true;
}
return runnerConfig.allowedSources.some((allowedSource) => sourceMatchesAllowedBoundary(sourceUrl, allowedSource))
|| Object.values(runnerConfig.trustedSources).some((trustedSource) => sourceUrl === trustedSource);
}
function skillIdFor(deviceId, skill) {
return `${deviceId}:${slugifySkillName(skill?.name)}`;
}
function findRuntimeSkill(runtime, deviceId, skillId) {
return (runtime.lastSkills ?? []).find((skill) => skillIdFor(deviceId, skill) === skillId);
}
async function ensureSkillDirectory(skillsDir, skill) {
if (!skill?.path) {
throw new Error("SKILL_NOT_FOUND_ON_DEVICE");
}
const skillFile = resolve(skill.path);
assertInsideSkillsDir(skillsDir, skillFile);
const skillDir = dirname(skillFile);
await stat(skillFile);
return skillDir;
}
async function fileExists(filePath) {
try {
await stat(filePath);
return true;
} catch {
return false;
}
}
async function checksumFileForSkill(targetDir) {
const manifestPath = join(targetDir, "manifest.json");
if (await fileExists(manifestPath)) {
return manifestPath;
}
const skillPath = join(targetDir, "SKILL.md");
if (await fileExists(skillPath)) {
return skillPath;
}
throw new Error("SKILL_CHECKSUM_TARGET_NOT_FOUND");
}
async function verifyChecksumIfRequested(request, targetDir) {
const expectedChecksum = trimToDefined(request.expectedChecksum ?? request.checksum);
if (!expectedChecksum) {
return;
}
const checksumPath = await checksumFileForSkill(targetDir);
const actualChecksum = createHash("sha256").update(await readFile(checksumPath)).digest("hex");
if (actualChecksum.toLowerCase() !== expectedChecksum.toLowerCase()) {
throw new Error("SKILL_CHECKSUM_MISMATCH");
}
}
async function backupSkillDirectory(runnerConfig, skillDir, skillName) {
assertInsideSkillsDir(runnerConfig.skillsDir, skillDir);
const backupsDir = resolve(runnerConfig.skillsDir, ".boss-skill-backups");
assertInsideSkillsDir(runnerConfig.skillsDir, backupsDir);
await mkdir(backupsDir, { recursive: true });
const backupName = `${new Date().toISOString().replace(/[:.]/g, "-")}-${slugifySkillName(skillName || basename(skillDir))}`;
const backupDir = resolve(backupsDir, backupName);
assertInsideSkillsDir(backupsDir, backupDir);
await cp(skillDir, backupDir, { recursive: true, force: false });
return backupDir;
}
async function restoreSkillBackup(runnerConfig, skillDir, backupDir) {
if (!backupDir) {
return;
}
assertInsideSkillsDir(runnerConfig.skillsDir, skillDir);
assertInsideSkillsDir(resolve(runnerConfig.skillsDir, ".boss-skill-backups"), backupDir);
await rm(skillDir, { recursive: true, force: true });
await cp(backupDir, skillDir, { recursive: true, force: true });
}
async function runCommand(command, args, options = {}) {
const timeoutMs = options.timeoutMs ?? 120_000;
return await new Promise((resolveCommand, rejectCommand) => {
const child = spawn(command, args, {
cwd: options.cwd,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
const timeout = setTimeout(() => {
child.kill("SIGKILL");
rejectCommand(new Error(`${command} timeout after ${timeoutMs}ms`));
}, timeoutMs);
child.stdout.on("data", (chunk) => {
stdout += String(chunk);
});
child.stderr.on("data", (chunk) => {
stderr += String(chunk);
});
child.on("error", (error) => {
clearTimeout(timeout);
rejectCommand(error);
});
child.on("close", (code) => {
clearTimeout(timeout);
if (code === 0) {
resolveCommand({ stdout, stderr });
return;
}
rejectCommand(new Error(stderr.trim() || stdout.trim() || `${command} exited ${code}`));
});
});
}
async function git(args, options) {
return runCommand("git", args, options);
}
async function installOrUpdateFromSource(request, runnerConfig, existingSkill) {
const sourceUrl = resolveRequestSourceUrl(request, runnerConfig);
if (!sourceUrl && !existingSkill) {
throw new Error("SOURCE_URL_REQUIRED");
}
if (sourceUrl && !isAllowedSource(sourceUrl, runnerConfig)) {
throw new Error("SKILL_SOURCE_NOT_ALLOWED");
}
await mkdir(join(runnerConfig.skillsDir, "remote"), { recursive: true });
const targetDir = existingSkill
? await ensureSkillDirectory(runnerConfig.skillsDir, existingSkill)
: resolve(runnerConfig.skillsDir, "remote", slugifySkillName(sourceName(sourceUrl)));
assertInsideSkillsDir(runnerConfig.skillsDir, targetDir);
const backupDir = existingSkill ? await backupSkillDirectory(runnerConfig, targetDir, existingSkill.name) : null;
try {
if (sourceUrl && !existingSkill) {
await git(["clone", "--depth", "1", sourceUrl, targetDir], {
timeoutMs: runnerConfig.timeoutMs,
});
} else {
await git(["fetch", "--all", "--tags", "--prune"], {
cwd: targetDir,
timeoutMs: runnerConfig.timeoutMs,
});
}
const targetVersion = trimToDefined(request.targetVersion);
if (targetVersion) {
await git(["checkout", targetVersion], {
cwd: targetDir,
timeoutMs: runnerConfig.timeoutMs,
});
} else if (existingSkill) {
await git(["pull", "--ff-only"], {
cwd: targetDir,
timeoutMs: runnerConfig.timeoutMs,
});
}
await verifyChecksumIfRequested(request, targetDir);
} catch (error) {
if (existingSkill) {
await restoreSkillBackup(runnerConfig, targetDir, backupDir).catch(() => {});
} else {
await rm(targetDir, { recursive: true, force: true });
}
throw error;
}
return `Skill 已${existingSkill ? "更新" : "安装"}${targetDir}`;
}
async function rollbackSkill(request, runnerConfig, runtime) {
const skill = findRuntimeSkill(runtime, request.deviceId, request.skillId);
const targetVersion = trimToDefined(request.rollbackToVersion);
if (!targetVersion) {
throw new Error("ROLLBACK_VERSION_REQUIRED");
}
const skillDir = await ensureSkillDirectory(runnerConfig.skillsDir, skill);
const backupDir = await backupSkillDirectory(runnerConfig, skillDir, skill?.name);
try {
await git(["fetch", "--all", "--tags", "--prune"], {
cwd: skillDir,
timeoutMs: runnerConfig.timeoutMs,
});
await git(["checkout", targetVersion], {
cwd: skillDir,
timeoutMs: runnerConfig.timeoutMs,
});
await verifyChecksumIfRequested(request, skillDir);
} catch (error) {
await restoreSkillBackup(runnerConfig, skillDir, backupDir).catch(() => {});
throw error;
}
return `Skill 已回滚到 ${targetVersion}`;
}
async function uninstallSkill(request, runnerConfig, runtime) {
const skill = findRuntimeSkill(runtime, request.deviceId, request.skillId);
const skillDir = await ensureSkillDirectory(runnerConfig.skillsDir, skill);
const backupDir = await backupSkillDirectory(runnerConfig, skillDir, skill?.name);
try {
await rm(skillDir, { recursive: true, force: true });
} catch (error) {
await restoreSkillBackup(runnerConfig, skillDir, backupDir).catch(() => {});
throw error;
}
return `Skill 已卸载:${skill?.name ?? request.skillId}`;
}
async function lockSkillVersion(request, runnerConfig) {
const lockedVersion = trimToDefined(request.lockedVersion);
if (!lockedVersion) {
throw new Error("LOCKED_VERSION_REQUIRED");
}
const lockPath = resolve(runnerConfig.skillsDir, ".boss-skill-locks.json");
assertInsideSkillsDir(runnerConfig.skillsDir, lockPath);
let locks = {};
try {
locks = JSON.parse(await readFile(lockPath, "utf8"));
} catch {
locks = {};
}
locks[request.skillId || request.sourceUrl] = {
lockedVersion,
updatedAt: new Date().toISOString(),
};
await mkdir(runnerConfig.skillsDir, { recursive: true });
await writeFile(lockPath, `${JSON.stringify(locks, null, 2)}\n`, "utf8");
return `Skill 已锁定版本:${lockedVersion}`;
}
export async function executeSkillLifecycleRequest(request, config, runtime) {
const runnerConfig = getSkillLifecycleRunnerConfig(process.env, config);
if (!runnerConfig.enabled) {
return {
status: "failed",
error: "SKILL_LIFECYCLE_RUNNER_DISABLED",
};
}
try {
const existingSkill = request.skillId
? findRuntimeSkill(runtime, request.deviceId, request.skillId)
: null;
let resultSummary = "";
if (request.action === "install") {
resultSummary = await installOrUpdateFromSource(request, runnerConfig, existingSkill);
} else if (request.action === "update") {
resultSummary = await installOrUpdateFromSource(request, runnerConfig, existingSkill);
} else if (request.action === "uninstall") {
resultSummary = await uninstallSkill(request, runnerConfig, runtime);
} else if (request.action === "rollback") {
resultSummary = await rollbackSkill(request, runnerConfig, runtime);
} else if (request.action === "version_lock") {
resultSummary = await lockSkillVersion(request, runnerConfig);
} else {
throw new Error("SKILL_LIFECYCLE_ACTION_UNSUPPORTED");
}
return {
status: "completed",
resultSummary,
};
} catch (error) {
return {
status: "failed",
error: error instanceof Error ? error.message : String(error),
};
}
}

2646
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,34 +9,45 @@
"postbuild": "mkdir -p .next/standalone/.next && rm -rf .next/standalone/.next/static .next/standalone/public && cp -R .next/static .next/standalone/.next/static && cp -R public .next/standalone/public",
"start": "BOSS_RUNTIME_ROOT=\"$PWD\" BOSS_STATE_FILE=\"$PWD/data/boss-state.json\" node .next/standalone/server.js",
"lint": "eslint",
"admin:web:dev": "cd apps/boss-admin-web && npm run dev",
"admin:web:build": "cd apps/boss-admin-web && npm run build",
"admin:web:publish": "npm --prefix apps/boss-admin-web run build",
"test:master-agent-controls": "tsx --test tests/master-agent-chat-controls.test.ts",
"apk:debug": "cd android && ./gradlew assembleDebug && cd .. && zsh ./scripts/publish-apk-to-public.sh",
"apk:release": "zsh ./scripts/build-release-apk.sh",
"aab:release": "zsh ./scripts/build-release-aab.sh"
},
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@capacitor/android": "^8.2.0",
"@capacitor/app": "^8.1.0",
"@capacitor/cli": "^8.2.0",
"@capacitor/core": "^8.2.0",
"@capacitor/preferences": "^8.0.1",
"ali-oss": "^6.23.0",
"@refinedev/core": "^5.0.12",
"@refinedev/nextjs-router": "^7.0.5",
"@tanstack/react-query": "^5.100.5",
"antd": "^5.29.3",
"clsx": "^2.1.1",
"next": "16.2.1",
"proxy-agent": "^5.0.0",
"dayjs": "^1.11.20",
"next": "16.2.4",
"pg": "^8.20.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/ali-oss": "^6.23.3",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
},
"overrides": {
"postcss": "8.5.12"
}
}

Some files were not shown because too many files have changed in this diff Show More