93 Commits

Author SHA1 Message Date
AI Bot
3b51641d99 chore: checkpoint Boss app v2.5.11 2026-06-08 12:22:50 +08:00
AI Bot
bddbe8b5ba fix: mark backup snapshots without metadata invalid 2026-06-06 19:26:18 +08:00
AI Bot
cfd41b4fbf fix: harden backup verification before restore 2026-06-06 19:21:02 +08:00
AI Bot
58cc4a1a5a docs: record enterprise safety runtime status 2026-06-06 19:16:25 +08:00
AI Bot
1ec6d003d1 fix: return safe task recovery projection 2026-06-06 19:12:18 +08:00
AI Bot
1edfa6ecd5 feat: add backup verification and restore preview 2026-06-06 19:08:24 +08:00
AI Bot
643da5b738 feat: add master agent task recovery endpoint 2026-06-06 19:05:42 +08:00
AI Bot
755e30612c feat: explain task phase in android progress cards 2026-06-06 19:05:07 +08:00
AI Bot
7973c441e4 docs: plan enterprise safety recovery phase one 2026-06-06 17:41:11 +08:00
AI Bot
a7e4b96ce3 docs: plan enterprise backup and recovery 2026-06-06 16:42:24 +08:00
AI Bot
9e81d8a960 fix: route master agent through codex device pool 2026-06-06 12:31:04 +08:00
AI Bot
684b98c5c1 fix: expose codex cli to local agent launchd 2026-06-05 15:14:43 +08:00
AI Bot
e4e6f6597a fix: remove folder intro panels across surfaces 2026-06-05 14:50:13 +08:00
AI Bot
4e2636ec8b fix: remove folder page helper card 2026-06-05 14:44:01 +08:00
AI Bot
9807c7a275 fix: compact chat subpage typography 2026-06-05 10:53:54 +08:00
AI Bot
eb8961fc3f fix: align android typography with wechat density 2026-06-05 10:34:03 +08:00
AI Bot
a38b3a3093 fix: restore compact android ui density 2026-06-05 09:49:46 +08:00
AI Bot
6f143ea6f9 feat: add android codex remote control actions 2026-06-04 17:26:19 +08:00
AI Bot
025e749618 feat: queue codex remote control actions 2026-06-04 17:12:23 +08:00
AI Bot
b93bc22160 feat: add codex remote control daemon actions 2026-06-04 15:25:18 +08:00
AI Bot
63338c3d76 feat: expose codex remote control status 2026-06-04 15:18:52 +08:00
AI Bot
a5d44b0cac feat: show codex app server summaries on android 2026-06-04 15:08:27 +08:00
AI Bot
3080f57dbc feat: show codex app server drift on android 2026-06-04 14:56:44 +08:00
AI Bot
0eaf78c3c2 feat: expose codex app-server protocol drift summary 2026-06-04 14:43:27 +08:00
AI Bot
5bf2216cb0 feat: surface codex thread collaboration capabilities 2026-06-04 14:34:34 +08:00
AI Bot
de9f85bd21 feat: sync app server thread replies 2026-06-04 14:09:46 +08:00
AI Bot
dbdaab8d0f feat: mirror boss messages via app server 2026-06-03 15:00:25 +08:00
AI Bot
0c3437a36f feat: fork codex threads 2026-06-03 14:49:43 +08:00
AI Bot
5537fde7a6 feat: sync codex thread metadata 2026-06-03 14:38:15 +08:00
AI Bot
0186ef7057 feat: sync codex thread goals 2026-06-03 14:21:27 +08:00
AI Bot
cc31b0d836 feat: sync codex thread names 2026-06-03 14:06:15 +08:00
AI Bot
0bcdcbfb9d feat: add controlled codex thread archive 2026-06-03 13:47:50 +08:00
AI Bot
0fb588e339 feat: add controlled codex thread compaction 2026-06-03 13:39:01 +08:00
AI Bot
7a30c2a8d9 feat: add controlled codex thread rollback 2026-06-03 13:30:24 +08:00
AI Bot
13201e6aee feat: interrupt canceled codex app-server turns 2026-06-03 13:12:23 +08:00
AI Bot
142fb2a4b3 feat: summarize codex stream progress events 2026-06-03 12:53:43 +08:00
AI Bot
bc9a586e81 feat: expose codex stream delta events 2026-06-03 12:37:23 +08:00
AI Bot
b31238b6e2 feat: expose codex runtime lifecycle events 2026-06-03 12:27:41 +08:00
AI Bot
f23ed9f188 feat: expose codex mcp guardian governance 2026-06-03 12:13:57 +08:00
AI Bot
afeb352fe3 feat: expose codex review sandbox search governance 2026-06-03 12:03:36 +08:00
AI Bot
ca64a4c498 feat: expose codex migration marketplace governance 2026-06-03 11:55:48 +08:00
AI Bot
0fdee4bcf7 feat: expose codex fs command governance 2026-06-03 11:44:07 +08:00
AI Bot
21e514a895 feat: expose codex account config governance 2026-06-03 11:36:44 +08:00
AI Bot
ca92133019 feat: expose codex plugin governance capabilities 2026-06-03 11:22:03 +08:00
AI Bot
b0526215c5 feat: expose codex thread action capabilities 2026-06-03 11:14:12 +08:00
AI Bot
0071dec860 feat: surface codex app server hook governance 2026-06-03 11:03:45 +08:00
AI Bot
3c6a0c546b feat: align codex app server 0.136 2026-06-03 10:46:45 +08:00
AI Bot
1ae81fa3af feat: summarize codex app server turns 2026-06-03 10:17:07 +08:00
AI Bot
74b333ba2f feat: surface codex app server thread visibility 2026-06-03 10:09:07 +08:00
AI Bot
c0c88444ec feat: surface codex app server governance summaries 2026-06-03 09:59:06 +08:00
AI Bot
88b028ad2b feat: expand codex app server discovery 2026-06-02 23:11:21 +08:00
AI Bot
94e0cc8bad feat: surface codex windows sandbox progress 2026-06-01 19:16:23 +08:00
AI Bot
b0a778ee68 feat: surface codex hook lifecycle progress 2026-06-01 18:48:45 +08:00
AI Bot
32a9c9a26a feat: surface codex image generation progress 2026-06-01 18:41:10 +08:00
AI Bot
5d62560217 feat: surface codex reasoning summaries 2026-06-01 18:20:04 +08:00
AI Bot
2ca2737520 feat: surface codex tool activity progress 2026-06-01 18:04:39 +08:00
AI Bot
2a5dccf5cb feat: map codex thread collaboration progress 2026-06-01 17:52:29 +08:00
AI Bot
defa3da185 feat: surface codex account runtime notices 2026-06-01 17:40:03 +08:00
AI Bot
26b5e97614 feat: surface codex thread config progress 2026-06-01 17:18:28 +08:00
AI Bot
591638f35f feat: surface codex runtime status 2026-05-31 03:59:53 +08:00
AI Bot
cee1e7938e feat: map codex realtime thread status 2026-05-31 03:54:43 +08:00
AI Bot
f333676c36 feat: discover codex app-server capabilities 2026-05-31 03:44:02 +08:00
AI Bot
4800352e22 feat: surface codex app-server approval progress 2026-05-31 03:36:07 +08:00
AI Bot
b9d3cca2e7 feat: adapt codex app-server protocol updates 2026-05-31 03:25:30 +08:00
AI Bot
e1aed590f8 feat: harden enterprise control plane 2026-05-17 02:20:08 +08:00
AI Bot
67511c31f4 fix: detect helper screen recording permission 2026-05-13 10:35:59 +08:00
AI Bot
04505da747 fix: reconcile boss agent screen permission state 2026-05-13 10:10:58 +08:00
AI Bot
a2d6dbd012 fix: remove nonessential boss agent permission requests 2026-05-13 02:50:59 +08:00
AI Bot
a77c70ad0c fix: make boss agent permissions match computer use minimum 2026-05-13 02:33:15 +08:00
AI Bot
a6d57b683a fix: keep boss agent permission grants stable across updates 2026-05-13 02:15:43 +08:00
AI Bot
1ac9472c44 fix: route mac permissions through native agent requests 2026-05-13 01:59:55 +08:00
AI Bot
feba68ac2b fix: point local network permission to correct mac settings 2026-05-13 01:13:40 +08:00
AI Bot
842c2249a1 fix: sign boss agent bundle for permissions 2026-05-13 00:44:42 +08:00
AI Bot
73327be8b0 fix: sync native agent permission states 2026-05-13 00:18:19 +08:00
AI Bot
2ff75087b3 fix: add boss agent app icon 2026-05-13 00:01:01 +08:00
AI Bot
8d3f68cebe fix: split agent permission and skill tabs 2026-05-12 23:39:13 +08:00
AI Bot
29740f35c7 fix: align agent permissions with native app 2026-05-12 23:22:27 +08:00
AI Bot
5b3f43014d feat: add one-time agent permission setup 2026-05-12 18:39:58 +08:00
AI Bot
315cc5cd54 feat: surface agent permissions and skills 2026-05-12 18:27:10 +08:00
AI Bot
7c371ed644 feat: add mac boss-agent desktop app 2026-05-12 17:04:40 +08:00
AI Bot
b12a1c7401 fix: report local agent cli capability from config 2026-05-12 13:53:25 +08:00
AI Bot
1c1140b1fd feat: support deepseek api for master agent 2026-05-12 12:52:47 +08:00
AI Bot
4de64ac01c feat: route desktop control to authorized devices 2026-05-12 12:15:43 +08:00
AI Bot
bc199dcf5c test: add remote control stress budgets 2026-05-11 23:25:52 +08:00
AI Bot
9c8ffebb92 test: harden remote control stress flow 2026-05-11 23:12:47 +08:00
AI Bot
a311280238 feat: ship enterprise control and desktop governance 2026-05-11 14:59:26 +08:00
AI Bot
0757d07521 docs: add desktop dialog guard design 2026-05-09 20:49:52 +08:00
kris
2c719168b6 docs: plan multi-user rbac foundation 2026-04-26 18:50:42 +08:00
kris
ba83fe0aed docs: design multi-user rbac skill governance 2026-04-26 18:22:41 +08:00
kris
916528de2b docs: add master agent background notification plan 2026-04-21 16:26:10 +08:00
kris
a9ed7c911d docs: add master agent background notification design 2026-04-21 16:24:25 +08:00
kris
bb237fdd4f fix: keep chat scroll usable while sending 2026-04-18 05:23:14 +08:00
kris
449f84fcbc feat: refine mobile master agent sync and chat rendering 2026-04-18 04:51:50 +08:00
2040 changed files with 308947 additions and 3973 deletions

15
.gitignore vendored
View File

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

View File

@@ -10,8 +10,10 @@
2. `docs/architecture/repo_map_cn.md` 2. `docs/architecture/repo_map_cn.md`
3. `docs/architecture/current_runtime_and_deploy_status_cn.md` 3. `docs/architecture/current_runtime_and_deploy_status_cn.md`
4. `docs/architecture/api_and_service_inventory_cn.md` 4. `docs/architecture/api_and_service_inventory_cn.md`
5. `docs/architecture/boss_server_connection_and_deploy_cn.md` 5. `docs/architecture/enterprise_ai_ops_architecture_cn.md`
6. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md` 6. `docs/architecture/rbac_skill_regression_matrix_cn.md`
7. `docs/architecture/boss_server_connection_and_deploy_cn.md`
8. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md`
## 当前有效目录 ## 当前有效目录
@@ -53,17 +55,52 @@
- `POST http://127.0.0.1:3000/api/auth/restore` 正常,已验证可用原生 restore token 恢复登录态 - `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/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/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` - `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 包 - `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 执行期间仍能正常回包 - 当前这台开发机的 `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` 默认实现收束 - 当前 Boss 已新增 `src/lib/execution/` 执行底座抽象层;当前生产主链仍然沿用 `local-agent -> codex exec resume`,只是执行责任已开始通过 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现收束
- 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有在显式配置 `BOSS_CLAW_*` 且可用性探测通过时,`master-agent` 当前对话里才会出现并允许选择 `claw-runtime` - 当前 `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` 不可用,运行时会自动回退到默认后端,并在前台显示明确原因 - 如果历史上已经保存过 `backendOverride=claw-runtime`,但当前 `Claw Runtime` 不可用,运行时会自动回退到默认后端,并在前台显示明确原因
- 当前 `oh-my-codex` 已以最小 `OmxTeamBackendAdapter` 形式接入执行底座,但默认关闭;当前已经接到 Web 群聊详情页 / 原生群资料页的编排后端选择卡,可在 `Boss Native``OMX Team` 间切换OMX 不可用时会自动回退到默认后端并明确提示原因 - 当前 `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 -> 回写群聊账本` 这条链 - 当前仓库已自带一个本地 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 -> 异步回流` 整条链 - 当前仓库已自带一个本地 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不再假装执行成功
- 当前电脑控制中枢的生产范围先明确收敛为 `macOS`:意图路由会给 browser/desktop 控制任务写入 `controlPlatform=macos`,其中浏览器控制仍走 `openai-computer-use`,桌面 GUI 控制默认走 `codex-computer-use`Codex Computer Use 不可用时再回退 `cua-driver-computer-use`Windows 控制入口暂不参与当前运行链路,后续再单独做平台分支
- 当前 browser/desktop 控制结果已经会作为 `control_summary` 正式写回会话账本,并保留目标 URL / 应用名Android 原生端会以单独控制结果卡片展示,便于把“执行什么”和“执行结果”与普通聊天正文区分开
- 当前 `scripts/browser-control-smoke.mjs` 已经能对目标 URL 做一次真实最小探测:抓取页面标题并写回聊天结果;桌面 GUI 控制默认先走 `scripts/codex-computer-use-runtime.mjs`,由 Codex App Server 发起 Codex Computer Use 执行;失败后自动回退 `scripts/cua-driver-computer-use-runtime.mjs`,通过外部 `cua-driver` 执行 `launch_app -> get_window_state -> 可选 type_text/press_key -> get_window_state` 闭环;`scripts/computer-use-smoke.mjs` 仍保留为旧兜底和回归资产
- 受控 Mac 需要先安装并授权 `cua-driver`Boss runtime 会优先搜索 `PATH`,再搜索 `~/.local/bin/cua-driver``/usr/local/bin/cua-driver``/opt/homebrew/bin/cua-driver``/Applications/CuaDriver.app/Contents/MacOS/cua-driver`;如果仍找不到,会明确返回 `CUA_DRIVER_COMMAND_NOT_FOUND`,不会伪装成执行成功
- 当前默认本机配置已把 `browserAutomation / computerUse` 两项能力直接上报为在线起步态,所以 Boss App 里这台 Mac 会显示“可做浏览器控制 / 桌面控制”;如果某条链路要临时收起,只需要改 `local-agent/config.cloud.json`
- 当前 `local-agent` 已新增 `Codex App Server` runnerboss-agent 默认打开 `codexAppServerEnabled`,通过 `codex app-server` stdio 接入 `conversation_reply / dispatch_execution`,也可灰度切到 `ws://127.0.0.1:<port>``unix://PATH` 本机长驻 App ServerWebSocket/Unix WebSocket handshake 支持 `Authorization: Bearer <token>`,优先用 `codexAppServerAuthTokenFile` 保存本地 token。失败时只在 turn 未启动前回退 `codex exec resume`,避免重复执行同一轮对话。设备 heartbeat 会单独上报 `codexAppServer` capability并按 `codexAppServerDiscoveryTtlMs` 缓存 `model/list / skills/list / plugin/list / app/list / modelProvider/capabilities/read` 的能力摘要,供 APP/后台模型选择和治理页读取。2026-05-31 起runner 会吸收 App Server 的 plan / diff / item / subagent 事件并归一到 Boss `execution_progress` 进度卡,执行中通过 `POST /api/v1/master-agent/tasks/[taskId]/progress` 实时刷新;后续已补 `approvals / warnings / fileChanges / threadStatus / realtime / modelRoute / tokenUsage / mcpServers / remoteControl / threadGoal / threadSettings / compaction / accountStatus / modelVerification / threadCollaboration / toolActivities / reasoningSummary / windowsSandbox` 等结构化摘要。Android 原生进度卡可显示线程状态、实时状态、线程配置、线程协作、工具活动、思考摘要、账号状态、运行状态、Windows 沙箱状态、安全提醒、审批状态和文件变更摘要且不展示完整命令、diff、系统提示词、密钥、SDP、音频原始数据、raw realtime item、remote installationId、本地绝对路径或 Windows sandbox sourcePath。本机 `codex-cli 0.136.0-alpha.2` 协议快照已生成在 `docs/protocol-snapshots/codex-app-server/0.136.0-alpha.2/`,新增确认 `skills/extraRoots/set`。配置 `codexAppServerSkillExtraRoots` / `BOSS_CODEX_APP_SERVER_SKILL_EXTRA_ROOTS`runner 会先下发共享 Skill 根,再拉取 `skills/list`metadata 只保留根目录数量、basename 和下发状态。当前 Inter-Thread Broker任务携带源/目标 Codex 线程时可通过 `thread/read -> thread/inject_items -> turn/start` 完成受控线程协作;服务端新增 `POST /api/v1/projects/[projectId]/thread-collaboration` 作为 APP/后台可调用入口;任务携带 `targetCodexTurnId` 时 runner 会改用 `turn/steer` 干预活跃 turn新版官方 `ThreadItem.collabToolCall` 会额外提取目标数量和 agent 状态集合,但仍不保存源/目标线程 ID、prompt 或 agent 私有消息。
- 当前 App Server heartbeat discovery 已扩展到 `experimentalFeature/list / collaborationMode/list / permissionProfile/list / mcpServerStatus/list`,设备详情页会展示“治理:实验特性 / 协作模式 / MCP / 权限”摘要MCP 只保留服务名、工具数量、资源数量和认证状态permission profile 只保留 id/description不保存本地路径、resource URI、文件规则、token 或工具参数。
- 当前 App Server heartbeat discovery 已继续扩展到 `account/read / account/rateLimits/read / config/read / configRequirements/read / externalAgentConfig/detect`设备详情页会展示账号、套餐、额度、App 配置、托管要求和外部 Agent 迁移候选摘要该链路只保存计数、开关和状态不保存邮箱、API key、完整 config、本地路径、迁移描述或外部 Agent 原始内容。
- 当前 App Server heartbeat discovery 已新增 `thread/list / thread/loaded/list` 线程可见性摘要设备详情页会展示线程总数、已加载线程、活跃线程和最新更新时间metadata 只保留非归档线程的 `id / name / sourceKind / status / updatedAt / loaded` 轻量目录,不保存 cwd、本地路径、turn 内容、用户正文或内部 prompt。
- 当前 App Server heartbeat discovery 已新增 `thread/turns/list` turn 运行态摘要,设备详情页会展示总轮次、运行中轮次、完成轮次和最新 turn 更新时间;请求固定使用 `itemsView=summary`metadata 只保留每个线程的 turn 计数、最近状态、更新时间和最终 `agentMessage` 安全摘要不保存用户输入、reasoning 原文、命令输出、原始 items、内部 prompt 或系统提示词。
- 当前 App Server heartbeat discovery 会把非归档可见线程的最终 `agentMessage` 合并进 `projectCandidates.recentAssistantMessages`;服务端据此把 Codex Desktop 自己产生的新回复反向同步到 Boss APP 会话列表、preview、lastMessageAt 和未读数。已有本地扫描候选优先保留 folder/thread 映射App Server 只补充最新回复摘要。
- 当前 App Server heartbeat discovery 已新增线程操作能力摘要,设备详情页会展示“线程操作”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `thread/archive / thread/compact/start / thread/shellCommand / turn/interrupt` 等写操作。
- 当前 App Server heartbeat discovery 已支持 `skills/extraRoots/set` 共享 Skill 根目录下发摘要,设备详情页会展示“共享 Skill 根”metadata 不保存根目录绝对路径、Skill 文件路径、token 或配置原文。
- 当前 App Server heartbeat discovery 已支持 `hooks/list` 钩子治理摘要设备详情页会展示“Hook”metadata 只保留 hook 数、启用数、受管 / 可信 / 修改 / 未信任计数、warning / error 计数和事件 / handler 类型,不保存 hook key、command、sourcePath、statusMessage、hash、error message 或本地路径。
- 当前 App Server heartbeat discovery 已新增插件治理能力摘要,设备详情页会展示“插件治理”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `plugin/install / plugin/uninstall / plugin/share/*` 等写操作。
- 当前 App Server heartbeat discovery 已新增账号与配置治理能力摘要,设备详情页会展示“账号治理 / 配置治理”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `account/login/* / account/logout / config/* / skills/config/write` 等写操作。
- 当前 App Server heartbeat discovery 已新增文件系统与命令会话治理能力摘要,设备详情页会展示“文件治理 / 命令会话”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `fs/*``command/exec/write``command/exec/terminate` 等读写或命令控制 API。
- 当前 App Server heartbeat discovery 已新增外部 Agent 迁移、Marketplace 和实验特性治理能力摘要,设备详情页会展示“迁移治理 / 市场治理 / 实验特性治理”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `externalAgentConfig/import``marketplace/*``experimentalFeature/enablement/set` 等写操作。
- 当前 App Server heartbeat discovery 已新增审查、Windows 沙箱和文件搜索事件能力摘要,设备详情页会展示“审查治理 / Windows 沙箱 / 文件搜索事件”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `review/start``windowsSandbox/setupStart` 等动作。
- 当前 App Server heartbeat discovery 已新增 MCP、用户交互和 Guardian 治理能力摘要设备详情页会展示“MCP 治理 / 用户交互 / Guardian 治理”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `mcpServer/tool/call``item/tool/requestUserInput``thread/approveGuardianDeniedAction` 等动作。
- 当前 App Server heartbeat discovery 已新增运行事件、扩展事件和线程生命周期事件能力摘要,设备详情页会展示“运行事件 / 扩展事件 / 线程生命周期”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中主动触发进程、插件、Skill 或线程生命周期动作。
- 当前 App Server heartbeat discovery 已新增流式增量事件能力摘要,设备详情页会展示“流式增量”;该摘要只来自 runner 安全 catalog 和协议快照证明,用于识别 agent delta、plan delta、reasoning delta、MCP progress、command output 和 file output 这类实时事件,不保存或展示原始增量内容。
- `GET http://127.0.0.1:4317/api/v1/skills` 正常,已返回本机扫描到的 Codex Skill - `GET http://127.0.0.1:4317/api/v1/skills` 正常,已返回本机扫描到的 Codex Skill
- `POST http://127.0.0.1:4317/api/v1/heartbeat` 正常,且会顺带触发 `thread-context` 上报 - `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` - `launchd` 已加载:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist`
服务器: 服务器:
@@ -103,12 +140,14 @@ Android APK
- `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk` - `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
- 当前最新 release 构建版本:`2.5.11``versionCode=24` - 当前最新 release 构建版本:`2.5.11``versionCode=24`
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局` - 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
- 真机开发约束:用户已明确切换到当前连接的 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 真机无线调试如果要尽量稳定,优先使用“同一局域网 + 初次 USB 启用后执行 `adb tcpip 5555` + `adb connect <phone-ip>:5555`”这条链路;它通常比只依赖系统“无线调试配对码”更稳
- Android 系统层面对“无线调试”没有真正的永久不掉线开关;重启手机、切 WiFi、切热点、ADB server 重启、USB 调试被重新切换后,都可能导致无线调试自动失效 - Android 系统层面对“无线调试”没有真正的永久不掉线开关;重启手机、切 WiFi、切热点、ADB server 重启、USB 调试被重新切换后,都可能导致无线调试自动失效
- 真机调试时建议固定同一 SSID、避免代理/VPN 改路、开发者选项里开启“保持唤醒”,并在需要长时间稳定调试时优先保留 USB 兜底;如果必须完全避免自动断开,不要只依赖无线调试 - 真机调试时建议固定同一 SSID、避免代理/VPN 改路、开发者选项里开启“保持唤醒”,并在需要长时间稳定调试时优先保留 USB 兜底;如果必须完全避免自动断开,不要只依赖无线调试
- 当前原生活动页已经覆盖会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、主 Agent 提示词 / 记忆、技能、运维中心、关于 - 当前原生活动页已经覆盖会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、主 Agent 提示词 / 记忆、技能、运维中心、关于
- 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab会话首页是简单聊天列表`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口 - 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab会话首页是简单聊天列表`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口
- 当前会话首页右上角已切回 `+` 入口:直接从首页发起独立群聊;设备页右上角仍是 `+添加` - 当前会话首页右上角已切回 `+` 入口:直接从首页发起独立群聊;设备页右上角 `+添加` 仅最高管理员可见,子账号只保留刷新。
- 当前会话首页已升级成“项目聚合 + 线程下钻”的结构:如果某个 Codex 文件夹只导入了 1 个线程,会话列表直接显示这个线程;如果同一文件夹导入了多个线程,会话首页只显示该文件夹归档项,点进去再看这个项目下的全部线程 - 当前会话首页已升级成“项目聚合 + 线程下钻”的结构:如果某个 Codex 文件夹只导入了 1 个线程,会话列表直接显示这个线程;如果同一文件夹导入了多个线程,会话首页只显示该文件夹归档项,点进去再看这个项目下的全部线程
- 当前会话首页的数据源已分成两层:`/api/v1/conversations` 继续保留平铺线程视图给群聊创建、转发等内部能力使用;首页和原生根页改走 `/api/v1/conversations/home`,文件夹归档详情走 `/api/v1/conversation-folders/[folderKey]` - 当前会话首页的数据源已分成两层:`/api/v1/conversations` 继续保留平铺线程视图给群聊创建、转发等内部能力使用;首页和原生根页改走 `/api/v1/conversations/home`,文件夹归档详情走 `/api/v1/conversation-folders/[folderKey]`
- 当前会话搜索仍然保留线程可达性:如果命中单线程项目,会直接进入该线程;如果命中多线程项目里的某条线程,结果会显示 `项目 / 线程`,点击后先进入项目文件夹页并定位到对应线程,不会把首页重新打平成线程列表 - 当前会话搜索仍然保留线程可达性:如果命中单线程项目,会直接进入该线程;如果命中多线程项目里的某条线程,结果会显示 `项目 / 线程`,点击后先进入项目文件夹页并定位到对应线程,不会把首页重新打平成线程列表
@@ -147,6 +186,7 @@ Android APK
- 当前设备导入 `review` 已补 owner/admin 鉴权,并改成真正的异步审核链:`review` 只负责排队 `device_import_resolution` 任务并返回 queued 状态,等 local-agent 完成回写后才把决议写回草稿和会话账本 - 当前设备导入 `review` 已补 owner/admin 鉴权,并改成真正的异步审核链:`review` 只负责排队 `device_import_resolution` 任务并返回 queued 状态,等 local-agent 完成回写后才把决议写回草稿和会话账本
- 当前原生 APP 会话页的“刷新失败”已按当前 tab 的主数据源独立判错:`会话` 只看会话请求本身,`设备` 只看设备请求,`我的` 只在 `settings + ota` 同时失败时才提示刷新失败 - 当前原生 APP 会话页的“刷新失败”已按当前 tab 的主数据源独立判错:`会话` 只看会话请求本身,`设备` 只看设备请求,`我的` 只在 `settings + ota` 同时失败时才提示刷新失败
- 当前 `设备``我的` 根页已收口为简单列表;`运维与修复 / AI 账号 / 技能` 保留在一级 `我的``审计对话` 作为置顶会话保留在会话首页 - 当前 `设备``我的` 根页已收口为简单列表;`运维与修复 / AI 账号 / 技能` 保留在一级 `我的``审计对话` 作为置顶会话保留在会话首页
- 当前原生 `我的` 根页已开始按登录角色过滤入口:`member` 只显示个人安全、设置、已授权 Skill 和关于;`admin / highest_admin` 才显示运维、AI 账号、附件存储和 Telegram 管理入口;`用户与权限``highest_admin` 可见
- 原生客户端当前直接调用 `https://boss.hyzq.net` 的 Boss API不再打开 WebView - 原生客户端当前直接调用 `https://boss.hyzq.net` 的 Boss API不再打开 WebView
- `2.0.1` 已修复华为真机上因 `Theme.SplashScreen``AppCompatActivity` 不兼容导致的启动闪退 - `2.0.1` 已修复华为真机上因 `Theme.SplashScreen``AppCompatActivity` 不兼容导致的启动闪退
- `2.1.0` 已在本机连接的华为真机上完成签名包覆盖安装与启动复核,原生三栏入口和子活动页声明已全部接通 - `2.1.0` 已在本机连接的华为真机上完成签名包覆盖安装与启动复核,原生三栏入口和子活动页声明已全部接通
@@ -159,6 +199,7 @@ Android APK
- `2.5.1` 继续收口微信式原生 UI聊天页普通态顶部已隐藏刷新按钮只保留右上角“信息”发起群聊页顶部说明和选择区已压成更轻的会话式密度候选线程继续复用微信式会话卡片 - `2.5.1` 继续收口微信式原生 UI聊天页普通态顶部已隐藏刷新按钮只保留右上角“信息”发起群聊页顶部说明和选择区已压成更轻的会话式密度候选线程继续复用微信式会话卡片
- `2.5.2` 继续补齐深层原生页:`项目目标 / 版本迭代记录 / 会话信息 / 群资料` 已进一步向设计图收口;附件消息卡片的分析状态和动作文案也压成了更轻的微信式层级 - `2.5.2` 继续补齐深层原生页:`项目目标 / 版本迭代记录 / 会话信息 / 群资料` 已进一步向设计图收口;附件消息卡片的分析状态和动作文案也压成了更轻的微信式层级
- `2.5.4` 已把 `我的` 根页收口成微信式资料区 + 白底菜单列表,并同步把 `设置 / 账号与安全 / AI 账号 / 技能 / 运维与修复` 的顶部说明从重 `soft panel` 降成轻量列表说明 - `2.5.4` 已把 `我的` 根页收口成微信式资料区 + 白底菜单列表,并同步把 `设置 / 账号与安全 / AI 账号 / 技能 / 运维与修复` 的顶部说明从重 `soft panel` 降成轻量列表说明
- `2.5.11` 已补齐第一批遗漏功能:聊天长按“删除”接通服务端账本删除与实时刷新;原生 `我的 > 附件与存储` 可直接切换服务器文件存储 / 阿里 OSS后台通知覆盖所有会话里的主 Agent 回复browser/desktop runtime 未配置时改为明确失败而不是占位成功
- `2.5.5` 已补上群资料页的“修复群成员”主链:历史脏群会明确提示失效成员,并允许重新选择真实线程成员写回群资料;`approval_required` 群聊也已补齐“确认 / 拒绝”两条审批动作 - `2.5.5` 已补上群资料页的“修复群成员”主链:历史脏群会明确提示失效成员,并允许重新选择真实线程成员写回群资料;`approval_required` 群聊也已补齐“确认 / 拒绝”两条审批动作
## 本地启动 ## 本地启动
@@ -188,6 +229,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/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/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/devices](http://127.0.0.1:3000/devices)
- 平台总后台入口:[http://127.0.0.1:3000/enterprise-admin](http://127.0.0.1:3000/enterprise-admin),生产域名 `https://admin.boss.hyzq.net/` 根路径直接承载新独立 PC 后台;`/admin` 仅保留为跳转到根域的兼容入口
## 设备端本地服务 ## 设备端本地服务
@@ -212,6 +254,23 @@ cd /Users/kris/code/boss
./scripts/install-local-launchagent.sh /Users/kris/code/boss/local-agent/config.example.json ./scripts/install-local-launchagent.sh /Users/kris/code/boss/local-agent/config.example.json
``` ```
构建 macOS 桌面状态应用 `boss-agent.app`
```bash
cd /Users/kris/code/boss
npm run mac:agent
open dist/boss-agent.app
```
说明:
- `boss-agent.app` 是本机 `local-agent` 的 macOS WebView 外壳,默认打开 `http://127.0.0.1:4317/boss-agent`
- 未绑定账号时会显示可扫码的 Boss APP 绑定二维码已绑定后显示账号、API、服务器、授权、本机权限获取和本机 Skill 部署情况
- boss-agent 已支持 Mac 端 OTA打包脚本会发布 `public/downloads/boss-agent-mac-latest.zip``boss-agent-mac-latest.json`;本机 agent 通过 `/api/v1/boss-agent/ota/check` 检查更新,通过 `/api/v1/boss-agent/ota/apply` 下载、校验并拉起安装器。安装器会保留所有 `config*.json`,并优先沿用当前 LaunchAgent active config 或自定义设备配置,避免多台 Mac 覆盖安装时误切回默认设备身份。
- 正式分发可设置 `BOSS_AGENT_CODESIGN_IDENTITY='Developer ID Application: ...'``BOSS_AGENT_NOTARIZE=1`,再用 `BOSS_AGENT_NOTARY_PROFILE` 或 Apple ID/team/password 环境变量走 `notarytool + stapler` 公证;未设置时仍保留本地开发签名 / ad-hoc 回退。
- 本机权限按 Codex Computer Use 的最小权限模型收敛为 `辅助功能 + 屏幕录制` 两项;权限页会打开对应 macOS 隐私设置入口,授权完成后由系统持久保存,后续控制过程只静默校验并使用,不在任务执行中临时申请更多权限。
- 本机状态 JSON 可通过 `GET http://127.0.0.1:4317/api/v1/boss-agent/status` 查看,不会返回设备 token 明文
device-agent 当前职责: device-agent 当前职责:
- 上报设备状态、账号、5h/7d 额度和项目列表 - 上报设备状态、账号、5h/7d 额度和项目列表
@@ -221,6 +280,8 @@ device-agent 当前职责:
- 轮询云端 `/api/v1/master-agent/tasks/claim`,并用当前电脑已登录的 `codex` 账号执行主 Agent 任务 - 轮询云端 `/api/v1/master-agent/tasks/claim`,并用当前电脑已登录的 `codex` 账号执行主 Agent 任务
- 将主 Agent 执行结果回写到云端 `/api/v1/master-agent/tasks/[taskId]/complete` - 将主 Agent 执行结果回写到云端 `/api/v1/master-agent/tasks/[taskId]/complete`
- 对普通单线程会话,认领到的 `conversation_reply` 任务会直接恢复到目标 Codex 线程,并把线程原始回复回写到对应聊天窗口 - 对普通单线程会话,认领到的 `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 汇总一起回写到群聊消息账本 - 对群聊线程分发任务,认领到的 `dispatch_execution` 任务会把原始线程结果和主 Agent 汇总一起回写到群聊消息账本
- `local-agent``conversation_reply` 当前会优先使用 `codex exec resume <targetCodexThreadRef>`,只有缺失真实线程引用时才退回 `--ephemeral` - `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 协议执行 - `local-agent``dispatch_execution` 当前会按 `orchestrationBackendId` 分流:默认继续走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行
@@ -232,7 +293,7 @@ device-agent 当前职责:
- Codex 项目/线程扫描当前已搬到 worker 线程执行,避免 `.codex/logs_1.sqlite``state_5.sqlite` 的同步扫描阻塞主线程 HTTP 响应 - Codex 项目/线程扫描当前已搬到 worker 线程执行,避免 `.codex/logs_1.sqlite``state_5.sqlite` 的同步扫描阻塞主线程 HTTP 响应
- 如果某个历史群聊里已经没有真实线程成员,当前不会再表现成“发了没反应”,而是会在群里追加一条 `system_notice`,提示用户先重新整理群成员 - 如果某个历史群聊里已经没有真实线程成员,当前不会再表现成“发了没反应”,而是会在群里追加一条 `system_notice`,提示用户先重新整理群成员
- 设备导入审核当前已经升级成 `local-agent -> codex exec -> complete` 的真实任务链Web 和 Android 前台都会在 `pending_resolution` 阶段显示“主 Agent 审核中”并自动刷新,审核失败时保留当前勾选以便重新生成 - 设备导入审核当前已经升级成 `local-agent -> codex exec -> complete` 的真实任务链Web 和 Android 前台都会在 `pending_resolution` 阶段显示“主 Agent 审核中”并自动刷新,审核失败时保留当前勾选以便重新生成
- 提供本地 `/health``/api/v1/device``/api/v1/skills``/api/v1/heartbeat` - 提供本地 `/boss-agent``/api/v1/boss-agent/status``/api/v1/boss-agent/ota/check``/api/v1/boss-agent/ota/apply``/health``/api/v1/device``/api/v1/skills``/api/v1/heartbeat`
当前常驻默认值: 当前常驻默认值:
@@ -249,6 +310,8 @@ device-agent 当前职责:
- APK 发布脚本:`scripts/publish-apk-to-public.sh` - APK 发布脚本:`scripts/publish-apk-to-public.sh`
- `systemd` 配置:`deployment/systemd/boss-web.service` - `systemd` 配置:`deployment/systemd/boss-web.service`
- `Caddy` 配置:`deployment/Caddyfile` - `Caddy` 配置:`deployment/Caddyfile`
- 平台总后台域名解析:`admin.boss.hyzq.net` 当前已解析到 `106.53.170.158`Caddy 独立站点会把根路径内部 rewrite 到 `/admin-web/index.html`,浏览器地址栏保持 `https://admin.boss.hyzq.net/`
- 服务器 Caddy 还有 `gptpluscontrol-boss-caddy-reconcile.timer` 周期性重写:如果改域名入口,必须同步更新 `/home/ubuntu/build/gptpluscontrol/deploy/server/caddy.boss_hyzq_net.gptpluscontrol.conf`,否则会再次生成重复站点块
- 邮件配置:`deployment/mail/` - 邮件配置:`deployment/mail/`
- Android 原生入口:`android/app/src/main/java/com/hyzq/boss/MainActivity.java` - Android 原生入口:`android/app/src/main/java/com/hyzq/boss/MainActivity.java`
- Android API 客户端:`android/app/src/main/java/com/hyzq/boss/BossApiClient.java` - Android API 客户端:`android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
@@ -309,6 +372,7 @@ npm run aab:release
- Web 生产启动、服务器 `systemd` 和部署构建当前都显式设置了 `BOSS_RUNTIME_ROOT`,避免 `process.cwd()` 在 standalone / 服务器构建阶段误把整个仓库根目录带进 tracing - Web 生产启动、服务器 `systemd` 和部署构建当前都显式设置了 `BOSS_RUNTIME_ROOT`,避免 `process.cwd()` 在 standalone / 服务器构建阶段误把整个仓库根目录带进 tracing
- `next.config.ts` 已显式排除 `deployment / docs / design / local-agent / prompts / scripts / android` 等非运行时目录,避免服务器端 standalone tracing 卷入运维资产导致构建失败 - `next.config.ts` 已显式排除 `deployment / docs / design / local-agent / prompts / scripts / android` 等非运行时目录,避免服务器端 standalone tracing 卷入运维资产导致构建失败
- 文件写入已经改成串行事务队列 + 原子写入 + `data/boss-state.json.bak` 备份恢复,`heartbeat` 和 APP 日志并发写不会再互相覆盖 - 文件写入已经改成串行事务队列 + 原子写入 + `data/boss-state.json.bak` 备份恢复,`heartbeat` 和 APP 日志并发写不会再互相覆盖
- 文件状态写入层已默认开启自动历史快照,按 `BOSS_STATE_AUTO_BACKUP_INTERVAL_MS` 节流生成 `data/backups/state-snapshot-*.json`,并按 `BOSS_STATE_AUTO_BACKUP_KEEP` 控制保留数量;最高管理员后台“备份与回退”页可创建手动快照、查看自动快照和恢复到指定快照
- 当前文件存储里已经包含: - 当前文件存储里已经包含:
- `projects / messages / goals / versions` - `projects / messages / goals / versions`
- `authAccounts / otaUpdates / otaUpdateLogs` - `authAccounts / otaUpdates / otaUpdateLogs`
@@ -343,21 +407,21 @@ npm run aab:release
- 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;因此会话页会自动出现这台设备当前真实运行的 Codex 线程窗口 - 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;因此会话页会自动出现这台设备当前真实运行的 Codex 线程窗口
- 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;如果某个项目下存在多个线程,会话首页会先显示项目归档项,而不是把所有线程平铺在首页 - 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;如果某个项目下存在多个线程,会话首页会先显示项目归档项,而不是把所有线程平铺在首页
- 对已经绑定的生产设备,如果某些自动导入线程已经不再出现在最新 `projectCandidates[]` 中,服务端会在下一次 heartbeat 自动清理这些过时会话,避免旧线程长期滞留首页 - 对已经绑定的生产设备,如果某些自动导入线程已经不再出现在最新 `projectCandidates[]` 中,服务端会在下一次 heartbeat 自动清理这些过时会话,避免旧线程长期滞留首页
- 登录页当前已临时切到免验证模式,点击“登录”会直接进入会话首页 - 认证现在已经有最小会话链路:登录后会写入 `boss_session` Cookie默认保持 30 天,`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 接口都要求有效会话;临时免验证登录默认关闭,仅在显式设置 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 时启用
- 认证现在已经有最小会话链路:登录后会写入 `boss_session` Cookie默认保持 30 天,`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 接口都要求有效会话
- 新增 `GET /api/auth/session``POST /api/auth/logout``POST /api/auth/restore` - 新增 `GET /api/auth/session``POST /api/auth/logout``POST /api/auth/restore`
- 当前同一账号已经支持多个登录端并存Web 与原生 Android 的 `我的 > 账号与安全` 可查看和撤销登录会话,最高管理员可以管理所有活跃会话
- 原生 Android 客户端当前会把 `boss_session / restore token / account` 存到 `SharedPreferences`,用于重启后恢复会话 - 原生 Android 客户端当前会把 `boss_session / restore token / account` 存到 `SharedPreferences`,用于重启后恢复会话
- 验证码新增防刷与防重放60 秒冷却、15 分钟窗口限流,登录连续失败 5 次后会锁定 10 分钟 - 验证码新增防刷与防重放60 秒冷却、15 分钟窗口限流,登录连续失败 5 次后会锁定 10 分钟
- `POST /api/auth/send-code` 现在会先按用途校验账号状态:登录 / 忘记密码要求账号已存在,注册要求账号尚未注册 - `POST /api/auth/send-code` 现在会先按用途校验账号状态:登录 / 忘记密码要求账号已存在,注册要求账号尚未注册
- 当前登录页已临时放开成“一键进入”,账号密码验证码输入暂时不作为拦截条件 - 当前登录页默认走账号密码验证码校验,不再把开发兜底作为生产默认能力
- `POST /api/auth/send-code` 与固定验证码 `000000` 仍保留给注册 / 重置密码和后续认证收口,不作为当前登录页前置条件 - `POST /api/auth/send-code` 当前仍支持 fixed 模式,但验证码登录也必须先申请验证码并消费账本里的有效记录;不能只靠固定码直接登录
- 新注册和重置密码现在使用 `scrypt` 哈希;历史 `sha256` 密码会在下一次密码登录时自动迁移 - 新注册和重置密码现在使用 `scrypt` 哈希;历史 `sha256` 密码会在下一次密码登录时自动迁移
- 原生 Android 当前把 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等复杂能力下沉到二级或更深层入口,不再把线程预算 / 转发 / 运维说明堆在主聊天页和一级我的页 - 原生 Android 当前把 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等复杂能力下沉到二级或更深层入口,不再把线程预算 / 转发 / 运维说明堆在主聊天页和一级我的页
- 原生 OTA 当前除了整包下载和系统安装器拉起,还会在关于页保留本地下载状态;离开关于页再回来时,仍能看到进行中 / 失败 / 待授权 / 可安装状态 - 原生 OTA 当前除了整包下载和系统安装器拉起,还会在关于页保留本地下载状态;离开关于页再回来时,仍能看到进行中 / 失败 / 待授权 / 可安装状态
- Android 本地 Gradle 验证当前必须串行执行,避免并发 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug` 相互踩坏中间产物 - Android 本地 Gradle 验证当前必须串行执行,避免并发 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug` 相互踩坏中间产物
- 当前默认最高管理员账号:`17600003315` - 当前默认最高管理员账号:`krisolo`
- 当前默认测试密码`boss123456` - 当前默认测试密码由线上初始化配置管理,文档不再明文记录
- 当前本机 Codex 节点 `mac-studio` 已绑定到 `17600003315` - 当前本机 Codex 节点 `mac-studio` 已绑定到 `krisolo`
- 主 Agent 对话当前真实执行链路是:`Boss Web -> 写入用户消息 -> 返回 queued/running -> master-agent task queue -> local-agent / OpenAI API -> complete task -> project ledger` - 主 Agent 对话当前真实执行链路是:`Boss Web -> 写入用户消息 -> 返回 queued/running -> master-agent task queue -> local-agent / OpenAI API -> complete task -> project ledger`
- `master-agent` 单聊当前已改成“快速入队 + 异步回流”:发送后会立即返回任务包和 `masterReplyState`,前台先显示“主 Agent 思考中”,真实回复稍后自动回写到账本 - `master-agent` 单聊当前已改成“快速入队 + 异步回流”:发送后会立即返回任务包和 `masterReplyState`,前台先显示“主 Agent 思考中”,真实回复稍后自动回写到账本
- 原生 Android 当前会把 `master-agent` 的等待态保留在消息流里:发送后常驻显示“主 Agent 思考中”,超时后改成“主 Agent 回复超时 + 重试等待”,收到新回复后会自动清掉,不再只靠 toast 提示 - 原生 Android 当前会把 `master-agent` 的等待态保留在消息流里:发送后常驻显示“主 Agent 思考中”,超时后改成“主 Agent 回复超时 + 重试等待”,收到新回复后会自动清掉,不再只靠 toast 提示
@@ -368,7 +432,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/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` 查询日志分页 - `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 或匹配登录会话 - 设备写接口 `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 私有桶,下载链会按附件快照生成签名地址,避免用户后续修改配置后旧附件失效 - 当前用户已可在 `我的 > 附件与存储` 切到阿里 OSS 私有桶,下载链会按附件快照生成签名地址,避免用户后续修改配置后旧附件失效
- 图片 / PDF / 文本默认自动进入主 Agent 附件分析;视频 / Office / 大文件默认手动触发 - 图片 / PDF / 文本默认自动进入主 Agent 附件分析;视频 / Office / 大文件默认手动触发

View File

@@ -2,15 +2,25 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <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" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:name=".BossApplication"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme"
android:forceDarkAllowed="false">
<service
android:name=".BossBackgroundRealtimeService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<activity <activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
@@ -48,8 +58,11 @@
<activity android:name=".DeviceImportDraftActivity" android:exported="false" android:screenOrientation="portrait" /> <activity android:name=".DeviceImportDraftActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".SkillInventoryActivity" 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=".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=".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=".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=".OpenAiOnboardingActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".MasterAgentPromptActivity" 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" /> <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 android.widget.LinearLayout;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
@@ -76,11 +77,7 @@ public class AboutActivity extends BossScreenActivity {
restoreDownloadUiState(); restoreDownloadUiState();
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent); realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE); IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ContextCompat.registerReceiver(this, otaDownloadReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED);
registerReceiver(otaDownloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
} else {
registerReceiver(otaDownloadReceiver, filter);
}
reload(); reload();
} }
@@ -491,11 +488,9 @@ public class AboutActivity extends BossScreenActivity {
persistDownloadUiState(); persistDownloadUiState();
refreshDownloadStateSection(); refreshDownloadStateSection();
if (!getPackageManager().canRequestPackageInstalls()) { if (!canInstallDownloadedPackages()) {
showMessage("请先允许 Boss 安装未知来源应用,然后重新打开安装包。"); showMessage("请先允许 Boss 安装未知来源应用,然后重新打开安装包。");
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName())); openUnknownAppSourcesSettings();
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
return; return;
} }
@@ -566,7 +561,7 @@ public class AboutActivity extends BossScreenActivity {
return OtaDownloadStateMapper.failed(fileName); return OtaDownloadStateMapper.failed(fileName);
} }
if (downloadedApkUri != null) { if (downloadedApkUri != null) {
if (!getPackageManager().canRequestPackageInstalls()) { if (!canInstallDownloadedPackages()) {
return OtaDownloadStateMapper.waitingInstallPermission(fileName); return OtaDownloadStateMapper.waitingInstallPermission(fileName);
} }
return OtaDownloadStateMapper.readyToInstall(fileName); return OtaDownloadStateMapper.readyToInstall(fileName);
@@ -580,9 +575,7 @@ public class AboutActivity extends BossScreenActivity {
downloadLatestApk(); downloadLatestApk();
break; break;
case OPEN_INSTALL_PERMISSION: case OPEN_INSTALL_PERMISSION:
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName())); openUnknownAppSourcesSettings();
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
break; break;
case INSTALL_APK: case INSTALL_APK:
installDownloadedApk(); installDownloadedApk();
@@ -598,11 +591,9 @@ public class AboutActivity extends BossScreenActivity {
showMessage("当前没有可安装的更新包"); showMessage("当前没有可安装的更新包");
return; return;
} }
if (!getPackageManager().canRequestPackageInstalls()) { if (!canInstallDownloadedPackages()) {
showMessage("请先开启安装未知来源应用权限"); showMessage("请先开启安装未知来源应用权限");
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName())); openUnknownAppSourcesSettings();
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
return; return;
} }
Intent installIntent = new Intent(Intent.ACTION_VIEW); Intent installIntent = new Intent(Intent.ACTION_VIEW);
@@ -622,6 +613,19 @@ public class AboutActivity extends BossScreenActivity {
return "boss-android-latest.apk"; 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() { private void restoreDownloadUiState() {
android.content.SharedPreferences prefs = getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE); android.content.SharedPreferences prefs = getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE);
activeDownloadId = prefs.getLong(KEY_ACTIVE_DOWNLOAD_ID, -1L); 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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -66,6 +66,53 @@ public class BossApiClient {
return response; 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 { public ApiResponse restoreSession() throws IOException, JSONException {
if (getRestoreToken().isEmpty()) { if (getRestoreToken().isEmpty()) {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_RESTORE_TOKEN")); 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); 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 { public ApiResponse getConversations() throws IOException, JSONException {
return requestWithRestoreRaw( return requestWithRestoreRaw(
"GET", "GET",
@@ -107,12 +165,30 @@ public class BossApiClient {
return requestWithRestore("GET", "/api/v1/conversation-folders/" + encode(folderKey), null); 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 { public ApiResponse getProjectDetail(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null); return requestWithRestoreRaw(
"GET",
"/api/v1/projects/" + encode(projectId),
null,
DEFAULT_CONNECT_TIMEOUT_MS,
CONVERSATIONS_READ_TIMEOUT_MS
);
} }
public ApiResponse getProjectMessages(String projectId) throws IOException, JSONException { public ApiResponse getProjectMessages(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/messages", null); return requestWithRestoreRaw(
"GET",
"/api/v1/projects/" + encode(projectId) + "/messages",
null,
DEFAULT_CONNECT_TIMEOUT_MS,
CONVERSATIONS_READ_TIMEOUT_MS
);
} }
public ApiResponse getDispatchPlans(String projectId) throws IOException, JSONException { public ApiResponse getDispatchPlans(String projectId) throws IOException, JSONException {
@@ -147,6 +223,22 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload); return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
} }
public ApiResponse updateMasterAgentModeModels(
@Nullable String fastModelOverride,
@Nullable String deepModelOverride,
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride
) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("fastModelOverride", fastModelOverride == null ? JSONObject.NULL : fastModelOverride);
payload.put("deepModelOverride", deepModelOverride == null ? JSONObject.NULL : deepModelOverride);
if (modelOverride != null || reasoningEffortOverride != null) {
payload.put("modelOverride", modelOverride == null ? JSONObject.NULL : modelOverride);
payload.put("reasoningEffortOverride", reasoningEffortOverride == null ? JSONObject.NULL : reasoningEffortOverride);
}
return requestWithRestore("POST", "/api/v1/projects/master-agent/agent-controls", payload);
}
public ApiResponse updateProjectTakeoverSettings( public ApiResponse updateProjectTakeoverSettings(
String projectId, String projectId,
@Nullable Boolean takeoverEnabled, @Nullable Boolean takeoverEnabled,
@@ -238,6 +330,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 { public ApiResponse retryDispatchPlan(String projectId, String planId) throws IOException, JSONException {
return requestWithRestoreRaw( return requestWithRestoreRaw(
"POST", "POST",
@@ -283,6 +385,18 @@ public class BossApiClient {
return requestWithRestore("PATCH", "/api/v1/projects/" + encode(projectId) + "/dispatch-reminder", payload); 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 { public ApiResponse sendProjectMessage(String projectId, String body, String kind) throws IOException, JSONException {
JSONObject payload = new JSONObject(); JSONObject payload = new JSONObject();
payload.put("body", body); payload.put("body", body);
@@ -296,6 +410,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( public ApiResponse uploadAttachment(
String projectId, String projectId,
String fileName, String fileName,
@@ -434,10 +556,30 @@ public class BossApiClient {
return updateDevice(deviceId, payload); return updateDevice(deviceId, payload);
} }
public ApiResponse queueCodexRemoteControl(String deviceId, String action, String reason) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("action", action);
payload.put("confirmed", true);
payload.put("reason", reason == null ? "" : reason);
return requestWithRestore(
"POST",
"/api/v1/devices/" + encode(deviceId) + "/codex-remote-control",
payload
);
}
public ApiResponse getDeviceSkills(String deviceId) throws IOException, JSONException { public ApiResponse getDeviceSkills(String deviceId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/devices/" + encode(deviceId) + "/skills", null); return requestWithRestore("GET", "/api/v1/devices/" + encode(deviceId) + "/skills", null);
} }
public ApiResponse getSkillLifecycleRequests() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/admin/skills/requests", null);
}
public ApiResponse createSkillLifecycleRequest(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/admin/skills/requests", payload == null ? new JSONObject() : payload);
}
public ApiResponse getDeviceEnrollments() throws IOException, JSONException { public ApiResponse getDeviceEnrollments() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/devices/enrollments", null); return requestWithRestore("GET", "/api/v1/devices/enrollments", null);
} }
@@ -468,6 +610,14 @@ public class BossApiClient {
return requestWithRestore("GET", "/api/v1/accounts", null); 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 { public ApiResponse createAccount(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/accounts", payload); return requestWithRestore("POST", "/api/v1/accounts", payload);
} }
@@ -490,6 +640,10 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/accounts/" + encode(accountId) + "/validate", new JSONObject()); return requestWithRestore("POST", "/api/v1/accounts/" + encode(accountId) + "/validate", new JSONObject());
} }
public ApiResponse validateDraftAccount(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/accounts/validate-draft", payload);
}
public ApiResponse onboardOpenAiApiAccount(JSONObject payload) throws IOException, JSONException { public ApiResponse onboardOpenAiApiAccount(JSONObject payload) throws IOException, JSONException {
return onboardAccount("/api/v1/accounts/onboard/openai-api", payload); return onboardAccount("/api/v1/accounts/onboard/openai-api", payload);
} }
@@ -526,6 +680,14 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/settings", payload); 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 { public ApiResponse getOtaStatus() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/user/ota", null); return requestWithRestore("GET", "/api/v1/user/ota", null);
} }
@@ -543,13 +705,19 @@ public class BossApiClient {
} }
public ApiResponse logout() throws IOException, JSONException { public ApiResponse logout() throws IOException, JSONException {
ApiResponse response = request("POST", "/api/auth/logout", new JSONObject(), false); try {
return request("POST", "/api/auth/logout", new JSONObject(), false);
} finally {
clearSession();
}
}
public void clearLocalAuthState() {
clearSession(); clearSession();
return response;
} }
public String getAccountLabel() { public String getAccountLabel() {
return prefs.getString(KEY_ACCOUNT, "17600003315"); return prefs.getString(KEY_ACCOUNT, "krisolo");
} }
public String getDisplayName() { public String getDisplayName() {
@@ -594,9 +762,9 @@ public class BossApiClient {
int readTimeoutMs int readTimeoutMs
) throws IOException, JSONException { ) throws IOException, JSONException {
ApiResponse response = requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs); ApiResponse response = requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
if (response.statusCode == 401 && !getRestoreToken().isEmpty()) { if (response.statusCode == 401) {
ApiResponse restored = restoreSession(); ApiResponse recovered = !getRestoreToken().isEmpty() ? restoreSession() : autoLogin();
if (restored.ok()) { if (recovered.ok()) {
return requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs); return requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
} }
} }
@@ -689,7 +857,16 @@ public class BossApiClient {
private ApiResponse executeConnection(HttpURLConnection connection, boolean expectProtected) throws IOException, JSONException { private ApiResponse executeConnection(HttpURLConnection connection, boolean expectProtected) throws IOException, JSONException {
int statusCode = connection.getResponseCode(); int statusCode = connection.getResponseCode();
captureSessionCookie(connection.getHeaderFields()); 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) { if (statusCode == 401 && !expectProtected) {
clearSession(); clearSession();
@@ -802,8 +979,12 @@ public class BossApiClient {
} }
private JSONObject readJson(InputStream stream) throws IOException, JSONException { private JSONObject readJson(InputStream stream) throws IOException, JSONException {
return readJsonBody(stream).json;
}
private JsonBody readJsonBody(InputStream stream) throws IOException, JSONException {
if (stream == null) { if (stream == null) {
return new JSONObject(); return new JsonBody(new JSONObject(), true);
} }
StringBuilder builder = new StringBuilder(); StringBuilder builder = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) { try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
@@ -814,9 +995,13 @@ public class BossApiClient {
} }
String raw = builder.toString().trim(); String raw = builder.toString().trim();
if (raw.isEmpty()) { 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 { private String readText(InputStream stream) throws IOException {
@@ -850,9 +1035,13 @@ public class BossApiClient {
private void captureSessionCookie(Map<String, List<String>> headers) { private void captureSessionCookie(Map<String, List<String>> headers) {
if (headers == null) return; if (headers == null) return;
List<String> setCookieHeaders = headers.get("Set-Cookie"); List<String> setCookieHeaders = null;
if (setCookieHeaders == null) { for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
setCookieHeaders = headers.get("set-cookie"); String headerName = entry.getKey();
if (headerName != null && "set-cookie".equalsIgnoreCase(headerName)) {
setCookieHeaders = entry.getValue();
break;
}
} }
if (setCookieHeaders == null) return; if (setCookieHeaders == null) return;
@@ -916,6 +1105,8 @@ public class BossApiClient {
prefs.edit() prefs.edit()
.remove(KEY_SESSION_COOKIE) .remove(KEY_SESSION_COOKIE)
.remove(KEY_RESTORE_TOKEN) .remove(KEY_RESTORE_TOKEN)
.remove(KEY_ACCOUNT)
.remove(KEY_DISPLAY_NAME)
.apply(); .apply();
} }
@@ -945,6 +1136,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 static class DownloadedAttachment {
public final int statusCode; public final int statusCode;
public final String fileName; 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

@@ -0,0 +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.content.res.Configuration;
import android.graphics.Color; import android.graphics.Color;
import android.graphics.Typeface; import android.graphics.Typeface;
import android.os.Build;
import android.text.SpannableStringBuilder; import android.text.SpannableStringBuilder;
import android.text.Spanned; import android.text.Spanned;
import android.text.SpannedString; import android.text.SpannedString;
@@ -27,6 +28,8 @@ public final class BossMarkdown {
private static final Pattern HEADING_PATTERN = Pattern.compile("^(#{1,3})\\s+(.+)$"); private static final Pattern HEADING_PATTERN = Pattern.compile("^(#{1,3})\\s+(.+)$");
private static final Pattern BULLET_PATTERN = Pattern.compile("^[-*]\\s+(.+)$"); private static final Pattern BULLET_PATTERN = Pattern.compile("^[-*]\\s+(.+)$");
private static final Pattern ORDERED_PATTERN = Pattern.compile("^(\\d+)\\.\\s+(.+)$"); private static final Pattern ORDERED_PATTERN = Pattern.compile("^(\\d+)\\.\\s+(.+)$");
private static final Pattern LABEL_SECTION_PATTERN = Pattern.compile("^([^:\\n]{1,24})[:]\\s*(.+)$");
private static final Pattern MARKDOWN_LINK_PATTERN = Pattern.compile("\\[([^\\]\\n]{1,90})\\]\\((https?://[^\\s)]+)\\)");
private static final Pattern INLINE_TOKEN_PATTERN = Pattern.compile("(\\*\\*([^*]+)\\*\\*)|(`([^`]+)`)");; private static final Pattern INLINE_TOKEN_PATTERN = Pattern.compile("(\\*\\*([^*]+)\\*\\*)|(`([^`]+)`)");;
private static final LruCache<String, CharSequence> RENDER_CACHE = new LruCache<>(180); private static final LruCache<String, CharSequence> RENDER_CACHE = new LruCache<>(180);
@@ -43,7 +46,7 @@ public final class BossMarkdown {
} }
Palette palette = Palette.resolve(context, outgoing); Palette palette = Palette.resolve(context, outgoing);
SpannableStringBuilder builder = new SpannableStringBuilder(); SpannableStringBuilder builder = new SpannableStringBuilder();
String normalized = markdown.replace("\r\n", "\n").replace('\r', '\n'); String normalized = normalizeMarkdownLinks(markdown).replace("\r\n", "\n").replace('\r', '\n');
String[] lines = normalized.split("\n", -1); String[] lines = normalized.split("\n", -1);
boolean inCodeFence = false; boolean inCodeFence = false;
List<String> codeLines = new ArrayList<>(); List<String> codeLines = new ArrayList<>();
@@ -86,6 +89,12 @@ public final class BossMarkdown {
continue; continue;
} }
Matcher labelMatcher = LABEL_SECTION_PATTERN.matcher(trimmed);
if (labelMatcher.matches()) {
appendLabelSection(builder, labelMatcher.group(1), labelMatcher.group(2), palette);
continue;
}
if (trimmed.startsWith(">")) { if (trimmed.startsWith(">")) {
appendQuote(builder, trimmed.substring(1).trim(), palette); appendQuote(builder, trimmed.substring(1).trim(), palette);
continue; continue;
@@ -109,6 +118,21 @@ public final class BossMarkdown {
return (outgoing ? "out" : "in") + "|" + uiMode + "|" + markdown; return (outgoing ? "out" : "in") + "|" + uiMode + "|" + markdown;
} }
private static String normalizeMarkdownLinks(String markdown) {
Matcher matcher = MARKDOWN_LINK_PATTERN.matcher(markdown);
StringBuffer buffer = new StringBuffer();
while (matcher.find()) {
String label = matcher.group(1) == null ? "链接" : matcher.group(1).trim();
label = label.replace("`", "").trim();
if (TextUtils.isEmpty(label)) {
label = "链接";
}
matcher.appendReplacement(buffer, Matcher.quoteReplacement(label));
}
matcher.appendTail(buffer);
return buffer.toString();
}
private static void appendHeading(SpannableStringBuilder builder, String text, int level, Palette palette) { private static void appendHeading(SpannableStringBuilder builder, String text, int level, Palette palette) {
ensureBlockSeparation(builder, true); ensureBlockSeparation(builder, true);
int start = builder.length(); int start = builder.length();
@@ -148,8 +172,26 @@ public final class BossMarkdown {
ensureBlockSeparation(builder, false); ensureBlockSeparation(builder, false);
int start = builder.length(); int start = builder.length();
appendInlineStyled(builder, TextUtils.isEmpty(text) ? "引用" : text, palette); appendInlineStyled(builder, TextUtils.isEmpty(text) ? "引用" : text, palette);
builder.setSpan(new QuoteSpan(palette.quoteColor, BossUi.dp(palette.context, 3), BossUi.dp(palette.context, 8)), QuoteSpan quoteSpan = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); ? 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');
}
private static void appendLabelSection(
SpannableStringBuilder builder,
String label,
String content,
Palette palette
) {
ensureBlockSeparation(builder, true);
int labelStart = builder.length();
builder.append(label.trim());
builder.setSpan(new StyleSpan(Typeface.BOLD), labelStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.setSpan(new RelativeSizeSpan(1.03f), labelStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
builder.append('\n');
appendInlineStyled(builder, content.trim(), palette);
builder.append('\n'); 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

@@ -13,6 +13,7 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.SocketTimeoutException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Iterator; import java.util.Iterator;
@@ -90,6 +91,11 @@ final class BossRealtimeClient {
if (!running) { if (!running) {
return; return;
} }
if (shouldReconnectImmediately(error)) {
Log.i(TAG, "Realtime stream timed out while idle; reconnecting immediately");
backoffMs = INITIAL_BACKOFF_MS;
continue;
}
if (shouldAttemptSessionRestore(error)) { if (shouldAttemptSessionRestore(error)) {
try { try {
BossApiClient.ApiResponse restored = apiClient.restoreSession(); BossApiClient.ApiResponse restored = apiClient.restoreSession();
@@ -130,6 +136,7 @@ final class BossRealtimeClient {
connection.setRequestProperty("Accept", "text/event-stream"); connection.setRequestProperty("Accept", "text/event-stream");
connection.setRequestProperty("Cache-Control", "no-cache"); connection.setRequestProperty("Cache-Control", "no-cache");
connection.setRequestProperty("x-boss-native-app", "1"); connection.setRequestProperty("x-boss-native-app", "1");
connection.setRequestProperty("x-boss-realtime-capabilities", "message-patch-v1");
String cookie = apiClient.getSessionCookie(); String cookie = apiClient.getSessionCookie();
if (!cookie.isEmpty()) { if (!cookie.isEmpty()) {
connection.setRequestProperty("Cookie", cookie); connection.setRequestProperty("Cookie", cookie);
@@ -174,6 +181,10 @@ final class BossRealtimeClient {
&& apiClient.hasRestoreToken(); && apiClient.hasRestoreToken();
} }
static boolean shouldReconnectImmediately(@Nullable Exception error) {
return error instanceof SocketTimeoutException;
}
private void dispatchEventBlock(String rawBlock) { private void dispatchEventBlock(String rawBlock) {
BossRealtimeEvent event = parseEventBlock(rawBlock); BossRealtimeEvent event = parseEventBlock(rawBlock);
if (event == null || event.eventName.isEmpty()) { if (event == null || event.eventName.isEmpty()) {

File diff suppressed because it is too large Load Diff

View File

@@ -253,12 +253,6 @@ public class ConversationFolderActivity extends BossScreenActivity {
folderDeviceId = folder.optString("deviceId", folderDeviceId == null ? "" : folderDeviceId).trim(); folderDeviceId = folder.optString("deviceId", folderDeviceId == null ? "" : folderDeviceId).trim();
int threadCount = folder.optInt("threadCount", 0); int threadCount = folder.optInt("threadCount", 0);
configureScreen(resolvedFolderName, threadCount + " 个线程"); configureScreen(resolvedFolderName, threadCount + " 个线程");
appendContent(BossUi.buildSoftPanel(
this,
"项目内部线程页",
resolvedFolderName,
"点击线程后进入具体聊天窗口。"
));
JSONArray threads = folder.optJSONArray("threads"); JSONArray threads = folder.optJSONArray("threads");
updateTrackedProjectIds(threads); updateTrackedProjectIds(threads);
@@ -269,24 +263,6 @@ public class ConversationFolderActivity extends BossScreenActivity {
} }
ArrayList<Integer> targetIndices = resolveTargetThreadIndices(threads); ArrayList<Integer> targetIndices = resolveTargetThreadIndices(threads);
if (!targetIndices.isEmpty()) {
String matchedLabel = targetProjectLabel;
if ((matchedLabel == null || matchedLabel.isEmpty())) {
JSONObject firstTarget = threads.optJSONObject(targetIndices.get(0));
if (firstTarget != null) {
matchedLabel = firstTarget.optString("threadTitle", "");
}
}
appendContent(BossUi.buildSoftPanel(
this,
"已定位到目标线程",
matchedLabel == null || matchedLabel.isEmpty()
? "文件夹页已打开,并将匹配线程置顶显示。"
: matchedLabel,
targetIndices.size() + " 个匹配项已置顶"
));
}
for (int i = 0; i < targetIndices.size(); i++) { for (int i = 0; i < targetIndices.size(); i++) {
renderThreadAtIndex(threads, targetIndices.get(i), true); renderThreadAtIndex(threads, targetIndices.get(i), true);
} }

View File

@@ -18,6 +18,7 @@ import java.util.Map;
public class ConversationInfoActivity extends BossScreenActivity { public class ConversationInfoActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id"; public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name"; 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 static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private String projectId; private String projectId;
@@ -178,17 +179,9 @@ public class ConversationInfoActivity extends BossScreenActivity {
takeoverInheritedFromGlobal = agentControls != null && agentControls.optBoolean("takeoverInheritedFromGlobal", false); takeoverInheritedFromGlobal = agentControls != null && agentControls.optBoolean("takeoverInheritedFromGlobal", false);
configureScreen("会话信息", buildSubtitle(threadMeta, participantCount)); configureScreen("会话信息", buildSubtitle(threadMeta, participantCount));
appendContent(BossUi.buildSimpleProfileHeader(
this,
projectName,
"单线程会话",
buildHeaderDetail(project, threadMeta, participantCount)
));
appendThreadStatusSummary(threadStatusPayload);
appendTakeoverControl(); appendTakeoverControl();
appendContent(BossUi.buildWechatMenuRow( appendConversationInfoItem(BossUi.buildWechatMenuRow(
this, this,
"发起群聊", "发起群聊",
"选择其他线程加入新群", "选择其他线程加入新群",
@@ -197,7 +190,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
v -> openGroupCreate() v -> openGroupCreate()
)); ));
appendContent(BossUi.buildWechatMenuRow( appendConversationInfoItem(BossUi.buildWechatMenuRow(
this, this,
"线程详情", "线程详情",
"查看当前线程聊天与项目", "查看当前线程聊天与项目",
@@ -206,7 +199,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
v -> openProject(projectId, projectName) v -> openProject(projectId, projectName)
)); ));
appendContent(BossUi.buildWechatMenuRow( appendConversationInfoItem(BossUi.buildWechatMenuRow(
this, this,
"线程状态", "线程状态",
"状态文档和最近进展事件", "状态文档和最近进展事件",
@@ -215,7 +208,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
v -> openThreadStatus() v -> openThreadStatus()
)); ));
appendContent(BossUi.buildWechatMenuRow( appendConversationInfoItem(BossUi.buildWechatMenuRow(
this, this,
"参与线程", "参与线程",
participantCount <= 0 ? "暂无参与线程" : "" + participantCount + "", participantCount <= 0 ? "暂无参与线程" : "" + participantCount + "",
@@ -225,7 +218,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
)); ));
if (participants == null || participants.length() == 0) { if (participants == null || participants.length() == 0) {
appendContent(BossUi.buildWechatMenuRow( appendConversationInfoItem(BossUi.buildWechatMenuRow(
this, this,
"暂无参与线程", "暂无参与线程",
"下拉刷新后重试", "下拉刷新后重试",
@@ -237,7 +230,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
for (int i = 0; i < participants.length(); i++) { for (int i = 0; i < participants.length(); i++) {
JSONObject participant = participants.optJSONObject(i); JSONObject participant = participants.optJSONObject(i);
if (participant == null) continue; if (participant == null) continue;
appendContent(buildParticipantRow(participant)); appendConversationInfoItem(buildParticipantRow(participant));
} }
} }
@@ -246,86 +239,34 @@ public class ConversationInfoActivity extends BossScreenActivity {
private void appendTakeoverControl() { private void appendTakeoverControl() {
SwitchCompat takeoverSwitch = new SwitchCompat(this); SwitchCompat takeoverSwitch = new SwitchCompat(this);
takeoverSwitch.setText("开启"); takeoverSwitch.setShowText(false);
takeoverSwitch.setText(null);
takeoverSwitch.setChecked(takeoverEnabled); takeoverSwitch.setChecked(takeoverEnabled);
takeoverSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> saveTakeoverSetting(isChecked)); takeoverSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> saveTakeoverSetting(isChecked));
appendContent(BossUi.buildFormCell( appendConversationInfoItem(BossUi.buildWechatSwitchRow(
this, this,
"主 Agent 协同接管", "主 Agent 协同接管",
takeoverInheritedFromGlobal takeoverInheritedFromGlobal
? "当前跟随全局默认开启。主 Agent 会协同推进,但不会抢走你直接控制线程开发的能力。" ? "跟随全局默认开启"
: "这个线程单独开启主 Agent 协同推进。不会抢走你直接控制线程开发的能力。", : "线程单独开启",
takeoverSwitch takeoverSwitch
)); ));
} }
private void appendThreadStatusSummary(@Nullable JSONObject threadStatusPayload) { private void appendConversationInfoItem(android.view.View view) {
if (threadStatusPayload == null) { android.view.ViewGroup.LayoutParams currentParams = view.getLayoutParams();
return; 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"); params.bottomMargin = BossUi.dp(this, 8);
if (document == null) { view.setLayoutParams(params);
return; appendContent(view);
}
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();
} }
private LinearLayout buildParticipantRow(JSONObject participant) { private LinearLayout buildParticipantRow(JSONObject participant) {
@@ -445,6 +386,10 @@ public class ConversationInfoActivity extends BossScreenActivity {
throw new IllegalStateException(response.message()); throw new IllegalStateException(response.message());
} }
runOnUiThread(() -> { 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 协同接管"); showMessage(enabled ? "已开启主 Agent 协同接管" : "已关闭主 Agent 协同接管");
reload(); reload();
}); });
@@ -517,25 +462,6 @@ public class ConversationInfoActivity extends BossScreenActivity {
return folder + " · " + suffix; 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) { private String resolveThreadId(JSONObject project, @Nullable JSONObject threadMeta) {
if (threadMeta != null) { if (threadMeta != null) {
String threadId = threadMeta.optString("threadId", ""); String threadId = threadMeta.optString("threadId", "");

View File

@@ -178,6 +178,90 @@ public class DeviceDetailActivity extends BossScreenActivity {
null, null,
null null
)); ));
if (WechatSurfaceMapper.hasCodexAppServerCapability(device)) {
appendContent(BossUi.buildWechatMenuRow(
this,
"Codex App Server",
WechatSurfaceMapper.deviceCodexAppServerStatusLabel(device),
WechatSurfaceMapper.deviceCodexAppServerDetailLabel(device),
null,
null
));
if (WechatSurfaceMapper.hasCodexAppServerMetadata(device)) {
appendContent(BossUi.buildWechatMenuRow(
this,
"模型",
WechatSurfaceMapper.deviceCodexModelSummary(device),
null,
null,
null
));
appendContent(BossUi.buildWechatMenuRow(
this,
"扩展",
WechatSurfaceMapper.deviceCodexExtensionSummary(device),
null,
null,
null
));
appendContent(BossUi.buildWechatMenuRow(
this,
"治理",
WechatSurfaceMapper.deviceCodexGovernanceSummary(device),
null,
null,
null
));
appendContent(BossUi.buildWechatMenuRow(
this,
"账号",
WechatSurfaceMapper.deviceCodexAccountSummary(device),
null,
null,
null
));
appendContent(BossUi.buildWechatMenuRow(
this,
"线程",
WechatSurfaceMapper.deviceCodexThreadSummary(device),
null,
null,
null
));
appendContent(BossUi.buildWechatMenuRow(
this,
"轮次",
WechatSurfaceMapper.deviceCodexTurnSummary(device),
null,
null,
null
));
appendContent(BossUi.buildWechatMenuRow(
this,
"线程操作",
WechatSurfaceMapper.deviceCodexThreadActionSummary(device),
null,
null,
null
));
}
appendContent(BossUi.buildWechatMenuRow(
this,
"线程协作",
WechatSurfaceMapper.deviceCodexThreadCollaborationSummary(device),
null,
null,
null
));
appendContent(BossUi.buildWechatMenuRow(
this,
"协议漂移",
WechatSurfaceMapper.deviceCodexProtocolDriftSummary(device),
null,
null,
null
));
}
appendContent(BossUi.buildWechatMenuRow( appendContent(BossUi.buildWechatMenuRow(
this, this,
"默认执行模式", "默认执行模式",
@@ -186,6 +270,30 @@ public class DeviceDetailActivity extends BossScreenActivity {
null, null,
v -> showPreferredExecutionModeDialog(device) v -> showPreferredExecutionModeDialog(device)
)); ));
appendContent(BossUi.buildWechatMenuRow(
this,
"Codex 远程控制",
"默认走 Codex Computer Use失效时回退 boss-agent 本机控制",
null,
null,
null
));
appendContent(BossUi.buildWechatMenuRow(
this,
"启动远控",
"拉起本机 Codex Remote Control 守护进程",
"需在线设备",
null,
v -> showCodexRemoteControlConfirmDialog("start")
));
appendContent(BossUi.buildWechatMenuRow(
this,
"停止远控",
"停止本机 Codex Remote Control 守护进程",
"需在线设备",
null,
v -> showCodexRemoteControlConfirmDialog("stop")
));
if (primaryPolicy != null) { if (primaryPolicy != null) {
appendContent(BossUi.buildWechatMenuRow( appendContent(BossUi.buildWechatMenuRow(
this, this,
@@ -283,6 +391,19 @@ public class DeviceDetailActivity extends BossScreenActivity {
.show(); .show();
} }
private void showCodexRemoteControlConfirmDialog(String action) {
String normalizedAction = "stop".equals(action) ? "stop" : "start";
boolean startAction = "start".equals(normalizedAction);
new AlertDialog.Builder(this)
.setTitle(startAction ? "启动 Codex 远控" : "停止 Codex 远控")
.setMessage("该操作会由这台电脑的 boss-agent 本机执行,并进入权限审计。")
.setNegativeButton("取消", null)
.setPositiveButton(startAction ? "确认启动" : "确认停止", (dialog, which) ->
queueCodexRemoteControl(normalizedAction)
)
.show();
}
private void openEditDialog() { private void openEditDialog() {
executor.execute(() -> { executor.execute(() -> {
try { try {
@@ -298,6 +419,34 @@ public class DeviceDetailActivity extends BossScreenActivity {
}); });
} }
private void queueCodexRemoteControl(String action) {
if (deviceId == null || deviceId.trim().isEmpty()) {
showMessage("缺少设备 ID");
return;
}
boolean startAction = "start".equals(action);
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.queueCodexRemoteControl(
deviceId,
action,
startAction ? "APP 设备详情页确认启动 Codex 远控" : "APP 设备详情页确认停止 Codex 远控"
);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
showMessage(startAction ? "已提交启动远控" : "已提交停止远控");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage((startAction ? "启动远控失败:" : "停止远控失败:") + error.getMessage());
});
}
});
}
private void savePreferredExecutionMode(String preferredExecutionMode) { private void savePreferredExecutionMode(String preferredExecutionMode) {
setRefreshing(true); setRefreshing(true);
executor.execute(() -> { executor.execute(() -> {

View File

@@ -1,16 +1,25 @@
package com.hyzq.boss; package com.hyzq.boss;
import android.Manifest;
import android.content.res.ColorStateList;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.text.Editable; import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher; import android.text.TextWatcher;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.ViewParent; import android.view.ViewParent;
import android.view.animation.AccelerateDecelerateInterpolator; import android.view.animation.AccelerateDecelerateInterpolator;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button; import android.widget.Button;
import android.widget.EditText; import android.widget.EditText;
import android.widget.FrameLayout; import android.widget.FrameLayout;
@@ -21,6 +30,7 @@ import android.widget.ScrollView;
import android.widget.TextView; import android.widget.TextView;
import android.widget.Toast; import android.widget.Toast;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.DiffUtil;
@@ -43,14 +53,18 @@ import java.util.function.Supplier;
public class MainActivity extends AppCompatActivity { public class MainActivity extends AppCompatActivity {
public static final String EXTRA_INITIAL_TAB = "initial_tab"; public static final String EXTRA_INITIAL_TAB = "initial_tab";
public static final String EXTRA_FORCE_LOGOUT = "force_logout";
private static final int REQUEST_POST_NOTIFICATIONS = 2101;
private static final String UI_PREFS = "boss_native_client"; 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_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 ROOT_BACK_EXIT_WINDOW_MS = 1_500L;
private static final long CONVERSATION_AUTO_REFRESH_MS = 12_000L; 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_DEBOUNCE_MS = 350L;
private static final long REALTIME_REFRESH_THROTTLE_MS = 900L; private static final long REALTIME_REFRESH_THROTTLE_MS = 900L;
private final ExecutorService executor = Executors.newSingleThreadExecutor(); private final ExecutorService executor = Executors.newSingleThreadExecutor();
private final ExecutorService sessionExecutor = Executors.newSingleThreadExecutor();
private final Handler uiHandler = new Handler(Looper.getMainLooper()); private final Handler uiHandler = new Handler(Looper.getMainLooper());
private BossApiClient apiClient; private BossApiClient apiClient;
@@ -62,7 +76,16 @@ public class MainActivity extends AppCompatActivity {
private View mainTopBar; private View mainTopBar;
private TextView loginTitle; private TextView loginTitle;
private TextView loginHint; 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 loginButton;
private Button loginModeButton;
private Button registerModeButton;
private Button forgotModeButton;
private ProgressBar loginProgress; private ProgressBar loginProgress;
private ImageButton backButton; private ImageButton backButton;
@@ -90,6 +113,7 @@ public class MainActivity extends AppCompatActivity {
private String activeTab = "conversations"; private String activeTab = "conversations";
private String preferredEntryTab = "conversations"; private String preferredEntryTab = "conversations";
private @Nullable String requestedInitialTab; private @Nullable String requestedInitialTab;
private String authMode = "login";
private boolean userSelectedTab = false; private boolean userSelectedTab = false;
private long lastRootBackPressedAt = 0L; private long lastRootBackPressedAt = 0L;
private @Nullable JSONObject sessionData; private @Nullable JSONObject sessionData;
@@ -105,9 +129,11 @@ public class MainActivity extends AppCompatActivity {
private boolean conversationQuickActionsVisible = false; private boolean conversationQuickActionsVisible = false;
private boolean conversationAutoRefreshArmed = false; private boolean conversationAutoRefreshArmed = false;
private boolean conversationAutoRefreshEnabled = false; private boolean conversationAutoRefreshEnabled = false;
private boolean conversationRootUsesGroupedHomeFeed = false;
private boolean rootTabRefreshInFlight = false; private boolean rootTabRefreshInFlight = false;
private boolean pendingRootTabRefresh = false; private boolean pendingRootTabRefresh = false;
private boolean realtimeRefreshScheduled = false; private boolean realtimeRefreshScheduled = false;
private boolean notificationPermissionRequestScheduled = false;
private final java.util.HashMap<String, Long> recentRealtimeEventTimestamps = new java.util.HashMap<>(); private final java.util.HashMap<String, Long> recentRealtimeEventTimestamps = new java.util.HashMap<>();
private final Set<String> selectedConversationProjectIds = new LinkedHashSet<>(); private final Set<String> selectedConversationProjectIds = new LinkedHashSet<>();
private @Nullable RootPagerAdapter rootPagerAdapter; private @Nullable RootPagerAdapter rootPagerAdapter;
@@ -140,18 +166,45 @@ public class MainActivity extends AppCompatActivity {
protected void onCreate(@Nullable Bundle savedInstanceState) { protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main); setContentView(R.layout.activity_main);
apiClient = new BossApiClient(this); apiClient = createApiClient();
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent); realtimeClient = createRealtimeClient(apiClient);
bindViews(); bindViews();
bindActions(); bindActions();
configureBackNavigation();
if (isForceLogoutIntent(getIntent())) {
forceLogoutToLoginPanel();
return;
}
applyInitialTab(getIntent()); applyInitialTab(getIntent());
bootstrapSession(); 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);
}
@Override
public void onRealtimeConnectionChanged(boolean connected) {
runOnUiThread(() -> handleRealtimeConnectionChanged(connected));
}
});
}
@Override @Override
protected void onNewIntent(Intent intent) { protected void onNewIntent(Intent intent) {
super.onNewIntent(intent); super.onNewIntent(intent);
setIntent(intent); setIntent(intent);
if (isForceLogoutIntent(intent)) {
forceLogoutToLoginPanel();
return;
}
applyInitialTab(intent); applyInitialTab(intent);
if (contentPanel.getVisibility() == View.VISIBLE) { if (contentPanel.getVisibility() == View.VISIBLE) {
maybeApplyPreferredEntry(); maybeApplyPreferredEntry();
@@ -159,32 +212,57 @@ public class MainActivity extends AppCompatActivity {
} }
} }
@Override private boolean isForceLogoutIntent(@Nullable Intent intent) {
public void onBackPressed() { return intent != null && intent.getBooleanExtra(EXTRA_FORCE_LOGOUT, false);
}
private void forceLogoutToLoginPanel() {
apiClient.clearLocalAuthState();
sessionData = null;
conversationsData = null;
devicesData = null;
otaData = null;
showLogin("已退出登录。点击登录可重新进入系统。");
}
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) { if (contentPanel.getVisibility() == View.VISIBLE && conversationSearchMode) {
exitConversationSearchMode(true); exitConversationSearchMode(true);
return; return true;
} }
if (contentPanel.getVisibility() == View.VISIBLE && conversationQuickActionsVisible) { if (contentPanel.getVisibility() == View.VISIBLE && conversationQuickActionsVisible) {
hideConversationQuickActions(true); hideConversationQuickActions(true);
return; return true;
} }
if (contentPanel.getVisibility() == View.VISIBLE && !"conversations".equals(activeTab)) { if (contentPanel.getVisibility() == View.VISIBLE && !"conversations".equals(activeTab)) {
setActiveTab("conversations", false); setActiveTab("conversations", false);
persistLastRootTab("conversations"); persistLastRootTab("conversations");
return; return true;
} }
if (contentPanel.getVisibility() == View.VISIBLE) { if (contentPanel.getVisibility() == View.VISIBLE) {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
if (now - lastRootBackPressedAt < ROOT_BACK_EXIT_WINDOW_MS) { if (now - lastRootBackPressedAt < ROOT_BACK_EXIT_WINDOW_MS) {
moveTaskToBack(true); moveTaskToBack(true);
return; return true;
} }
lastRootBackPressedAt = now; lastRootBackPressedAt = now;
showMessage("再按一次返回,应用进入后台"); showMessage("再按一次返回,应用进入后台");
return; return true;
} }
super.onBackPressed(); return false;
} }
@Override @Override
@@ -192,6 +270,7 @@ public class MainActivity extends AppCompatActivity {
cancelConversationAutoRefresh(); cancelConversationAutoRefresh();
cancelRealtimeRefreshSchedule(); cancelRealtimeRefreshSchedule();
stopRealtimeUpdates(); stopRealtimeUpdates();
sessionExecutor.shutdownNow();
executor.shutdownNow(); executor.shutdownNow();
super.onDestroy(); super.onDestroy();
} }
@@ -202,6 +281,17 @@ public class MainActivity extends AppCompatActivity {
conversationAutoRefreshEnabled = true; conversationAutoRefreshEnabled = true;
updateConversationAutoRefresh(); updateConversationAutoRefresh();
updateRealtimeSubscription(); updateRealtimeSubscription();
maybeRequestNotificationPermission();
if (
contentPanel != null &&
contentPanel.getVisibility() == View.VISIBLE &&
"conversations".equals(activeTab) &&
apiClient != null &&
apiClient.hasSessionHints() &&
!rootTabRefreshInFlight
) {
refreshConversationsData();
}
} }
@Override @Override
@@ -220,7 +310,16 @@ public class MainActivity extends AppCompatActivity {
mainTopBar = findViewById(R.id.main_top_bar); mainTopBar = findViewById(R.id.main_top_bar);
loginTitle = findViewById(R.id.login_title); loginTitle = findViewById(R.id.login_title);
loginHint = findViewById(R.id.login_hint); 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); 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); loginProgress = findViewById(R.id.login_progress);
backButton = findViewById(R.id.back_button); backButton = findViewById(R.id.back_button);
topTitle = findViewById(R.id.top_title); topTitle = findViewById(R.id.top_title);
@@ -247,12 +346,17 @@ public class MainActivity extends AppCompatActivity {
loginTitle.setText(WechatSurfaceMapper.loginTitle()); loginTitle.setText(WechatSurfaceMapper.loginTitle());
loginHint.setText(WechatSurfaceMapper.loginHintText()); loginHint.setText(WechatSurfaceMapper.loginHintText());
loginButton.setText(WechatSurfaceMapper.loginButtonLabel()); loginButton.setText(WechatSurfaceMapper.loginButtonLabel());
setAuthMode("login", WechatSurfaceMapper.loginHintText());
BossWindowInsets.applyStatusBarInset(loginShell); BossWindowInsets.applyStatusBarInset(loginShell);
BossWindowInsets.applyStatusBarInset(mainTopBar); BossWindowInsets.applyStatusBarInset(mainTopBar);
} }
private void bindActions() { 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.setVisibility(View.GONE);
backButton.setOnClickListener(v -> { backButton.setOnClickListener(v -> {
if (conversationSearchMode) { if (conversationSearchMode) {
@@ -336,7 +440,7 @@ public class MainActivity extends AppCompatActivity {
} }
setLoginLoading(true, "正在恢复上次登录状态..."); setLoginLoading(true, "正在恢复上次登录状态...");
executor.execute(() -> { sessionExecutor.execute(() -> {
try { try {
BossApiClient.ApiResponse sessionResponse = apiClient.getSession(); BossApiClient.ApiResponse sessionResponse = apiClient.getSession();
if (!sessionResponse.ok()) { if (!sessionResponse.ok()) {
@@ -353,15 +457,52 @@ public class MainActivity extends AppCompatActivity {
} catch (Exception ignored) { } catch (Exception ignored) {
// Fall back to login panel. // Fall back to login panel.
} }
runOnUiThread(() -> setLoginLoading(false, WechatSurfaceMapper.loginHintText())); runOnUiThread(() -> setLoginLoading(false, "登录已过期,请重新输入账号密码。"));
}); });
} }
private void performAutoLogin() { private void performPrimaryAuthAction() {
setLoginLoading(true, "正在创建会话..."); String account = inputText(loginAccountInput);
executor.execute(() -> { 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 { try {
BossApiClient.ApiResponse response = apiClient.autoLogin(); BossApiClient.ApiResponse response = apiClient.loginWithPassword(account, password);
if (response.ok()) { if (response.ok()) {
JSONObject session = response.json.optJSONObject("session"); JSONObject session = response.json.optJSONObject("session");
runOnUiThread(() -> { runOnUiThread(() -> {
@@ -377,6 +518,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() { void refreshCurrentTab() {
if (rootTabRefreshInFlight) { if (rootTabRefreshInFlight) {
pendingRootTabRefresh = true; pendingRootTabRefresh = true;
@@ -401,9 +614,11 @@ public class MainActivity extends AppCompatActivity {
JSONObject session = ensureActiveSession(); JSONObject session = ensureActiveSession();
BossApiClient.ApiResponse conversations = null; BossApiClient.ApiResponse conversations = null;
boolean conversationsOk = false; boolean conversationsOk = false;
boolean usedGroupedHomeFeed = false;
try { try {
conversations = apiClient.getConversationHome(); conversations = apiClient.getConversationHome();
conversationsOk = conversations.ok(); conversationsOk = conversations.ok();
usedGroupedHomeFeed = conversationsOk;
} catch (Exception ignored) { } catch (Exception ignored) {
conversationsOk = false; conversationsOk = false;
} }
@@ -413,6 +628,7 @@ public class MainActivity extends AppCompatActivity {
if (fallbackConversations.ok()) { if (fallbackConversations.ok()) {
conversations = fallbackConversations; conversations = fallbackConversations;
conversationsOk = true; conversationsOk = true;
usedGroupedHomeFeed = false;
} }
} catch (Exception ignored) { } catch (Exception ignored) {
conversationsOk = false; conversationsOk = false;
@@ -421,18 +637,24 @@ public class MainActivity extends AppCompatActivity {
BossApiClient.ApiResponse finalConversations = conversations; BossApiClient.ApiResponse finalConversations = conversations;
final boolean finalConversationsOk = conversationsOk; final boolean finalConversationsOk = conversationsOk;
final boolean finalUsedGroupedHomeFeed = usedGroupedHomeFeed;
runOnUiThread(() -> { runOnUiThread(() -> {
sessionData = session; sessionData = session;
JSONArray refreshedConversations = finalConversations == null JSONArray refreshedConversations = finalConversations == null
? null ? null
: WechatSurfaceMapper.normalizeConversationHomeFeed( : finalUsedGroupedHomeFeed
finalConversations.json.optJSONArray("conversations") ? finalConversations.json.optJSONArray("conversations")
); : WechatSurfaceMapper.normalizeConversationHomeFeed(
finalConversations.json.optJSONArray("conversations")
);
conversationsData = WechatSurfaceMapper.resolveRefreshValue( conversationsData = WechatSurfaceMapper.resolveRefreshValue(
conversationsData, conversationsData,
refreshedConversations, refreshedConversations,
finalConversationsOk finalConversationsOk
); );
if (finalConversationsOk) {
conversationRootUsesGroupedHomeFeed = finalUsedGroupedHomeFeed;
}
maybeApplyPreferredEntry(); maybeApplyPreferredEntry();
renderCurrentTab(); renderCurrentTab();
startRefreshing(false); startRefreshing(false);
@@ -600,7 +822,11 @@ public class MainActivity extends AppCompatActivity {
return false; return false;
} }
JSONObject conversationItem = event.payload.optJSONObject("conversationItem"); 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; return false;
} }
runOnUiThread(() -> { runOnUiThread(() -> {
@@ -610,7 +836,7 @@ public class MainActivity extends AppCompatActivity {
} }
conversationsData = WechatSurfaceMapper.mergeConversationHomeItem( conversationsData = WechatSurfaceMapper.mergeConversationHomeItem(
conversationsData, conversationsData,
conversationItem, patchItem,
affectedProjectId affectedProjectId
); );
renderCurrentTab(); renderCurrentTab();
@@ -653,7 +879,9 @@ public class MainActivity extends AppCompatActivity {
private boolean shouldRefreshConversationsTab(BossRealtimeEvent event) { private boolean shouldRefreshConversationsTab(BossRealtimeEvent event) {
if ("conversation.context_indicator.updated".equals(event.eventName)) { if ("conversation.context_indicator.updated".equals(event.eventName)) {
return false; return hasProjectId(event)
|| hasDeviceId(event)
|| event.payload.optJSONArray("conversations") != null;
} }
if ("conversation.updated".equals(event.eventName)) { if ("conversation.updated".equals(event.eventName)) {
return hasProjectId(event) || hasDeviceId(event); return hasProjectId(event) || hasDeviceId(event);
@@ -718,6 +946,7 @@ public class MainActivity extends AppCompatActivity {
BossApiClient.ApiResponse ota = null; BossApiClient.ApiResponse ota = null;
BossApiClient.ApiResponse settings = null; BossApiClient.ApiResponse settings = null;
boolean conversationsOk = false; boolean conversationsOk = false;
boolean usedGroupedHomeFeed = false;
boolean devicesOk = false; boolean devicesOk = false;
boolean otaOk = false; boolean otaOk = false;
boolean settingsOk = false; boolean settingsOk = false;
@@ -725,6 +954,7 @@ public class MainActivity extends AppCompatActivity {
try { try {
conversations = apiClient.getConversationHome(); conversations = apiClient.getConversationHome();
conversationsOk = conversations.ok(); conversationsOk = conversations.ok();
usedGroupedHomeFeed = conversationsOk;
} catch (Exception ignored) { } catch (Exception ignored) {
conversationsOk = false; conversationsOk = false;
} }
@@ -734,6 +964,7 @@ public class MainActivity extends AppCompatActivity {
if (fallbackConversations.ok()) { if (fallbackConversations.ok()) {
conversations = fallbackConversations; conversations = fallbackConversations;
conversationsOk = true; conversationsOk = true;
usedGroupedHomeFeed = false;
} }
} catch (Exception ignored) { } catch (Exception ignored) {
conversationsOk = false; conversationsOk = false;
@@ -764,6 +995,7 @@ public class MainActivity extends AppCompatActivity {
BossApiClient.ApiResponse finalOta = ota; BossApiClient.ApiResponse finalOta = ota;
BossApiClient.ApiResponse finalSettings = settings; BossApiClient.ApiResponse finalSettings = settings;
final boolean finalConversationsOk = conversationsOk; final boolean finalConversationsOk = conversationsOk;
final boolean finalUsedGroupedHomeFeed = usedGroupedHomeFeed;
final boolean finalDevicesOk = devicesOk; final boolean finalDevicesOk = devicesOk;
final boolean finalOtaOk = otaOk; final boolean finalOtaOk = otaOk;
final boolean finalSettingsOk = settingsOk; final boolean finalSettingsOk = settingsOk;
@@ -771,14 +1003,19 @@ public class MainActivity extends AppCompatActivity {
sessionData = finalSession; sessionData = finalSession;
JSONArray refreshedConversations = finalConversations == null JSONArray refreshedConversations = finalConversations == null
? null ? null
: WechatSurfaceMapper.normalizeConversationHomeFeed( : finalUsedGroupedHomeFeed
finalConversations.json.optJSONArray("conversations") ? finalConversations.json.optJSONArray("conversations")
); : WechatSurfaceMapper.normalizeConversationHomeFeed(
finalConversations.json.optJSONArray("conversations")
);
conversationsData = WechatSurfaceMapper.resolveRefreshValue( conversationsData = WechatSurfaceMapper.resolveRefreshValue(
conversationsData, conversationsData,
refreshedConversations, refreshedConversations,
finalConversationsOk finalConversationsOk
); );
if (finalConversationsOk) {
conversationRootUsesGroupedHomeFeed = finalUsedGroupedHomeFeed;
}
devicesData = WechatSurfaceMapper.resolveRefreshValue( devicesData = WechatSurfaceMapper.resolveRefreshValue(
devicesData, devicesData,
finalDevices == null ? null : finalDevices.json.optJSONArray("devices"), finalDevices == null ? null : finalDevices.json.optJSONArray("devices"),
@@ -854,6 +1091,7 @@ public class MainActivity extends AppCompatActivity {
private void showLogin(String hint) { private void showLogin(String hint) {
loginPanel.setVisibility(View.VISIBLE); loginPanel.setVisibility(View.VISIBLE);
contentPanel.setVisibility(View.GONE); contentPanel.setVisibility(View.GONE);
setAuthMode("login", hint);
setLoginLoading(false, hint); setLoginLoading(false, hint);
stopRealtimeUpdates(); stopRealtimeUpdates();
} }
@@ -864,15 +1102,76 @@ public class MainActivity extends AppCompatActivity {
setActiveTab(activeTab, false); setActiveTab(activeTab, false);
updateConversationAutoRefresh(); updateConversationAutoRefresh();
updateRealtimeSubscription(); updateRealtimeSubscription();
scheduleNotificationPermissionRequest();
} }
private void setLoginLoading(boolean loading, String hint) { private void setLoginLoading(boolean loading, String hint) {
loginProgress.setVisibility(loading ? View.VISIBLE : View.GONE); loginProgress.setVisibility(loading ? View.VISIBLE : View.GONE);
loginButton.setEnabled(!loading); 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); 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) { private void setActiveTab(String tab, boolean fromUser) {
if (!"conversations".equals(tab)) { if (!"conversations".equals(tab)) {
exitConversationSelectionMode(); exitConversationSelectionMode();
@@ -931,14 +1230,27 @@ public class MainActivity extends AppCompatActivity {
} }
private void updateTabStyles() { private void updateTabStyles() {
styleTab(tabConversations, "conversations".equals(activeTab)); styleTab(tabConversations, "conversations".equals(activeTab), R.drawable.ic_boss_tab_chat);
styleTab(tabDevices, "devices".equals(activeTab)); styleTab(tabDevices, "devices".equals(activeTab), R.drawable.ic_boss_tab_devices);
styleTab(tabMe, "me".equals(activeTab)); styleTab(tabMe, "me".equals(activeTab), R.drawable.ic_boss_tab_me);
} }
private void styleTab(Button button, boolean active) { private void styleTab(Button button, boolean active, int iconRes) {
button.setBackgroundResource(active ? R.drawable.bg_tab_active : R.drawable.bg_tab_inactive); int color = getColor(active ? R.color.boss_green : R.color.boss_text_muted);
button.setTextColor(getColor(active ? R.color.boss_green : R.color.boss_text_muted)); button.setBackgroundColor(Color.TRANSPARENT);
button.setTextColor(color);
button.setTextSize(10);
button.setAllCaps(false);
button.setGravity(android.view.Gravity.CENTER);
Drawable topIcon = getDrawable(iconRes);
if (topIcon != null) {
int iconSize = BossUi.dp(this, 18);
topIcon.setBounds(0, 0, iconSize, iconSize);
}
button.setCompoundDrawables(null, topIcon, null, null);
button.setCompoundDrawablePadding(BossUi.dp(this, 2));
button.setCompoundDrawableTintList(ColorStateList.valueOf(color));
button.setPadding(0, BossUi.dp(this, 3), 0, BossUi.dp(this, 1));
} }
private void configureTopAction(WechatSurfaceMapper.RootTopAction action) { private void configureTopAction(WechatSurfaceMapper.RootTopAction action) {
@@ -959,7 +1271,7 @@ public class MainActivity extends AppCompatActivity {
topSearchInput.setVisibility(View.GONE); topSearchInput.setVisibility(View.GONE);
backButton.setVisibility(View.GONE); backButton.setVisibility(View.GONE);
searchButton.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); refreshButton.setVisibility(View.VISIBLE);
configureTopAction(action); configureTopAction(action);
} }
@@ -994,7 +1306,7 @@ public class MainActivity extends AppCompatActivity {
refreshButton.setEnabled(true); refreshButton.setEnabled(true);
return; return;
} }
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode); WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode, currentSessionRole());
configureTopAction(action); configureTopAction(action);
refreshButton.setEnabled(!"refresh".equals(action.actionKey) || !refreshing); refreshButton.setEnabled(!"refresh".equals(action.actionKey) || !refreshing);
refreshButton.setAlpha(refreshing && "refresh".equals(action.actionKey) ? 0.45f : 1f); refreshButton.setAlpha(refreshing && "refresh".equals(action.actionKey) ? 0.45f : 1f);
@@ -1025,7 +1337,7 @@ public class MainActivity extends AppCompatActivity {
toggleConversationQuickActions(); toggleConversationQuickActions();
return; return;
} }
String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode).actionKey; String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode, currentSessionRole()).actionKey;
if ("add_device".equals(actionKey)) { if ("add_device".equals(actionKey)) {
startActivity(new Intent(this, DeviceEnrollmentActivity.class)); startActivity(new Intent(this, DeviceEnrollmentActivity.class));
return; return;
@@ -1166,6 +1478,24 @@ public class MainActivity extends AppCompatActivity {
showMessage("缺少 folderKey"); showMessage("缺少 folderKey");
return; return;
} }
if (conversationSearchMode) {
String matchedProjectId = item.optString("searchMatchProjectId", "").trim();
String matchedProjectLabel = item.optString("searchMatchLabel", "").trim();
if (!matchedProjectId.isEmpty() && !matchedProjectLabel.isEmpty()) {
openProject(matchedProjectId, matchedProjectLabel);
exitConversationSearchMode(true);
return;
}
openConversationFolder(
folderKey,
resolveConversationFolderName(item, row),
item.optString("searchMatchProjectId", ""),
item.optJSONArray("searchMatchProjectIds"),
item.optString("searchMatchLabel", "")
);
exitConversationSearchMode(true);
return;
}
openConversationFolder( openConversationFolder(
folderKey, folderKey,
resolveConversationFolderName(item, row), resolveConversationFolderName(item, row),
@@ -1180,6 +1510,9 @@ public class MainActivity extends AppCompatActivity {
return; return;
} }
String projectName = finalDisplayRow.threadTitle.isEmpty() ? "未命名会话" : finalDisplayRow.threadTitle; String projectName = finalDisplayRow.threadTitle.isEmpty() ? "未命名会话" : finalDisplayRow.threadTitle;
if (conversationSearchMode) {
exitConversationSearchMode(true);
}
openProject(projectId, projectName); openProject(projectId, projectName);
}) })
)); ));
@@ -1247,14 +1580,14 @@ public class MainActivity extends AppCompatActivity {
if (count > 0) { if (count > 0) {
TextView selectedView = new TextView(this); TextView selectedView = new TextView(this);
selectedView.setText("已选 " + count + " 个线程"); selectedView.setText("已选 " + count + " 个线程");
selectedView.setTextSize(13); selectedView.setTextSize(12);
selectedView.setTextColor(getColor(R.color.boss_text_primary)); selectedView.setTextColor(getColor(R.color.boss_text_primary));
summaryWrap.addView(selectedView); summaryWrap.addView(selectedView);
} }
if (count < 2) { if (count < 2) {
TextView hintView = new TextView(this); TextView hintView = new TextView(this);
hintView.setText("至少选择 2 个线程"); hintView.setText("至少选择 2 个线程");
hintView.setTextSize(12); hintView.setTextSize(11);
hintView.setTextColor(getColor(R.color.boss_text_muted)); hintView.setTextColor(getColor(R.color.boss_text_muted));
if (count > 0) { if (count > 0) {
hintView.setPadding(0, BossUi.dp(this, 4), 0, 0); hintView.setPadding(0, BossUi.dp(this, 4), 0, 0);
@@ -1292,10 +1625,7 @@ public class MainActivity extends AppCompatActivity {
hideConversationQuickActions(false); hideConversationQuickActions(false);
conversationSearchMode = true; conversationSearchMode = true;
syncTopActionVisualState(screenRefresh.isRefreshing()); syncTopActionVisualState(screenRefresh.isRefreshing());
topSearchInput.post(() -> { showConversationSearchKeyboard();
topSearchInput.requestFocus();
topSearchInput.setSelection(topSearchInput.getText().length());
});
} }
private void exitConversationSearchMode(boolean clearQuery) { private void exitConversationSearchMode(boolean clearQuery) {
@@ -1308,12 +1638,40 @@ public class MainActivity extends AppCompatActivity {
conversationSearchQuery = ""; conversationSearchQuery = "";
topSearchInput.setText(""); topSearchInput.setText("");
} }
hideConversationSearchKeyboard();
syncTopActionVisualState(screenRefresh != null && screenRefresh.isRefreshing()); syncTopActionVisualState(screenRefresh != null && screenRefresh.isRefreshing());
if (queryChanged && "conversations".equals(activeTab) && contentPanel.getVisibility() == View.VISIBLE) { if (queryChanged && "conversations".equals(activeTab) && contentPanel.getVisibility() == View.VISIBLE) {
renderConversationsRoot(); renderConversationsRoot();
} }
} }
private void showConversationSearchKeyboard() {
if (topSearchInput == null) {
return;
}
topSearchInput.post(() -> {
topSearchInput.requestFocus();
topSearchInput.setSelection(topSearchInput.getText().length());
InputMethodManager inputMethodManager =
(InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
if (inputMethodManager != null) {
inputMethodManager.showSoftInput(topSearchInput, InputMethodManager.SHOW_IMPLICIT);
}
});
}
private void hideConversationSearchKeyboard() {
if (topSearchInput == null) {
return;
}
InputMethodManager inputMethodManager =
(InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
if (inputMethodManager != null) {
inputMethodManager.hideSoftInputFromWindow(topSearchInput.getWindowToken(), 0);
}
topSearchInput.clearFocus();
}
private void toggleConversationSelection(String projectId) { private void toggleConversationSelection(String projectId) {
if (selectedConversationProjectIds.contains(projectId)) { if (selectedConversationProjectIds.contains(projectId)) {
selectedConversationProjectIds.remove(projectId); selectedConversationProjectIds.remove(projectId);
@@ -1494,6 +1852,7 @@ public class MainActivity extends AppCompatActivity {
} }
private void prepareConversationQuickActionMenu() { private void prepareConversationQuickActionMenu() {
quickActionAddDevice.setVisibility("highest_admin".equals(currentSessionRole()) ? View.VISIBLE : View.GONE);
conversationQuickActionsMenu.setVisibility(View.VISIBLE); conversationQuickActionsMenu.setVisibility(View.VISIBLE);
conversationQuickActionsMenu.setAlpha(0f); conversationQuickActionsMenu.setAlpha(0f);
conversationQuickActionsMenu.setTranslationY(-BossUi.dp(this, 6)); conversationQuickActionsMenu.setTranslationY(-BossUi.dp(this, 6));
@@ -1504,6 +1863,7 @@ public class MainActivity extends AppCompatActivity {
conversationQuickActionsMenu.setAlpha(0f); conversationQuickActionsMenu.setAlpha(0f);
conversationQuickActionsMenu.setTranslationY(-BossUi.dp(this, 6)); conversationQuickActionsMenu.setTranslationY(-BossUi.dp(this, 6));
conversationQuickActionsMenu.setVisibility(View.GONE); conversationQuickActionsMenu.setVisibility(View.GONE);
quickActionAddDevice.setVisibility(View.VISIBLE);
} }
static boolean matchesConversationQuery(JSONObject item, String rawQuery) { static boolean matchesConversationQuery(JSONObject item, String rawQuery) {
@@ -1657,7 +2017,7 @@ public class MainActivity extends AppCompatActivity {
(roleLabel.isEmpty() ? "主控账号已启用安全保护" : roleLabel + " · 主控账号已启用安全保护") (roleLabel.isEmpty() ? "主控账号已启用安全保护" : roleLabel + " · 主控账号已启用安全保护")
)); ));
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItems()) { for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItemsForRole(currentSessionRole())) {
screenContent.addView(BossUi.buildWechatMenuRow( screenContent.addView(BossUi.buildWechatMenuRow(
this, this,
item.title, item.title,
@@ -1807,15 +2167,66 @@ 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()
&& !rootTabRefreshInFlight
&& screenRefresh != null
&& !screenRefresh.isRefreshing()) {
refreshCurrentTab();
}
updateConversationAutoRefresh();
}
private void openMeEntry(String key) { private void openMeEntry(String key) {
if (!WechatSurfaceMapper.canOpenMeEntryForRole(key, currentSessionRole())) {
showMessage("当前账号没有权限打开这个入口。");
return;
}
Intent intent; Intent intent;
switch (key) { switch (key) {
case "security": case "security":
intent = new Intent(this, SecurityActivity.class); intent = new Intent(this, SecurityActivity.class);
break; break;
case "access":
intent = new Intent(this, AccessManagementActivity.class);
break;
case "ai_accounts": case "ai_accounts":
intent = new Intent(this, AiAccountsActivity.class); intent = new Intent(this, AiAccountsActivity.class);
break; break;
case "storage":
intent = new Intent(this, StorageSettingsActivity.class);
break;
case "telegram":
intent = new Intent(this, TelegramIntegrationActivity.class);
break;
case "settings": case "settings":
intent = new Intent(this, SettingsActivity.class); intent = new Intent(this, SettingsActivity.class);
break; break;
@@ -1835,6 +2246,13 @@ public class MainActivity extends AppCompatActivity {
startActivity(intent); startActivity(intent);
} }
private String currentSessionRole() {
if (sessionData == null) {
return "member";
}
return sessionData.optString("role", "member");
}
private void openSkillInventoryFromMe() { private void openSkillInventoryFromMe() {
String targetDeviceId = resolveSkillTargetDeviceId(); String targetDeviceId = resolveSkillTargetDeviceId();
if (targetDeviceId == null || targetDeviceId.isEmpty()) { if (targetDeviceId == null || targetDeviceId.isEmpty()) {

View File

@@ -0,0 +1,121 @@
package com.hyzq.boss;
import android.text.TextUtils;
import androidx.annotation.Nullable;
final class MasterAgentModePresets {
static final class ModePreset {
final String key;
final String label;
@Nullable final String modelOverride;
@Nullable final String reasoningEffortOverride;
ModePreset(
String key,
String label,
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride
) {
this.key = key;
this.label = label;
this.modelOverride = modelOverride;
this.reasoningEffortOverride = reasoningEffortOverride;
}
}
static final ModePreset DEFAULT = new ModePreset("default", "沿用默认", null, null);
private static final String DEFAULT_FAST_MODEL = "gpt-5.4-mini";
private static final String DEFAULT_DEEP_MODEL = "gpt-5.4";
private MasterAgentModePresets() {}
static ModePreset[] primaryChoices(@Nullable String fastModelOverride, @Nullable String deepModelOverride) {
return new ModePreset[]{
DEFAULT,
new ModePreset("fast", "快速反应", resolveFastModel(fastModelOverride), "low"),
new ModePreset("deep", "深度思考", resolveDeepModel(deepModelOverride), "high")
};
}
static String[] primaryChoiceLabels(@Nullable String fastModelOverride, @Nullable String deepModelOverride) {
return new String[]{
"沿用默认",
"快速反应(" + resolveFastModel(fastModelOverride) + "",
"深度思考(" + resolveDeepModel(deepModelOverride) + "",
"更多模型..."
};
}
static int findPrimaryChoiceIndex(
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
@Nullable String fastModelOverride,
@Nullable String deepModelOverride
) {
ModePreset preset = matchPreset(modelOverride, reasoningEffortOverride, fastModelOverride, deepModelOverride);
if (preset == null) {
return primaryChoiceLabels(fastModelOverride, deepModelOverride).length - 1;
}
ModePreset[] choices = primaryChoices(fastModelOverride, deepModelOverride);
for (int index = 0; index < choices.length; index += 1) {
if (choices[index].key.equals(preset.key)) {
return index;
}
}
return 0;
}
@Nullable
static ModePreset matchPreset(
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
@Nullable String fastModelOverride,
@Nullable String deepModelOverride
) {
String model = normalize(modelOverride);
String reasoning = normalize(reasoningEffortOverride);
if (TextUtils.isEmpty(model) && TextUtils.isEmpty(reasoning)) {
return DEFAULT;
}
for (ModePreset preset : primaryChoices(fastModelOverride, deepModelOverride)) {
if (TextUtils.equals(normalize(preset.modelOverride), model)
&& TextUtils.equals(normalize(preset.reasoningEffortOverride), reasoning)) {
return preset;
}
}
return null;
}
static String describeCurrentMode(
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
@Nullable String fastModelOverride,
@Nullable String deepModelOverride
) {
ModePreset preset = matchPreset(modelOverride, reasoningEffortOverride, fastModelOverride, deepModelOverride);
return preset == null ? "自定义" : preset.label;
}
static String resolveFastModel(@Nullable String fastModelOverride) {
String resolved = normalize(fastModelOverride);
return TextUtils.isEmpty(resolved) ? DEFAULT_FAST_MODEL : resolved;
}
static String resolveDeepModel(@Nullable String deepModelOverride) {
String resolved = normalize(deepModelOverride);
return TextUtils.isEmpty(resolved) ? DEFAULT_DEEP_MODEL : resolved;
}
@Nullable
private static String normalize(@Nullable String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
if (trimmed.isEmpty() || "null".equalsIgnoreCase(trimmed)) {
return null;
}
return trimmed;
}
}

View File

@@ -14,6 +14,30 @@ import java.util.Set;
public final class ProjectChatUiState { public final class ProjectChatUiState {
private 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 static final class SelectionState {
public final boolean multiSelecting; public final boolean multiSelecting;
public final Set<String> selectedMessageIds; public final Set<String> selectedMessageIds;
@@ -31,6 +55,7 @@ public final class ProjectChatUiState {
public final boolean showMultiSelectBar; public final boolean showMultiSelectBar;
public final boolean showRefresh; public final boolean showRefresh;
public final boolean showHeaderAction; public final boolean showHeaderAction;
public final boolean copyEnabled;
public final boolean forwardEnabled; public final boolean forwardEnabled;
public final String backLabel; public final String backLabel;
public final String title; public final String title;
@@ -42,6 +67,7 @@ public final class ProjectChatUiState {
boolean showMultiSelectBar, boolean showMultiSelectBar,
boolean showRefresh, boolean showRefresh,
boolean showHeaderAction, boolean showHeaderAction,
boolean copyEnabled,
boolean forwardEnabled, boolean forwardEnabled,
String backLabel, String backLabel,
String title, String title,
@@ -52,6 +78,7 @@ public final class ProjectChatUiState {
this.showMultiSelectBar = showMultiSelectBar; this.showMultiSelectBar = showMultiSelectBar;
this.showRefresh = showRefresh; this.showRefresh = showRefresh;
this.showHeaderAction = showHeaderAction; this.showHeaderAction = showHeaderAction;
this.copyEnabled = copyEnabled;
this.forwardEnabled = forwardEnabled; this.forwardEnabled = forwardEnabled;
this.backLabel = backLabel; this.backLabel = backLabel;
this.title = title; this.title = title;
@@ -81,6 +108,77 @@ public final class ProjectChatUiState {
return nearBottom || forced; 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) { public static String threadExecutionConflictTitle(@Nullable JSONObject conflict) {
if (conflict == null) { if (conflict == null) {
return "当前线程命中冲突保护"; return "当前线程命中冲突保护";
@@ -149,6 +247,10 @@ public final class ProjectChatUiState {
return state != null && state.multiSelecting && state.selectedMessageIds.size() >= 2; 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( public static SelectionState reconcileSelection(
@Nullable SelectionState current, @Nullable SelectionState current,
@Nullable List<String> availableMessageIds @Nullable List<String> availableMessageIds
@@ -181,6 +283,7 @@ public final class ProjectChatUiState {
true, true,
false, false,
false, false,
canCopySelection(selectionState),
canForwardSelection(selectionState), canForwardSelection(selectionState),
"取消", "取消",
"已选 " + selectedCount + "", "已选 " + selectedCount + "",
@@ -194,6 +297,7 @@ public final class ProjectChatUiState {
!conversationInfoReady, !conversationInfoReady,
conversationInfoReady, conversationInfoReady,
false, false,
false,
"返回", "返回",
isBlank(defaultTitle) ? "项目详情" : defaultTitle, isBlank(defaultTitle) ? "项目详情" : defaultTitle,
isBlank(defaultSubtitle) ? "原生页面" : defaultSubtitle isBlank(defaultSubtitle) ? "原生页面" : defaultSubtitle
@@ -420,6 +524,13 @@ public final class ProjectChatUiState {
if ("completed".equals(taskStatus) || "failed".equals(taskStatus)) { if ("completed".equals(taskStatus) || "failed".equals(taskStatus)) {
return new ReplyWaitSpec(false, null); 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"); JSONObject message = response.optJSONObject("message");
return new ReplyWaitSpec(true, message == null ? null : message.optString("id", "")); 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); return !isBlank(latestMessageId) && !baselineMessageId.trim().equals(latestMessageId);
} }
public static boolean shouldAutoRefreshConversation(
boolean shouldMaintainAutoRefresh,
boolean realtimeConnected,
boolean trackedMasterReplyTimedOut
) {
return shouldMaintainAutoRefresh && (!realtimeConnected || trackedMasterReplyTimedOut);
}
@Nullable @Nullable
public static String latestMessageId(@Nullable JSONArray messages) { public static String latestMessageId(@Nullable JSONArray messages) {
if (messages == null || messages.length() == 0) { if (messages == null || messages.length() == 0) {
@@ -457,10 +576,110 @@ public final class ProjectChatUiState {
return messageId.isEmpty() ? null : messageId; 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) { private static boolean isBlank(@Nullable String value) {
return value == null || value.trim().isEmpty(); 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) { private static String truncate(@Nullable String value, int maxLength) {
String normalized = value == null ? "" : value.trim(); String normalized = value == null ? "" : value.trim();
if (normalized.length() <= maxLength) { if (normalized.length() <= maxLength) {
@@ -468,4 +687,83 @@ public final class ProjectChatUiState {
} }
return normalized.substring(0, maxLength) + ""; 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

@@ -168,6 +168,25 @@ public class ProjectGoalsActivity extends BossScreenActivity {
"" ""
)); ));
JSONObject understanding = project.optJSONObject("projectUnderstanding");
if (understanding != null) {
String projectGoal = understanding.optString("projectGoal").trim();
String currentProgress = understanding.optString("currentProgress").trim();
String recommendedNextStep = understanding.optString("recommendedNextStep").trim();
if (!projectGoal.isEmpty() || !currentProgress.isEmpty() || !recommendedNextStep.isEmpty()) {
StringBuilder summary = new StringBuilder();
appendSummaryLine(summary, "项目目标", projectGoal);
appendSummaryLine(summary, "当前进度", currentProgress);
appendSummaryLine(summary, "建议下一步", recommendedNextStep);
appendContent(BossUi.buildCard(
this,
"同步项目摘要",
summary.toString().trim(),
understanding.optString("updatedAt", "")
));
}
}
if (goals == null || goals.length() == 0) { if (goals == null || goals.length() == 0) {
appendContent(BossUi.buildEmptyCard(this, "当前项目还没有目标。点击右上角新增即可。")); appendContent(BossUi.buildEmptyCard(this, "当前项目还没有目标。点击右上角新增即可。"));
} else { } else {
@@ -187,6 +206,16 @@ public class ProjectGoalsActivity extends BossScreenActivity {
setRefreshing(false); setRefreshing(false);
} }
private void appendSummaryLine(StringBuilder builder, String label, String value) {
if (value == null || value.trim().isEmpty()) {
return;
}
if (builder.length() > 0) {
builder.append('\n');
}
builder.append(label).append("").append(value.trim());
}
private LinearLayout buildGoalChecklistCard(JSONObject goal) { private LinearLayout buildGoalChecklistCard(JSONObject goal) {
LinearLayout card = BossUi.buildCard(this, "", "", ""); LinearLayout card = BossUi.buildCard(this, "", "", "");
card.removeAllViews(); card.removeAllViews();
@@ -213,7 +242,7 @@ public class ProjectGoalsActivity extends BossScreenActivity {
indicator.setLayoutParams(indicatorParams); indicator.setLayoutParams(indicatorParams);
indicator.setGravity(Gravity.CENTER); indicator.setGravity(Gravity.CENTER);
indicator.setText(completed ? "" : ""); indicator.setText(completed ? "" : "");
indicator.setTextSize(18); indicator.setTextSize(14);
indicator.setTextColor(getColor(completed ? R.color.boss_green : R.color.boss_text_muted)); indicator.setTextColor(getColor(completed ? R.color.boss_green : R.color.boss_text_muted));
row.addView(indicator); row.addView(indicator);
@@ -228,14 +257,14 @@ public class ProjectGoalsActivity extends BossScreenActivity {
TextView title = new TextView(this); TextView title = new TextView(this);
title.setText(goal.optString("text", "未命名目标")); title.setText(goal.optString("text", "未命名目标"));
title.setTextSize(16); title.setTextSize(14);
title.setTextColor(getColor(R.color.boss_text_primary)); title.setTextColor(getColor(R.color.boss_text_primary));
title.setLineSpacing(0f, 1.2f); title.setLineSpacing(0f, 1.2f);
texts.addView(title); texts.addView(title);
TextView note = new TextView(this); TextView note = new TextView(this);
note.setText(goal.optString("note", "暂无备注")); note.setText(goal.optString("note", "暂无备注"));
note.setTextSize(13); note.setTextSize(12);
note.setTextColor(getColor(R.color.boss_text_muted)); note.setTextColor(getColor(R.color.boss_text_muted));
note.setPadding(0, BossUi.dp(this, 8), 0, 0); note.setPadding(0, BossUi.dp(this, 8), 0, 0);
texts.addView(note); texts.addView(note);

View File

@@ -13,7 +13,7 @@ import java.util.Map;
public class ProjectVersionsActivity extends BossScreenActivity { public class ProjectVersionsActivity extends BossScreenActivity {
public static final String EXTRA_PROJECT_ID = "project_id"; public static final String EXTRA_PROJECT_ID = "project_id";
public static final String EXTRA_PROJECT_NAME = "project_name"; public static final String EXTRA_PROJECT_NAME = "project_name";
private static final String GOAL_REFRESH_NOTE = "project_goals.updated"; private static final String VERSION_REFRESH_NOTE = "project_versions.updated";
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L; private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private String projectId; private String projectId;
@@ -24,7 +24,7 @@ public class ProjectVersionsActivity extends BossScreenActivity {
protected void onCreate(@Nullable Bundle savedInstanceState) { protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID); projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
configureScreen("版本迭代记录", getIntent().getStringExtra(EXTRA_PROJECT_NAME)); configureScreen("版本记录", getIntent().getStringExtra(EXTRA_PROJECT_NAME));
setHeaderAction("只读", v -> showMessage("版本记录只读")); setHeaderAction("只读", v -> showMessage("版本记录只读"));
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent); realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
reload(); reload();
@@ -111,7 +111,7 @@ public class ProjectVersionsActivity extends BossScreenActivity {
return false; return false;
} }
String payloadNote = event.payload.optString("note", "").trim(); String payloadNote = event.payload.optString("note", "").trim();
return payloadProjectId.equals(projectId) && GOAL_REFRESH_NOTE.equals(payloadNote); return payloadProjectId.equals(projectId) && VERSION_REFRESH_NOTE.equals(payloadNote);
} }
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) { private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {

View File

@@ -5,6 +5,7 @@ import android.os.Bundle;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
public class SecurityActivity extends BossScreenActivity { public class SecurityActivity extends BossScreenActivity {
@@ -22,7 +23,11 @@ public class SecurityActivity extends BossScreenActivity {
try { try {
BossApiClient.ApiResponse response = apiClient.getSession(); BossApiClient.ApiResponse response = apiClient.getSession();
if (!response.ok()) throw new IllegalStateException(response.message()); 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) { } catch (Exception error) {
runOnUiThread(() -> { runOnUiThread(() -> {
setRefreshing(false); 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(); replaceContent();
appendContent(BossUi.buildWechatMenuRow( appendContent(BossUi.buildWechatMenuRow(
this, 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 -> { appendContent(BossUi.buildMenuRow(this, "打开设备页", "查看已绑定设备与状态", null, v -> {
Intent intent = new Intent(this, MainActivity.class); Intent intent = new Intent(this, MainActivity.class);
intent.putExtra(MainActivity.EXTRA_INITIAL_TAB, "devices"); intent.putExtra(MainActivity.EXTRA_INITIAL_TAB, "devices");
@@ -68,6 +100,57 @@ public class SecurityActivity extends BossScreenActivity {
setRefreshing(false); 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.putExtra(MainActivity.EXTRA_FORCE_LOGOUT, true);
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() { private void logout() {
setRefreshing(true); setRefreshing(true);
executor.execute(() -> { executor.execute(() -> {
@@ -79,6 +162,7 @@ public class SecurityActivity extends BossScreenActivity {
runOnUiThread(() -> { runOnUiThread(() -> {
setRefreshing(false); setRefreshing(false);
Intent intent = new Intent(this, MainActivity.class); Intent intent = new Intent(this, MainActivity.class);
intent.putExtra(MainActivity.EXTRA_FORCE_LOGOUT, true);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent); startActivity(intent);
finish(); finish();

View File

@@ -1,12 +1,18 @@
package com.hyzq.boss; package com.hyzq.boss;
import android.app.AlertDialog;
import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.text.InputType;
import android.text.TextUtils;
import android.widget.Button; import android.widget.Button;
import android.widget.EditText;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -58,8 +64,18 @@ public class SkillInventoryActivity extends BossScreenActivity {
String targetDeviceId = resolveTargetDeviceId(); String targetDeviceId = resolveTargetDeviceId();
BossApiClient.ApiResponse response = apiClient.getDeviceSkills(targetDeviceId); BossApiClient.ApiResponse response = apiClient.getDeviceSkills(targetDeviceId);
if (!response.ok()) throw new IllegalStateException(response.message()); if (!response.ok()) throw new IllegalStateException(response.message());
JSONObject lifecyclePayload = null;
try {
BossApiClient.ApiResponse lifecycleResponse = apiClient.getSkillLifecycleRequests();
if (lifecycleResponse.ok()) {
lifecyclePayload = lifecycleResponse.json;
}
} catch (Exception ignored) {
lifecyclePayload = null;
}
deviceId = targetDeviceId; deviceId = targetDeviceId;
runOnUiThread(() -> renderSkills(response.json)); JSONObject finalLifecyclePayload = lifecyclePayload;
runOnUiThread(() -> renderSkills(response.json, finalLifecyclePayload));
} catch (Exception error) { } catch (Exception error) {
runOnUiThread(() -> { runOnUiThread(() -> {
setRefreshing(false); setRefreshing(false);
@@ -203,9 +219,14 @@ public class SkillInventoryActivity extends BossScreenActivity {
} }
private void renderSkills(JSONObject payload) { private void renderSkills(JSONObject payload) {
renderSkills(payload, null);
}
private void renderSkills(JSONObject payload, @Nullable JSONObject lifecyclePayload) {
replaceContent(); replaceContent();
JSONObject device = payload.optJSONObject("device"); JSONObject device = payload.optJSONObject("device");
JSONArray skills = payload.optJSONArray("skills"); JSONArray skills = payload.optJSONArray("skills");
boolean canManageLifecycle = lifecyclePayload != null;
if (device != null) { if (device != null) {
deviceName = device.optString("name", deviceId); deviceName = device.optString("name", deviceId);
@@ -220,6 +241,10 @@ public class SkillInventoryActivity extends BossScreenActivity {
)); ));
} }
if (canManageLifecycle) {
appendSkillManagementWorkspace(lifecyclePayload);
}
if (skills == null || skills.length() == 0) { if (skills == null || skills.length() == 0) {
appendContent(BossUi.buildEmptyCard(this, "当前设备还没有同步 Skill。")); appendContent(BossUi.buildEmptyCard(this, "当前设备还没有同步 Skill。"));
setRefreshing(false); setRefreshing(false);
@@ -244,8 +269,217 @@ public class SkillInventoryActivity extends BossScreenActivity {
Button copyPath = BossUi.buildMiniActionButton(this, "复制路径", false); Button copyPath = BossUi.buildMiniActionButton(this, "复制路径", false);
copyPath.setOnClickListener(v -> BossUi.copyText(this, "Skill 路径", skill.optString("path", ""))); copyPath.setOnClickListener(v -> BossUi.copyText(this, "Skill 路径", skill.optString("path", "")));
card.addView(BossUi.buildInlineActionRow(this, copyInvocation, copyPath)); card.addView(BossUi.buildInlineActionRow(this, copyInvocation, copyPath));
if (canManageLifecycle) {
Button update = BossUi.buildMiniActionButton(this, "更新下发", true);
update.setOnClickListener(v -> queueSkillLifecycleRequest("update", skill, null, null, null, null, null));
Button rollback = BossUi.buildMiniActionButton(this, "回滚", false);
rollback.setOnClickListener(v -> showVersionedSkillRequestDialog("rollback", skill, "回滚", "rollbackToVersion"));
Button versionLock = BossUi.buildMiniActionButton(this, "版本锁定", false);
versionLock.setOnClickListener(v -> showVersionedSkillRequestDialog("version_lock", skill, "版本锁定", "lockedVersion"));
card.addView(BossUi.buildInlineActionRow(this, update, rollback, versionLock));
}
appendContent(card); appendContent(card);
} }
setRefreshing(false); setRefreshing(false);
} }
private void appendSkillManagementWorkspace(JSONObject lifecyclePayload) {
JSONArray requests = lifecyclePayload.optJSONArray("requests");
int requestCount = requests == null ? 0 : requests.length();
int queuedCount = countRequestsByStatus(requests, "queued");
int runningCount = countRunningRequests(requests);
LinearLayout card = new LinearLayout(this);
card.setOrientation(LinearLayout.VERTICAL);
card.addView(BossUi.buildWechatMenuRow(
this,
"Skill 管理分发",
"安装、更新、回滚、版本锁定和账号权限分配统一在这里处理。",
"Skill 请求状态:待执行 " + queuedCount + " · 执行中 " + runningCount + " · 最近请求 " + requestCount,
null,
null
));
Button installRemote = BossUi.buildMiniActionButton(this, "安装远端 Skill", true);
installRemote.setOnClickListener(v -> showInstallSkillDialog());
Button grantPermission = BossUi.buildMiniActionButton(this, "分配权限", false);
grantPermission.setOnClickListener(v -> startActivity(new Intent(this, AccessManagementActivity.class)));
card.addView(BossUi.buildInlineActionRow(this, installRemote, grantPermission));
if (requests != null && requests.length() > 0) {
int maxRows = Math.min(3, requests.length());
for (int index = 0; index < maxRows; index += 1) {
JSONObject request = requests.optJSONObject(index);
if (request == null) continue;
card.addView(BossUi.buildWechatMenuRow(
this,
"Skill 请求状态",
request.optString("action", "-") + " · " + request.optString("status", "-"),
request.optString("skillId", request.optString("sourceUrl", "-"))
+ " · " + request.optString("requestedAt", "-"),
null,
null
));
}
}
appendContent(card);
}
private void showInstallSkillDialog() {
LinearLayout form = new LinearLayout(this);
form.setOrientation(LinearLayout.VERTICAL);
int padding = BossUi.dp(this, 12);
form.setPadding(padding, padding, padding, 0);
EditText sourceUrl = buildSingleLineInput("Git URL 或可信来源 URL");
EditText targetVersion = buildSingleLineInput("目标版本,可选");
EditText checksum = buildSingleLineInput("SHA256 校验和,可选");
form.addView(sourceUrl);
form.addView(targetVersion);
form.addView(checksum);
new AlertDialog.Builder(this)
.setTitle("安装远端 Skill")
.setView(form)
.setNegativeButton("取消", null)
.setPositiveButton("下发", (dialog, which) -> {
String source = sourceUrl.getText().toString().trim();
if (TextUtils.isEmpty(source)) {
showMessage("请输入 Skill 来源 URL");
return;
}
queueSkillLifecycleRequest(
"install",
null,
source,
targetVersion.getText().toString().trim(),
checksum.getText().toString().trim(),
null,
null
);
})
.show();
}
private void showVersionedSkillRequestDialog(
String action,
JSONObject skill,
String title,
String versionField
) {
EditText input = buildSingleLineInput("请输入版本号");
new AlertDialog.Builder(this)
.setTitle(title)
.setView(input)
.setNegativeButton("取消", null)
.setPositiveButton("下发", (dialog, which) -> {
String version = input.getText().toString().trim();
if (TextUtils.isEmpty(version)) {
showMessage("请输入版本号");
return;
}
if ("rollbackToVersion".equals(versionField)) {
queueSkillLifecycleRequest(action, skill, null, null, null, version, null);
} else {
queueSkillLifecycleRequest(action, skill, null, null, null, null, version);
}
})
.show();
}
private EditText buildSingleLineInput(String hint) {
EditText input = new EditText(this);
input.setHint(hint);
input.setSingleLine(true);
input.setInputType(InputType.TYPE_CLASS_TEXT);
int verticalPadding = BossUi.dp(this, 8);
input.setPadding(0, verticalPadding, 0, verticalPadding);
return input;
}
private void queueSkillLifecycleRequest(
String action,
@Nullable JSONObject skill,
@Nullable String sourceUrl,
@Nullable String targetVersion,
@Nullable String checksum,
@Nullable String rollbackToVersion,
@Nullable String lockedVersion
) {
try {
JSONObject payload = new JSONObject();
payload.put("action", action);
payload.put("deviceId", deviceId == null ? "" : deviceId);
if (skill != null) {
putIfNotBlank(payload, "skillId", skill.optString("skillId", ""));
}
putIfNotBlank(payload, "sourceUrl", sourceUrl);
putIfNotBlank(payload, "targetVersion", targetVersion);
putIfNotBlank(payload, "checksum", checksum);
putIfNotBlank(payload, "rollbackToVersion", rollbackToVersion);
putIfNotBlank(payload, "lockedVersion", lockedVersion);
putIfNotBlank(payload, "note", "boss-app-skill-management");
submitSkillLifecycleRequest(payload);
} catch (JSONException error) {
showMessage("Skill 请求构建失败:" + error.getMessage());
}
}
private void putIfNotBlank(JSONObject payload, String key, @Nullable String value) throws JSONException {
if (!TextUtils.isEmpty(value)) {
payload.put(key, value);
}
}
private void submitSkillLifecycleRequest(JSONObject payload) {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.createSkillLifecycleRequest(payload);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
showMessage("Skill 请求已下发");
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("Skill 请求失败:" + error.getMessage());
});
}
});
}
private static int countRequestsByStatus(@Nullable JSONArray requests, String status) {
if (requests == null) {
return 0;
}
int count = 0;
for (int index = 0; index < requests.length(); index += 1) {
JSONObject request = requests.optJSONObject(index);
if (request != null && status.equalsIgnoreCase(request.optString("status", ""))) {
count += 1;
}
}
return count;
}
private static int countRunningRequests(@Nullable JSONArray requests) {
if (requests == null) {
return 0;
}
int count = 0;
for (int index = 0; index < requests.length(); index += 1) {
JSONObject request = requests.optJSONObject(index);
if (request == null) continue;
String status = request.optString("status", "");
if ("claimed".equalsIgnoreCase(status)
|| "running".equalsIgnoreCase(status)
|| "processing".equalsIgnoreCase(status)) {
count += 1;
}
}
return count;
}
} }

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; import java.util.Map;
public final class WechatSurfaceMapper { 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( 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( private static final List<MeMenuItem> ROOT_ME_MENU_ITEMS = Arrays.asList(
new MeMenuItem("security", "账号与安全", "修改登录密码、设备安全与身份校验"), new MeMenuItem("security", "账号与安全", "修改登录密码、设备安全与身份校验"),
new MeMenuItem("settings", "设置", "默认首页、提醒方式与危险操作确认"), new MeMenuItem("settings", "设置", "默认首页、提醒方式与危险操作确认"),
new MeMenuItem("access", "用户与权限", "分配子账号、设备、项目与 Skill 权限"),
new MeMenuItem("ops", "运维与修复", "查看运维会话、修复回放与 standby 切换"), new MeMenuItem("ops", "运维与修复", "查看运维会话、修复回放与 standby 切换"),
new MeMenuItem("ai_accounts", "AI 账号", "管理主 GPT、备用 GPT 与 API 容灾"), 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("skills", "技能", "按设备查看 Skill 清单"),
new MeMenuItem("about", "关于", "当前版本、OTA 状态与更新内容") new MeMenuItem("about", "关于", "当前版本、OTA 状态与更新内容")
); );
@@ -59,14 +157,20 @@ public final class WechatSurfaceMapper {
JSONObject avatar = source.optJSONObject("avatar"); JSONObject avatar = source.optJSONObject("avatar");
boolean isGroup = source.optBoolean("isGroup", groupAvatarMembers.size() > 1); boolean isGroup = source.optBoolean("isGroup", groupAvatarMembers.size() > 1);
String conversationType = source.optString("conversationType", ""); String conversationType = source.optString("conversationType", "");
String threadTitle = trimLocalWorkspacePrefix( String folderLabel = normalizeConversationTitle(source.optString("folderLabel", ""));
source.optString("threadTitle", source.optString("title", source.optString("projectTitle", ""))) 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)) { if ("folder_archive".equals(conversationType)) {
threadTitle = source.optString( threadTitle = source.optString(
"projectTitle", "projectTitle",
source.optString("threadTitle", source.optString("title", source.optString("folderLabel", ""))) source.optString("threadTitle", source.optString("title", source.optString("folderLabel", "")))
); );
} else if (isPinnedSystemProject(projectId)) {
threadTitle = source.optString("projectTitle", threadTitle);
} }
String pinnedLabel = source.optString("topPinnedLabel", ""); String pinnedLabel = source.optString("topPinnedLabel", "");
return new ConversationRow( return new ConversationRow(
@@ -144,6 +248,116 @@ public final class WechatSurfaceMapper {
return builder.toString(); return builder.toString();
} }
public static boolean hasCodexAppServerCapability(JSONObject device) {
return resolveDeviceCapability(device, "codexAppServer") != null;
}
public static boolean hasCodexAppServerMetadata(JSONObject device) {
return resolveCodexAppServerMetadata(device) != null;
}
public static String deviceCodexAppServerStatusLabel(JSONObject device) {
JSONObject capability = resolveDeviceCapability(device, "codexAppServer");
boolean connected = capability != null && capability.optBoolean("connected", false);
return connected ? "已连接" : "未连接";
}
public static String deviceCodexAppServerDetailLabel(JSONObject device) {
JSONObject capability = resolveDeviceCapability(device, "codexAppServer");
if (capability == null) {
return "等待 boss-agent 上报 App Server 能力";
}
String lastSeenAt = capability.optString("lastSeenAt", "").trim();
if (lastSeenAt.isEmpty()) {
return "线程、模型、Skill 与协议探测";
}
return "最近上报 " + lastSeenAt;
}
public static String deviceCodexModelSummary(JSONObject device) {
JSONObject metadata = resolveCodexAppServerMetadata(device);
int modelCount = metadataArrayLength(metadata, "models");
if (modelCount <= 0) {
return "未发现";
}
return modelCount + ""
+ " · 默认 " + metadataText(metadata, "defaultModelId")
+ " · 快速 " + metadataText(metadata, "fastModelId")
+ " · 深度 " + metadataText(metadata, "deepModelId");
}
public static String deviceCodexExtensionSummary(JSONObject device) {
JSONObject metadata = resolveCodexAppServerMetadata(device);
return "Skill " + metadataArrayLength(metadata, "skills") + ""
+ " · Plugin " + metadataArrayLength(metadata, "plugins") + ""
+ " · App " + metadataArrayLength(metadata, "apps") + "";
}
public static String deviceCodexGovernanceSummary(JSONObject device) {
JSONObject metadata = resolveCodexAppServerMetadata(device);
return "实验特性 " + metadataArrayLength(metadata, "experimentalFeatures") + ""
+ " · 协作模式 " + metadataArrayLength(metadata, "collaborationModes") + ""
+ " · MCP " + metadataArrayLength(metadata, "mcpServers") + ""
+ " · 权限 " + metadataArrayLength(metadata, "permissionProfiles") + "";
}
public static String deviceCodexAccountSummary(JSONObject device) {
JSONObject metadata = resolveCodexAppServerMetadata(device);
JSONObject accountSummary = metadata == null ? null : metadata.optJSONObject("accountSummary");
JSONObject rateLimitSummary = metadata == null ? null : metadata.optJSONObject("rateLimitSummary");
return objectText(accountSummary, "authMode")
+ " · 套餐 " + objectText(accountSummary, "planType")
+ " · 额度 " + objectInt(rateLimitSummary, "maxUsedPercent") + "%";
}
public static String deviceCodexThreadSummary(JSONObject device) {
JSONObject summary = resolveCodexAppServerMetadataObject(device, "threadSummary");
return objectInt(summary, "threadCount") + ""
+ " · 已加载 " + objectInt(summary, "loadedThreadCount") + ""
+ " · 活跃 " + objectInt(summary, "activeThreadCount") + "";
}
public static String deviceCodexTurnSummary(JSONObject device) {
JSONObject summary = resolveCodexAppServerMetadataObject(device, "threadTurnSummary");
return objectInt(summary, "totalTurnCount") + ""
+ " · 运行中 " + objectInt(summary, "runningTurnCount") + ""
+ " · 完成 " + objectInt(summary, "completedTurnCount") + "";
}
public static String deviceCodexThreadActionSummary(JSONObject device) {
JSONObject summary = resolveCodexAppServerMetadataObject(device, "threadActionSummary");
return objectInt(summary, "actionCount") + ""
+ " · 生命周期 " + objectInt(summary, "lifecycleActionCount") + ""
+ " · 活跃干预 " + objectInt(summary, "liveTurnActionCount") + ""
+ " · " + (summary != null && summary.optBoolean("shellActionAvailable", false) ? "Shell 可用" : "Shell 不可用");
}
public static String deviceCodexThreadCollaborationSummary(JSONObject device) {
JSONObject summary = resolveCodexAppServerMetadataObject(device, "threadCollaborationSummary");
if (summary == null) {
return "等待线程协作探测";
}
String broker = summary.optBoolean("bossBrokerAvailable", false) ? "Boss Broker 可用" : "Boss Broker 不可用";
String handler = summary.optBoolean("collabToolCallHandlerAvailable", false) ? "协作事件可处理" : "协作事件不可处理";
String modes = summary.optInt("collaborationModeCount", 0) + " 种模式";
String direct = summary.optBoolean("directThreadChatSupported", false) ? "原生私聊" : "非原生私聊";
return broker + " · " + handler + " · " + modes + " · " + direct;
}
public static String deviceCodexProtocolDriftSummary(JSONObject device) {
JSONObject summary = resolveCodexAppServerMetadataObject(device, "protocolDriftSummary");
if (summary == null) {
return "等待协议探测";
}
String drift = "compatible".equals(summary.optString("driftLevel", "")) ? "兼容" : "告警";
String failedProbeCount = "失败探针 " + summary.optInt("failedProbeCount", 0) + "";
String docFollowupCount = "文档跟进 " + summary.optInt("docFollowupCount", 0) + "";
String fallbackStrategy = summary.optString("fallbackStrategy", "").contains("Boss Broker")
? "Boss Broker 兜底"
: "App Server 兜底";
return drift + " · " + failedProbeCount + " · " + docFollowupCount + " · " + fallbackStrategy;
}
public static String devicePreferredExecutionModeLabel(JSONObject device) { public static String devicePreferredExecutionModeLabel(JSONObject device) {
return "gui".equals(device == null ? "" : device.optString("preferredExecutionMode", "")) ? "GUI" : "CLI"; return "gui".equals(device == null ? "" : device.optString("preferredExecutionMode", "")) ? "GUI" : "CLI";
} }
@@ -188,10 +402,36 @@ public final class WechatSurfaceMapper {
return titles; 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() { public static MeMenuItem[] rootMeMenuItems() {
return ROOT_ME_MENU_ITEMS.toArray(new MeMenuItem[0]); 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) { public static MeMenuItem findMeMenuItem(String key) {
for (MeMenuItem item : ROOT_ME_MENU_ITEMS) { for (MeMenuItem item : ROOT_ME_MENU_ITEMS) {
if (item.key.equals(key)) { if (item.key.equals(key)) {
@@ -201,6 +441,34 @@ public final class WechatSurfaceMapper {
return null; 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() { public static String[] projectQuickActions() {
return PROJECT_QUICK_ACTIONS.toArray(new String[0]); return PROJECT_QUICK_ACTIONS.toArray(new String[0]);
} }
@@ -249,6 +517,10 @@ public final class WechatSurfaceMapper {
return "cancel_on_detach"; 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) { private static String buildContextStatusLabel(JSONObject source) {
if (source.optBoolean("mustFinishBeforeCompaction", false)) { if (source.optBoolean("mustFinishBeforeCompaction", false)) {
return "必须收尾"; return "必须收尾";
@@ -317,12 +589,57 @@ public final class WechatSurfaceMapper {
return capabilities.optJSONObject(capabilityKey); return capabilities.optJSONObject(capabilityKey);
} }
private static JSONObject resolveCodexAppServerMetadataObject(JSONObject device, String key) {
JSONObject metadata = resolveCodexAppServerMetadata(device);
return metadata == null ? null : metadata.optJSONObject(key);
}
private static JSONObject resolveCodexAppServerMetadata(JSONObject device) {
JSONObject capability = resolveDeviceCapability(device, "codexAppServer");
return capability == null ? null : capability.optJSONObject("metadata");
}
private static int metadataArrayLength(JSONObject metadata, String key) {
if (metadata == null) {
return 0;
}
JSONArray array = metadata.optJSONArray(key);
return array == null ? 0 : array.length();
}
private static String metadataText(JSONObject metadata, String key) {
if (metadata == null) {
return "未知";
}
String value = metadata.optString(key, "").trim();
return value.isEmpty() ? "未知" : value;
}
private static String objectText(JSONObject object, String key) {
if (object == null) {
return "未知";
}
String value = object.optString(key, "").trim();
return value.isEmpty() ? "未知" : value;
}
private static int objectInt(JSONObject object, String key) {
return object == null ? 0 : object.optInt(key, 0);
}
public static RootTopAction rootTopAction(String activeTab, boolean refreshing) { public static RootTopAction rootTopAction(String activeTab, boolean refreshing) {
return rootTopAction(activeTab, refreshing, false); return rootTopAction(activeTab, refreshing, false);
} }
public static RootTopAction rootTopAction(String activeTab, boolean refreshing, boolean selectionMode) { 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 ("devices".equals(activeTab)) {
if (!"highest_admin".equals(role)) {
return new RootTopAction("刷新", false, true, "refresh", "refresh");
}
return new RootTopAction("添加设备", false, true, "add", "add_device"); return new RootTopAction("添加设备", false, true, "add", "add_device");
} }
if ("conversations".equals(activeTab)) { if ("conversations".equals(activeTab)) {
@@ -714,9 +1031,156 @@ public final class WechatSurfaceMapper {
if (preview.matches("^已从设备.+导入线程《.+》[。.]?$")) { if (preview.matches("^已从设备.+导入线程《.+》[。.]?$")) {
return "已导入线程"; return "已导入线程";
} }
if (isLikelyProcessPreview(preview)) {
return "";
}
return preview; 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) { private static String trimLocalWorkspacePrefix(String value) {
String label = value == null ? "" : value.trim(); String label = value == null ? "" : value.trim();
if (label.isEmpty()) { 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

@@ -12,18 +12,18 @@
android:background="@color/boss_surface" android:background="@color/boss_surface"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingLeft="20dp" android:paddingLeft="16dp"
android:paddingTop="14dp" android:paddingTop="8dp"
android:paddingRight="20dp" android:paddingRight="16dp"
android:paddingBottom="12dp"> android:paddingBottom="7dp">
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_back_button" android:id="@+id/screen_back_button"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="返回" android:contentDescription="返回"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_back" android:src="@drawable/ic_boss_back"
android:tint="@color/boss_text_primary" /> android:tint="@color/boss_text_primary" />
@@ -37,43 +37,49 @@
<TextView <TextView
android:id="@+id/screen_title" android:id="@+id/screen_title"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:text="会话信息" android:text="会话信息"
android:textColor="@color/boss_text_primary" android:textColor="@color/boss_text_primary"
android:textSize="22sp" android:textSize="18sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView <TextView
android:id="@+id/screen_subtitle" android:id="@+id/screen_subtitle"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="3dp" android:layout_marginTop="3dp"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:text="单线程会话信息页" android:text="单线程会话信息页"
android:textColor="@color/boss_text_muted" android:textColor="@color/boss_text_muted"
android:textSize="12sp" /> android:textSize="11sp" />
</LinearLayout> </LinearLayout>
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_header_action" android:id="@+id/screen_header_action"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="更多" android:contentDescription="更多"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_more" android:src="@drawable/ic_boss_more"
android:tint="@color/boss_text_primary" android:tint="@color/boss_text_primary"
android:visibility="gone" /> android:visibility="gone" />
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_refresh_button" android:id="@+id/screen_refresh_button"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="刷新" android:contentDescription="刷新"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_refresh" android:src="@drawable/ic_boss_refresh"
android:tint="@color/boss_text_primary" /> android:tint="@color/boss_text_primary" />
</LinearLayout> </LinearLayout>

View File

@@ -12,18 +12,18 @@
android:background="@color/boss_surface" android:background="@color/boss_surface"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingLeft="20dp" android:paddingLeft="16dp"
android:paddingTop="14dp" android:paddingTop="8dp"
android:paddingRight="20dp" android:paddingRight="16dp"
android:paddingBottom="12dp"> android:paddingBottom="7dp">
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_back_button" android:id="@+id/screen_back_button"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="返回" android:contentDescription="返回"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_back" android:src="@drawable/ic_boss_back"
android:tint="@color/boss_text_primary" /> android:tint="@color/boss_text_primary" />
@@ -37,43 +37,49 @@
<TextView <TextView
android:id="@+id/screen_title" android:id="@+id/screen_title"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:text="标题" android:text="标题"
android:textColor="@color/boss_text_primary" android:textColor="@color/boss_text_primary"
android:textSize="22sp" android:textSize="18sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView <TextView
android:id="@+id/screen_subtitle" android:id="@+id/screen_subtitle"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="3dp" android:layout_marginTop="3dp"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:text="副标题" android:text="副标题"
android:textColor="@color/boss_text_muted" android:textColor="@color/boss_text_muted"
android:textSize="12sp" /> android:textSize="11sp" />
</LinearLayout> </LinearLayout>
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_header_action" android:id="@+id/screen_header_action"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="更多" android:contentDescription="更多"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_more" android:src="@drawable/ic_boss_more"
android:tint="@color/boss_text_primary" android:tint="@color/boss_text_primary"
android:visibility="gone" /> android:visibility="gone" />
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_refresh_button" android:id="@+id/screen_refresh_button"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="刷新" android:contentDescription="刷新"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_refresh" android:src="@drawable/ic_boss_refresh"
android:tint="@color/boss_text_primary" /> android:tint="@color/boss_text_primary" />
</LinearLayout> </LinearLayout>

View File

@@ -12,18 +12,18 @@
android:background="@color/boss_surface" android:background="@color/boss_surface"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingLeft="20dp" android:paddingLeft="16dp"
android:paddingTop="14dp" android:paddingTop="8dp"
android:paddingRight="20dp" android:paddingRight="16dp"
android:paddingBottom="12dp"> android:paddingBottom="7dp">
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_back_button" android:id="@+id/screen_back_button"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="返回" android:contentDescription="返回"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_back" android:src="@drawable/ic_boss_back"
android:tint="@color/boss_text_primary" /> android:tint="@color/boss_text_primary" />
@@ -37,43 +37,49 @@
<TextView <TextView
android:id="@+id/screen_title" android:id="@+id/screen_title"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:text="发起群聊" android:text="发起群聊"
android:textColor="@color/boss_text_primary" android:textColor="@color/boss_text_primary"
android:textSize="22sp" android:textSize="18sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView <TextView
android:id="@+id/screen_subtitle" android:id="@+id/screen_subtitle"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="3dp" android:layout_marginTop="3dp"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:text="从当前会话选择其他线程" android:text="从当前会话选择其他线程"
android:textColor="@color/boss_text_muted" android:textColor="@color/boss_text_muted"
android:textSize="12sp" /> android:textSize="11sp" />
</LinearLayout> </LinearLayout>
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_header_action" android:id="@+id/screen_header_action"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="更多" android:contentDescription="更多"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_more" android:src="@drawable/ic_boss_more"
android:tint="@color/boss_text_primary" android:tint="@color/boss_text_primary"
android:visibility="gone" /> android:visibility="gone" />
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_refresh_button" android:id="@+id/screen_refresh_button"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="刷新" android:contentDescription="刷新"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_refresh" android:src="@drawable/ic_boss_refresh"
android:tint="@color/boss_text_primary" /> android:tint="@color/boss_text_primary" />
</LinearLayout> </LinearLayout>

View File

@@ -12,18 +12,18 @@
android:background="@color/boss_surface" android:background="@color/boss_surface"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingLeft="20dp" android:paddingLeft="16dp"
android:paddingTop="14dp" android:paddingTop="8dp"
android:paddingRight="20dp" android:paddingRight="16dp"
android:paddingBottom="12dp"> android:paddingBottom="7dp">
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_back_button" android:id="@+id/screen_back_button"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="返回" android:contentDescription="返回"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_back" android:src="@drawable/ic_boss_back"
android:tint="@color/boss_text_primary" /> android:tint="@color/boss_text_primary" />
@@ -37,43 +37,49 @@
<TextView <TextView
android:id="@+id/screen_title" android:id="@+id/screen_title"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:text="群资料" android:text="群资料"
android:textColor="@color/boss_text_primary" android:textColor="@color/boss_text_primary"
android:textSize="22sp" android:textSize="18sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView <TextView
android:id="@+id/screen_subtitle" android:id="@+id/screen_subtitle"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="3dp" android:layout_marginTop="3dp"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:text="群聊资料页" android:text="群聊资料页"
android:textColor="@color/boss_text_muted" android:textColor="@color/boss_text_muted"
android:textSize="12sp" /> android:textSize="11sp" />
</LinearLayout> </LinearLayout>
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_header_action" android:id="@+id/screen_header_action"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="更多" android:contentDescription="更多"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_more" android:src="@drawable/ic_boss_more"
android:tint="@color/boss_text_primary" android:tint="@color/boss_text_primary"
android:visibility="gone" /> android:visibility="gone" />
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_refresh_button" android:id="@+id/screen_refresh_button"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="刷新" android:contentDescription="刷新"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_refresh" android:src="@drawable/ic_boss_refresh"
android:tint="@color/boss_text_primary" /> android:tint="@color/boss_text_primary" />
</LinearLayout> </LinearLayout>

View File

@@ -22,23 +22,23 @@
android:paddingBottom="40dp"> android:paddingBottom="40dp">
<TextView <TextView
android:layout_width="72dp" android:layout_width="60dp"
android:layout_height="72dp" android:layout_height="60dp"
android:background="@drawable/bg_secondary_button" android:background="@drawable/bg_secondary_button"
android:gravity="center" android:gravity="center"
android:text="B" android:text="B"
android:textColor="@color/boss_green" android:textColor="@color/boss_green"
android:textSize="28sp" android:textSize="18sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView <TextView
android:id="@+id/login_title" android:id="@+id/login_title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="22dp" android:layout_marginTop="18dp"
android:text="" android:text=""
android:textColor="@color/boss_text_primary" android:textColor="@color/boss_text_primary"
android:textSize="30sp" android:textSize="18sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView <TextView
@@ -50,13 +50,98 @@
android:lineSpacingExtra="4dp" android:lineSpacingExtra="4dp"
android:text="" android:text=""
android:textColor="@color/boss_text_muted" android:textColor="@color/boss_text_muted"
android:textSize="12sp" />
<EditText
android:id="@+id/login_account_input"
android:layout_width="match_parent"
android:layout_height="44dp"
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="14sp" /> android:textSize="14sp" />
<EditText
android:id="@+id/login_password_input"
android:layout_width="match_parent"
android:layout_height="44dp"
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="14sp" />
<EditText
android:id="@+id/login_confirm_password_input"
android:layout_width="match_parent"
android:layout_height="44dp"
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="14sp"
android:visibility="gone" />
<LinearLayout
android:id="@+id/login_code_row"
android:layout_width="match_parent"
android:layout_height="44dp"
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="14sp" />
<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="12sp" />
</LinearLayout>
<ProgressBar <ProgressBar
android:id="@+id/login_progress" android:id="@+id/login_progress"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="28dp" android:layout_marginTop="18dp"
android:visibility="gone" /> android:visibility="gone" />
<Button <Button
@@ -65,13 +150,53 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="22dp" android:layout_marginTop="22dp"
android:background="@drawable/bg_primary_button" android:background="@drawable/bg_primary_button"
android:paddingTop="14dp" android:paddingTop="12dp"
android:paddingBottom="14dp" android:paddingBottom="12dp"
android:text="" android:text=""
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="@color/boss_surface" android:textColor="@color/boss_surface"
android:textSize="18sp" android:textSize="14sp"
android:textStyle="bold" /> 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="12sp" />
<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="12sp" />
<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="12sp" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>
@@ -89,19 +214,19 @@
android:background="@color/boss_surface" android:background="@color/boss_surface"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingLeft="20dp" android:paddingLeft="16dp"
android:paddingTop="14dp" android:paddingTop="8dp"
android:paddingRight="20dp" android:paddingRight="16dp"
android:paddingBottom="12dp"> android:paddingBottom="7dp">
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/back_button" android:id="@+id/back_button"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:layout_marginRight="8dp" android:layout_marginRight="8dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="返回" android:contentDescription="返回"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_back" android:src="@drawable/ic_boss_back"
android:tint="@color/boss_text_primary" android:tint="@color/boss_text_primary"
android:visibility="gone" /> android:visibility="gone" />
@@ -121,7 +246,7 @@
android:maxLines="1" android:maxLines="1"
android:text="会话" android:text="会话"
android:textColor="@color/boss_text_primary" android:textColor="@color/boss_text_primary"
android:textSize="22sp" android:textSize="18sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView <TextView
@@ -131,46 +256,46 @@
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:text="" android:text=""
android:textColor="@color/boss_text_muted" android:textColor="@color/boss_text_muted"
android:textSize="12sp" android:textSize="11sp"
android:visibility="gone" /> android:visibility="gone" />
</LinearLayout> </LinearLayout>
<EditText <EditText
android:id="@+id/top_search_input" android:id="@+id/top_search_input"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="40dp" android:layout_height="34dp"
android:layout_weight="1" android:layout_weight="1"
android:background="@drawable/bg_secondary_button" android:background="@drawable/bg_secondary_button"
android:hint="搜索项目或线程" android:hint="搜索项目或线程"
android:imeOptions="actionSearch" android:imeOptions="actionSearch"
android:inputType="text" android:inputType="text"
android:paddingLeft="14dp" android:paddingLeft="12dp"
android:paddingTop="8dp" android:paddingTop="7dp"
android:paddingRight="14dp" android:paddingRight="12dp"
android:paddingBottom="8dp" android:paddingBottom="7dp"
android:textColor="@color/boss_text_primary" android:textColor="@color/boss_text_primary"
android:textColorHint="@color/boss_text_muted" android:textColorHint="@color/boss_text_muted"
android:textSize="15sp" android:textSize="14sp"
android:visibility="gone" /> android:visibility="gone" />
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/search_button" android:id="@+id/search_button"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:layout_marginRight="8dp" android:layout_marginRight="8dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="搜索" android:contentDescription="搜索"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_search" android:src="@drawable/ic_boss_search"
android:tint="@color/boss_text_primary" /> android:tint="@color/boss_text_primary" />
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/refresh_button" android:id="@+id/refresh_button"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="快捷操作" android:contentDescription="快捷操作"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_add" android:src="@drawable/ic_boss_add"
android:tint="@color/boss_text_primary" /> android:tint="@color/boss_text_primary" />
</LinearLayout> </LinearLayout>
@@ -188,51 +313,50 @@
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="72dp" android:layout_height="54dp"
android:background="@color/boss_surface" android:background="@color/boss_surface"
android:elevation="10dp" android:elevation="10dp"
android:gravity="center" android:gravity="center"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingTop="8dp" android:paddingTop="3dp"
android:paddingBottom="8dp" android:paddingBottom="3dp"
android:paddingLeft="12dp" android:paddingLeft="10dp"
android:paddingRight="12dp"> android:paddingRight="10dp">
<Button <Button
android:id="@+id/tab_conversations" android:id="@+id/tab_conversations"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="48dp" android:layout_height="match_parent"
android:layout_marginRight="6dp"
android:layout_weight="1" android:layout_weight="1"
android:background="@drawable/bg_tab_active" android:background="@android:color/transparent"
android:text="会话" android:text="会话"
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="@color/boss_green" android:textColor="@color/boss_green"
android:textSize="10sp"
android:textStyle="bold" /> android:textStyle="bold" />
<Button <Button
android:id="@+id/tab_devices" android:id="@+id/tab_devices"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="48dp" android:layout_height="match_parent"
android:layout_marginLeft="6dp"
android:layout_marginRight="6dp"
android:layout_weight="1" android:layout_weight="1"
android:background="@drawable/bg_tab_inactive" android:background="@android:color/transparent"
android:text="设备" android:text="设备"
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="@color/boss_text_muted" android:textColor="@color/boss_text_muted"
android:textSize="10sp"
android:textStyle="bold" /> android:textStyle="bold" />
<Button <Button
android:id="@+id/tab_me" android:id="@+id/tab_me"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="48dp" android:layout_height="match_parent"
android:layout_marginLeft="6dp"
android:layout_weight="1" android:layout_weight="1"
android:background="@drawable/bg_tab_inactive" android:background="@android:color/transparent"
android:text="我的" android:text="我的"
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="@color/boss_text_muted" android:textColor="@color/boss_text_muted"
android:textSize="10sp"
android:textStyle="bold" /> android:textStyle="bold" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
@@ -285,7 +409,7 @@
android:text="添加设备" android:text="添加设备"
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="@color/boss_quick_actions_menu_text" android:textColor="@color/boss_quick_actions_menu_text"
android:textSize="15sp" /> android:textSize="13sp" />
<Button <Button
android:id="@+id/quick_action_scan" android:id="@+id/quick_action_scan"
@@ -299,7 +423,7 @@
android:text="扫一扫" android:text="扫一扫"
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="@color/boss_quick_actions_menu_text" android:textColor="@color/boss_quick_actions_menu_text"
android:textSize="15sp" /> android:textSize="13sp" />
<Button <Button
android:id="@+id/quick_action_group_chat" android:id="@+id/quick_action_group_chat"
@@ -313,7 +437,7 @@
android:text="发起群聊" android:text="发起群聊"
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="@color/boss_quick_actions_menu_text" android:textColor="@color/boss_quick_actions_menu_text"
android:textSize="15sp" /> android:textSize="13sp" />
</LinearLayout> </LinearLayout>
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>

View File

@@ -12,18 +12,18 @@
android:background="@color/boss_surface" android:background="@color/boss_surface"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingLeft="20dp" android:paddingLeft="16dp"
android:paddingTop="14dp" android:paddingTop="8dp"
android:paddingRight="20dp" android:paddingRight="16dp"
android:paddingBottom="12dp"> android:paddingBottom="7dp">
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_back_button" android:id="@+id/screen_back_button"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="返回" android:contentDescription="返回"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_back" android:src="@drawable/ic_boss_back"
android:tint="@color/boss_text_primary" /> android:tint="@color/boss_text_primary" />
@@ -40,83 +40,134 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="end" android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1" android:maxLines="1"
android:text="项目详情" android:text="项目详情"
android:textColor="@color/boss_text_primary" android:textColor="@color/boss_text_primary"
android:textSize="20sp" android:textSize="18sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView <TextView
android:id="@+id/screen_subtitle" android:id="@+id/screen_subtitle"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="3dp" android:layout_marginTop="3dp"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:text="设备" android:text="设备"
android:textColor="@color/boss_text_muted" android:textColor="@color/boss_text_muted"
android:textSize="12sp" /> android:textSize="11sp" />
</LinearLayout> </LinearLayout>
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_header_action" android:id="@+id/screen_header_action"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="更多" android:contentDescription="更多"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_more" android:src="@drawable/ic_boss_more"
android:tint="@color/boss_text_primary" android:tint="@color/boss_text_primary"
android:visibility="gone" /> android:visibility="gone" />
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_refresh_button" android:id="@+id/screen_refresh_button"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="刷新" android:contentDescription="刷新"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_refresh" android:src="@drawable/ic_boss_refresh"
android:tint="@color/boss_text_primary" /> android:tint="@color/boss_text_primary" />
</LinearLayout> </LinearLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout <FrameLayout
android:id="@+id/screen_refresh_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1"> android:layout_weight="1"
android:clipChildren="false"
android:clipToPadding="false">
<ScrollView <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/project_chat_scroll" android:id="@+id/screen_refresh_layout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent">
android:fillViewport="true"
android:overScrollMode="ifContentScrolls">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical">
android:paddingLeft="12dp"
android:paddingTop="10dp"
android:paddingRight="12dp"
android:paddingBottom="20dp">
<LinearLayout <LinearLayout
android:id="@+id/project_chat_quick_actions" android:id="@+id/project_chat_quick_actions_container"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="12dp" android:background="@color/boss_bg_app"
android:orientation="horizontal" /> android:orientation="vertical"
android:paddingLeft="12dp"
android:paddingTop="8dp"
android:paddingRight="12dp"
android:paddingBottom="8dp">
<LinearLayout <LinearLayout
android:id="@+id/screen_content" 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_width="match_parent"
android:layout_height="wrap_content" android:layout_height="0dp"
android:orientation="vertical" /> 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> </LinearLayout>
</ScrollView> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/project_chat_scroll_bottom"
android:layout_width="44dp"
android:layout_height="44dp"
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="11dp"
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 <LinearLayout
android:id="@+id/project_chat_composer_row" android:id="@+id/project_chat_composer_row"
@@ -126,22 +177,21 @@
android:gravity="bottom" android:gravity="bottom"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingLeft="12dp" android:paddingLeft="12dp"
android:paddingTop="10dp" android:paddingTop="8dp"
android:paddingRight="12dp" android:paddingRight="12dp"
android:paddingBottom="12dp"> android:paddingBottom="10dp">
<Button <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/project_chat_attach" android:id="@+id/project_chat_attach"
android:layout_width="44dp" android:layout_width="40dp"
android:layout_height="44dp" android:layout_height="40dp"
android:layout_marginRight="8dp" android:layout_marginRight="8dp"
android:background="@drawable/bg_secondary_button" android:background="@drawable/bg_secondary_button"
android:minWidth="0dp" android:contentDescription="发送附件"
android:text="+" android:padding="10dp"
android:textAllCaps="false" android:scaleType="center"
android:textColor="@color/boss_text_primary" android:src="@drawable/ic_boss_add"
android:textSize="20sp" android:tint="@color/boss_text_primary" />
android:textStyle="bold" />
<EditText <EditText
android:id="@+id/project_chat_input" android:id="@+id/project_chat_input"
@@ -153,23 +203,25 @@
android:hint="输入消息" android:hint="输入消息"
android:inputType="textCapSentences|textMultiLine" android:inputType="textCapSentences|textMultiLine"
android:maxLines="4" android:maxLines="4"
android:minHeight="44dp" android:minHeight="40dp"
android:paddingLeft="14dp" android:paddingLeft="12dp"
android:paddingTop="10dp" android:paddingTop="8dp"
android:paddingRight="14dp" android:paddingRight="12dp"
android:paddingBottom="10dp" android:paddingBottom="8dp"
android:textColor="@color/boss_text_primary" android:textColor="@color/boss_text_primary"
android:textColorHint="@color/boss_text_muted" /> android:textColorHint="@color/boss_text_muted"
android:textSize="14sp" />
<Button <Button
android:id="@+id/project_chat_send" android:id="@+id/project_chat_send"
android:layout_width="72dp" android:layout_width="68dp"
android:layout_height="44dp" android:layout_height="40dp"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:background="@drawable/bg_primary_button" android:background="@drawable/bg_primary_button"
android:text="发送" android:text="发送"
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="@color/boss_surface" android:textColor="@color/boss_surface"
android:textSize="13sp"
android:textStyle="bold" /> android:textStyle="bold" />
</LinearLayout> </LinearLayout>
@@ -181,19 +233,34 @@
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingLeft="12dp" android:paddingLeft="12dp"
android:paddingTop="10dp" android:paddingTop="8dp"
android:paddingRight="12dp" android:paddingRight="12dp"
android:paddingBottom="12dp" android:paddingBottom="10dp"
android:visibility="gone"> android:visibility="gone">
<Button
android:id="@+id/project_chat_multi_copy"
android:layout_width="0dp"
android:layout_height="40dp"
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:textSize="13sp"
android:textStyle="bold" />
<Button <Button
android:id="@+id/project_chat_multi_forward" android:id="@+id/project_chat_multi_forward"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="44dp" android:layout_height="40dp"
android:layout_weight="1"
android:background="@drawable/bg_primary_button" android:background="@drawable/bg_primary_button"
android:text="转发" android:text="转发"
android:textAllCaps="false" android:textAllCaps="false"
android:textColor="@color/boss_surface" android:textColor="@color/boss_surface"
android:textSize="13sp"
android:textStyle="bold" /> android:textStyle="bold" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>

View File

@@ -12,18 +12,18 @@
android:background="@color/boss_surface" android:background="@color/boss_surface"
android:gravity="center_vertical" android:gravity="center_vertical"
android:orientation="horizontal" android:orientation="horizontal"
android:paddingLeft="20dp" android:paddingLeft="16dp"
android:paddingTop="14dp" android:paddingTop="8dp"
android:paddingRight="20dp" android:paddingRight="16dp"
android:paddingBottom="12dp"> android:paddingBottom="7dp">
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_back_button" android:id="@+id/screen_back_button"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="返回" android:contentDescription="返回"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_back" android:src="@drawable/ic_boss_back"
android:tint="@color/boss_text_primary" /> android:tint="@color/boss_text_primary" />
@@ -37,43 +37,49 @@
<TextView <TextView
android:id="@+id/screen_title" android:id="@+id/screen_title"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:text="标题" android:text="标题"
android:textColor="@color/boss_text_primary" android:textColor="@color/boss_text_primary"
android:textSize="22sp" android:textSize="18sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView <TextView
android:id="@+id/screen_subtitle" android:id="@+id/screen_subtitle"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="3dp" android:layout_marginTop="3dp"
android:ellipsize="end"
android:includeFontPadding="false"
android:maxLines="1"
android:text="副标题" android:text="副标题"
android:textColor="@color/boss_text_muted" android:textColor="@color/boss_text_muted"
android:textSize="12sp" /> android:textSize="11sp" />
</LinearLayout> </LinearLayout>
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_header_action" android:id="@+id/screen_header_action"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="更多" android:contentDescription="更多"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_more" android:src="@drawable/ic_boss_more"
android:tint="@color/boss_text_primary" android:tint="@color/boss_text_primary"
android:visibility="gone" /> android:visibility="gone" />
<androidx.appcompat.widget.AppCompatImageButton <androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/screen_refresh_button" android:id="@+id/screen_refresh_button"
android:layout_width="40dp" android:layout_width="34dp"
android:layout_height="40dp" android:layout_height="34dp"
android:layout_marginLeft="8dp" android:layout_marginLeft="8dp"
android:background="@drawable/bg_top_icon_button" android:background="@drawable/bg_top_icon_button"
android:contentDescription="刷新" android:contentDescription="刷新"
android:padding="8dp" android:padding="6dp"
android:src="@drawable/ic_boss_refresh" android:src="@drawable/ic_boss_refresh"
android:tint="@color/boss_text_primary" /> android:tint="@color/boss_text_primary" />
</LinearLayout> </LinearLayout>
@@ -95,6 +101,8 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:background="@color/boss_panel" android:background="@color/boss_panel"
android:orientation="vertical" android:orientation="vertical"
android:paddingLeft="12dp"
android:paddingRight="12dp"
android:paddingTop="8dp" android:paddingTop="8dp"
android:paddingBottom="24dp" /> android:paddingBottom="24dp" />
</ScrollView> </ScrollView>

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

@@ -9,7 +9,7 @@
<item name="android:windowBackground">@color/boss_bg_app</item> <item name="android:windowBackground">@color/boss_bg_app</item>
</style> </style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar"> <style name="AppTheme.NoActionBar" parent="Theme.AppCompat.Light.NoActionBar">
<item name="windowActionBar">false</item> <item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item> <item name="windowNoTitle">true</item>
<item name="android:windowBackground">@color/boss_bg_app</item> <item name="android:windowBackground">@color/boss_bg_app</item>

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

@@ -1,7 +1,9 @@
package com.hyzq.boss; package com.hyzq.boss;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
@@ -10,7 +12,6 @@ import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.EditText; import android.widget.EditText;
import android.widget.Spinner; import android.widget.Spinner;
import android.widget.SpinnerAdapter;
import android.widget.TextView; import android.widget.TextView;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
@@ -35,6 +36,7 @@ import java.net.HttpURLConnection;
import java.net.ProtocolException; import java.net.ProtocolException;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
@@ -46,78 +48,15 @@ import java.util.concurrent.TimeUnit;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
@Config(sdk = 34) @Config(sdk = 34)
public class AiAccountsActivityTest { public class AiAccountsActivityTest {
@Test
public void submitOpenAiOnboarding_reportsExplicitPrimaryControllerSuccessAndRefreshesSummary() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"),
200,
"{\"ok\":true,\"accountId\":\"acc-1\"}",
"{\"ok\":false,\"message\":\"ONBOARD_FAILED\"}"
),
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts/acc-1/validate"),
200,
"{\"ok\":true,\"message\":\"校验通过\"}",
"{\"ok\":false,\"message\":\"VALIDATION_FAILED\"}"
)
));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
int initialReloadCount = activity.reloadCount;
ReflectionHelpers.callInstanceMethod(
activity,
"submitOpenAiOnboarding",
ReflectionHelpers.ClassParameter.from(String.class, "主 GPT"),
ReflectionHelpers.ClassParameter.from(String.class, "OpenAI 平台账号"),
ReflectionHelpers.ClassParameter.from(String.class, "sk-test"),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
ReflectionHelpers.ClassParameter.from(String.class, "sk-test-key")
);
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("OpenAI 平台账号已登录,并设为当前主控。", ShadowToast.getTextOfLatestToast());
assertEquals(initialReloadCount + 1, activity.reloadCount);
}
@Test
public void submitOpenAiOnboarding_showsClearChineseFailurePrefix() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"),
403,
"{\"ok\":false,\"message\":\"API Key 无效\"}",
"{\"ok\":false,\"message\":\"API Key 无效\"}"
)
));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
ReflectionHelpers.callInstanceMethod(
activity,
"submitOpenAiOnboarding",
ReflectionHelpers.ClassParameter.from(String.class, "主 GPT"),
ReflectionHelpers.ClassParameter.from(String.class, "OpenAI 平台账号"),
ReflectionHelpers.ClassParameter.from(String.class, "sk-test"),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
ReflectionHelpers.ClassParameter.from(String.class, "bad-key")
);
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("OpenAI 平台账号登录失败API Key 无效", ShadowToast.getTextOfLatestToast());
assertEquals(1, activity.reloadCount);
}
@Test @Test
public void activeIdentityCardOffersMainAgentTestEntry() throws Exception { public void activeIdentityCardOffersMainAgentTestEntry() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
JSONObject activeIdentity = new JSONObject() JSONObject activeIdentity = new JSONObject()
.put("accountId", "acc-1") .put("accountId", "acc-1")
.put("label", " GPT") .put("label", "Agent")
.put("displayName", "OpenAI 平台账号") .put("displayName", "ChatGPT OAuth 主链路账号")
.put("roleLabel", " GPT") .put("roleLabel", "链路")
.put("providerLabel", "OpenAI API") .put("providerLabel", "ChatGPT登录")
.put("statusLabel", "ready") .put("statusLabel", "ready")
.put("note", "当前账号可直接生成主 Agent 回复。") .put("note", "当前账号可直接生成主 Agent 回复。")
.put("canGenerate", true); .put("canGenerate", true);
@@ -140,62 +79,547 @@ public class AiAccountsActivityTest {
} }
@Test @Test
public void openAliyunQwenOnboardingDialogUsesPresetModelsWithCustomFallback() throws Exception { public void renderAccountsShowsStructuredSectionsAndExpandedEntries() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
JSONObject payload = new JSONObject()
.put("activeIdentity", new JSONObject()
.put("accountId", "chatgpt-primary")
.put("label", "主Agent")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "ChatGPT登录")
.put("statusLabel", "ready")
.put("canGenerate", true))
.put("accounts", new org.json.JSONArray()
.put(new JSONObject()
.put("accountId", "chatgpt-primary")
.put("label", "主Agent")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "ChatGPT登录")
.put("provider", "chatgpt_oauth")
.put("role", "primary")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", true))
.put(new JSONObject()
.put("accountId", "hyzq-backup")
.put("label", "备用API")
.put("displayName", "环宇智擎 备用账号")
.put("roleLabel", "备用链路")
.put("providerLabel", "环宇智擎")
.put("provider", "hyzq_api")
.put("role", "backup")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", false)
.put("apiKeyConfigured", true)
.put("apiBaseUrl", "https://api.hyzq2046.com/v1"))
.put(new JSONObject()
.put("accountId", "master-node")
.put("label", "主Agent")
.put("displayName", "绑定电脑上的 Codex 节点")
.put("roleLabel", "主链路")
.put("providerLabel", "主Agent 节点")
.put("provider", "master_codex_node")
.put("role", "primary")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", false)));
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
);
View root = activity.findViewById(R.id.screen_content);
assertNotNull(root);
assertTrue(viewTreeContainsText(root, "主要API配置"));
assertTrue(viewTreeContainsText(root, "备用API配置"));
assertFalse(viewTreeContainsText(root, "OAuth 登录"));
assertFalse(viewTreeContainsText(root, "API 接入"));
assertFalse(viewTreeContainsText(root, "谷歌登录"));
assertFalse(viewTreeContainsText(root, "ChatGPT登录"));
assertFalse(viewTreeContainsText(root, "阿里"));
assertFalse(viewTreeContainsText(root, "Minimax"));
assertFalse(viewTreeContainsText(root, "GLM"));
assertFalse(viewTreeContainsText(root, "环宇智擎"));
assertFalse(viewTreeContainsText(root, "自定义"));
assertFalse(viewTreeContainsText(root, "绑定设备节点"));
}
@Test
public void tappingPrimaryConfigEntryOpensPrimaryDetailPage() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(activity, "openAliyunQwenOnboardingDialog"); ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
);
View root = activity.findViewById(R.id.screen_content);
View entry = findClickableViewContainingText(root, "主要API配置");
assertNotNull(entry);
entry.performClick();
ShadowActivity shadowActivity = Shadows.shadowOf(activity);
Intent nextIntent = shadowActivity.getNextStartedActivity();
assertNotNull(nextIntent);
assertEquals(AiAccountsActivity.class.getName(), nextIntent.getComponent().getClassName());
assertEquals("primary", nextIntent.getStringExtra("ai_accounts_role"));
}
@Test
public void detailPageShowsOnlySelectedRoleConfiguration() throws Exception {
Intent intent = new Intent(
org.robolectric.RuntimeEnvironment.getApplication(),
TestAiAccountsActivity.class
);
intent.putExtra("ai_accounts_role", "primary");
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
JSONObject payload = new JSONObject()
.put("accounts", new org.json.JSONArray()
.put(new JSONObject()
.put("accountId", "chatgpt-primary")
.put("label", "主要API")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "ChatGPT登录")
.put("provider", "chatgpt_oauth")
.put("role", "primary")
.put("model", "gpt-5.4-mini")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", true))
.put(new JSONObject()
.put("accountId", "hyzq-primary")
.put("label", "主要API")
.put("displayName", "环宇智擎 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "环宇智擎")
.put("provider", "hyzq_api")
.put("role", "primary")
.put("model", "gpt-5.4")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", false)
.put("apiKeyConfigured", true)
.put("apiBaseUrl", "https://api.hyzq2046.com/v1")));
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
);
View root = activity.findViewById(R.id.screen_content);
assertNotNull(root);
assertTrue(viewTreeContainsText(root, "当前使用方式"));
assertTrue(viewTreeContainsText(root, "主Agent模式"));
assertTrue(viewTreeContainsText(root, "快速反应模型"));
assertTrue(viewTreeContainsText(root, "深度思考模型"));
assertTrue(viewTreeContainsText(root, "ChatGPT登录"));
assertTrue(viewTreeContainsText(root, "OAuth 登录"));
assertTrue(viewTreeContainsText(root, "当前模型gpt-5.4-mini"));
assertTrue(viewTreeContainsText(root, "当前:沿用默认"));
assertTrue(viewTreeContainsText(root, "当前gpt-5.4-mini"));
assertTrue(viewTreeContainsText(root, "当前gpt-5.4"));
assertTrue(viewTreeContainsText(root, "API 接入"));
assertTrue(viewTreeContainsText(root, "已配置ChatGPT登录"));
assertTrue(viewTreeContainsText(root, "已配置:环宇智擎"));
assertFalse(viewTreeContainsText(root, "谷歌登录"));
assertFalse(viewTreeContainsText(root, "阿里"));
assertFalse(viewTreeContainsText(root, "Minimax"));
assertFalse(viewTreeContainsText(root, "GLM"));
assertFalse(viewTreeContainsText(root, "自定义"));
assertFalse(viewTreeContainsText(root, "可编辑配置"));
assertFalse(viewTreeContainsText(root, "当前已保存"));
assertFalse(viewTreeContainsText(root, "只读状态"));
assertFalse(viewTreeContainsText(root, "备用API配置"));
}
@Test
public void currentMethodEntryOpensCurrentAccountEditor() throws Exception {
Intent intent = new Intent(
org.robolectric.RuntimeEnvironment.getApplication(),
TestAiAccountsActivity.class
);
intent.putExtra("ai_accounts_role", "primary");
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
ReflectionHelpers.setField(activity, "currentMasterAgentModelOverride", "gpt-5.4-mini");
ReflectionHelpers.setField(activity, "currentMasterAgentReasoningEffortOverride", "low");
ReflectionHelpers.setField(activity, "currentFastModelOverride", "gpt-5.4-mini");
ReflectionHelpers.setField(activity, "currentDeepModelOverride", "gpt-5.4");
JSONObject payload = new JSONObject()
.put("accounts", new org.json.JSONArray()
.put(new JSONObject()
.put("accountId", "chatgpt-primary")
.put("label", "主要API")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "ChatGPT登录")
.put("provider", "chatgpt_oauth")
.put("role", "primary")
.put("model", "gpt-5.4-mini")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", true)));
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
);
View root = activity.findViewById(R.id.screen_content);
View entry = findClickableViewContainingText(root, "当前使用方式");
assertNotNull(entry);
entry.performClick();
Shadows.shadowOf(Looper.getMainLooper()).idle(); Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog(); AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog); assertNotNull(dialog);
View dialogRoot = dialog.getWindow().getDecorView();
View root = dialog.getWindow().getDecorView(); assertTrue(viewTreeContainsText(dialogRoot, "账号快捷登录"));
Spinner modelSpinner = findSpinnerContainingItem(root, "qwen3.5-plus"); assertTrue(viewTreeContainsText(dialogRoot, "选择模型"));
assertNotNull(modelSpinner);
SpinnerAdapter adapter = modelSpinner.getAdapter();
assertNotNull(adapter);
assertEquals(3, adapter.getCount());
assertEquals("qwen3.5-plus", adapter.getItem(0).toString());
assertEquals("qwen3.5-flash", adapter.getItem(1).toString());
assertEquals("自定义模型", adapter.getItem(2).toString());
assertEquals("qwen3.5-plus", modelSpinner.getSelectedItem().toString());
EditText customModelInput = findEditTextWithHint(root, "自定义模型");
assertNotNull(customModelInput);
} }
@Test @Test
public void openAccountEditorShowsCustomFallbackForNonPresetAliyunModel() throws Exception { public void fastModeEntryOpensDedicatedModelPicker() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get(); Intent intent = new Intent(
JSONObject existing = new JSONObject() org.robolectric.RuntimeEnvironment.getApplication(),
.put("accountId", "acc-1") TestAiAccountsActivity.class
.put("label", "备用 GPT") );
.put("displayName", "阿里百炼备用账号") intent.putExtra("ai_accounts_role", "primary");
.put("provider", "aliyun_qwen_api") TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
.put("model", "qwen-custom-x"); ReflectionHelpers.setField(activity, "currentFastModelOverride", "gpt-4.1");
ReflectionHelpers.setField(activity, "currentDeepModelOverride", "gpt-5.4");
ReflectionHelpers.callInstanceMethod( ReflectionHelpers.callInstanceMethod(
activity, activity,
"openAccountEditor", "renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, existing), ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject()
.put("accounts", new org.json.JSONArray()
.put(new JSONObject()
.put("accountId", "chatgpt-primary")
.put("label", "主要API")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "ChatGPT登录")
.put("provider", "chatgpt_oauth")
.put("role", "primary")
.put("model", "gpt-5.4-mini")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", true))))
);
View root = activity.findViewById(R.id.screen_content);
View entry = findClickableViewContainingText(root, "快速反应模型");
assertNotNull(entry);
entry.performClick();
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View dialogRoot = dialog.getWindow().getDecorView();
assertTrue(viewTreeContainsText(dialogRoot, "快速反应模型"));
assertTrue(viewTreeContainsText(dialogRoot, "gpt-4.1"));
}
@Test
public void tappingOauthEntryShowsOauthProviderChooser() throws Exception {
Intent intent = new Intent(
org.robolectric.RuntimeEnvironment.getApplication(),
TestAiAccountsActivity.class
);
intent.putExtra("ai_accounts_role", "primary");
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
);
View root = activity.findViewById(R.id.screen_content);
View entry = findClickableViewContainingText(root, "OAuth 登录");
assertNotNull(entry);
entry.performClick();
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View dialogRoot = dialog.getWindow().getDecorView();
assertTrue(viewTreeContainsText(dialogRoot, "谷歌登录"));
assertTrue(viewTreeContainsText(dialogRoot, "ChatGPT登录"));
}
@Test
public void tappingApiEntryShowsApiProviderChooser() throws Exception {
Intent intent = new Intent(
org.robolectric.RuntimeEnvironment.getApplication(),
TestAiAccountsActivity.class
);
intent.putExtra("ai_accounts_role", "primary");
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
);
View root = activity.findViewById(R.id.screen_content);
View entry = findClickableViewContainingText(root, "API 接入");
assertNotNull(entry);
entry.performClick();
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View dialogRoot = dialog.getWindow().getDecorView();
assertTrue(viewTreeContainsText(dialogRoot, "阿里"));
assertTrue(viewTreeContainsText(dialogRoot, "Minimax"));
assertTrue(viewTreeContainsText(dialogRoot, "GLM"));
assertTrue(viewTreeContainsText(dialogRoot, "环宇智擎"));
assertTrue(viewTreeContainsText(dialogRoot, "自定义"));
}
@Test
public void defaultApiBaseUrlForProviderSupportsExpandedApiProviders() {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
String openai = ReflectionHelpers.callInstanceMethod(
activity,
"defaultApiBaseUrlForProvider",
ReflectionHelpers.ClassParameter.from(String.class, "openai_api")
);
String aliyun = ReflectionHelpers.callInstanceMethod(
activity,
"defaultApiBaseUrlForProvider",
ReflectionHelpers.ClassParameter.from(String.class, "aliyun_qwen_api")
);
String minimax = ReflectionHelpers.callInstanceMethod(
activity,
"defaultApiBaseUrlForProvider",
ReflectionHelpers.ClassParameter.from(String.class, "minimax_api")
);
String glm = ReflectionHelpers.callInstanceMethod(
activity,
"defaultApiBaseUrlForProvider",
ReflectionHelpers.ClassParameter.from(String.class, "glm_api")
);
String hyzq = ReflectionHelpers.callInstanceMethod(
activity,
"defaultApiBaseUrlForProvider",
ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api")
);
String custom = ReflectionHelpers.callInstanceMethod(
activity,
"defaultApiBaseUrlForProvider",
ReflectionHelpers.ClassParameter.from(String.class, "custom_api")
);
assertEquals("https://api.openai.com/v1", openai);
assertEquals("https://dashscope.aliyuncs.com/compatible-mode/v1", aliyun);
assertEquals("https://api.minimaxi.com/v1", minimax);
assertEquals("https://open.bigmodel.cn/api/paas/v4", glm);
assertEquals("https://api.hyzq2046.com/v1", hyzq);
assertEquals("", custom);
}
@Test
public void openOauthAccountDialogShowsLoginAction() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"openOauthAccountDialog",
ReflectionHelpers.ClassParameter.from(String.class, "primary"),
ReflectionHelpers.ClassParameter.from(String.class, "google_oauth"),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View root = dialog.getWindow().getDecorView();
assertTrue(viewTreeContainsText(root, "账号快捷登录"));
assertTrue(viewTreeContainsText(root, "谷歌登录"));
Spinner modelSpinner = findSpinner(root);
assertNotNull(modelSpinner);
assertFalse(modelSpinner.isEnabled());
assertFalse(modelSpinner.isClickable());
}
@Test
public void openOauthAccountDialogEnablesModelSelectionWhenAccountIsReady() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
JSONObject existing = new JSONObject()
.put("label", "主要API")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("accountIdentifier", "kris@example.com")
.put("model", "gpt-5.4")
.put("loginStatusNote", "已登录")
.put("enabled", true)
.put("isActive", true)
.put("status", "ready")
.put("statusLabel", "ready");
ReflectionHelpers.callInstanceMethod(
activity,
"openOauthAccountDialog",
ReflectionHelpers.ClassParameter.from(String.class, "primary"),
ReflectionHelpers.ClassParameter.from(String.class, "chatgpt_oauth"),
ReflectionHelpers.ClassParameter.from(JSONObject.class, existing)
);
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View root = dialog.getWindow().getDecorView();
Spinner modelSpinner = findSpinner(root);
assertNotNull(modelSpinner);
assertTrue(modelSpinner.isEnabled());
assertTrue(modelSpinner.isClickable());
}
@Test
public void openApiAccountDialogLocksModelSelectionBeforeValidation() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"openApiAccountDialog",
ReflectionHelpers.ClassParameter.from(String.class, "backup"),
ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api"),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(String.class, null) ReflectionHelpers.ClassParameter.from(String.class, null)
); );
Shadows.shadowOf(Looper.getMainLooper()).idle(); Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog(); AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog); assertNotNull(dialog);
View root = dialog.getWindow().getDecorView(); View root = dialog.getWindow().getDecorView();
Spinner modelSpinner = findSpinnerContainingItem(root, "自定义模型"); assertNotNull(findEditTextWithHint(root, "账号标识 / 备注"));
assertNotNull(findEditTextWithHint(root, "API Key"));
Spinner modelSpinner = findSpinner(root);
assertNotNull(modelSpinner); assertNotNull(modelSpinner);
SpinnerAdapter adapter = modelSpinner.getAdapter(); assertFalse(modelSpinner.isEnabled());
assertNotNull(adapter); assertEquals(0, ((android.widget.ArrayAdapter<?>) modelSpinner.getAdapter()).getCount());
assertEquals(3, adapter.getCount()); }
assertEquals("自定义模型", modelSpinner.getSelectedItem().toString());
EditText customModelInput = findEditTextWithHint(root, "自定义模型"); @Test
assertNotNull(customModelInput); public void applyDraftValidatedModelsEnablesModelSelection() throws Exception {
assertEquals("qwen-custom-x", customModelInput.getText().toString()); TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
Spinner spinner = new Spinner(activity);
android.widget.ArrayAdapter<String> adapter = new android.widget.ArrayAdapter<>(
activity,
android.R.layout.simple_spinner_dropdown_item,
new ArrayList<>()
);
spinner.setAdapter(adapter);
spinner.setEnabled(false);
org.json.JSONArray models = new org.json.JSONArray().put("gpt-5.4-mini").put("gpt-5.4");
ReflectionHelpers.callInstanceMethod(
activity,
"applyValidatedApiModels",
ReflectionHelpers.ClassParameter.from(Spinner.class, spinner),
ReflectionHelpers.ClassParameter.from(android.widget.ArrayAdapter.class, adapter),
ReflectionHelpers.ClassParameter.from(org.json.JSONArray.class, models),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4")
);
assertTrue(spinner.isEnabled());
assertEquals(2, adapter.getCount());
assertEquals("gpt-5.4", spinner.getSelectedItem());
}
@Test
public void saveExpandedApiProviderUsesGenericCreateFlowAndAutoFillsBaseUrl() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
RecordingConnection createConnection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts"),
200,
"{\"ok\":true,\"accountId\":\"acc-1\"}",
"{\"ok\":false,\"message\":\"SAVE_FAILED\"}"
);
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(createConnection));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
int initialReloadCount = activity.reloadCount;
ReflectionHelpers.callInstanceMethod(
activity,
"saveAccount",
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(String.class, "备用API"),
ReflectionHelpers.ClassParameter.from(String.class, "环宇智擎备用账号"),
ReflectionHelpers.ClassParameter.from(String.class, "fallback@example.com"),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, "hyzq-secret"),
ReflectionHelpers.ClassParameter.from(String.class, "待校验"),
ReflectionHelpers.ClassParameter.from(boolean.class, true),
ReflectionHelpers.ClassParameter.from(boolean.class, false),
ReflectionHelpers.ClassParameter.from(String.class, "backup"),
ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api")
);
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("AI 账号已新增", ShadowToast.getTextOfLatestToast());
assertEquals(initialReloadCount + 1, activity.reloadCount);
JSONObject requestJson = new JSONObject(createConnection.getCapturedRequestBody());
assertEquals("hyzq_api", requestJson.getString("provider"));
assertEquals("backup", requestJson.getString("role"));
assertEquals("https://api.hyzq2046.com/v1", requestJson.getString("apiBaseUrl"));
assertEquals("hyzq-secret", requestJson.getString("apiKey"));
}
@Test
public void saveOauthAccountUsesGenericCreateFlow() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
RecordingConnection createConnection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts"),
200,
"{\"ok\":true,\"accountId\":\"acc-2\"}",
"{\"ok\":false,\"message\":\"SAVE_FAILED\"}"
);
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(createConnection));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
ReflectionHelpers.callInstanceMethod(
activity,
"saveAccount",
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(String.class, "主Agent"),
ReflectionHelpers.ClassParameter.from(String.class, "ChatGPT OAuth 主链路账号"),
ReflectionHelpers.ClassParameter.from(String.class, "kris@example.com"),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, "待网页登录"),
ReflectionHelpers.ClassParameter.from(boolean.class, true),
ReflectionHelpers.ClassParameter.from(boolean.class, true),
ReflectionHelpers.ClassParameter.from(String.class, "primary"),
ReflectionHelpers.ClassParameter.from(String.class, "chatgpt_oauth")
);
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("AI 账号已新增", ShadowToast.getTextOfLatestToast());
JSONObject requestJson = new JSONObject(createConnection.getCapturedRequestBody());
assertEquals("chatgpt_oauth", requestJson.getString("provider"));
assertEquals("primary", requestJson.getString("role"));
assertEquals("待网页登录", requestJson.getString("loginStatusNote"));
assertEquals("", requestJson.getString("apiBaseUrl"));
} }
private static final class TestAiAccountsActivity extends AiAccountsActivity { private static final class TestAiAccountsActivity extends AiAccountsActivity {
@@ -279,8 +703,6 @@ public class AiAccountsActivityTest {
private final int responseCodeValue; private final int responseCodeValue;
private final String responseBody; private final String responseBody;
private final String errorBody; private final String errorBody;
private String requestMethodValue = "GET";
private String contentTypeValue = "";
RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) { RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
super(url); super(url);
@@ -301,16 +723,11 @@ public class AiAccountsActivityTest {
public void connect() {} public void connect() {}
@Override @Override
public void setRequestMethod(String method) throws ProtocolException { public void setRequestMethod(String method) throws ProtocolException {}
requestMethodValue = method;
}
@Override @Override
public void setRequestProperty(String key, String value) { public void setRequestProperty(String key, String value) {
requestHeaders.put(key, value); requestHeaders.put(key, value);
if ("Content-Type".equalsIgnoreCase(key)) {
contentTypeValue = value;
}
} }
@Override @Override
@@ -337,6 +754,10 @@ public class AiAccountsActivityTest {
public Map<String, List<String>> getHeaderFields() { public Map<String, List<String>> getHeaderFields() {
return Collections.emptyMap(); return Collections.emptyMap();
} }
String getCapturedRequestBody() {
return requestBody.toString(StandardCharsets.UTF_8);
}
} }
private static final class InMemorySharedPreferences implements SharedPreferences { private static final class InMemorySharedPreferences implements SharedPreferences {
@@ -484,32 +905,6 @@ public class AiAccountsActivityTest {
return false; return false;
} }
private static Spinner findSpinnerContainingItem(View root, String expectedText) {
if (root instanceof Spinner) {
Spinner spinner = (Spinner) root;
SpinnerAdapter adapter = spinner.getAdapter();
if (adapter != null) {
for (int index = 0; index < adapter.getCount(); index += 1) {
Object item = adapter.getItem(index);
if (item != null && item.toString().contains(expectedText)) {
return spinner;
}
}
}
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
Spinner match = findSpinnerContainingItem(group.getChildAt(index), expectedText);
if (match != null) {
return match;
}
}
return null;
}
private static EditText findEditTextWithHint(View root, String expectedText) { private static EditText findEditTextWithHint(View root, String expectedText) {
if (root instanceof EditText) { if (root instanceof EditText) {
CharSequence hint = ((EditText) root).getHint(); CharSequence hint = ((EditText) root).getHint();
@@ -529,4 +924,41 @@ public class AiAccountsActivityTest {
} }
return null; return null;
} }
private static EditText findEditTextWithText(View root, String expectedText) {
if (root instanceof EditText) {
CharSequence text = ((EditText) root).getText();
if (text != null && text.toString().contains(expectedText)) {
return (EditText) root;
}
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
EditText match = findEditTextWithText(group.getChildAt(index), expectedText);
if (match != null) {
return match;
}
}
return null;
}
private static Spinner findSpinner(View root) {
if (root instanceof Spinner) {
return (Spinner) root;
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
Spinner match = findSpinner(group.getChildAt(index));
if (match != null) {
return match;
}
}
return null;
}
} }

View File

@@ -58,6 +58,23 @@ public class BossApiClientDeviceModeTest {
); );
} }
@Test
public void queueCodexRemoteControlWritesConfirmedActionBody() throws Exception {
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/devices/device-1/codex-remote-control")
);
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
apiClient.queueCodexRemoteControl("device-1", "start", "APP 设备详情页确认启动");
assertEquals("/api/v1/devices/device-1/codex-remote-control", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(
"{\"action\":\"start\",\"confirmed\":true,\"reason\":\"APP 设备详情页确认启动\"}",
connection.requestBody()
);
}
private static final class RecordingBossApiClient extends BossApiClient { private static final class RecordingBossApiClient extends BossApiClient {
private final RecordingConnection connection; private final RecordingConnection connection;
private String lastPath = ""; private String lastPath = "";

View File

@@ -81,6 +81,22 @@ public class BossApiClientDispatchPlansTest {
assertEquals("no-cache", connection.getRequestProperty("Pragma")); 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 @Test
public void confirmDispatchPlanWritesApprovedTargetProjectIds() throws Exception { public void confirmDispatchPlanWritesApprovedTargetProjectIds() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/confirm")); 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()); 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 @Test
public void retryDispatchPlanUsesProjectScopedRetryEndpoint() throws Exception { public void retryDispatchPlanUsesProjectScopedRetryEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/retry")); RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/retry"));
@@ -170,6 +199,27 @@ public class BossApiClientDispatchPlansTest {
); );
} }
@Test
public void updateMasterAgentModeModelsWritesFastAndDeepModelMappings() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/agent-controls"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.updateMasterAgentModeModels(
"gpt-4.1",
"gpt-5.1",
"gpt-4.1",
"low"
);
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/master-agent/agent-controls", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(
"{\"fastModelOverride\":\"gpt-4.1\",\"deepModelOverride\":\"gpt-5.1\",\"modelOverride\":\"gpt-4.1\",\"reasoningEffortOverride\":\"low\"}",
connection.requestBody()
);
}
@Test @Test
public void getMasterAgentPromptProfileUsesScopedEndpoint() throws Exception { public void getMasterAgentPromptProfileUsesScopedEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/prompt-profile")); RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/prompt-profile"));
@@ -210,6 +260,34 @@ public class BossApiClientDispatchPlansTest {
assertEquals("GET", connection.requestMethodValue); assertEquals("GET", connection.requestMethodValue);
} }
@Test
public void getProjectDetailUsesExtendedReadTimeoutForChatPages() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getProjectDetail("thread-1");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/thread-1", apiClient.lastPath);
assertEquals("GET", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(30000, connection.readTimeoutValue);
}
@Test
public void getProjectMessagesUsesExtendedReadTimeoutForRealtimeRefreshes() 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.getProjectMessages("thread-1");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/thread-1/messages", apiClient.lastPath);
assertEquals("GET", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(30000, connection.readTimeoutValue);
}
@Test @Test
public void createMasterAgentMemoryWritesStructuredPayload() throws Exception { public void createMasterAgentMemoryWritesStructuredPayload() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/memories")); RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/memories"));
@@ -261,6 +339,153 @@ public class BossApiClientDispatchPlansTest {
assertEquals(20000, connection.readTimeoutValue); 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 @Test
public void onboardOpenAiApiAccountUsesDedicatedRouteAndSetsActive() throws Exception { public void onboardOpenAiApiAccountUsesDedicatedRouteAndSetsActive() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api")); RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"));
@@ -287,7 +512,7 @@ public class BossApiClientDispatchPlansTest {
public void rememberIdentityDoesNotOverwriteSessionIdentityFromAiAccountOnboardingResponse() throws Exception { public void rememberIdentityDoesNotOverwriteSessionIdentityFromAiAccountOnboardingResponse() throws Exception {
InMemorySharedPreferences prefs = new InMemorySharedPreferences(); InMemorySharedPreferences prefs = new InMemorySharedPreferences();
prefs.edit() prefs.edit()
.putString("account", "17600003315") .putString("account", "krisolo")
.putString("display_name", "Boss 超级管理员") .putString("display_name", "Boss 超级管理员")
.apply(); .apply();
BossApiClient apiClient = new BossApiClient(prefs, "https://boss.hyzq.net"); BossApiClient apiClient = new BossApiClient(prefs, "https://boss.hyzq.net");
@@ -300,7 +525,7 @@ public class BossApiClientDispatchPlansTest {
apiClient.rememberIdentity(onboardingResponse); apiClient.rememberIdentity(onboardingResponse);
assertEquals("17600003315", apiClient.getAccountLabel()); assertEquals("krisolo", apiClient.getAccountLabel());
assertEquals("Boss 超级管理员", apiClient.getDisplayName()); assertEquals("Boss 超级管理员", apiClient.getDisplayName());
} }
@@ -338,7 +563,11 @@ public class BossApiClientDispatchPlansTest {
private String lastPath = ""; private String lastPath = "";
RecordingBossApiClient(RecordingConnection connection) { 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; this.connection = connection;
} }
@@ -362,6 +591,7 @@ public class BossApiClientDispatchPlansTest {
private static final class ScriptedBossApiClient extends BossApiClient { private static final class ScriptedBossApiClient extends BossApiClient {
private final Map<String, RecordingConnection> connections; private final Map<String, RecordingConnection> connections;
private String lastPath = ""; private String lastPath = "";
private RecordingConnection lastConnection;
ScriptedBossApiClient(RecordingConnection... connections) { ScriptedBossApiClient(RecordingConnection... connections) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net"); super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
@@ -378,6 +608,7 @@ public class BossApiClientDispatchPlansTest {
if (connection == null) { if (connection == null) {
throw new IllegalStateException("Missing scripted connection for " + path); throw new IllegalStateException("Missing scripted connection for " + path);
} }
lastConnection = connection;
return connection; return connection;
} }
@@ -392,6 +623,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 static final class RecordingConnection extends HttpURLConnection {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream(); private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private final Map<String, String> requestHeaders = new HashMap<>(); private final Map<String, String> requestHeaders = new HashMap<>();
@@ -401,9 +691,15 @@ public class BossApiClientDispatchPlansTest {
private final int responseCodeValue; private final int responseCodeValue;
private final String responseBody; private final String responseBody;
private final String errorBody; private final String errorBody;
private final Map<String, java.util.List<String>> responseHeaders = new HashMap<>();
RecordingConnection(URL url) { 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) { RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
@@ -472,6 +768,11 @@ public class BossApiClientDispatchPlansTest {
return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8)); return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8));
} }
@Override
public Map<String, java.util.List<String>> getHeaderFields() {
return responseHeaders;
}
String requestBody() { String requestBody() {
return requestBody.toString(StandardCharsets.UTF_8); return requestBody.toString(StandardCharsets.UTF_8);
} }

View File

@@ -0,0 +1,251 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import android.content.SharedPreferences;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.ProtocolException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossApiClientLogoutTest {
@Test
public void logoutClearsAllCachedIdentityHints() throws Exception {
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
prefs.edit().putString("session_cookie", "boss_session=session-token").apply();
BossApiClient apiClient = new RecordingBossApiClient(prefs);
apiClient.rememberIdentity(new JSONObject()
.put("restoreToken", "restore-token")
.put("account", "honor_user")
.put("displayName", "荣耀测试账号"));
BossApiClient.ApiResponse response = apiClient.logout();
assertEquals(200, response.statusCode);
assertFalse(prefs.contains("session_cookie"));
assertFalse(prefs.contains("restore_token"));
assertFalse(prefs.contains("account"));
assertFalse(prefs.contains("display_name"));
}
@Test
public void logoutClearsLocalAuthEvenWhenServerRequestFails() throws Exception {
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
prefs.edit()
.putString("session_cookie", "boss_session=session-token")
.putString("restore_token", "restore-token")
.putString("account", "honor_user")
.putString("display_name", "荣耀测试账号")
.apply();
BossApiClient apiClient = new FailingLogoutBossApiClient(prefs);
try {
apiClient.logout();
} catch (IOException expected) {
// Local logout state must still be cleared if the network request fails.
}
assertFalse(prefs.contains("session_cookie"));
assertFalse(prefs.contains("restore_token"));
assertFalse(prefs.contains("account"));
assertFalse(prefs.contains("display_name"));
}
private static final class RecordingBossApiClient extends BossApiClient {
RecordingBossApiClient(SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
}
@Override
HttpURLConnection openConnection(String path) throws java.io.IOException {
return new RecordingConnection(new URL("https://boss.hyzq.net" + path));
}
}
private static final class FailingLogoutBossApiClient extends BossApiClient {
FailingLogoutBossApiClient(SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
}
@Override
HttpURLConnection openConnection(String path) throws IOException {
throw new IOException("network down");
}
}
private static final class RecordingConnection extends HttpURLConnection {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private String requestMethodValue = "GET";
RecordingConnection(URL url) {
super(url);
}
@Override
public void disconnect() {}
@Override
public boolean usingProxy() {
return false;
}
@Override
public void connect() {}
@Override
public void setRequestMethod(String method) throws ProtocolException {
requestMethodValue = method;
}
@Override
public String getRequestMethod() {
return requestMethodValue;
}
@Override
public int getResponseCode() {
return 200;
}
@Override
public OutputStream getOutputStream() {
return requestBody;
}
@Override
public Map<String, List<String>> getHeaderFields() {
return Map.of("Set-Cookie", List.of("boss_session=; Max-Age=0; Path=/"));
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
}
}
private static final class InMemorySharedPreferences implements SharedPreferences {
private final Map<String, String> values = new HashMap<>();
@Override
public Map<String, ?> getAll() {
return Collections.unmodifiableMap(values);
}
@Override
public String getString(String key, String defValue) {
return values.getOrDefault(key, defValue);
}
@Override
public Set<String> getStringSet(String key, Set<String> defValues) {
throw new UnsupportedOperationException();
}
@Override
public int getInt(String key, int defValue) {
throw new UnsupportedOperationException();
}
@Override
public long getLong(String key, long defValue) {
throw new UnsupportedOperationException();
}
@Override
public float getFloat(String key, float defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean getBoolean(String key, boolean defValue) {
throw new UnsupportedOperationException();
}
@Override
public boolean contains(String key) {
return values.containsKey(key);
}
@Override
public Editor edit() {
return new Editor() {
@Override
public Editor putString(String key, String value) {
values.put(key, value);
return this;
}
@Override
public Editor remove(String key) {
values.remove(key);
return this;
}
@Override
public Editor clear() {
values.clear();
return this;
}
@Override
public void apply() {}
@Override
public boolean commit() {
return true;
}
@Override
public Editor putStringSet(String key, Set<String> values) {
throw new UnsupportedOperationException();
}
@Override
public Editor putInt(String key, int value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putLong(String key, long value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putFloat(String key, float value) {
throw new UnsupportedOperationException();
}
@Override
public Editor putBoolean(String key, boolean value) {
throw new UnsupportedOperationException();
}
};
}
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
}
}

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

@@ -60,4 +60,32 @@ public class BossMarkdownTest {
assertEquals("(空消息)", rendered.toString()); assertEquals("(空消息)", rendered.toString());
} }
@Test
public void render_normalizesColonSectionsIntoReadableBlocks() {
Context context = RuntimeEnvironment.getApplication();
CharSequence rendered = BossMarkdown.render(
context,
"项目目标:完成 Boss 真机回归\n" +
"当前进度:已完成 UI 调整\n" +
"下一步:推送到 Gitea",
false
);
assertTrue(rendered instanceof Spanned);
Spanned spanned = (Spanned) rendered;
String text = spanned.toString();
assertTrue(text.contains("项目目标"));
assertTrue(text.contains("完成 Boss 真机回归"));
assertTrue(text.indexOf("项目目标") < text.indexOf("完成 Boss 真机回归"));
assertTrue(text.contains("当前进度"));
assertTrue(text.contains("已完成 UI 调整"));
assertTrue(text.indexOf("当前进度") < text.indexOf("已完成 UI 调整"));
assertTrue(text.contains("下一步"));
assertTrue(text.contains("推送到 Gitea"));
assertTrue(text.indexOf("下一步") < text.indexOf("推送到 Gitea"));
assertTrue(text.contains("\n"));
}
} }

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,13 +1,18 @@
package com.hyzq.boss; package com.hyzq.boss;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull; import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner; import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config; import org.robolectric.annotation.Config;
import java.net.SocketTimeoutException;
import java.io.IOException;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
@Config(sdk = 34) @Config(sdk = 34)
public class BossRealtimeClientTest { public class BossRealtimeClientTest {
@@ -37,4 +42,10 @@ public class BossRealtimeClientTest {
public void parseEventBlockReturnsNullForEmptyEventPayloads() { public void parseEventBlockReturnsNullForEmptyEventPayloads() {
assertNull(BossRealtimeClient.parseEventBlock("event: conversation.updated\n\n")); assertNull(BossRealtimeClient.parseEventBlock("event: conversation.updated\n\n"));
} }
@Test
public void socketTimeoutReconnectsImmediately() {
assertTrue(BossRealtimeClient.shouldReconnectImmediately(new SocketTimeoutException("timeout")));
assertFalse(BossRealtimeClient.shouldReconnectImmediately(new IOException("boom")));
}
} }

View File

@@ -50,18 +50,29 @@ public class BossUiConversationRowTest {
TextView previewView = (TextView) centerColumn.getChildAt(centerColumn.getChildCount() - 1); TextView previewView = (TextView) centerColumn.getChildAt(centerColumn.getChildCount() - 1);
String metrics = String.format( String metrics = String.format(
"row=%d center=%d trailing=%d title=%d preview=%d", "row=%d height=%d avatarHeight=%d center=%dx%d trailing=%dx%d title=%dx%d preview=%dx%d",
rowView.getMeasuredWidth(), rowView.getMeasuredWidth(),
rowView.getMeasuredHeight(),
rowView.getChildAt(0).getMeasuredHeight(),
centerColumn.getMeasuredWidth(), centerColumn.getMeasuredWidth(),
centerColumn.getMeasuredHeight(),
trailingColumn.getMeasuredWidth(), trailingColumn.getMeasuredWidth(),
trailingColumn.getMeasuredHeight(),
titleView.getMeasuredWidth(), titleView.getMeasuredWidth(),
previewView.getMeasuredWidth() titleView.getMeasuredHeight(),
previewView.getMeasuredWidth(),
previewView.getMeasuredHeight()
); );
assertEquals("列表项应使用微信式扁平 padding: " + metrics, BossUi.dp(context, 16), rowView.getPaddingLeft()); assertEquals("列表项应对标微信当前紧凑密度: " + metrics, BossUi.dp(context, 12), rowView.getPaddingLeft());
assertEquals("列表项应使用微信式扁平 padding: " + metrics, BossUi.dp(context, 12), rowView.getPaddingTop()); assertEquals("列表项应对标微信当前紧凑密度: " + metrics, BossUi.dp(context, 6), rowView.getPaddingTop());
assertEquals("列表项应使用微信式扁平 padding: " + metrics, BossUi.dp(context, 16), rowView.getPaddingRight()); assertEquals("列表项应对标微信当前紧凑密度: " + metrics, BossUi.dp(context, 12), rowView.getPaddingRight());
assertEquals("列表项应使用微信式扁平 padding: " + metrics, BossUi.dp(context, 12), rowView.getPaddingBottom()); assertEquals("列表项应对标微信当前紧凑密度: " + metrics, BossUi.dp(context, 6), rowView.getPaddingBottom());
assertEquals("列表项不应保持卡片式浮层感: " + metrics, 0f, rowView.getElevation(), 0.01f); assertEquals("列表项不应保持卡片式浮层感: " + metrics, 0f, rowView.getElevation(), 0.01f);
assertTrue("单条会话行应维持微信式有效信息面积: " + metrics, rowView.getMeasuredHeight() <= BossUi.dp(context, 68));
assertEquals("会话头像应回到微信列表的小尺寸: " + metrics, BossUi.dp(context, 40), rowView.getChildAt(0).getLayoutParams().width);
assertEquals("会话头像应回到微信列表的小尺寸: " + metrics, BossUi.dp(context, 40), rowView.getChildAt(0).getLayoutParams().height);
assertEquals("标题字号应对标微信列表主标题", 14f, titleView.getTextSize() / context.getResources().getDisplayMetrics().scaledDensity, 0.5f);
assertEquals("预览字号应对标微信列表辅助文字", 11f, previewView.getTextSize() / context.getResources().getDisplayMetrics().scaledDensity, 0.5f);
assertTrue("中间文字列不应被挤成 0 宽: " + metrics, centerColumn.getMeasuredWidth() > 0); assertTrue("中间文字列不应被挤成 0 宽: " + metrics, centerColumn.getMeasuredWidth() > 0);
assertTrue("标题需要保留可见宽度: " + metrics, titleView.getMeasuredWidth() > 0); assertTrue("标题需要保留可见宽度: " + metrics, titleView.getMeasuredWidth() > 0);
assertTrue("预览需要保留可见宽度: " + metrics, previewView.getMeasuredWidth() > 0); assertTrue("预览需要保留可见宽度: " + metrics, previewView.getMeasuredWidth() > 0);

View File

@@ -0,0 +1,28 @@
package com.hyzq.boss;
import static org.junit.Assert.assertSame;
import android.content.Context;
import android.widget.EditText;
import android.widget.LinearLayout;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossUiFormCellTest {
@Test
public void buildFormCell_detachesFieldFromPreviousParentBeforeReusingIt() {
Context context = RuntimeEnvironment.getApplication();
EditText field = new EditText(context);
BossUi.buildFormCell(context, "模型", "第一次渲染", field);
LinearLayout secondCell = BossUi.buildFormCell(context, "模型", "刷新后重建", field);
assertSame(secondCell, field.getParent());
}
}

View File

@@ -1,5 +1,7 @@
package com.hyzq.boss; package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import android.content.Context; import android.content.Context;
@@ -38,4 +40,28 @@ public class BossUiMessageBubbleTest {
assertTrue(bodyView.getText().toString().contains("重点")); assertTrue(bodyView.getText().toString().contains("重点"));
assertTrue(bodyView.getText().toString().contains("代码")); assertTrue(bodyView.getText().toString().contains("代码"));
} }
@Test
public void buildMessageBubble_usesWechatCompactTextMetrics() {
Context context = RuntimeEnvironment.getApplication();
LinearLayout wrapper = BossUi.buildMessageBubble(
context,
"线程",
"这是一条普通对话内容",
"10:26",
false,
"结果"
);
TextView metaView = (TextView) wrapper.getChildAt(0);
LinearLayout bubble = (LinearLayout) wrapper.getChildAt(1);
TextView kindView = (TextView) bubble.getChildAt(0);
TextView bodyView = (TextView) bubble.getChildAt(1);
assertFalse("消息时间/发送人不应保留默认字体留白", metaView.getIncludeFontPadding());
assertFalse("消息类型标签不应保留默认字体留白", kindView.getIncludeFontPadding());
assertFalse("消息正文不应保留默认字体留白", bodyView.getIncludeFontPadding());
assertEquals("消息正文应对标微信聊天正文", 14f, bodyView.getTextSize() / context.getResources().getDisplayMetrics().scaledDensity, 0.5f);
}
} }

View File

@@ -1,10 +1,13 @@
package com.hyzq.boss; package com.hyzq.boss;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.View; import android.view.View;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import android.widget.Button;
import android.widget.TextView; import android.widget.TextView;
import org.json.JSONObject; import org.json.JSONObject;
@@ -13,6 +16,7 @@ import org.junit.runner.RunWith;
import org.robolectric.Robolectric; import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner; import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config; import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.util.ReflectionHelpers; import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
@@ -21,31 +25,45 @@ public class BossUiRootSurfaceTest {
@Test @Test
public void renderMeRoot_usesWechatProfileHeaderAndFlatMenuRows() throws Exception { public void renderMeRoot_usesWechatProfileHeaderAndFlatMenuRows() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get(); 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( ReflectionHelpers.setField(
activity, activity,
"sessionData", "sessionData",
new JSONObject() new JSONObject()
.put("displayName", "Kris") .put("displayName", "Kris")
.put("account", "17600003315") .put("account", "krisolo")
.put("role", "highest_admin") .put("role", "highest_admin")
); );
ReflectionHelpers.callInstanceMethod(activity, "renderMeRoot"); ReflectionHelpers.callInstanceMethod(activity, "renderMeRoot");
LinearLayout content = activity.findViewById(R.id.screen_content); LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
assertEquals("我的页应是资料头 + 6 条菜单", 7, content.getChildCount()); assertEquals("我的页应是资料头 + 9 条菜单", 10, content.getChildCount());
View header = content.getChildAt(0); View header = content.getChildAt(0);
assertEquals("资料头不应保留浮层卡片感", 0f, header.getElevation(), 0.01f); assertEquals("资料头不应保留浮层卡片感", 0f, header.getElevation(), 0.01f);
assertTrue("资料头应保持横向布局", header instanceof LinearLayout);
View avatar = ((LinearLayout) header).getChildAt(0);
assertEquals("我的页头像应对标微信资料入口尺寸", BossUi.dp(activity, 52), avatar.getLayoutParams().width);
assertEquals("我的页头像应对标微信资料入口尺寸", BossUi.dp(activity, 52), avatar.getLayoutParams().height);
assertTrue(viewTreeContainsText(header, "Kris")); assertTrue(viewTreeContainsText(header, "Kris"));
assertTrue(viewTreeContainsText(header, "17600003315")); assertTrue(viewTreeContainsText(header, "krisolo"));
assertTrue(viewTreeContainsText(header, "最高管理员")); assertTrue(viewTreeContainsText(header, "最高管理员"));
assertTrue(viewTreeContainsText(header, "主控账号已启用安全保护")); assertTrue(viewTreeContainsText(header, "主控账号已启用安全保护"));
assertTrue(viewTreeContainsText(content, "账号与安全")); assertTrue(viewTreeContainsText(content, "账号与安全"));
assertTrue(viewTreeContainsText(content, "设置")); assertTrue(viewTreeContainsText(content, "设置"));
assertTrue(viewTreeContainsText(content, "用户与权限"));
assertTrue(viewTreeContainsText(content, "运维与修复")); assertTrue(viewTreeContainsText(content, "运维与修复"));
assertTrue(viewTreeContainsText(content, "AI 账号")); assertTrue(viewTreeContainsText(content, "AI 账号"));
assertTrue(viewTreeContainsText(content, "附件与存储"));
assertTrue(viewTreeContainsText(content, "Telegram 接入"));
assertTrue(viewTreeContainsText(content, "技能")); assertTrue(viewTreeContainsText(content, "技能"));
assertTrue(viewTreeContainsText(content, "关于")); assertTrue(viewTreeContainsText(content, "关于"));
@@ -55,6 +73,46 @@ 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());
assertEquals("顶部标题应对标微信页面标题", 18f, ((TextView) activity.findViewById(R.id.top_title)).getTextSize() / activity.getResources().getDisplayMetrics().scaledDensity, 0.5f);
assertEquals("底栏高度应对标微信底栏有效面积", BossUi.dp(activity, 54), ((View) conversations.getParent()).getLayoutParams().height);
assertNotNull("会话 tab 应显示顶部图标", conversations.getCompoundDrawables()[1]);
assertNotNull("设备 tab 应显示顶部图标", devices.getCompoundDrawables()[1]);
assertNotNull("我的 tab 应显示顶部图标", me.getCompoundDrawables()[1]);
assertEquals("底栏文字应对标微信底栏标签", 10f, conversations.getTextSize() / activity.getResources().getDisplayMetrics().scaledDensity, 0.5f);
}
private static boolean viewTreeContainsText(View root, String expectedText) { private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) { if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText(); CharSequence text = ((TextView) root).getText();

View File

@@ -21,9 +21,9 @@ public class BossUiTopActionStyleTest {
BossUi.applyTopIconButtonStyle(context, button); BossUi.applyTopIconButtonStyle(context, button);
assertEquals(BossUi.dp(context, 40), button.getMinimumWidth()); assertEquals(BossUi.dp(context, 34), button.getMinimumWidth());
assertEquals(BossUi.dp(context, 40), button.getMinimumHeight()); assertEquals(BossUi.dp(context, 34), button.getMinimumHeight());
assertEquals(BossUi.dp(context, 8), button.getPaddingLeft()); assertEquals(BossUi.dp(context, 6), button.getPaddingLeft());
assertEquals(BossUi.dp(context, 8), button.getPaddingTop()); assertEquals(BossUi.dp(context, 6), button.getPaddingTop());
} }
} }

View File

@@ -1,6 +1,7 @@
package com.hyzq.boss; package com.hyzq.boss;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import android.content.Intent; import android.content.Intent;
@@ -22,6 +23,7 @@ import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows; import org.robolectric.Shadows;
import org.robolectric.annotation.Config; import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowDialog; import org.robolectric.shadows.ShadowDialog;
import java.time.Duration;
import org.robolectric.util.ReflectionHelpers; import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
@@ -53,8 +55,8 @@ public class ConversationFolderActivityTest {
assertEquals(View.GONE, refreshButton.getVisibility()); assertEquals(View.GONE, refreshButton.getVisibility());
assertEquals("Talking", String.valueOf(titleView.getText())); assertEquals("Talking", String.valueOf(titleView.getText()));
assertEquals("3 个线程", String.valueOf(subtitleView.getText())); assertEquals("3 个线程", String.valueOf(subtitleView.getText()));
assertTrue(viewTreeContainsText(content, "Talking")); assertFalse("项目抽屉页不应展示冗余说明卡片标题", viewTreeContainsText(content, "项目内部线程页"));
assertTrue(viewTreeContainsText(content, "项目内部线程页")); assertFalse("项目抽屉页不应展示冗余说明卡片文案", viewTreeContainsText(content, "点击线程后进入具体聊天窗口。"));
ReflectionHelpers.callInstanceMethod(activity, "showMoreMenu"); ReflectionHelpers.callInstanceMethod(activity, "showMoreMenu");
@@ -84,11 +86,12 @@ public class ConversationFolderActivityTest {
); );
LinearLayout content = activity.findViewById(R.id.screen_content); LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "已定位到目标线程")); assertFalse("项目抽屉页不应展示搜索定位说明卡片", viewTreeContainsText(content, "已定位到目标线程"));
assertFalse("项目抽屉页不应展示匹配项置顶说明文案", viewTreeContainsText(content, "个匹配项已置顶"));
assertTrue(viewTreeContainsText(content, "目标线程")); assertTrue(viewTreeContainsText(content, "目标线程"));
assertTrue(viewTreeContainsText(content, "发布回滚")); assertTrue(viewTreeContainsText(content, "发布回滚"));
assertEquals(2, countTextOccurrences(content, "目标线程")); assertEquals(2, countTextOccurrences(content, "目标线程"));
assertTrue(countTextOccurrences(content, "发布回滚") >= 3); assertEquals(2, countTextOccurrences(content, "发布回滚"));
assertEquals(0, countTextOccurrences(content, "project-1")); assertEquals(0, countTextOccurrences(content, "project-1"));
} }
@@ -112,7 +115,7 @@ public class ConversationFolderActivityTest {
); );
LinearLayout content = activity.findViewById(R.id.screen_content); LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "已定位到目标线程")); assertFalse("项目抽屉页不应展示搜索定位说明卡片", viewTreeContainsText(content, "已定位到目标线程"));
assertTrue(viewTreeContainsText(content, "日志收口")); assertTrue(viewTreeContainsText(content, "日志收口"));
assertEquals(0, countTextOccurrences(content, "project-99")); assertEquals(0, countTextOccurrences(content, "project-99"));
assertEquals(1, countTextOccurrences(content, "目标线程")); assertEquals(1, countTextOccurrences(content, "目标线程"));
@@ -145,7 +148,7 @@ public class ConversationFolderActivityTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2")) 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); assertEquals(1, activity.reloadCount);
} }

View File

@@ -7,6 +7,8 @@ import static org.junit.Assert.assertTrue;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.ImageButton; import android.widget.ImageButton;
@@ -15,6 +17,7 @@ import android.widget.ListView;
import android.widget.TextView; import android.widget.TextView;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.SwitchCompat;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
@@ -37,7 +40,7 @@ import java.util.concurrent.TimeUnit;
@Config(sdk = 34) @Config(sdk = 34)
public class ConversationInfoActivityTest { public class ConversationInfoActivityTest {
@Test @Test
public void renderConversationUsesLightweightHeaderMenuAndThreadList() throws Exception { public void renderConversationOmitsProfileHeaderAndStartsWithUsefulSettings() throws Exception {
Intent intent = new Intent() Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1") .putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归"); .putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
@@ -55,22 +58,81 @@ public class ConversationInfoActivityTest {
); );
LinearLayout content = activity.findViewById(R.id.screen_content); LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content.getChildAt(0), "北区试产线回归")); assertFalse(viewTreeContainsText(content, "线程状态摘要"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "单线程会话")); assertFalse(viewTreeContainsTextFragment(content, "当前进度:已经记录最近 2 条进展"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "线程状态摘要")); assertFalse(viewTreeContainsTextFragment(content, "建议下一步:继续同步 Android 只读页"));
assertTrue(viewTreeContainsTextFragment(content.getChildAt(1), "当前进度:已经记录最近 2 条进展")); assertFalse(viewTreeContainsText(content, "单线程会话"));
assertTrue(viewTreeContainsTextFragment(content.getChildAt(1), "建议下一步:继续同步 Android 只读页")); assertTrue(viewTreeContainsText(content.getChildAt(0), "主 Agent 协同接管"));
assertTrue(viewTreeContainsText(content.getChildAt(2), "主 Agent 协同接管")); assertTrue(viewTreeContainsText(content.getChildAt(1), "发起群聊"));
assertTrue(viewTreeContainsText(content.getChildAt(3), "发起群聊")); assertTrue(viewTreeContainsText(content.getChildAt(1), "选择其他线程加入新群"));
assertTrue(viewTreeContainsText(content.getChildAt(3), "选择其他线程加入新群")); assertTrue(viewTreeContainsText(content.getChildAt(2), "线程详情"));
assertTrue(viewTreeContainsText(content.getChildAt(4), "线程详情")); assertTrue(viewTreeContainsText(content.getChildAt(2), "查看当前线程聊天与项目"));
assertTrue(viewTreeContainsText(content.getChildAt(4), "查看当前线程聊天与项目"));
assertTrue(viewTreeContainsText(content, "参与线程")); assertTrue(viewTreeContainsText(content, "参与线程"));
assertTrue(viewTreeContainsText(content, "硬件审计协作")); assertTrue(viewTreeContainsText(content, "硬件审计协作"));
assertFalse(viewTreeContainsText(content, "从当前会话选择其他线程,创建新的独立群聊")); assertFalse(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, 12), takeoverRow.getPaddingLeft());
assertEquals(BossUi.dp(activity, 12), 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 @Test
public void threadDetailMenuRowStillOpensProjectDetail() throws Exception { public void threadDetailMenuRowStillOpensProjectDetail() throws Exception {
Intent intent = new Intent() Intent intent = new Intent()
@@ -235,6 +297,42 @@ public class ConversationInfoActivityTest {
assertEquals(1, apiClient.autoLoginCalls); 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 @Test
public void matchingProjectMessagesUpdatedEventTriggersReload() throws Exception { public void matchingProjectMessagesUpdatedEventTriggersReload() throws Exception {
Intent intent = new Intent() Intent intent = new Intent()
@@ -393,6 +491,23 @@ public class ConversationInfoActivityTest {
return null; 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 { public static class TestConversationInfoActivity extends ConversationInfoActivity {
private boolean reloadEnabled; private boolean reloadEnabled;
private boolean delegateReloadToSuper; private boolean delegateReloadToSuper;
@@ -474,7 +589,7 @@ public class ConversationInfoActivityTest {
200, 200,
new JSONObject() new JSONObject()
.put("ok", true) .put("ok", true)
.put("session", new JSONObject().put("account", "17600003315")) .put("session", new JSONObject().put("account", "krisolo"))
); );
} }

View File

@@ -76,6 +76,72 @@ public class DeviceDetailActivityTest {
assertTrue(viewTreeContainsText(content, "未连接")); assertTrue(viewTreeContainsText(content, "未连接"));
} }
@Test
public void renderDeviceShowsCodexRemoteControlActions() throws Exception {
TestDeviceDetailActivity activity = Robolectric
.buildActivity(
TestDeviceDetailActivity.class,
new Intent()
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderDevice",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDevicePayload())
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "Codex 远程控制"));
assertTrue(viewTreeContainsText(content, "启动远控"));
assertTrue(viewTreeContainsText(content, "停止远控"));
assertTrue(viewTreeContainsText(content, "默认走 Codex Computer Use"));
}
@Test
public void renderDeviceShowsCodexAppServerProtocolAndCollaborationSummary() throws Exception {
TestDeviceDetailActivity activity = Robolectric
.buildActivity(
TestDeviceDetailActivity.class,
new Intent()
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderDevice",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildCodexAppServerPayload())
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "Codex App Server"));
assertTrue(viewTreeContainsText(content, "已连接"));
assertTrue(viewTreeContainsText(content, "模型"));
assertTrue(viewTreeContainsText(content, "2 个 · 默认 gpt-5.4 · 快速 gpt-5.4-mini · 深度 gpt-5.4"));
assertTrue(viewTreeContainsText(content, "扩展"));
assertTrue(viewTreeContainsText(content, "Skill 1 个 · Plugin 1 个 · App 1 个"));
assertTrue(viewTreeContainsText(content, "治理"));
assertTrue(viewTreeContainsText(content, "实验特性 2 个 · 协作模式 2 个 · MCP 2 个 · 权限 1 个"));
assertTrue(viewTreeContainsText(content, "账号"));
assertTrue(viewTreeContainsText(content, "chatgpt · 套餐 pro · 额度 42%"));
assertTrue(viewTreeContainsText(content, "线程"));
assertTrue(viewTreeContainsText(content, "3 个 · 已加载 2 个 · 活跃 1 个"));
assertTrue(viewTreeContainsText(content, "轮次"));
assertTrue(viewTreeContainsText(content, "3 个 · 运行中 1 个 · 完成 2 个"));
assertTrue(viewTreeContainsText(content, "线程操作"));
assertTrue(viewTreeContainsText(content, "11 项 · 生命周期 5 项 · 活跃干预 2 项 · Shell 可用"));
assertTrue(viewTreeContainsText(content, "线程协作"));
assertTrue(viewTreeContainsText(content, "Boss Broker 可用 · 协作事件可处理 · 2 种模式 · 非原生私聊"));
assertTrue(viewTreeContainsText(content, "协议漂移"));
assertTrue(viewTreeContainsText(content, "兼容 · 失败探针 0 个 · 文档跟进 3 项 · Boss Broker 兜底"));
}
@Test @Test
public void renderDeviceShowsProjectScopedConflictCardAndActions() throws Exception { public void renderDeviceShowsProjectScopedConflictCardAndActions() throws Exception {
TestDeviceDetailActivity activity = Robolectric TestDeviceDetailActivity activity = Robolectric
@@ -187,6 +253,43 @@ public class DeviceDetailActivityTest {
assertEquals("allow_always", apiClient.lastPayload.optString("conflictDecision")); assertEquals("allow_always", apiClient.lastPayload.optString("conflictDecision"));
} }
@Test
public void codexRemoteControlConfirmDialogQueuesStartAction() throws Exception {
TestDeviceDetailActivity activity = Robolectric
.buildActivity(
TestDeviceDetailActivity.class,
new Intent()
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
)
.setup()
.get();
RecordingBossApiClient apiClient = new RecordingBossApiClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE),
"https://boss.hyzq.net"
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
ReflectionHelpers.callInstanceMethod(
activity,
"showCodexRemoteControlConfirmDialog",
ReflectionHelpers.ClassParameter.from(String.class, "start")
);
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
AlertDialog dialog = (AlertDialog) latestDialog;
dialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, apiClient.queueCodexRemoteControlCalls);
assertEquals("device-1", apiClient.lastCodexRemoteControlDeviceId);
assertEquals("start", apiClient.lastCodexRemoteControlAction);
assertEquals("APP 设备详情页确认启动 Codex 远控", apiClient.lastCodexRemoteControlReason);
}
@Test @Test
public void matchingDevicesUpdatedEventTriggersReload() throws Exception { public void matchingDevicesUpdatedEventTriggersReload() throws Exception {
TestDeviceDetailActivity activity = Robolectric TestDeviceDetailActivity activity = Robolectric
@@ -297,7 +400,7 @@ public class DeviceDetailActivityTest {
.put("id", "device-1") .put("id", "device-1")
.put("name", "Mac Studio") .put("name", "Mac Studio")
.put("avatar", "M") .put("avatar", "M")
.put("account", "17600003315") .put("account", "krisolo")
.put("status", "online") .put("status", "online")
.put("quota5h", 75) .put("quota5h", 75)
.put("quota7d", 88) .put("quota7d", 88)
@@ -333,6 +436,67 @@ public class DeviceDetailActivityTest {
return payload; return payload;
} }
private static JSONObject buildCodexAppServerPayload() throws Exception {
JSONObject payload = buildDevicePayload();
JSONObject capabilities = payload
.getJSONObject("workspace")
.getJSONObject("selectedDevice")
.getJSONObject("capabilities");
capabilities.put(
"codexAppServer",
new JSONObject()
.put("connected", true)
.put("lastSeenAt", "2026-06-04T10:00:00+08:00")
.put("metadata", new JSONObject()
.put("models", new JSONArray()
.put(new JSONObject().put("id", "gpt-5.4"))
.put(new JSONObject().put("id", "gpt-5.4-mini")))
.put("defaultModelId", "gpt-5.4")
.put("fastModelId", "gpt-5.4-mini")
.put("deepModelId", "gpt-5.4")
.put("skills", new JSONArray().put(new JSONObject().put("name", "image2-ui-prototype")))
.put("plugins", new JSONArray().put(new JSONObject().put("id", "github")))
.put("apps", new JSONArray().put(new JSONObject().put("id", "canva")))
.put("experimentalFeatures", new JSONArray()
.put(new JSONObject().put("name", "multi_agent"))
.put(new JSONObject().put("name", "apps")))
.put("collaborationModes", new JSONArray()
.put(new JSONObject().put("id", "solo"))
.put(new JSONObject().put("id", "plan")))
.put("mcpServers", new JSONArray()
.put(new JSONObject().put("name", "github"))
.put(new JSONObject().put("name", "figma")))
.put("permissionProfiles", new JSONArray().put(new JSONObject().put("id", ":workspace")))
.put("accountSummary", new JSONObject()
.put("authMode", "chatgpt")
.put("planType", "pro"))
.put("rateLimitSummary", new JSONObject().put("maxUsedPercent", 42))
.put("threadSummary", new JSONObject()
.put("threadCount", 3)
.put("loadedThreadCount", 2)
.put("activeThreadCount", 1))
.put("threadTurnSummary", new JSONObject()
.put("totalTurnCount", 3)
.put("runningTurnCount", 1)
.put("completedTurnCount", 2))
.put("threadActionSummary", new JSONObject()
.put("actionCount", 11)
.put("lifecycleActionCount", 5)
.put("liveTurnActionCount", 2)
.put("shellActionAvailable", true))
.put("threadCollaborationSummary", new JSONObject()
.put("bossBrokerAvailable", true)
.put("collabToolCallHandlerAvailable", true)
.put("directThreadChatSupported", false)
.put("collaborationModeCount", 2))
.put("protocolDriftSummary", new JSONObject()
.put("driftLevel", "compatible")
.put("failedProbeCount", 0)
.put("docFollowupCount", 3)
.put("fallbackStrategy", "Boss Broker + App Server 注入/执行"))));
return payload;
}
public static class TestDeviceDetailActivity extends DeviceDetailActivity { public static class TestDeviceDetailActivity extends DeviceDetailActivity {
boolean reloadEnabled = true; boolean reloadEnabled = true;
int reloadCount; int reloadCount;
@@ -360,6 +524,10 @@ public class DeviceDetailActivityTest {
private int updateDeviceCalls; private int updateDeviceCalls;
private String lastDeviceId; private String lastDeviceId;
private JSONObject lastPayload; private JSONObject lastPayload;
private int queueCodexRemoteControlCalls;
private String lastCodexRemoteControlDeviceId;
private String lastCodexRemoteControlAction;
private String lastCodexRemoteControlReason;
RecordingBossApiClient(android.content.SharedPreferences prefs, String baseUrl) { RecordingBossApiClient(android.content.SharedPreferences prefs, String baseUrl) {
super(prefs, baseUrl); super(prefs, baseUrl);
@@ -376,6 +544,19 @@ public class DeviceDetailActivityTest {
throw new RuntimeException(error); throw new RuntimeException(error);
} }
} }
@Override
public ApiResponse queueCodexRemoteControl(String deviceId, String action, String reason) {
queueCodexRemoteControlCalls += 1;
lastCodexRemoteControlDeviceId = deviceId;
lastCodexRemoteControlAction = action;
lastCodexRemoteControlReason = reason;
try {
return new ApiResponse(200, new JSONObject().put("ok", true));
} catch (Exception error) {
throw new RuntimeException(error);
}
}
} }
private static final class DirectExecutorService extends AbstractExecutorService { private static final class DirectExecutorService extends AbstractExecutorService {

View File

@@ -0,0 +1,301 @@
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.Intent;
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", ""));
}
@Test
public void forceLogoutIntentClearsExistingContentSessionAndShowsLogin() throws Exception {
TestRestoreBootstrapSessionMainActivity activity =
Robolectric.buildActivity(TestRestoreBootstrapSessionMainActivity.class).setup().get();
waitFor(() -> activity.apiClient.restoreCalls > 0 && activity.apiClient.homeCalls > 0);
Intent intent = new Intent(activity, MainActivity.class);
intent.putExtra("force_logout", true);
activity.onNewIntent(intent);
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);
JSONObject sessionData = ReflectionHelpers.getField(activity, "sessionData");
assertEquals(View.VISIBLE, loginPanel.getVisibility());
assertEquals(View.GONE, contentPanel.getVisibility());
assertEquals(null, sessionData);
}
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; package com.hyzq.boss;
import static org.junit.Assert.assertFalse; 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 static org.junit.Assert.assertTrue;
import android.Manifest;
import android.content.Context;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.robolectric.Robolectric; import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner; import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config; import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowApplication;
import org.robolectric.util.ReflectionHelpers; import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
@@ -18,6 +26,10 @@ public class MainActivityConversationAutoRefreshTest {
org.robolectric.android.controller.ActivityController<MainActivity> controller = org.robolectric.android.controller.ActivityController<MainActivity> controller =
Robolectric.buildActivity(MainActivity.class).setup().resume(); Robolectric.buildActivity(MainActivity.class).setup().resume();
MainActivity activity = controller.get(); MainActivity activity = controller.get();
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
.edit()
.putString("session_cookie", "boss_session=test")
.apply();
ReflectionHelpers.callInstanceMethod(activity, "showContent"); ReflectionHelpers.callInstanceMethod(activity, "showContent");
assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed")); assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
@@ -35,4 +47,53 @@ public class MainActivityConversationAutoRefreshTest {
controller.pause(); controller.pause();
assertFalse(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed")); 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

@@ -5,8 +5,11 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertSame; import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.Manifest;
import android.view.View; import android.view.View;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText; import android.widget.EditText;
import android.widget.ImageButton; import android.widget.ImageButton;
import android.widget.LinearLayout; import android.widget.LinearLayout;
@@ -21,6 +24,9 @@ import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner; import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config; import org.robolectric.annotation.Config;
import org.robolectric.Shadows; 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; import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
@@ -124,7 +130,60 @@ public class MainActivityConversationSearchTest {
} }
@Test @Test
public void searchHitInsideArchivedProject_keepsProjectContextAndOpensFolderPage() throws Exception { public void searchMode_showsSoftKeyboardWhenActivated() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ImageButton searchButton = activity.findViewById(R.id.search_button);
searchButton.performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
EditText searchInput = activity.findViewById(R.id.top_search_input);
InputMethodManager inputMethodManager = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
ShadowInputMethodManager shadowInputMethodManager = Shadow.extract(inputMethodManager);
assertTrue(searchInput.isFocused());
assertTrue(shadowInputMethodManager.isSoftInputVisible());
}
@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");
Shadows.shadowOf(activity.getMainLooper()).idle();
ImageButton searchButton = activity.findViewById(R.id.search_button);
searchButton.performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
EditText searchInput = activity.findViewById(R.id.top_search_input);
searchInput.setText("树莓派");
Shadows.shadowOf(activity.getMainLooper()).idle();
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
View row = getRecyclerChild(list, 0);
row.performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
InputMethodManager inputMethodManager = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
ShadowInputMethodManager shadowInputMethodManager = Shadow.extract(inputMethodManager);
assertEquals(ProjectDetailActivity.class.getName(), nextIntent.getComponent().getClassName());
assertEquals("p1", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID));
assertFalse(ReflectionHelpers.getField(activity, "conversationSearchMode"));
assertEquals("", searchInput.getText().toString());
assertFalse(shadowInputMethodManager.isSoftInputVisible());
assertFalse(activity.isFinishing());
}
@Test
public void searchHitInsideArchivedProject_opensMatchedThreadDetailAndClearsSearchState() throws Exception {
ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get(); MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray() ReflectionHelpers.setField(activity, "conversationsData", new JSONArray()
.put(new JSONObject() .put(new JSONObject()
@@ -155,14 +214,51 @@ public class MainActivityConversationSearchTest {
row.performClick(); row.performClick();
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
assertEquals(ProjectDetailActivity.class.getName(), nextIntent.getComponent().getClassName());
assertEquals("thread-revert-1", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID));
assertEquals("发布回滚", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME));
assertFalse(ReflectionHelpers.getField(activity, "conversationSearchMode"));
assertEquals("", searchInput.getText().toString());
assertFalse(activity.isFinishing());
}
@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()
.put("projectId", "folder-boss")
.put("conversationType", "folder_archive")
.put("folderKey", "mac-studio:boss")
.put("folderLabel", "Boss")
.put("projectTitle", "Boss")
.put("threadTitle", "Boss")
.put("lastMessagePreview", "最近:发布回滚")
.put("latestReplyLabel", "11:00")
.put("searchAliases", new JSONArray().put("发布回滚"))
.put("searchTargetProjectIds", new JSONArray().put("thread-revert-1"))));
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(activity, "enterConversationSearchMode");
Shadows.shadowOf(activity.getMainLooper()).idle();
EditText searchInput = activity.findViewById(R.id.top_search_input);
searchInput.setText("Boss");
Shadows.shadowOf(activity.getMainLooper()).idle();
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
View row = getRecyclerChild(list, 0);
row.performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity(); Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
assertEquals(ConversationFolderActivity.class.getName(), nextIntent.getComponent().getClassName()); assertEquals(ConversationFolderActivity.class.getName(), nextIntent.getComponent().getClassName());
assertEquals("mac-studio:boss", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY)); assertEquals("mac-studio:boss", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_FOLDER_KEY));
assertEquals("Boss", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_FOLDER_NAME)); assertFalse(ReflectionHelpers.getField(activity, "conversationSearchMode"));
assertEquals("thread-revert-1", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_ID)); assertEquals("", searchInput.getText().toString());
assertEquals(2, nextIntent.getStringArrayExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS).length);
assertEquals("thread-revert-2", nextIntent.getStringArrayExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_IDS)[1]);
assertEquals("发布回滚", nextIntent.getStringExtra(ConversationFolderActivity.EXTRA_TARGET_PROJECT_LABEL));
} }
private static JSONArray buildConversations() throws Exception { private static JSONArray buildConversations() throws Exception {

View File

@@ -90,6 +90,7 @@ public class MainActivityConversationSelectionTest {
public void topPlusAction_opensWechatStyleDropdownMenu() throws Exception { public void topPlusAction_opensWechatStyleDropdownMenu() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get(); MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations()); ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.setField(activity, "sessionData", new JSONObject().put("role", "highest_admin"));
ReflectionHelpers.callInstanceMethod(activity, "showContent"); ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
@@ -106,6 +107,27 @@ public class MainActivityConversationSelectionTest {
assertTrue(viewTreeContainsText(menu, "发起群聊")); 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) { private static View getRecyclerChild(RecyclerView recyclerView, int position) {
RecyclerView.Adapter adapter = recyclerView.getAdapter(); RecyclerView.Adapter adapter = recyclerView.getAdapter();
int viewType = adapter.getItemViewType(position); int viewType = adapter.getItemViewType(position);
@@ -188,6 +210,28 @@ public class MainActivityConversationSelectionTest {
return false; 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) { private static boolean viewTreeContainsContentDescription(View root, String expectedText) {
CharSequence description = root.getContentDescription(); CharSequence description = root.getContentDescription();
if (expectedText.contentEquals(description)) { if (expectedText.contentEquals(description)) {

View File

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

View File

@@ -3,6 +3,7 @@ package com.hyzq.boss;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences;
import android.os.Looper; import android.os.Looper;
import org.json.JSONObject; import org.json.JSONObject;
import org.json.JSONArray; import org.json.JSONArray;
@@ -15,6 +16,7 @@ import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers; import org.robolectric.util.ReflectionHelpers;
import java.io.IOException; import java.io.IOException;
import java.time.Duration;
import java.util.function.BooleanSupplier; import java.util.function.BooleanSupplier;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
@@ -24,6 +26,15 @@ public class MainActivityRealtimeTest {
public void conversationRealtimeEventRefreshesVisibleConversationTab() throws Exception { public void conversationRealtimeEventRefreshesVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent"); 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( ReflectionHelpers.callInstanceMethod(
activity, activity,
"handleRealtimeEvent", "handleRealtimeEvent",
@@ -33,6 +44,8 @@ public class MainActivityRealtimeTest {
) )
); );
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.conversationRefreshCount); assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount); assertEquals(0, activity.deviceRefreshCount);
@@ -78,6 +91,15 @@ public class MainActivityRealtimeTest {
public void deviceScopedConversationEventRefreshesVisibleConversationTab() throws Exception { public void deviceScopedConversationEventRefreshesVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent"); 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( ReflectionHelpers.callInstanceMethod(
activity, activity,
"handleRealtimeEvent", "handleRealtimeEvent",
@@ -87,6 +109,8 @@ public class MainActivityRealtimeTest {
) )
); );
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.conversationRefreshCount); assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount); assertEquals(0, activity.deviceRefreshCount);
@@ -108,6 +132,8 @@ public class MainActivityRealtimeTest {
) )
); );
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.conversationRefreshCount); assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount); assertEquals(0, activity.deviceRefreshCount);
@@ -129,13 +155,15 @@ public class MainActivityRealtimeTest {
) )
); );
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.conversationRefreshCount); assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount); assertEquals(0, activity.deviceRefreshCount);
} }
@Test @Test
public void distinctConversationEventsBackToBackBothRefreshVisibleConversationTab() throws Exception { public void distinctConversationEventsBackToBackCoalesceIntoSingleVisibleConversationRefresh() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent"); ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod( ReflectionHelpers.callInstanceMethod(
@@ -161,8 +189,10 @@ public class MainActivityRealtimeTest {
) )
); );
Shadows.shadowOf(activity.getMainLooper()).idle(); 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); assertEquals(0, activity.deviceRefreshCount);
} }
@@ -176,6 +206,9 @@ public class MainActivityRealtimeTest {
ReflectionHelpers.ClassParameter.from(String.class, "devices"), ReflectionHelpers.ClassParameter.from(String.class, "devices"),
ReflectionHelpers.ClassParameter.from(boolean.class, false) ReflectionHelpers.ClassParameter.from(boolean.class, false)
); );
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
ReflectionHelpers.callInstanceMethod( ReflectionHelpers.callInstanceMethod(
activity, activity,
"handleRealtimeEvent", "handleRealtimeEvent",
@@ -187,6 +220,8 @@ public class MainActivityRealtimeTest {
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount); assertEquals(0, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.deviceRefreshCount); assertEquals(1, activity.deviceRefreshCount);
assertEquals(0, activity.meRefreshCount); assertEquals(0, activity.meRefreshCount);
} }
@@ -201,6 +236,9 @@ public class MainActivityRealtimeTest {
ReflectionHelpers.ClassParameter.from(String.class, "me"), ReflectionHelpers.ClassParameter.from(String.class, "me"),
ReflectionHelpers.ClassParameter.from(boolean.class, false) ReflectionHelpers.ClassParameter.from(boolean.class, false)
); );
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
ReflectionHelpers.callInstanceMethod( ReflectionHelpers.callInstanceMethod(
activity, activity,
"handleRealtimeEvent", "handleRealtimeEvent",
@@ -213,6 +251,8 @@ public class MainActivityRealtimeTest {
assertEquals(0, activity.conversationRefreshCount); assertEquals(0, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount); assertEquals(0, activity.deviceRefreshCount);
assertEquals(0, activity.meRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.meRefreshCount); assertEquals(1, activity.meRefreshCount);
} }
@@ -220,6 +260,15 @@ public class MainActivityRealtimeTest {
public void burstConversationRealtimeEventsCoalesceIntoSingleFollowUpRefresh() throws Exception { public void burstConversationRealtimeEventsCoalesceIntoSingleFollowUpRefresh() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get(); TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent"); 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.setField(activity, "rootTabRefreshInFlight", true);
ReflectionHelpers.callInstanceMethod( ReflectionHelpers.callInstanceMethod(
@@ -253,6 +302,7 @@ public class MainActivityRealtimeTest {
assertEquals(0, activity.conversationRefreshCount); assertEquals(0, activity.conversationRefreshCount);
activity.completeRealtimeTabRefresh(); activity.completeRealtimeTabRefresh();
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
waitFor(() -> activity.conversationRefreshCount == 1); waitFor(() -> activity.conversationRefreshCount == 1);
assertEquals(1, activity.conversationRefreshCount); assertEquals(1, activity.conversationRefreshCount);
@@ -261,7 +311,28 @@ public class MainActivityRealtimeTest {
} }
@Test @Test
public void refreshConversationsData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception { public void realtimeDisconnectTriggersImmediateConversationFallbackRefresh() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
.edit()
.putString("session_cookie", "boss_session=test")
.apply();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeConnectionChanged",
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
assertEquals(0, activity.meRefreshCount);
}
@Test
public void refreshConversationsData_prefersGroupedHomeFeedOverFlatConversationFeed() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get(); MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
@@ -273,18 +344,47 @@ public class MainActivityRealtimeTest {
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
activity.refreshConversationsData(); activity.refreshConversationsData();
waitFor(() -> apiClient.homeCalls > 0 || apiClient.conversationsCalls > 0); waitFor(() -> apiClient.homeCalls > 0);
assertEquals(1, apiClient.homeCalls); assertEquals(1, apiClient.homeCalls);
assertEquals(0, apiClient.conversationsCalls); 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 @Test
public void refreshConversationsData_groupsFlatFallbackFeedWhenHomeFeedFails() throws Exception { public void refreshConversationsData_groupsFlatFallbackFeedWhenHomeFeedFails() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get(); MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
RecordingRejectedConversationSourceClient apiClient = new RecordingRejectedConversationSourceClient( RecordingRejectedHomeConversationSourceClient apiClient = new RecordingRejectedHomeConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE) activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
); );
ReflectionHelpers.setField(activity, "apiClient", apiClient); ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -307,7 +407,7 @@ public class MainActivityRealtimeTest {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get(); MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
RecordingIOExceptionConversationSourceClient apiClient = new RecordingIOExceptionConversationSourceClient( RecordingIOExceptionHomeConversationSourceClient apiClient = new RecordingIOExceptionHomeConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE) activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
); );
ReflectionHelpers.setField(activity, "apiClient", apiClient); ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -326,7 +426,7 @@ public class MainActivityRealtimeTest {
} }
@Test @Test
public void refreshAllData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception { public void refreshAllData_prefersGroupedHomeFeedOverFlatConversationFeed() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get(); MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
@@ -342,18 +442,51 @@ public class MainActivityRealtimeTest {
"refreshAllData", "refreshAllData",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject()) ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject())
); );
waitFor(() -> apiClient.homeCalls > 0 || apiClient.conversationsCalls > 0); waitFor(() -> apiClient.homeCalls > 0);
assertEquals(1, apiClient.homeCalls); assertEquals(1, apiClient.homeCalls);
assertEquals(0, apiClient.conversationsCalls); 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 @Test
public void refreshAllData_groupsFlatFallbackFeedWhenHomeFeedFails() throws Exception { public void refreshAllData_groupsFlatFallbackFeedWhenHomeFeedFails() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get(); MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
RecordingRejectedConversationSourceClient apiClient = new RecordingRejectedConversationSourceClient( RecordingRejectedHomeConversationSourceClient apiClient = new RecordingRejectedHomeConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE) activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
); );
ReflectionHelpers.setField(activity, "apiClient", apiClient); ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -380,7 +513,7 @@ public class MainActivityRealtimeTest {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get(); MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
RecordingIOExceptionConversationSourceClient apiClient = new RecordingIOExceptionConversationSourceClient( RecordingIOExceptionHomeConversationSourceClient apiClient = new RecordingIOExceptionHomeConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE) activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
); );
ReflectionHelpers.setField(activity, "apiClient", apiClient); ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -424,6 +557,13 @@ public class MainActivityRealtimeTest {
int deviceRefreshCount; int deviceRefreshCount;
int meRefreshCount; int meRefreshCount;
@Override
BossApiClient createApiClient() {
SharedPreferences prefs = getSharedPreferences("boss_native_client", Context.MODE_PRIVATE);
prefs.edit().clear().apply();
return new InertBootstrapApiClient(prefs);
}
@Override @Override
void refreshConversationsData() { void refreshConversationsData() {
conversationRefreshCount += 1; conversationRefreshCount += 1;
@@ -443,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 homeCalls;
int conversationsCalls; int conversationsCalls;
int sessionCalls; int sessionCalls;
@@ -451,7 +612,7 @@ public class MainActivityRealtimeTest {
int settingsCalls; int settingsCalls;
int otaCalls; int otaCalls;
RecordingRejectedConversationSourceClient(android.content.SharedPreferences prefs) { RecordingRejectedHomeConversationSourceClient(android.content.SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net"); super(prefs, "https://boss.hyzq.net");
} }
@@ -468,7 +629,7 @@ public class MainActivityRealtimeTest {
conversationsCalls += 1; conversationsCalls += 1;
return new ApiResponse(200, new JSONObject() return new ApiResponse(200, new JSONObject()
.put("ok", true) .put("ok", true)
.put("conversations", buildFlatConversations())); .put("conversations", RecordingConversationSourceClient.buildFlatConversations()));
} }
@Override @Override
@@ -477,7 +638,7 @@ public class MainActivityRealtimeTest {
return new ApiResponse(200, new JSONObject() return new ApiResponse(200, new JSONObject()
.put("ok", true) .put("ok", true)
.put("session", new JSONObject() .put("session", new JSONObject()
.put("account", "17600003315") .put("account", "krisolo")
.put("displayName", "Boss 超级管理员"))); .put("displayName", "Boss 超级管理员")));
} }
@@ -505,32 +666,6 @@ public class MainActivityRealtimeTest {
.put("ok", true) .put("ok", true)
.put("hasOta", false)); .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 { private static final class RecordingConversationSourceClient extends BossApiClient {
@@ -567,7 +702,7 @@ public class MainActivityRealtimeTest {
return new ApiResponse(200, new JSONObject() return new ApiResponse(200, new JSONObject()
.put("ok", true) .put("ok", true)
.put("session", new JSONObject() .put("session", new JSONObject()
.put("account", "17600003315") .put("account", "krisolo")
.put("displayName", "Boss 超级管理员"))); .put("displayName", "Boss 超级管理员")));
} }
@@ -598,13 +733,15 @@ public class MainActivityRealtimeTest {
private static JSONArray buildHomeConversations() throws org.json.JSONException { private static JSONArray buildHomeConversations() throws org.json.JSONException {
return new JSONArray().put(new JSONObject() return new JSONArray().put(new JSONObject()
.put("projectId", "folder-boss") .put("projectId", "mac-studio:boss")
.put("conversationType", "folder_archive") .put("conversationType", "folder_archive")
.put("folderKey", "mac-studio:boss") .put("folderKey", "mac-studio:boss")
.put("projectTitle", "Boss") .put("projectTitle", "Boss")
.put("threadTitle", "Boss") .put("threadTitle", "Boss")
.put("threadCount", 2)
.put("folderLabel", "2 个线程 · 最近:发布回滚") .put("folderLabel", "2 个线程 · 最近:发布回滚")
.put("searchAliases", new JSONArray().put("发布回滚").put("Android UI 收尾")) .put("searchAliases", new JSONArray().put("发布回滚").put("Android UI 收尾"))
.put("searchTargetProjectIds", new JSONArray().put("thread-revert").put("thread-ui"))
.put("lastMessagePreview", "最近:发布回滚") .put("lastMessagePreview", "最近:发布回滚")
.put("latestReplyLabel", "11:00")); .put("latestReplyLabel", "11:00"));
} }
@@ -636,7 +773,7 @@ public class MainActivityRealtimeTest {
} }
} }
private static final class RecordingIOExceptionConversationSourceClient extends BossApiClient { private static final class RecordingIOExceptionHomeConversationSourceClient extends BossApiClient {
int homeCalls; int homeCalls;
int conversationsCalls; int conversationsCalls;
int sessionCalls; int sessionCalls;
@@ -644,7 +781,7 @@ public class MainActivityRealtimeTest {
int settingsCalls; int settingsCalls;
int otaCalls; int otaCalls;
RecordingIOExceptionConversationSourceClient(android.content.SharedPreferences prefs) { RecordingIOExceptionHomeConversationSourceClient(android.content.SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net"); super(prefs, "https://boss.hyzq.net");
} }
@@ -659,7 +796,7 @@ public class MainActivityRealtimeTest {
conversationsCalls += 1; conversationsCalls += 1;
return new ApiResponse(200, new JSONObject() return new ApiResponse(200, new JSONObject()
.put("ok", true) .put("ok", true)
.put("conversations", buildFlatConversations())); .put("conversations", RecordingConversationSourceClient.buildFlatConversations()));
} }
@Override @Override
@@ -668,7 +805,7 @@ public class MainActivityRealtimeTest {
return new ApiResponse(200, new JSONObject() return new ApiResponse(200, new JSONObject()
.put("ok", true) .put("ok", true)
.put("session", new JSONObject() .put("session", new JSONObject()
.put("account", "17600003315") .put("account", "krisolo")
.put("displayName", "Boss 超级管理员"))); .put("displayName", "Boss 超级管理员")));
} }
@@ -696,31 +833,5 @@ public class MainActivityRealtimeTest {
.put("ok", true) .put("ok", true)
.put("hasOta", false)); .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, 200,
new JSONObject() new JSONObject()
.put("ok", true) .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)); 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 @Test
public void selectionPreservesInsertionOrder() { public void selectionPreservesInsertionOrder() {
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m2"); ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m2");
@@ -104,6 +112,7 @@ public class ProjectChatUiStateTest {
assertTrue(chromeState.showMultiSelectBar); assertTrue(chromeState.showMultiSelectBar);
assertFalse(chromeState.showRefresh); assertFalse(chromeState.showRefresh);
assertFalse(chromeState.showHeaderAction); assertFalse(chromeState.showHeaderAction);
assertTrue(chromeState.copyEnabled);
assertTrue(chromeState.forwardEnabled); assertTrue(chromeState.forwardEnabled);
assertEquals("取消", chromeState.backLabel); assertEquals("取消", chromeState.backLabel);
assertEquals("已选 2 条", chromeState.title); assertEquals("已选 2 条", chromeState.title);
@@ -120,6 +129,7 @@ public class ProjectChatUiStateTest {
assertFalse(chromeState.showMultiSelectBar); assertFalse(chromeState.showMultiSelectBar);
assertFalse(chromeState.showRefresh); assertFalse(chromeState.showRefresh);
assertTrue(chromeState.showHeaderAction); assertTrue(chromeState.showHeaderAction);
assertFalse(chromeState.copyEnabled);
assertFalse(chromeState.forwardEnabled); assertFalse(chromeState.forwardEnabled);
assertEquals("返回", chromeState.backLabel); assertEquals("返回", chromeState.backLabel);
assertEquals("北区试产线回归", chromeState.title); assertEquals("北区试产线回归", chromeState.title);
@@ -136,6 +146,7 @@ public class ProjectChatUiStateTest {
assertFalse(chromeState.showMultiSelectBar); assertFalse(chromeState.showMultiSelectBar);
assertTrue(chromeState.showRefresh); assertTrue(chromeState.showRefresh);
assertFalse(chromeState.showHeaderAction); assertFalse(chromeState.showHeaderAction);
assertFalse(chromeState.copyEnabled);
assertFalse(chromeState.forwardEnabled); assertFalse(chromeState.forwardEnabled);
assertEquals("返回", chromeState.backLabel); assertEquals("返回", chromeState.backLabel);
assertEquals("北区试产线回归", chromeState.title); assertEquals("北区试产线回归", chromeState.title);
@@ -196,9 +207,10 @@ public class ProjectChatUiStateTest {
} }
@Test @Test
public void queuedReplyTaskStartsReplyWaitFromRequestMessageId() throws Exception { public void queuedReplyTaskStartsReplyWaitFromImmediateReplyWhenPresent() throws Exception {
JSONObject response = new JSONObject() JSONObject response = new JSONObject()
.put("message", new JSONObject().put("id", "msg-user-1")) .put("message", new JSONObject().put("id", "msg-user-1"))
.put("replyMessage", new JSONObject().put("id", "msg-master-ack-1"))
.put("task", new JSONObject() .put("task", new JSONObject()
.put("taskId", "task-1") .put("taskId", "task-1")
.put("taskType", "conversation_reply") .put("taskType", "conversation_reply")
@@ -207,7 +219,7 @@ public class ProjectChatUiStateTest {
ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterSend(response); ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterSend(response);
assertTrue(waitSpec.shouldWait); assertTrue(waitSpec.shouldWait);
assertEquals("msg-user-1", waitSpec.baselineMessageId); assertEquals("msg-master-ack-1", waitSpec.baselineMessageId);
} }
@Test @Test
@@ -250,6 +262,318 @@ public class ProjectChatUiStateTest {
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, "")); 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 @Test
public void threadExecutionConflictCopyExplainsPreferredGuiModeAsProjectScoped() throws Exception { public void threadExecutionConflictCopyExplainsPreferredGuiModeAsProjectScoped() throws Exception {
JSONObject conflict = new JSONObject() JSONObject conflict = new JSONObject()

View File

@@ -1,6 +1,7 @@
package com.hyzq.boss; package com.hyzq.boss;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
@@ -18,6 +19,7 @@ import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.robolectric.Robolectric; import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner; import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config; import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowDialog; import org.robolectric.shadows.ShadowDialog;
import org.robolectric.util.ReflectionHelpers; import org.robolectric.util.ReflectionHelpers;
@@ -52,7 +54,67 @@ public class ProjectDetailActivityMasterAgentMenuTest {
} }
@Test @Test
public void normalConversationMoreMenuShowsInfoAndRefresh() { public void masterAgentModelOptionsIncludeFastAndDeepChoices() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
.setup()
.get();
String[] options = ReflectionHelpers.callInstanceMethod(activity, "buildMasterAgentModelOptions");
assertArrayEquals(
new String[]{"沿用默认", "gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1", "自定义..."},
options
);
}
@Test
public void masterAgentModelOptionsKeepCurrentCustomChoice() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "currentAgentModelOverride", "gpt-4.1-mini");
String[] options = ReflectionHelpers.callInstanceMethod(activity, "buildMasterAgentModelOptions");
assertArrayEquals(
new String[]{"沿用默认", "gpt-4.1-mini", "gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1", "自定义..."},
options
);
}
@Test
public void masterAgentModelPickerShowsFastAndDeepModes() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(activity, "showMasterAgentModelPicker");
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
AlertDialog actionDialog = (AlertDialog) latestDialog;
ListView listView = actionDialog.getListView();
assertMenuItem(listView, 0, "沿用默认");
assertMenuItem(listView, 1, "快速反应gpt-5.4-mini");
assertMenuItem(listView, 2, "深度思考gpt-5.4");
assertMenuItem(listView, 3, "更多模型...");
}
@Test
public void normalConversationHeaderActionOpensConversationInfoDirectly() {
Intent intent = new Intent() Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1") .putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss 移动控制台"); .putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss 移动控制台");
@@ -61,15 +123,11 @@ public class ProjectDetailActivityMasterAgentMenuTest {
.setup() .setup()
.get(); .get();
ReflectionHelpers.callInstanceMethod(activity, "showConversationMoreMenu"); ReflectionHelpers.callInstanceMethod(activity, "openConversationInfo");
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog(); Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
assertTrue(latestDialog instanceof AlertDialog); assertNotNull(nextIntent);
AlertDialog actionDialog = (AlertDialog) latestDialog; assertEquals(ConversationInfoActivity.class.getName(), nextIntent.getComponent().getClassName());
ListView listView = actionDialog.getListView();
assertMenuItem(listView, 0, "会话信息");
assertMenuItem(listView, 1, "刷新");
} }
@Test @Test

View File

@@ -1,11 +1,19 @@
package com.hyzq.boss; package com.hyzq.boss;
import static org.junit.Assert.assertEquals; 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.fail;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import android.app.Dialog;
import android.app.NotificationManager;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.os.Looper; import android.os.Looper;
import android.view.View;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
@@ -13,8 +21,12 @@ import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.robolectric.Robolectric; import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner; import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.Shadows; import org.robolectric.Shadows;
import org.robolectric.annotation.Config; 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 org.robolectric.util.ReflectionHelpers;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
@@ -43,9 +55,11 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1")) new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
) )
); );
Shadows.shadowOf(activity.getMainLooper()).idle(); drainRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount); waitFor(() -> activity.messageReloadCount == 1);
assertEquals(0, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
} }
@Test @Test
@@ -67,7 +81,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2")) new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
) )
); );
Shadows.shadowOf(activity.getMainLooper()).idle(); drainRealtimeDebounce(activity);
assertEquals(0, activity.reloadCount); assertEquals(0, activity.reloadCount);
} }
@@ -91,7 +105,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-2")) new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-2"))
) )
); );
Shadows.shadowOf(activity.getMainLooper()).idle(); drainRealtimeDebounce(activity);
assertEquals(0, activity.reloadCount); assertEquals(0, activity.reloadCount);
} }
@@ -129,9 +143,11 @@ public class ProjectDetailActivityRealtimeTest {
) )
) )
); );
Shadows.shadowOf(activity.getMainLooper()).idle(); drainRealtimeDebounce(activity);
assertEquals(2, activity.reloadCount); waitFor(() -> activity.loadCallCount == 1);
assertEquals(1, activity.loadCallCount);
assertEquals(0, activity.messageReloadCount);
} }
@Test @Test
@@ -156,9 +172,11 @@ public class ProjectDetailActivityRealtimeTest {
) )
) )
); );
Shadows.shadowOf(activity.getMainLooper()).idle(); drainRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount); waitFor(() -> activity.loadCallCount == 1);
assertEquals(1, activity.loadCallCount);
assertEquals(0, activity.messageReloadCount);
} }
@Test @Test
@@ -194,9 +212,161 @@ public class ProjectDetailActivityRealtimeTest {
) )
) )
); );
drainRealtimeDebounce(activity);
waitFor(() -> activity.messageReloadCount == 1);
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(); Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.reloadCount); 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 @Test
@@ -220,7 +390,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1")) new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
) )
); );
Shadows.shadowOf(activity.getMainLooper()).idle(); drainRealtimeDebounce(activity);
assertTrue(activity.awaitFirstLoadStarted()); assertTrue(activity.awaitFirstLoadStarted());
ReflectionHelpers.callInstanceMethod( ReflectionHelpers.callInstanceMethod(
@@ -239,18 +409,69 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-1")) new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-1"))
) )
); );
Shadows.shadowOf(activity.getMainLooper()).idle(); drainRealtimeDebounce(activity);
assertEquals(1, activity.loadCallCount); assertEquals(0, activity.loadCallCount);
assertEquals(1, activity.messageLoadCallCount);
assertEquals(0, activity.renderCount); assertEquals(0, activity.renderCount);
activity.releaseFirstLoad(); activity.releaseFirstLoad();
waitFor(() -> activity.renderCount == 2 && activity.loadCallCount == 2); waitFor(() -> activity.renderCount == 2 && activity.messageLoadCallCount == 1 && activity.loadCallCount == 1);
assertEquals(2, activity.loadCallCount); assertEquals(1, activity.loadCallCount);
assertEquals(1, activity.messageLoadCallCount);
assertEquals(2, activity.renderCount); assertEquals(2, activity.renderCount);
} }
@Test
public void realtimeDisconnectTriggersImmediateConversationFallbackReload() 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();
activity.getSharedPreferences("boss_native_client", android.content.Context.MODE_PRIVATE)
.edit()
.putString("session_cookie", "boss_session=test")
.apply();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeConnectionChanged",
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
drainRealtimeDebounce(activity);
waitFor(() -> activity.loadCallCount == 1);
assertEquals(1, activity.loadCallCount);
}
@Test
public void reloadSnapshotAfterDestroyDoesNotCrashWhenExecutorsAreShutdown() 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()
.pause()
.destroy()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"reloadSnapshot",
ReflectionHelpers.ClassParameter.from(boolean.class, false),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
assertEquals(0, activity.loadCallCount);
}
private static void waitFor(BooleanSupplier condition) throws Exception { private static void waitFor(BooleanSupplier condition) throws Exception {
long deadlineAt = System.currentTimeMillis() + 2_000L; long deadlineAt = System.currentTimeMillis() + 2_000L;
while (System.currentTimeMillis() < deadlineAt) { while (System.currentTimeMillis() < deadlineAt) {
@@ -263,9 +484,54 @@ public class ProjectDetailActivityRealtimeTest {
fail("condition not met before timeout"); 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 { public static class TestRealtimeProjectDetailActivity extends ProjectDetailActivity {
int reloadCount; int reloadCount;
int messageReloadCount;
volatile int loadCallCount; volatile int loadCallCount;
volatile int messageLoadCallCount;
volatile int renderCount; volatile int renderCount;
private CountDownLatch firstLoadStarted; private CountDownLatch firstLoadStarted;
private CountDownLatch releaseFirstLoad; private CountDownLatch releaseFirstLoad;
@@ -296,6 +562,26 @@ public class ProjectDetailActivityRealtimeTest {
super.reload(); super.reload();
} }
@Override
ProjectSnapshot loadProjectMessagesSnapshotForRefresh() throws Exception {
messageReloadCount += 1;
messageLoadCallCount += 1;
if (messageLoadCallCount == 1 && firstLoadStarted != null && releaseFirstLoad != null) {
firstLoadStarted.countDown();
releaseFirstLoad.await(2, TimeUnit.SECONDS);
}
return new ProjectSnapshot(
new JSONObject().put(
"project",
new JSONObject()
.put("name", "北区试产线")
.put("messages", new JSONArray())
),
null,
null
);
}
@Override @Override
ProjectSnapshot loadProjectSnapshotForRefresh() throws Exception { ProjectSnapshot loadProjectSnapshotForRefresh() throws Exception {
loadCallCount += 1; loadCallCount += 1;
@@ -321,4 +607,22 @@ public class ProjectDetailActivityRealtimeTest {
setRefreshing(false); 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

@@ -28,7 +28,7 @@ public class ProjectGoalsActivityUiTest {
TestProjectGoalsActivity activity = Robolectric TestProjectGoalsActivity activity = Robolectric
.buildActivity(TestProjectGoalsActivity.class, new Intent() .buildActivity(TestProjectGoalsActivity.class, new Intent()
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, "project-1") .putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "北区试产线回归")) .putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "北区试产线回归需要只展示一行避免堆叠"))
.setup() .setup()
.get(); .get();
@@ -38,16 +38,48 @@ public class ProjectGoalsActivityUiTest {
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject()) ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject())
); );
activity.configureScreen("项目目标", "北区试产线回归需要只展示一行避免堆叠");
LinearLayout content = activity.findViewById(R.id.screen_content); LinearLayout content = activity.findViewById(R.id.screen_content);
TextView subtitle = activity.findViewById(R.id.screen_subtitle);
assertTrue(viewTreeContainsText(content, "主 Agent 已整理项目目标 · 已完成 1/3")); assertTrue(viewTreeContainsText(content, "主 Agent 已整理项目目标 · 已完成 1/3"));
assertTrue(viewTreeContainsSubstring(content, "完成北区试产线全链路回归")); assertTrue(viewTreeContainsSubstring(content, "完成北区试产线全链路回归"));
assertTrue(viewTreeContainsSubstring(content, "已完成 · 09:12 由主 Agent 复核")); assertTrue(viewTreeContainsSubstring(content, "已完成 · 09:12 由主 Agent 复核"));
assertTrue(viewTreeContainsText(content, "当前约束")); assertTrue(viewTreeContainsText(content, "当前约束"));
assertTrue(hasHorizontalContentPadding(content, BossUi.dp(activity, 12)));
assertTrue(subtitle.getMaxLines() <= 1);
assertTrue(String.valueOf(subtitle.getEllipsize()).contains("END"));
assertFalse(viewTreeContainsText(content, "标记完成")); assertFalse(viewTreeContainsText(content, "标记完成"));
assertFalse(viewTreeContainsText(content, "编辑目标")); assertFalse(viewTreeContainsText(content, "编辑目标"));
assertFalse(((SwipeRefreshLayout) activity.findViewById(R.id.screen_refresh_layout)).isRefreshing()); assertFalse(((SwipeRefreshLayout) activity.findViewById(R.id.screen_refresh_layout)).isRefreshing());
} }
@Test
public void renderGoalsShowsSyncedProjectUnderstandingSummary() throws Exception {
TestProjectGoalsActivity activity = Robolectric
.buildActivity(TestProjectGoalsActivity.class, new Intent()
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectGoalsActivity.EXTRA_PROJECT_NAME, "北区试产线回归需要只展示一行避免堆叠"))
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderGoals",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject()
.put("projectUnderstanding", new JSONObject()
.put("projectGoal", "完成北区试产线与主 Agent 接管回归")
.put("currentProgress", "已把最新核对结果同步到项目目标页顶部")
.put("recommendedNextStep", "继续完成 Gitea 推送和真机回归")
.put("updatedAt", "2026-04-18T10:28:00.000Z")))
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "同步项目摘要"));
assertTrue(viewTreeContainsSubstring(content, "完成北区试产线与主 Agent 接管回归"));
assertTrue(viewTreeContainsSubstring(content, "已把最新核对结果同步到项目目标页顶部"));
assertTrue(viewTreeContainsSubstring(content, "继续完成 Gitea 推送和真机回归"));
}
private static JSONObject buildProject() throws Exception { private static JSONObject buildProject() throws Exception {
JSONArray goals = new JSONArray() JSONArray goals = new JSONArray()
.put(new JSONObject() .put(new JSONObject()
@@ -106,6 +138,10 @@ public class ProjectGoalsActivityUiTest {
return false; return false;
} }
private static boolean hasHorizontalContentPadding(LinearLayout content, int minPaddingPx) {
return content.getPaddingLeft() >= minPaddingPx && content.getPaddingRight() >= minPaddingPx;
}
public static class TestProjectGoalsActivity extends ProjectGoalsActivity { public static class TestProjectGoalsActivity extends ProjectGoalsActivity {
@Override @Override
protected void reload() { protected void reload() {

View File

@@ -19,7 +19,7 @@ import java.lang.reflect.Method;
@Config(sdk = 34) @Config(sdk = 34)
public class ProjectVersionsActivityTest { public class ProjectVersionsActivityTest {
@Test @Test
public void matchingGoalRefreshMarkerTriggersReload() throws Exception { public void matchingVersionRefreshMarkerTriggersReload() throws Exception {
Intent intent = new Intent() Intent intent = new Intent()
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1") .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入"); .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入");
@@ -40,7 +40,7 @@ public class ProjectVersionsActivityTest {
"conversation.updated", "conversation.updated",
new JSONObject() new JSONObject()
.put("projectId", "project-1") .put("projectId", "project-1")
.put("note", "project_goals.updated") .put("note", "project_versions.updated")
) )
); );
Shadows.shadowOf(activity.getMainLooper()).idle(); Shadows.shadowOf(activity.getMainLooper()).idle();
@@ -49,7 +49,7 @@ public class ProjectVersionsActivityTest {
} }
@Test @Test
public void sameProjectNonGoalEventDoesNotTriggerReload() throws Exception { public void sameProjectNonVersionEventDoesNotTriggerReload() throws Exception {
Intent intent = new Intent() Intent intent = new Intent()
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1") .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入"); .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入");

View File

@@ -1,6 +1,7 @@
package com.hyzq.boss; package com.hyzq.boss;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import android.content.Intent; import android.content.Intent;
@@ -28,7 +29,7 @@ public class ProjectVersionsActivityUiTest {
TestProjectVersionsActivity activity = Robolectric TestProjectVersionsActivity activity = Robolectric
.buildActivity(TestProjectVersionsActivity.class, new Intent() .buildActivity(TestProjectVersionsActivity.class, new Intent()
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1") .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "北区试产线回归")) .putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "北区试产线回归需要只展示一行避免堆叠"))
.setup() .setup()
.get(); .get();
@@ -38,11 +39,18 @@ public class ProjectVersionsActivityUiTest {
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject()) ReflectionHelpers.ClassParameter.from(JSONObject.class, buildProject())
); );
activity.configureScreen("版本记录", "北区试产线回归需要只展示一行避免堆叠");
LinearLayout content = activity.findViewById(R.id.screen_content); LinearLayout content = activity.findViewById(R.id.screen_content);
TextView title = activity.findViewById(R.id.screen_title);
TextView subtitle = activity.findViewById(R.id.screen_subtitle);
assertEquals("版本记录", String.valueOf(title.getText()));
assertTrue(viewTreeContainsText(content, "仅主 Agent 可发布迭代记录")); assertTrue(viewTreeContainsText(content, "仅主 Agent 可发布迭代记录"));
assertTrue(viewTreeContainsText(content, "v1.2.8 已发布")); assertTrue(viewTreeContainsText(content, "v1.2.8 已发布"));
assertTrue(viewTreeContainsSubstring(content, "• 优化 OTA 实时提示")); assertTrue(viewTreeContainsSubstring(content, "• 优化 OTA 实时提示"));
assertTrue(viewTreeContainsText(content, "主 Agent 复核记录")); assertTrue(viewTreeContainsText(content, "主 Agent 复核记录"));
assertTrue(hasHorizontalContentPadding(content, BossUi.dp(activity, 12)));
assertTrue(subtitle.getMaxLines() <= 1);
assertTrue(String.valueOf(subtitle.getEllipsize()).contains("END"));
assertFalse(viewTreeContainsText(content, "版本记录只读")); assertFalse(viewTreeContainsText(content, "版本记录只读"));
assertFalse(((SwipeRefreshLayout) activity.findViewById(R.id.screen_refresh_layout)).isRefreshing()); assertFalse(((SwipeRefreshLayout) activity.findViewById(R.id.screen_refresh_layout)).isRefreshing());
} }
@@ -98,6 +106,10 @@ public class ProjectVersionsActivityUiTest {
return false; return false;
} }
private static boolean hasHorizontalContentPadding(LinearLayout content, int minPaddingPx) {
return content.getPaddingLeft() >= minPaddingPx && content.getPaddingRight() >= minPaddingPx;
}
public static class TestProjectVersionsActivity extends ProjectVersionsActivity { public static class TestProjectVersionsActivity extends ProjectVersionsActivity {
@Override @Override
protected void reload() { protected void reload() {

View File

@@ -1,10 +1,15 @@
package com.hyzq.boss; package com.hyzq.boss;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.content.Intent; import android.content.Intent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import org.json.JSONObject; import org.json.JSONObject;
import org.json.JSONArray;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.robolectric.Robolectric; import org.robolectric.Robolectric;
@@ -68,6 +73,61 @@ public class SkillInventoryActivityTest {
assertEquals(0, activity.reloadCount); assertEquals(0, activity.reloadCount);
} }
@Test
public void renderSkillsShowsManagementDispatchWorkspaceWhenLifecycleRequestsAreAvailable() throws Exception {
Intent intent = new Intent()
.putExtra(SkillInventoryActivity.EXTRA_DEVICE_ID, "device-1")
.putExtra(SkillInventoryActivity.EXTRA_DEVICE_NAME, "Mac Studio");
TestSkillInventoryActivity activity = Robolectric
.buildActivity(TestSkillInventoryActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderSkills",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildSkillPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildLifecyclePayload())
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "Skill 管理分发"));
assertTrue(viewTreeContainsText(content, "安装远端 Skill"));
assertTrue(viewTreeContainsText(content, "分配权限"));
assertTrue(viewTreeContainsText(content, "Skill 请求状态"));
assertTrue(viewTreeContainsText(content, "更新下发"));
assertTrue(viewTreeContainsText(content, "版本锁定"));
}
private static JSONObject buildSkillPayload() throws Exception {
return new JSONObject()
.put("device", new JSONObject()
.put("id", "device-1")
.put("name", "Mac Studio"))
.put("skills", new JSONArray()
.put(new JSONObject()
.put("skillId", "device-1:boss-server-debug")
.put("name", "boss-server-debug")
.put("description", "服务器排障")
.put("category", "ops")
.put("path", "/Users/kris/.codex/skills/boss-server-debug/SKILL.md")
.put("invocation", "使用 boss-server-debug")
.put("updatedAt", "2026-06-08T10:00:00+08:00")));
}
private static JSONObject buildLifecyclePayload() throws Exception {
return new JSONObject()
.put("ok", true)
.put("requests", new JSONArray()
.put(new JSONObject()
.put("requestId", "skill-request-1")
.put("action", "update")
.put("status", "queued")
.put("deviceId", "device-1")
.put("skillId", "device-1:boss-server-debug")
.put("requestedAt", "2026-06-08T10:01:00+08:00")));
}
public static class TestSkillInventoryActivity extends SkillInventoryActivity { public static class TestSkillInventoryActivity extends SkillInventoryActivity {
private boolean reloadEnabled; private boolean reloadEnabled;
private int reloadCount; private int reloadCount;
@@ -81,4 +141,23 @@ public class SkillInventoryActivityTest {
setRefreshing(false); setRefreshing(false);
} }
} }
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;
}
} }

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); 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 @Test
public void toDeviceRow_mapsLegacyWechatThreeLineSummary() throws Exception { public void toDeviceRow_mapsLegacyWechatThreeLineSummary() throws Exception {
JSONObject item = new StubJSONObject() JSONObject item = new StubJSONObject()
.withString("name", "Mac Studio") .withString("name", "Mac Studio")
.withString("avatar", "M") .withString("avatar", "M")
.withString("status", "online") .withString("status", "online")
.withString("account", "17600003315") .withString("account", "krisolo")
.withStringArray("projects", "北区试产线回归", "容灾切换验证") .withStringArray("projects", "北区试产线回归", "容灾切换验证")
.withInt("quota5h", 8) .withInt("quota5h", 8)
.withInt("quota7d", 22); .withInt("quota7d", 22);
@@ -151,7 +234,7 @@ public class WechatSurfaceMapperTest {
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item); WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
assertEquals("Mac Studio", row.title); assertEquals("Mac Studio", row.title);
assertEquals("账号: 17600003315 · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle); assertEquals("账号: krisolo · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle);
assertEquals("额度: 5h 8% · 7d 22%", row.meta); assertEquals("额度: 5h 8% · 7d 22%", row.meta);
assertEquals("M", row.avatarLabel); assertEquals("M", row.avatarLabel);
assertEquals("online", row.statusKey); assertEquals("online", row.statusKey);
@@ -162,12 +245,12 @@ public class WechatSurfaceMapperTest {
JSONObject item = new StubJSONObject() JSONObject item = new StubJSONObject()
.withString("name", "Mac Studio") .withString("name", "Mac Studio")
.withString("status", "abnormal") .withString("status", "abnormal")
.withString("account", "17600003315"); .withString("account", "krisolo");
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item); WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
assertEquals("Mac Studio", row.title); assertEquals("Mac Studio", row.title);
assertEquals("账号: 17600003315", row.subtitle); assertEquals("账号: krisolo", row.subtitle);
assertEquals("额度: 暂无 · 状态异常", row.meta); assertEquals("额度: 暂无 · 状态异常", row.meta);
assertEquals("abnormal", row.statusKey); assertEquals("abnormal", row.statusKey);
} }
@@ -177,7 +260,7 @@ public class WechatSurfaceMapperTest {
JSONObject item = new StubJSONObject() JSONObject item = new StubJSONObject()
.withString("name", "Mac Studio") .withString("name", "Mac Studio")
.withString("status", "online") .withString("status", "online")
.withString("account", "17600003315") .withString("account", "krisolo")
.withString("note", "书房主机") .withString("note", "书房主机")
.withString("endpoint", "https://boss.hyzq.net/device/mac-studio") .withString("endpoint", "https://boss.hyzq.net/device/mac-studio")
.withStringArray("projects", "master-agent", "android-app"); .withStringArray("projects", "master-agent", "android-app");
@@ -185,14 +268,14 @@ public class WechatSurfaceMapperTest {
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(item); WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(item);
assertEquals("Mac Studio", summary.title); 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); assertEquals("额度: 暂无 · 书房主机 · https://boss.hyzq.net/device/mac-studio · 项目 master-agent, android-app", summary.meta);
} }
@Test @Test
public void rootMeMenuTitles_matchLegacyWechatMenuWithOpsEntry() throws Exception { public void rootMeMenuTitles_matchLegacyWechatMenuWithOpsEntry() throws Exception {
assertArrayEquals( assertArrayEquals(
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"}, new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitles() WechatSurfaceMapper.rootMeMenuTitles()
); );
} }
@@ -208,7 +291,7 @@ public class WechatSurfaceMapperTest {
@Test @Test
public void mainPage_keepsOpsEntryInStableWechatMenuOrder() throws Exception { public void mainPage_keepsOpsEntryInStableWechatMenuOrder() throws Exception {
assertArrayEquals( assertArrayEquals(
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"}, new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitles() WechatSurfaceMapper.rootMeMenuTitles()
); );
} }
@@ -292,7 +375,7 @@ public class WechatSurfaceMapperTest {
JSONArray devices = new StubObjectArray( JSONArray devices = new StubObjectArray(
new StubJSONObject() new StubJSONObject()
.withString("id", "device-b") .withString("id", "device-b")
.withString("account", "17600003315"), .withString("account", "krisolo"),
new StubJSONObject() new StubJSONObject()
.withString("id", "device-c") .withString("id", "device-c")
.withString("account", "other-account") .withString("account", "other-account")
@@ -311,7 +394,7 @@ public class WechatSurfaceMapperTest {
null, null,
"stale-device-id", "stale-device-id",
"missing-bound-device", "missing-bound-device",
"17600003315", "krisolo",
devices devices
); );
@@ -380,15 +463,20 @@ public class WechatSurfaceMapperTest {
public void meMenuItems_useStableKeysInsteadOfDisplayTitlesForRouting() throws Exception { public void meMenuItems_useStableKeysInsteadOfDisplayTitlesForRouting() throws Exception {
WechatSurfaceMapper.MeMenuItem[] items = WechatSurfaceMapper.rootMeMenuItems(); WechatSurfaceMapper.MeMenuItem[] items = WechatSurfaceMapper.rootMeMenuItems();
assertEquals(6, items.length); assertEquals(9, items.length);
assertEquals("security", items[0].key); assertEquals("security", items[0].key);
assertEquals("账号与安全", items[0].title); assertEquals("账号与安全", items[0].title);
assertEquals("settings", items[1].key); assertEquals("settings", items[1].key);
assertEquals("ops", items[2].key); assertEquals("access", items[2].key);
assertEquals("运维与修复", items[2].title); assertEquals("用户与权限", items[2].title);
assertEquals("ai_accounts", items[3].key); assertEquals("ops", items[3].key);
assertEquals("skills", items[4].key); assertEquals("运维与修复", items[3].title);
assertEquals("about", items[5].key); 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 @Test

View File

@@ -40,6 +40,17 @@ public class WechatSurfaceMapperTopActionTest {
assertEquals("add_device", action.actionKey); 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 @Test
public void rootTopAction_keepsRefreshOnMeTab() { public void rootTopAction_keepsRefreshOnMeTab() {
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("me", true); 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"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,230 @@
export interface BossAdminMenuItem {
key: string;
label: string;
children?: BossAdminMenuItem[];
}
export interface BossAdminTaskSlaRow extends Record<string, unknown> {
taskId: string;
riskId: string;
projectId: string;
deviceId: string;
taskType: string;
status: string;
phase: string;
summary: string;
slaLevel: "ok" | "watch" | "breached" | "recoverable" | "terminal";
severity: "info" | "warning" | "critical";
slaDueAt: string;
lastProgressAt: string;
attemptLabel: string;
stale: boolean;
recoverable: boolean;
autoRecoverable: boolean;
recommendedAction: string;
}
export interface BossAdminTaskSlaPanel {
generatedAt: string;
summary: Record<string, number>;
rows: BossAdminTaskSlaRow[];
}
export interface BossAdminBackofficePayload {
ok: boolean;
surface: "platform" | "enterprise";
currentCompany: Record<string, unknown> | null;
menuTree: BossAdminMenuItem[];
insights: {
onboardingSteps: string[];
serviceStatuses: Array<Record<string, unknown>>;
openingPreview: Array<Record<string, unknown>>;
deliveryChecklist: Array<Record<string, unknown>>;
recentCompanies: Array<Record<string, unknown>>;
customerHealthRows: Array<Record<string, unknown>>;
riskAggregates: Array<Record<string, unknown>>;
customerFollowups: Array<Record<string, unknown>>;
enterpriseGoals: Array<Record<string, unknown>>;
organizationUnits: string[];
departmentProgress: Array<Record<string, unknown>>;
masterAgentSummary: string[];
permissionHighlights: string[];
agentFlowSteps: string[];
skillUsageAudit: Array<Record<string, unknown>>;
recoveryActions: string[];
backupStatus: Record<string, unknown>;
dataSafetySummary: Record<string, unknown>;
taskRiskSummary: Record<string, unknown>;
taskSlaPanel: BossAdminTaskSlaPanel;
capabilitySummary: Record<string, number>;
surface: "platform" | "enterprise";
};
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>;
}
export interface BossAdminBackupSnapshot {
snapshotId: string;
fileName: string;
absolutePath: string;
bytes: number;
sha256: string;
createdAt: string;
actorAccount?: string;
reason?: string;
schemaVersion?: number;
}
export interface BossAdminBackupStatus {
mode: "file";
backupDir: string;
stateFile: string;
restorePointCount: number;
lastBackupAt?: string;
status: "ready" | "empty" | "error";
detail?: string;
}
export interface BossAdminBackupsPayload {
ok: boolean;
status: BossAdminBackupStatus;
snapshots: BossAdminBackupSnapshot[];
}
export interface BossAdminSkillLifecycleRequest extends Record<string, unknown> {
requestId: string;
action: string;
status: string;
deviceId: string;
skillId?: string;
sourceUrl?: string;
targetVersion?: string;
rollbackToVersion?: string;
lockedVersion?: string;
requestedAt?: string;
completedAt?: string;
resultSummary?: string;
error?: string;
}
export interface BossAdminSkillLifecycleRequestsPayload {
ok: boolean;
requests: BossAdminSkillLifecycleRequest[];
}
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(scope: "platform" | "enterprise" = "platform"): Promise<BossAdminBackofficePayload> {
return requestJson<BossAdminBackofficePayload>(`/api/v1/admin/backoffice?scope=${scope}`);
}
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),
});
}
export async function fetchSkillLifecycleRequests(): Promise<BossAdminSkillLifecycleRequestsPayload> {
return requestJson<BossAdminSkillLifecycleRequestsPayload>("/api/v1/admin/skills/requests", {
method: "GET",
});
}
export async function postDeviceCodexRemoteControl(
deviceId: string,
payload: { action: "start" | "stop"; reason?: string },
) {
return requestJson<Record<string, unknown>>(
`/api/v1/devices/${encodeURIComponent(deviceId)}/codex-remote-control`,
{
method: "POST",
body: JSON.stringify({
...payload,
confirmed: true,
}),
},
);
}
export async function fetchAdminBackups(): Promise<BossAdminBackupsPayload> {
return requestJson<BossAdminBackupsPayload>("/api/v1/admin/backups");
}
export async function createAdminBackup(reason: string) {
return requestJson<Record<string, unknown>>("/api/v1/admin/backups", {
method: "POST",
body: JSON.stringify({
action: "create_snapshot",
reason,
}),
});
}
export async function restoreAdminBackup(snapshotId: string) {
return requestJson<Record<string, unknown>>("/api/v1/admin/backups", {
method: "POST",
body: JSON.stringify({
action: "restore_snapshot",
snapshotId,
}),
});
}

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,426 @@
:root {
color: #102018;
background: #eef4ef;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
body {
margin: 0;
min-width: 1360px;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(16, 185, 129, 0.16), transparent 30%),
linear-gradient(135deg, #f7fbf8 0%, #eef4ef 54%, #e6f0ec 100%);
}
.boss-admin-shell {
display: grid;
grid-template-columns: 300px 1fr;
min-height: 100vh;
}
.boss-admin-sidebar {
padding: 28px 20px;
background: rgba(255, 255, 255, 0.82);
border-right: 1px solid rgba(16, 32, 24, 0.08);
backdrop-filter: blur(22px);
}
.boss-admin-brand {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 22px;
}
.boss-admin-brand-mark {
display: grid;
place-items: center;
width: 46px;
height: 46px;
color: white;
font-weight: 900;
background: #10b981;
border-radius: 17px;
box-shadow: 0 14px 28px rgba(16, 185, 129, 0.24);
}
.boss-admin-brand h1 {
margin: 0;
font-size: 20px;
letter-spacing: -0.02em;
}
.boss-admin-brand p,
.boss-admin-eyebrow {
margin: 0;
color: #68766e;
font-size: 13px;
}
.boss-admin-surface-switch {
display: grid;
gap: 10px;
margin-bottom: 24px;
}
.boss-admin-surface-card {
display: grid;
gap: 4px;
width: 100%;
padding: 14px;
color: #526158;
font: inherit;
text-align: left;
cursor: pointer;
background: rgba(247, 250, 248, 0.78);
border: 1px solid rgba(16, 32, 24, 0.08);
border-radius: 18px;
}
.boss-admin-surface-card span {
font-size: 15px;
font-weight: 800;
}
.boss-admin-surface-card small {
color: #7b8780;
font-size: 12px;
}
.boss-admin-surface-card.active {
color: #0b6b4c;
background: #e5f8ef;
border-color: rgba(16, 185, 129, 0.36);
box-shadow: 0 12px 30px rgba(16, 185, 129, 0.12);
}
.boss-admin-menu {
display: grid;
gap: 8px;
}
.boss-admin-visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.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: 22px;
}
.boss-admin-header h2 {
margin: 5px 0 0;
font-size: 30px;
line-height: 1.15;
letter-spacing: -0.03em;
}
.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-hero {
grid-column: 1 / -1;
overflow: hidden;
background:
linear-gradient(135deg, rgba(16, 185, 129, 0.16), rgba(255, 255, 255, 0.94)),
white;
}
.boss-admin-hero h3 {
max-width: 820px;
margin: 8px 0 22px;
font-size: 25px;
line-height: 1.25;
letter-spacing: -0.03em;
}
.boss-admin-metrics {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
}
.boss-admin-metrics.compact {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-top: 18px;
}
.boss-admin-metric {
display: grid;
gap: 8px;
padding: 18px;
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(16, 32, 24, 0.08);
border-radius: 18px;
}
.boss-admin-metric span {
color: #6a766f;
font-size: 13px;
}
.boss-admin-metric strong {
color: #102018;
font-size: 30px;
line-height: 1;
}
.boss-admin-metric.green strong {
color: #10b981;
}
.boss-admin-metric.red strong {
color: #f04452;
}
.boss-admin-metric.orange strong {
color: #f97316;
}
.boss-admin-form {
max-width: 540px;
}
.boss-admin-form-gap {
margin-top: 10px;
}
.boss-admin-steps {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.boss-admin-step {
display: flex;
gap: 10px;
align-items: center;
padding: 14px;
color: #51625a;
background: rgba(255, 255, 255, 0.76);
border: 1px solid rgba(16, 32, 24, 0.08);
border-radius: 16px;
}
.boss-admin-step span {
display: grid;
place-items: center;
width: 26px;
height: 26px;
color: #0f7a55;
background: #dbf7ea;
border-radius: 50%;
}
.boss-admin-step.active {
color: #0b6b4c;
border-color: rgba(16, 185, 129, 0.34);
box-shadow: 0 10px 24px rgba(16, 185, 129, 0.12);
}
.boss-admin-action-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.boss-admin-status-list,
.boss-admin-check-list,
.boss-admin-goal-list {
display: grid;
gap: 12px;
}
.boss-admin-status-row,
.boss-admin-check-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: #f7faf8;
border: 1px solid rgba(16, 32, 24, 0.06);
border-radius: 14px;
}
.boss-admin-check-row {
justify-content: flex-start;
gap: 12px;
}
.boss-admin-check-row span {
display: grid;
place-items: center;
width: 24px;
height: 24px;
color: #f97316;
background: #fff7ed;
border-radius: 50%;
}
.boss-admin-check-row span.done {
color: #10b981;
background: #dcfce7;
}
.boss-admin-goal-row {
display: grid;
gap: 8px;
padding: 14px;
background: #f7faf8;
border-radius: 14px;
}
.boss-admin-goal-row > div {
display: flex;
justify-content: space-between;
}
.boss-admin-org-grid,
.boss-admin-capability-grid,
.boss-admin-recovery-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.boss-admin-org-node,
.boss-admin-recovery-card,
.boss-admin-capability-grid > div {
padding: 16px;
color: #18352a;
font-weight: 800;
background: #f7faf8;
border: 1px solid rgba(16, 32, 24, 0.06);
border-radius: 16px;
}
.boss-admin-capability-grid span {
display: block;
color: #68766e;
font-size: 12px;
font-weight: 600;
}
.boss-admin-capability-grid strong {
color: #10b981;
font-size: 28px;
}
.boss-admin-permission-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.boss-admin-flow {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.boss-admin-flow-node {
padding: 14px 16px;
color: #0b6b4c;
font-weight: 800;
background: #e5f8ef;
border-radius: 16px;
}
.boss-admin-flow-arrow {
color: #8b9790;
font-size: 20px;
}
.boss-admin-backup-status {
margin-top: 16px;
}
.ant-card {
min-width: 0;
overflow: hidden;
border-radius: 22px;
box-shadow: 0 18px 50px rgba(16, 32, 24, 0.07);
}
.boss-admin-wide-card {
grid-column: 1 / -1;
}
.ant-card .ant-card-body {
min-width: 0;
overflow: hidden;
}
.ant-table-wrapper {
max-width: 100%;
overflow-x: auto;
}
.ant-table-wrapper .ant-table {
min-width: 720px;
border-radius: 16px;
}
.ant-table-wrapper .ant-table-cell {
white-space: normal;
word-break: break-word;
}
.ant-table-wrapper .ant-btn,
.boss-admin-action-strip .ant-btn {
white-space: nowrap;
}

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

@@ -0,0 +1,368 @@
import Cocoa
import WebKit
import ApplicationServices
final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate {
private var window: NSWindow?
private var webView: WKWebView?
private var activeTab = "overview"
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.regular)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleApplicationDidBecomeActive),
name: NSApplication.didBecomeActiveNotification,
object: nil
)
NSAppleEventManager.shared().setEventHandler(
self,
andSelector: #selector(handleGetUrlEvent(_:withReplyEvent:)),
forEventClass: AEEventClass(kInternetEventClass),
andEventID: AEEventID(kAEGetURL)
)
let webConfiguration = WKWebViewConfiguration()
let webView = WKWebView(frame: .zero, configuration: webConfiguration)
webView.setValue(false, forKey: "drawsBackground")
webView.navigationDelegate = self
self.webView = webView
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1180, height: 780),
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.title = "boss-agent"
window.titlebarAppearsTransparent = true
window.isMovableByWindowBackground = true
window.contentView = webView
window.center()
window.makeKeyAndOrderFront(nil)
self.window = window
loadAgentPanel(tab: activeTab)
NSApp.activate(ignoringOtherApps: true)
handleLaunchPermissionRequestIfNeeded()
}
private func loadAgentPanel(tab: String? = nil) {
activeTab = normalizedTab(tab ?? activeTab)
var components = URLComponents()
components.scheme = "http"
components.host = "127.0.0.1"
components.port = 4317
components.path = "/boss-agent"
components.queryItems = [URLQueryItem(name: "tab", value: activeTab)] + nativePermissionQueryItems()
guard let url = components.url else {
loadFallback()
return
}
webView?.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData))
}
@objc private func handleApplicationDidBecomeActive() {
loadAgentPanel(tab: activeTab)
}
private func handleLaunchPermissionRequestIfNeeded() {
let arguments = CommandLine.arguments
guard
let targetIndex = arguments.firstIndex(of: "--request-permission"),
arguments.indices.contains(targetIndex + 1)
else {
return
}
let target = arguments[targetIndex + 1]
let returnTab = commandLineValue(after: "--return-tab", in: arguments) ?? "permissions"
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
self?.handlePermissionTarget(target, returnTab: returnTab)
}
}
private func commandLineValue(after flag: String, in arguments: [String]) -> String? {
guard let index = arguments.firstIndex(of: flag), arguments.indices.contains(index + 1) else {
return nil
}
return arguments[index + 1]
}
@objc private func handleGetUrlEvent(_ event: NSAppleEventDescriptor, withReplyEvent replyEvent: NSAppleEventDescriptor) {
guard
let urlString = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue,
let url = URL(string: urlString),
isBossAgentDeepLink(url)
else {
return
}
handleBossAgentDeepLink(url)
}
func application(_ application: NSApplication, open urls: [URL]) {
for url in urls where isBossAgentDeepLink(url) {
handleBossAgentDeepLink(url)
}
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
loadFallback()
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
loadFallback()
}
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
guard let url = navigationAction.request.url else {
decisionHandler(.allow)
return
}
if isPermissionSetupUrl(url) {
decisionHandler(.cancel)
handlePermissionSetupNavigation(url)
return
}
if isAgentPanelUrl(url) && !hasNativePermissionQuery(url) {
decisionHandler(.cancel)
loadAgentPanel(tab: queryValue("tab", in: url))
return
}
if isAgentPanelUrl(url), let tab = queryValue("tab", in: url) {
activeTab = normalizedTab(tab)
}
decisionHandler(.allow)
}
private func isPermissionSetupUrl(_ url: URL) -> Bool {
url.path == "/api/v1/boss-agent/permissions/open"
}
private func isBossAgentDeepLink(_ url: URL) -> Bool {
url.scheme == "boss-agent"
}
private func handleBossAgentDeepLink(_ url: URL) {
if url.host == "permissions" && url.path == "/open" {
handlePermissionTarget(
queryValue("target", in: url) ?? "core",
returnTab: queryValue("returnTab", in: url) ?? "permissions"
)
return
}
if url.host == "tab" {
loadAgentPanel(tab: String(url.path.dropFirst()))
}
}
private func isAgentPanelUrl(_ url: URL) -> Bool {
let host = url.host ?? ""
return (host == "127.0.0.1" || host == "localhost") && url.port == 4317 && (url.path == "/boss-agent" || url.path == "/")
}
private func hasNativePermissionQuery(_ url: URL) -> Bool {
URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?
.contains(where: { $0.name.hasPrefix("native") }) == true
}
private func queryValue(_ name: String, in url: URL) -> String? {
URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?
.first(where: { $0.name == name })?
.value
}
private func normalizedTab(_ value: String?) -> String {
switch value {
case "permissions", "skills", "license", "logs", "overview":
return value ?? "overview"
default:
return "overview"
}
}
private func handlePermissionSetupNavigation(_ url: URL) {
handlePermissionTarget(
queryValue("target", in: url) ?? "core",
returnTab: queryValue("returnTab", in: url) ?? activeTab
)
}
private func handlePermissionTarget(_ target: String, returnTab rawReturnTab: String) {
let permissionTarget = normalizedPermissionTarget(target)
let returnTab = normalizedTab(rawReturnTab)
activeTab = returnTab
UserDefaults.standard.set(permissionTarget, forKey: "lastPermissionRequestTarget")
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "lastPermissionRequestAt")
NSLog("boss-agent permission request target=%@ returnTab=%@", permissionTarget, returnTab)
NSApp.activate(ignoringOtherApps: true)
requestNativePermission(for: permissionTarget)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in
if let settingsUrl = self?.systemSettingsUrl(for: permissionTarget) {
NSWorkspace.shared.open(settingsUrl)
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
self?.loadAgentPanel(tab: returnTab)
}
}
private func nativePermissionQueryItems() -> [URLQueryItem] {
let accessibility = AXIsProcessTrusted() ? "granted" : "missing"
let screenRecording = screenRecordingStatus()
UserDefaults.standard.set(accessibility, forKey: "native.accessibility")
UserDefaults.standard.set(screenRecording, forKey: "native.screenRecording")
return [
URLQueryItem(name: "nativeAccessibility", value: accessibility),
URLQueryItem(name: "nativeScreenRecording", value: screenRecording),
]
}
private func screenRecordingStatus() -> String {
if #available(macOS 10.15, *) {
if CGPreflightScreenCaptureAccess() {
return "granted"
}
if CGRequestScreenCaptureAccess() {
return "granted"
}
return tccPermissionStatus(service: "kTCCServiceScreenCapture") ?? "missing"
}
return "unknown"
}
private func tccPermissionStatus(service: String) -> String? {
let clients = service == "kTCCServiceScreenCapture"
? "'com.hyzq.boss.agent','site.hyzq.boss.computer-use-helper'"
: "'com.hyzq.boss.agent'"
let query = "select auth_value from access where client in (\(clients)) and service='\(service)' order by auth_value desc limit 1;"
let databasePaths = [
"/Library/Application Support/com.apple.TCC/TCC.db",
"\(NSHomeDirectory())/Library/Application Support/com.apple.TCC/TCC.db",
]
for databasePath in databasePaths where FileManager.default.fileExists(atPath: databasePath) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3")
process.arguments = [databasePath, query]
let output = Pipe()
process.standardOutput = output
process.standardError = Pipe()
do {
try process.run()
process.waitUntilExit()
} catch {
continue
}
let data = output.fileHandleForReading.readDataToEndOfFile()
let value = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
if value == "2" {
return "granted"
}
if value == "0" {
return "missing"
}
}
return nil
}
private func requestNativePermission(for target: String) {
let targets: [String]
if target == "core" {
targets = ["accessibility", "screenRecording"]
} else {
targets = [target]
}
for permission in targets {
requestSingleNativePermission(permission)
}
}
private func requestSingleNativePermission(_ permission: String) {
switch permission {
case "accessibility":
requestAccessibilityPermission()
case "screenRecording":
requestScreenRecordingPermission()
default:
break
}
}
private func requestAccessibilityPermission() {
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
let options = [promptKey: true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
}
private func requestScreenRecordingPermission() {
if #available(macOS 10.15, *) {
_ = CGRequestScreenCaptureAccess()
} else {
_ = CGPreflightScreenCaptureAccess()
}
}
private func systemSettingsUrl(for target: String) -> URL? {
let mapping = [
"core": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Accessibility",
"accessibility": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Accessibility",
"screenRecording": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture",
]
return URL(string: mapping[normalizedPermissionTarget(target)] ?? mapping["core"]!)
}
private func normalizedPermissionTarget(_ target: String) -> String {
switch target {
case "accessibility", "screenRecording", "core":
return target
default:
return "core"
}
}
private func loadFallback() {
let html = """
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<style>
body { margin:0; min-height:100vh; display:grid; place-items:center; background:#f6f8f5; font-family:-apple-system,BlinkMacSystemFont,'PingFang SC',sans-serif; color:#111418; }
.card { width:520px; padding:32px; border-radius:24px; background:white; border:1px solid #e8ece9; box-shadow:0 24px 70px rgba(22,38,29,.12); }
h1 { margin:0 0 10px; font-size:28px; letter-spacing:-.04em; }
p { color:#707982; line-height:1.7; margin:0; }
</style>
<body>
<section class="card">
<h1>boss-agent 未启动</h1>
<p>请先启动本机 local-agent 服务,然后重新打开 boss-agent。</p>
</section>
</body>
</html>
"""
webView?.loadHTMLString(html, baseURL: nil)
}
}
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.run()

View File

@@ -10,6 +10,24 @@ boss.hyzq.net {
reverse_proxy 127.0.0.1:3000 reverse_proxy 127.0.0.1:3000
} }
admin.boss.hyzq.net {
encode zstd gzip
handle /admin-web/* {
root * /opt/boss/public
file_server
}
@adminRoot path /
handle @adminRoot {
root * /opt/boss/public
rewrite * /admin-web/index.html
file_server
}
reverse_proxy 127.0.0.1:3000
}
http://106.53.170.158 { http://106.53.170.158 {
encode zstd gzip 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 __BOSS_AGENT_ROOT__ &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

@@ -8,7 +8,7 @@
<array> <array>
<string>/bin/zsh</string> <string>/bin/zsh</string>
<string>-lc</string> <string>-lc</string>
<string>cd /Users/kris/code/boss &amp;&amp; ./scripts/start-local-agent.sh __BOSS_AGENT_CONFIG__</string> <string>cd __BOSS_AGENT_ROOT__ &amp;&amp; ./scripts/start-local-agent.sh __BOSS_AGENT_CONFIG__</string>
</array> </array>
<key>RunAtLoad</key> <key>RunAtLoad</key>
<true/> <true/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

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` UI 已删除,`/admin` 仅保留为跳转到根路径 `/` 的兼容入口;生产域名 `https://admin.boss.hyzq.net/` 直接承载新独立 PC 后台。
- 独立后台只消费 Admin BFF不直接读取 `boss-state.json`
- 独立后台当前复用 Boss Cookie 登录态,后续再绑定 `admin.boss.hyzq.net` 的独立部署。
- `/api/v1/admin/backoffice` 仍只允许 `highest_admin`,并过滤 `passwordHash``mfaSecret` 和 session token。
## 当前落地范围
- `/admin` 页面收敛为兼容跳转,不再承载旧 Next 管理 UI。
- 新增 `/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` 页面直接跳转根路径 `/`,生产根路径由 `admin.boss.hyzq.net` 站点内部 rewrite 到独立后台静态入口。
-`highest_admin` 访问 `/enterprise-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,13 @@
2. `docs/architecture/repo_map_cn.md` 2. `docs/architecture/repo_map_cn.md`
3. `docs/architecture/current_runtime_and_deploy_status_cn.md` 3. `docs/architecture/current_runtime_and_deploy_status_cn.md`
4. `docs/architecture/api_and_service_inventory_cn.md` 4. `docs/architecture/api_and_service_inventory_cn.md`
5. `docs/architecture/boss_server_connection_and_deploy_cn.md` 5. `docs/architecture/enterprise_ai_ops_architecture_cn.md`
6. `docs/architecture/wechat_project_conversation_mapping_cn.md` 6. `docs/architecture/rbac_skill_regression_matrix_cn.md`
7. `docs/architecture/thread_context_budget_and_handoff_protocol_cn.md` 7. `docs/architecture/boss_server_connection_and_deploy_cn.md`
8. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md` 8. `docs/architecture/wechat_project_conversation_mapping_cn.md`
9. `docs/architecture/thread_context_budget_and_handoff_protocol_cn.md`
10. `docs/architecture/dependency_security_audit_cn.md`
11. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md`
## 3. 当前有效实现边界 ## 3. 当前有效实现边界
@@ -40,6 +43,7 @@
- `src/lib/boss-storage-server-file.ts`:服务器文件存储上传 / 读取 - `src/lib/boss-storage-server-file.ts`:服务器文件存储上传 / 读取
- `src/lib/boss-storage-aliyun-oss.ts`:阿里 OSS 私有桶上传 / 签名下载 - `src/lib/boss-storage-aliyun-oss.ts`:阿里 OSS 私有桶上传 / 签名下载
- `src/lib/boss-ota.ts`APK OTA 产物定位与元数据读取 - `src/lib/boss-ota.ts`APK OTA 产物定位与元数据读取
- `src/lib/boss-agent-ota.ts`boss-agent macOS 运行包 OTA 产物定位与元数据读取
- `src/lib/boss-projections.ts`:当前聚合 BFF 投影视图 - `src/lib/boss-projections.ts`:当前聚合 BFF 投影视图
- `src/components/app-runtime.tsx`APP 日志桥、SSE 刷新和 Skill 面板 - `src/components/app-runtime.tsx`APP 日志桥、SSE 刷新和 Skill 面板
- `local-agent/server.mjs`:设备端心跳和 thread-context 上报服务 - `local-agent/server.mjs`:设备端心跳和 thread-context 上报服务
@@ -58,6 +62,7 @@
- `android/app/src/main/java/com/hyzq/boss/AttachmentComposerState.java`:原生附件发送确认规则与待上传附件模型 - `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/BossWindowInsets.java`:原生顶部安全区处理,负责把状态栏 / 刘海区让出来
- `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.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/AiAccountsActivity.java`:原生 AI 账号管理页
- `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`:原生运维 / 审计中心 - `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`:原生运维 / 审计中心
- `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`:原生微信式 surface contract - `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`:原生微信式 surface contract
@@ -97,10 +102,12 @@
- `POST /api/auth/login` 正常,会写入 `boss_session` - `POST /api/auth/login` 正常,会写入 `boss_session`
- `boss_session` 当前默认保持 30 天 - `boss_session` 当前默认保持 30 天
- `GET /api/auth/session` 正常 - `GET /api/auth/session` 正常
- `GET/POST /api/v1/auth/sessions` 正常,已支持基础跨端会话治理和单会话撤销
- `POST /api/auth/restore` 正常,原生 Android 客户端可用 `restore token` 自动恢复登录态 - `POST /api/auth/restore` 正常,原生 Android 客户端可用 `restore token` 自动恢复登录态
- `GET /api/v1/app-logs` 正常,可按登录态分页读取 APP 日志 - `GET /api/v1/app-logs` 正常,可按登录态分页读取 APP 日志
- `POST /api/v1/projects/master-agent/messages` 正常,已验证通过 `local-agent -> codex exec -> complete` 返回真实主 Agent 回复 - `POST /api/v1/projects/master-agent/messages` 正常,已验证通过 `local-agent -> codex exec -> complete` 返回真实主 Agent 回复
- `GET /api/v1/user/ota/package` 正常,当前会返回最新 APK - `GET /api/v1/user/ota/package` 正常,当前会返回最新 APK
- `GET /api/v1/boss-agent/ota``GET /api/v1/boss-agent/ota/package` 已接入 boss-agent Mac 端 OTA要求设备 token打包脚本会发布 `boss-agent-mac-latest.zip/json`
- `npm run apk:release` 正常,已能输出 signed release APK - `npm run apk:release` 正常,已能输出 signed release APK
- 当前原生 Android 页面已覆盖会话、设备、我的三栏和主要二级页,不再依赖 WebView 承载业务页面 - 当前原生 Android 页面已覆盖会话、设备、我的三栏和主要二级页,不再依赖 WebView 承载业务页面
- 本地 `device-agent` 正常 - 本地 `device-agent` 正常
@@ -137,11 +144,43 @@
- 线程改名当前遵循微信最新逻辑:从聊天页右上角进入会话信息页,再进行改名 - 线程改名当前遵循微信最新逻辑:从聊天页右上角进入会话信息页,再进行改名
- 当前已支持从单线程会话发起独立群聊:原会话保留,新群聊自动命名并可在群资料页改名 - 当前已支持从单线程会话发起独立群聊:原会话保留,新群聊自动命名并可在群资料页改名
- 当前群聊编排主链已经补到第一阶段:群聊消息先进入主 Agent主 Agent 生成推荐下发方案,用户确认后再创建执行单;执行完成后线程原始结果会回群,主 Agent 再追加汇总 - 当前群聊编排主链已经补到第一阶段:群聊消息先进入主 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 可上报真实候选线程,系统会生成导入草稿;用户勾选后可生成导入决议,并把选中的线程真正落成聊天窗口 - 当前设备导入主链已经补到第一阶段:设备 heartbeat 可上报真实候选线程,系统会生成导入草稿;用户勾选后可生成导入决议,并把选中的线程真正落成聊天窗口
- 当前设备导入草稿不会再被旧 `projects` 字段绕过;只有 `apply` 之后,候选线程才会真正变成聊天窗口 - 当前设备导入草稿不会再被旧 `projects` 字段绕过;只有 `apply` 之后,候选线程才会真正变成聊天窗口
- 当前设备导入 `review` 已经会留下 `device_import_resolution` master task 轨迹,但决议内容仍是服务端 heuristic 版,尚未真正交给 `local-agent -> codex exec` - 当前设备导入 `review` 已经会留下 `device_import_resolution` master task 轨迹,但决议内容仍是服务端 heuristic 版,尚未真正交给 `local-agent -> codex exec`
- Web 和原生 Android 当前都已经接上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台页面;已绑定生产设备继续保留 heartbeat 自动导入链路 - Web 和原生 Android 当前都已经接上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台页面;已绑定生产设备继续保留 heartbeat 自动导入链路
- 原生首页的刷新失败策略当前已改成按当前 tab 独立判错,不会再因为 `设备 / 设置 / OTA` 的旁路请求失败把会话页刷新一并判成失败 - 原生首页的刷新失败策略当前已改成按当前 tab 独立判错,不会再因为 `设备 / 设置 / OTA` 的旁路请求失败把会话页刷新一并判成失败
- 当前量产方向已经明确为“Boss 企业控制面 + 可插拔执行协议”:多租户、权限、审批、审计、备份、回退和 Skill 治理由 Boss 承担Codex App Server / Codex MCP / Codex CLI / Computer Use / 业务系统 API 都作为 provider 接入;详见 `docs/architecture/enterprise_ai_ops_architecture_cn.md`
- 当前 Codex App Server 已完成二十九批接入boss-agent 默认开启 `local-agent/codex-app-server-runner.mjs` 作为 Codex 绑定入口,优先走 `codex app-server` stdio也可灰度连接 `ws://127.0.0.1:<port>``unix://PATH` 同机长驻 App Server长驻连接支持 `Authorization: Bearer <token>`,配置上优先使用 `codexAppServerAuthTokenFile`。turn 启动前失败才回退 CLIturn 启动后不重复执行;桌面远程控制默认先走 `codex-computer-use`,失败后回退 `cua-driver-computer-use`。2026-06-04 已按本机 `codex-cli 0.136.0-alpha.2` 重新生成协议快照 `docs/protocol-snapshots/codex-app-server/0.136.0-alpha.2/`manifest 识别 151 个 method 和当前 schema 暴露的 ThreadItem 类型。
- App Server runner 已把 plan、diff、item、approval、warning、file change、thread status、realtime、model route、token usage、MCP、remote control、thread goal、settings、compaction、account、model verification、collab、tool activity、reasoning summary、image generation、hook、Windows sandbox 和 stream delta 归一到 Boss `execution_progress` 卡片;字段白名单只保留安全摘要,不保存 SDP、音频原始数据、raw item、remote installationId、cwd、turnId、配置文件路径、collab 源/目标线程 ID、receiverThreadIds、collab prompt、agentsStates 私有消息、共享 Skill 根绝对路径、hook key/command/sourcePath/statusMessage/hash/error message、tool arguments/result/contentItems、web URL token、命令正文/输出、raw reasoning content、reasoning item id、imageGeneration revisedPrompt/result、Windows sandbox sourcePath/samplePaths、本地绝对路径或未清洗密钥。
- Heartbeat discovery 已能缓存 `model/list / skills/list / skills/extraRoots/set / hooks/list / plugin/list / app/list / modelProvider/capabilities/read / experimentalFeature/list / collaborationMode/list / permissionProfile/list / mcpServerStatus/list / account/read / account/rateLimits/read / config/read / configRequirements/read / externalAgentConfig/detect / thread/list / thread/loaded/list / thread/turns/list` 的能力摘要。`thread/turns/list` 固定使用 `itemsView=summary`,只额外提取最终 `agentMessage` 安全摘要,并合并进 `projectCandidates.recentAssistantMessages` 让 Codex Desktop 自己产生的新回复反向同步到 Boss APP不保存用户正文、reasoning 原文、命令输出、原始 items、内部 prompt 或系统提示词。同批已补 `turn/steer` 活跃 turn 干预和 `POST /api/v1/projects/[projectId]/thread-collaboration` 服务端线程协作排队入口。
- 原生 Android `DeviceDetailActivity` 当前已展示 Codex App Server 连接态、模型、扩展、治理、账号、线程、轮次、线程操作、线程协作和协议漂移这些核心 metadata 摘要更深的插件治理、文件治理、MCP 治理、流式增量等长尾治理摘要仍以 Web 设备详情和后台治理页为主,后续可继续分批补齐到原生端。
- 第十九批另补 `threadActionSummary` 线程操作能力摘要:设备详情页会显示 archive / unarchive / fork / compact / rollback / rename / metadata / steer / interrupt / shell / unsubscribe 等能力分组;该字段只读,不在 heartbeat 中调用任何会改变线程状态的 App Server API。
- 第二十九批另补 `threadCollaborationSummary` 线程协作口径Web 与原生 Android 设备详情页会显示 Boss Broker、协作事件 handler、协作模式数量和“非原生私聊”状态。本机 0.136.0-alpha.2 生成 schema 已确认 `app/list``app/list/updated``configRequirements/read``mcpServerStatus/list``ThreadItem.contextCompaction`,但未声明 `collaborationMode/list``thread/turns/list``ThreadItem.collabToolCall`;因此当前产品层把线程间协作定义为 Boss 受控 Broker + App Server 注入/执行链路,不把它表述成 Codex 原生任意线程 P2P 聊天。
- 同批新增 `protocolDriftSummary` 协议漂移摘要Web 与原生 Android 设备详情页会显示兼容/告警、失败探针数、官方文档跟进项和 Boss Broker 兜底策略。该字段来自 discovery errors 的 method 级安全归一,不保存错误原文、线程 ID、用户正文或内部 prompt后续 Codex Server 更新时优先看这个摘要决定是否需要补 runner 或前台展示。
- 第二十批另补 `pluginGovernanceSummary` 插件治理能力摘要:设备详情页会显示 install / uninstall / read / skill-read / share 等能力分组;该字段只读,不在 heartbeat 中调用任何插件安装、卸载或共享写 API。
- 第二十一批另补 `accountGovernanceSummary / configGovernanceSummary` 账号与配置治理能力摘要:设备详情页会显示 login / logout / token refresh / add credits nudge / config write / MCP reload / Skill config write 等能力分组;这些字段只读,不在 heartbeat 中调用任何账号或配置写 API。
- 第二十二批另补 `fileSystemGovernanceSummary / commandSessionSummary` 文件系统与命令会话治理能力摘要:设备详情页会显示 file read/write/remove/watch 与 command stdin / resize / terminate / stream 等能力分组;这些字段只读,不在 heartbeat 中调用任何文件读写或命令控制 API。
- 第二十三批另补 `externalAgentGovernanceSummary / marketplaceGovernanceSummary / experimentalFeatureGovernanceSummary` 外部 Agent 迁移、Marketplace 和实验特性治理能力摘要:设备详情页会显示 external-agent import、marketplace add/remove/upgrade 和 experimental feature enablement 等能力分组;这些字段只读,不在 heartbeat 中调用任何迁移导入、marketplace 写入或实验特性启用 API。
- 第二十四批另补 `reviewGovernanceSummary / windowsSandboxGovernanceSummary / fuzzyFileSearchSummary` 审查、Windows 沙箱和文件搜索事件能力摘要:设备详情页会显示 review start、Windows sandbox readiness/setup 和 fuzzy file search updated/completed 等能力分组;这些字段只读,不在 heartbeat 中调用任何审查启动、沙箱设置或文件搜索动作。
- 第二十五批另补 `mcpGovernanceSummary / userInteractionGovernanceSummary / guardianGovernanceSummary` MCP、用户交互和 Guardian 治理能力摘要:设备详情页会显示 MCP OAuth/resource/tool/elicitation、tool requestUserInput、Guardian denied action approval 和 permission request approval 等能力分组;这些字段只读,不在 heartbeat 中调用任何 MCP、用户输入或 Guardian 放行动作。
- 第二十六批另补 `runtimeEventSummary / extensionEventSummary / threadLifecycleEventSummary` 运行事件、扩展事件和线程生命周期事件能力摘要:设备详情页会显示 process output/exited、raw response completed、skills changed、plugin installed、thread started/closed/archived/unarchived/name updated 等能力分组;这些字段只读,不在 heartbeat 中主动触发进程、插件、Skill 或线程生命周期动作。
- 第二十七批另补 `streamDeltaEventSummary` 流式增量事件能力摘要:设备详情页会显示 agent delta、plan delta、reasoning delta、MCP progress、command output、terminal interaction 和 file output 等能力分组;该字段只读,不保存原始增量文本、命令输出、推理正文或文件输出。
- 当前任务执行态也已补 `executionProgress.streamEvents`App Server runner 会把 agent / plan / reasoning / MCP / command / terminal / file 的流式 delta 归一成计数Android 进度卡展示“流式增量”,不保存或渲染原始 delta、命令输出、终端输入、推理正文或文件输出。
- 当前 App Server 任务取消已从“服务端标记”升级为“真实 turn 中断”:`POST /api/v1/master-agent/tasks/[taskId]/cancel` 仍负责把任务置为 `canceled`,新增 `GET /api/v1/master-agent/tasks/[taskId]/control-state` 供设备端轮询;`local-agent` 在 App Server turn 启动后会按取消状态调用 `turn/interrupt`,并把 `interrupted` 作为干净取消处理,避免取消后长任务继续跑或被误写成失败日志。
- 当前本机 `codex remote-control` 已确认为官方 App Server daemon 远控入口boss-agent 本机状态页会展示 `Codex Remote Control` 托管摘要和 `codex remote-control start --json` 默认启动命令,但状态页刷新不会自动启动 daemon。云端已补 `POST /api/v1/devices/[deviceId]/codex-remote-control`,要求显式 `confirmed=true`、设备在线和 `computer.control` 权限,成功后排 `device_maintenance / codex_remote_control` 任务给目标 local-agent 本机执行,并写入 `task.authorized / task.denied` 审计;独立 PC 后台已在设备表接入启动 / 停止按钮Android APP 设备详情页也已接入启动 / 停止远控原生确认入口。
- 当前已补 Codex App Server 受控线程回滚:`POST /api/v1/projects/[projectId]/thread-rollback` 会创建 `intentCategory=thread_rollback` 任务,`local-agent` 调用 `thread/rollback` 回滚目标线程最近 N 轮;该链路不启动新 turn不把 thread/turn/items 原文写回 APP只提示“线程历史已回滚”且不会自动还原本地文件变更。
- 当前已补 Codex App Server 受控线程压缩:`POST /api/v1/projects/[projectId]/thread-compact` 会创建 `intentCategory=thread_compact` 任务,`local-agent` 调用 `thread/compact/start` 发起目标线程上下文压缩;该链路不启动普通 turn不把 contextCompaction item 原文写回 APP只提示“上下文压缩已发起”。
- 当前已补 Codex App Server 受控线程归档 / 恢复:`POST /api/v1/projects/[projectId]/thread-archive` 会创建 `intentCategory=thread_archive|thread_unarchive` 任务,`local-agent` 直接调用 `thread/archive``thread/unarchive`;该链路不启动普通 turn不把 thread 原始字段写回 APP只提示“线程已归档/已恢复”。
- 当前已补 Codex App Server 受控线程改名:`POST /api/v1/projects/[projectId]/rename``mode=thread` 且绑定真实 `codexThreadRef` 时,会在本地 Boss 改名后创建 `intentCategory=thread_rename` 任务,`local-agent` 直接调用 `thread/name/set`;该链路不启动普通 turn不把 thread 原始字段写回 APP只提示“已同步 Codex 线程名称”。设备离线、并发冲突或 App Server 不可用不会回滚 Boss 本地改名。
- 当前已补 Codex App Server 受控线程 Git 元数据同步:`POST /api/v1/projects/[projectId]/thread-metadata` 会创建 `intentCategory=thread_metadata_sync` 任务,`local-agent` 直接调用 `thread/metadata/update`;当前只允许同步 `gitInfo.sha / branch / originUrl`,不会启动普通 turn也不允许写入任意 metadata。
- 当前已补 Codex App Server 受控线程分叉:`POST /api/v1/projects/[projectId]/thread-fork` 会创建 `intentCategory=thread_fork` 任务,`local-agent` 直接调用 `thread/fork`;当前不允许远程覆盖 model、sandbox、instructions 或 config也不会把 path、cwd、turns、instructionSources 写回 APP。新线程进入 Boss 会话列表仍依赖 thread discovery / 导入链路。
- 当前已补 Codex App Server 版 Boss 用户消息镜像:普通单线程 `conversation_reply` 任务携带 `mirrorBossUserMessageToCodexDesktop=true` 时,`local-agent/codex-app-server-runner.mjs` 会在 `thread/resume` 后、`turn/start` 前调用 `thread/inject_items`,把 Boss APP 用户原文作为 `role=user` 的 Responses item 写入目标 Codex 线程模型可见历史;任务结果只回传 `threadHistorySync.threadId / injectedItemCount / source`,不回传消息 ID、内部 prompt 或用户原文。CLI rollout 镜像仍保留为 App Server 不可用前的 fallback 链路。
- 当前 boss-agent 已支持 Mac OTA`local-agent/boss-agent-ota-runner.mjs` 默认开启,每 5 分钟检查服务端最新包;状态页可手动检查或下载并安装,安装时保留原绑定配置,只更新版本号和本机 runtime 路径。最新验证版本为 `20260516221619`,已在 MacBook Air `macbook-air` 上确认 OTA 下载校验、暂存、覆盖安装后不会误切到默认 `config.cloud.json`。正式分发脚本已预留 Developer ID 公证路径:`BOSS_AGENT_NOTARIZE=1` 配合 notary profile 或 Apple ID 凭据。
- 当前量产治理已补设备撤权和任务可靠性底座:`revoke_device` 会清空设备 token、标记离线并阻断 heartbeat / 任务认领 / Skill 同步 / 日志上报 / boss-agent OTA`MasterAgentTask` claim 会记录 attempt 和 lease运行中任务可按租约重试超过上限转 `timed_out`,用户或管理员可通过 cancel 接口转 `canceled` 且迟到 complete 不覆盖终态。
- 当前任务 SLA 面板、失败自动恢复和后台告警已沉淀为独立交接文档:`docs/architecture/task_sla_auto_recovery_admin_alerts_cn.md`。该文档记录 `taskSlaPanel``adminNotifications`、pre-turn 安全自动恢复边界、本地验证结果和后续云部署检查清单;当前尚未部署云端,等待新的服务器入口后再按文档执行。
- 当前群聊 `dispatch_execution` 完成回写已补幂等,重复完成不会再向群聊重复追加结果 - 当前群聊 `dispatch_execution` 完成回写已补幂等,重复完成不会再向群聊重复追加结果
- 当前已支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`,单条消息转发显示为普通转发消息,多条消息转发显示为聊天记录卡片 - 当前已支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`,单条消息转发显示为普通转发消息,多条消息转发显示为聊天记录卡片
- 当前已支持聊天附件主链:原生聊天框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;图片 / PDF / 文本默认自动进入主 Agent 附件分析,视频 / Office / 大文件默认手动触发 - 当前已支持聊天附件主链:原生聊天框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;图片 / PDF / 文本默认自动进入主 Agent 附件分析,视频 / Office / 大文件默认手动触发
@@ -150,17 +189,22 @@
- 移动端 UI 已去掉假的状态栏与桌面预览壳;底部一级导航固定在视口底部,返回逻辑不会再把 APP 根页直接弹回桌面 - 移动端 UI 已去掉假的状态栏与桌面预览壳;底部一级导航固定在视口底部,返回逻辑不会再把 APP 根页直接弹回桌面
- `项目目标` 支持用户编辑、主 Agent 复核、完成项自动划线 - `项目目标` 支持用户编辑、主 Agent 复核、完成项自动划线
- `版本迭代记录` 只读,由主 Agent 汇总 - `版本迭代记录` 只读,由主 Agent 汇总
- `我的` 根页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于` - `我的` 根页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 附件与存储 / Telegram 接入 / 技能 / 关于`,其中 `用户与权限` 仅最高管理员可见
- `我的 > 账号与安全` 已支持查看和撤销登录会话;最高管理员可管理全部活跃会话,子账号只能管理自己的会话
- `我的 > 用户与权限` 与 Web `/me/access` 共用 `/api/v1/admin/access`,可创建子账号、分配设备 / 项目 / Skill 权限,并查看同名 Skill 跨设备聚合PC 总后台已收敛到 `https://admin.boss.hyzq.net/` 根路径,`/admin` 仅保留跳转兼容
- 多用户 / RBAC / Skill / 主 Agent 权限和多设备控制的集中状态、回归矩阵与缺口清单见 `docs/architecture/rbac_skill_regression_matrix_cn.md`
- `我的 > 主 Agent 提示词 / 记忆` 当前可编辑管理员全局主提示词、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 项目记忆 - `我的 > 主 Agent 提示词 / 记忆` 当前可编辑管理员全局主提示词、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 项目记忆
- `我的 > AI 账号` 必须可查看和切换 `主 GPT / 备用 GPT / API 容灾` - `我的 > AI 账号` 必须可查看和切换 `主 GPT / 备用 GPT / API 容灾`
- 主 Agent 使用 `Master Codex Node` 时必须优先走授权 Codex 设备池:设备在线且 `Codex App Server / CLI / GUI` 至少一条模型通道在线才可用;首选设备不可用或执行失败会自动切下一台,全部 Codex 设备不可用后才使用用户配置的 API Key如果两类通道都没有APP 中提示“当前没有可用的模型渠道”
- `我的 > 技能` 必须按绑定设备展示 Skill并支持一键复制调用语句 - `我的 > 技能` 必须按绑定设备展示 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/*` 接口不能再裸奔 - 登录后必须形成最小会话,受保护页面和核心 `/api/v1/*` 接口不能再裸奔
- 必须保留登录、注册、忘记密码和验证码入口 - 必须保留登录、注册、忘记密码和验证码入口
## 6. 当前技术路线 ## 6. 当前技术路线
- Web`Next.js 16.2.1 + React 19` - Web`Next.js 16.2.4 + React 19`
- 数据:当前是文件型持久化 `data/boss-state.json` - 数据:当前是文件型持久化 `data/boss-state.json`
- 状态写入:串行事务队列 + 原子写入 + `.bak` 备份恢复 - 状态写入:串行事务队列 + 原子写入 + `.bak` 备份恢复
- device-agent原生 Node HTTP 服务 - device-agent原生 Node HTTP 服务
@@ -168,7 +212,7 @@
- 邮件:`Postfix + Dovecot` - 邮件:`Postfix + Dovecot`
- Android`AppCompatActivity + 原生 XML 布局 + HttpURLConnection` - Android`AppCompatActivity + 原生 XML 布局 + HttpURLConnection`
- 原生登录恢复:`SharedPreferences + restore token` - 原生登录恢复:`SharedPreferences + restore token`
- 当前最新原生 APK`2.5.4``versionCode=17` - 当前最新原生 APK`2.5.11``versionCode=24`
当前不要误判成已经用了: 当前不要误判成已经用了:
@@ -187,7 +231,7 @@ npm install
npm run build npm run build
npm run lint npm run lint
curl -sS http://127.0.0.1:3000/api/health 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/auth/session
curl -sS http://127.0.0.1:3000/api/v1/conversations 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/projects/master-agent
@@ -211,20 +255,21 @@ npm run apk:debug
## 8. 当前已知未完成项 ## 8. 当前已知未完成项
- 认证仍是 MVP 级别:虽然已有最小会话 Cookie,但还没有刷新令牌、跨端会话治理和 CSRF 防护 - 认证仍是 MVP 级别但已收紧:已有最小会话 Cookie、restore token 轮换、浏览器 CSRF 基础防护、子账号 MFA、基础跨端会话治理和后台高危动作审计临时免验证登录默认关闭只能通过 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 显式开启
- 当前已补“原生 restore token 自动恢复”,但这仍不是完整的多端会话系统 - 当前已补“原生 restore token 自动恢复”,但这仍不是完整的多端会话系统
- 当前默认最高管理员账号是 `17600003315`,默认密码 `boss123456`,并已绑定本机 Codex 节点 - 当前默认最高管理员账号是 `krisolo`,默认密码由线上初始化配置管理,并已绑定本机 Codex 节点
- 主 Agent 实时回复当前依赖被绑定设备的 `local-agent` 在线,并能在本机跑通 `codex exec` - 主 Agent 实时回复当前依赖被绑定设备的 `local-agent` 在线,并能在本机跑通 `codex exec`
- API 容灾当前由用户在 APP 的 `我的 > AI 账号` 中自行配置 `OpenAI API` 账号 - API 容灾当前由用户在 APP 的 `我的 > AI 账号` 中自行配置 `OpenAI API` 账号
- 服务器默认固定验证码仍是 `000000` - 服务器默认验证码模式仍是 fixed但验证码登录也必须先申请验证码不允许只靠固定码直接登录
- 服务器邮件栈已部署完成,应用内也已经支持 email 模式,但默认开关还没切到 email - 服务器邮件栈已部署完成,应用内也已经支持 email 模式,但默认开关还没切到 email
- OTA 版本中心、检查更新、执行升级和 APK 包下载已接通,但当前仍是文件型状态驱动的 MVP - OTA 版本中心、检查更新、执行升级和 APK 包下载已接通,但当前仍是文件型状态驱动的 MVP
- APP 实时日志同步、主 Agent 日志镜像、SSE 自动刷新和 Skill 同步页已经接通,但日志检索、告警和远程 Skill 管理仍未做 - APP 实时日志同步、主 Agent 日志镜像、SSE 自动刷新和 Skill 同步页已经接通日志检索已有基础分页,风险 SLA 通知账本已接入,外部通知渠道仍未做
- 设备导入主链当前已经具备后端闭环和 Web/Android 前台接线,后续重点改成继续细化导入筛选规则和主 Agent 理解策略,而不是再从 0 接页面 - 设备导入主链当前已经具备后端闭环和 Web/Android 前台接线;主 Agent 理解同步已经避免未接管状态下主动问线程,后续重点继续细化导入筛选规则和用户主动同步体验
- 数据库尚未替代文件存储 - Codex App Server 受控线程治理已接入 rollback / compact / archive / unarchive / rename / goal sync / git metadata sync / fork其中项目目标新增会在单线程且已绑定 `codexThreadRef` 时异步创建 `thread_goal_sync` 任务并调用 `thread/goal/set`Git 元数据同步通过 `thread_metadata_sync -> thread/metadata/update` 执行,线程分叉通过 `thread_fork -> thread/fork` 执行;这些都不是普通对话 turn也不代表文件变更或发布完成
- 数据库尚未替代文件存储;当前已补 `BOSS_STATE_STORE=postgres` 单行 JSONB 适配层、schema 和 `scripts/boss-state-store-maintenance.mjs` schema 校验 / 文件备份 / dry-run 迁移 / PostgreSQL 备份导出 / 备份恢复 / 文件回滚工具但生产仍默认文件状态。PostgreSQL 路径必须显式设置 `BOSS_STATE_STORE=postgres`,真实连接 / 写入还必须设置 `BOSS_DATABASE_URL`。最高管理员后台已新增 `GET/POST /api/v1/admin/backups` 文件状态快照能力,可手动创建、列出和恢复快照,恢复前会自动生成 pre-restore 快照;文件状态写入层已默认开启自动 `auto:writeState` 历史快照
- 域名入口的代理 / 分裂 DNS 结构仍未完全摸清 - 域名入口的代理 / 分裂 DNS 结构仍未完全摸清
- 当前只支持服务器文件存储和阿里 OSS尚未接更多对象存储或更丰富的附件详情页 - 当前只支持服务器文件存储和阿里 OSS尚未接更多对象存储或更丰富的附件详情页
- 认证有真实 session 和令牌吊销 - 认证有真实 session、restore token 轮换、单会话撤销、CSRF 基础防护和 MFA 开关,但还没有企业 SSO / IdP
## 9. 继续开发时的工作原则 ## 9. 继续开发时的工作原则

View File

@@ -16,7 +16,18 @@
- 当前原生恢复态:`restore token + SharedPreferences` - 当前原生恢复态:`restore token + SharedPreferences`
- 当前执行底座:`src/lib/execution/`,已包含 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现 - 当前执行底座:`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、风险与审计治理生产入口为 `https://admin.boss.hyzq.net/` 根路径Caddy 内部 rewrite 到 `/admin-web/index.html`,旧 `/admin` UI 已移除,仅作为跳转到根域的兼容入口
### 1.3 boss-android-native
- 形态:原生 Android 客户端 - 形态:原生 Android 客户端
- 原生入口:`android/app/src/main/java/com/hyzq/boss/MainActivity.java` - 原生入口:`android/app/src/main/java/com/hyzq/boss/MainActivity.java`
@@ -39,8 +50,11 @@
- `DeviceEnrollmentActivity` - `DeviceEnrollmentActivity`
- `SkillInventoryActivity` - `SkillInventoryActivity`
- `SecurityActivity` - `SecurityActivity`
- `AccessManagementActivity`
- `SettingsActivity` - `SettingsActivity`
- `StorageSettingsActivity`
- `AiAccountsActivity` - `AiAccountsActivity`
- `TelegramIntegrationActivity`
- `OpenAiOnboardingActivity` - `OpenAiOnboardingActivity`
- `OpsCenterActivity` - `OpsCenterActivity`
- `AboutActivity` - `AboutActivity`
@@ -53,16 +67,20 @@
- 单线程会话支持按微信最新逻辑改线程名 - 单线程会话支持按微信最新逻辑改线程名
- 当前已经支持从单线程会话发起独立群聊,群聊创建后作为新会话保留,原会话不升级 - 当前已经支持从单线程会话发起独立群聊,群聊创建后作为新会话保留,原会话不升级
- 当前单线程会话已经支持打开 `线程状态` 只读页,查看主 Agent 当前掌握的线程状态文档和最近进展事件 - 当前单线程会话已经支持打开 `线程状态` 只读页,查看主 Agent 当前掌握的线程状态文档和最近进展事件
- 当前已经支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除` - 当前已经支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`,其中删除会调用服务端账本删除接口并刷新会话预览
- 当前多选模式会切换成微信式 `取消 + 已选数量 + 底部转发` 状态 - 当前多选模式会切换成微信式 `取消 + 已选数量 + 底部转发` 状态
- 当前统一使用 `ForwardTargetActivity` 选择目标会话,替换旧的备注转发主链 - 当前统一使用 `ForwardTargetActivity` 选择目标会话,替换旧的备注转发主链
- 当前已支持聊天附件主链:输入框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;图片 / 视频先确认,文件直接发送 - 当前已支持聊天附件主链:输入框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;图片 / 视频先确认,文件直接发送
- 当前附件消息支持下载、原生打开、手动分析和自动分析状态展示 - 当前附件消息支持下载、原生打开、手动分析和自动分析状态展示
- 当前线程聊天消息会按该线程绑定的 Codex 电脑显示来源头像:单线程会话使用项目绑定设备头像,多设备 / 群聊消息会优先根据发送人里的设备名匹配对应电脑头像;主 Agent 总入口自身仍保留主 Agent 对话样式
- 当前已支持 `execution_progress` 执行进度卡:普通线程对话、主 Agent 托管线程和群聊目标线程执行时,会在对应聊天窗口显示“进度 / 线程状态 / 实时状态 / 线程配置 / 线程协作 / 工具活动 / 思考摘要 / 账号状态 / 运行状态 / 安全提醒 / 审批状态 / 文件变更 / 分支详情 / 生成结果 / 后台智能体”结构化卡片;运行状态里也会展示 Codex Windows 沙箱准备摘要;线程过程噪音仍走 `thread_process` 折叠
- `线程详情 / 运维调试` 仍保留对应原生活动页,但已退出主聊天面 - `线程详情 / 运维调试` 仍保留对应原生活动页,但已退出主聊天面
- 当前已补上本地发送中气泡、发送按钮状态控制,以及“只有接近底部才自动滚到底”的消息流行为 - 当前已补上本地发送中气泡、发送按钮状态控制,以及“只有接近底部才自动滚到底”的消息流行为
- 当前根页导航: - 当前根页导航:
- `MainActivity` 会记住最近一次停留的 `会话 / 设备 / 我的` tab - `MainActivity` 会记住最近一次停留的 `会话 / 设备 / 我的` tab
- 根页返回逻辑已改成“先回会话 tab再按一次返回进入后台” - 根页返回逻辑已改成“先回会话 tab再按一次返回进入后台”
- 当前设备详情页:
- `DeviceDetailActivity` 已同步展示 Codex App Server 连接态、模型、扩展、治理、账号、线程、轮次、线程操作、线程协作口径和协议漂移摘要;线程协作固定表达为 Boss Broker 受控协作,协议漂移只显示兼容/告警、失败探针数量、文档跟进数量和 Boss Broker 兜底,不渲染错误原文、线程 ID、用户正文或内部 prompt
- 当前会话列表: - 当前会话列表:
- 已切到“线程 = 会话窗口” - 已切到“线程 = 会话窗口”
- 主标题显示线程名 - 主标题显示线程名
@@ -74,17 +92,20 @@
- 保留版本与 OTA 操作 - 保留版本与 OTA 操作
- 当前已补上 OTA 下载进度、失败重试、安装授权提示和返回关于页后的本地状态恢复 - 当前已补上 OTA 下载进度、失败重试、安装授权提示和返回关于页后的本地状态恢复
- 当前 `我的` 根页: - 当前 `我的` 根页:
- 保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于` - 已按登录角色过滤入口:`member` 只显示 `账号与安全 / 设置 / 技能 / 关于`
- `admin / highest_admin` 额外显示 `运维与修复 / AI 账号 / 附件与存储 / Telegram 接入`
- `用户与权限``highest_admin` 可见,用于创建子账号和分配设备 / 项目 / Skill 权限
- `运维与修复` 直接进入 `OpsCenterActivity` - `运维与修复` 直接进入 `OpsCenterActivity`
- `技能` 入口会继续依赖服务端 Skill 授权过滤,不在客户端自行扩大可见范围
- 当前 `OpenAiOnboardingActivity` - 当前 `OpenAiOnboardingActivity`
- 会先自动打开 `OpenAI Platform` 登录页 - 会先自动打开 `OpenAI Platform` 登录页
- 支持继续打开 `API Keys` 页面 - 支持继续打开 `API Keys` 页面
- 回 APP 后可直接粘贴 key并设为当前主控 - 回 APP 后可直接粘贴 key并设为当前主控
- 登录成功后会直接给出 `测试主 Agent 对话` 入口 - 登录成功后会直接给出 `测试主 Agent 对话` 入口
- 当前登录:临时免验证,点击登录直接创建最高管理员会话 - 当前登录:默认要求账号密码或验证码校验;临时开发兜底只允许通过显式环境变量开启
- 当前会话恢复:`SharedPreferences` 中保存 `boss_session / restore_token / account` - 当前会话恢复:`SharedPreferences` 中保存 `boss_session / restore_token / account`
### 1.3 boss-local-agent ### 1.4 boss-local-agent
- 形态Node 原生 HTTP 服务 - 形态Node 原生 HTTP 服务
- 本地端口:默认 `4317` - 本地端口:默认 `4317`
@@ -94,21 +115,146 @@
- 当前新增职责:递归扫描本机 `~/.codex/skills` 并同步到设备 Skill 接口 - 当前新增职责:递归扫描本机 `~/.codex/skills` 并同步到设备 Skill 接口
- 当前完成回写:`conversation_reply / dispatch_execution` 会先标准化成统一远程执行结果,再调用 `/api/v1/master-agent/tasks/[taskId]/complete` - 当前完成回写:`conversation_reply / dispatch_execution` 会先标准化成统一远程执行结果,再调用 `/api/v1/master-agent/tasks/[taskId]/complete`
- 当前 `dispatch_execution` 会按 `orchestrationBackendId` 分流:默认走 `codex exec resume`,显式选择 `omx-team` 且本机配置可用时改走 `OMX Team Runtime` JSON 协议 - 当前 `dispatch_execution` 会按 `orchestrationBackendId` 分流:默认走 `codex exec resume`,显式选择 `omx-team` 且本机配置可用时改走 `OMX Team Runtime` JSON 协议
- 当前 Codex 任务完成回写会附带 `executionProgress` 快照:包含 Git diff 简表、GitHub CLI 可用状态和从执行回复中提取的产物文件名,服务端更新同一张 `execution_progress` 卡片,不重复刷屏
- 当前 `RemoteRuntimeAdapter` 还负责拦截固定模式的线程内部环境提示;命中后会直接改写成失败,避免把只读/cwd 这类脏文本写进聊天记录 - 当前 `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 能力
- 当前新增 Codex App Server runner`local-agent/codex-app-server-runner.mjs`。boss-agent 默认配置 `codexAppServerEnabled=true`,会接管 `conversation_reply / dispatch_execution`;它默认通过 stdio 启动 `codex app-server`,也支持 `codexAppServerTransport=ws + codexAppServerUrl=ws://127.0.0.1:<port>``codexAppServerTransport=unix + codexAppServerUrl=unix:///absolute/path.sock` 连接同机长驻 App Serverbearer token 可通过 `codexAppServerAuthTokenFile` 读取并在握手时发送 `Authorization: Bearer <token>`。runner 执行 `initialize -> thread/resume|thread/start -> turn/start|turn/steer`,并把 `item/agentMessage/delta``item/completed` 归一成 Boss 任务回复;当 App Server 对单个 JSON-RPC 请求返回 `-32001 / retry later`runner 会做最多 3 次指数退避重试。turn 启动前失败可回退 CLIturn 启动后失败不回退避免重复执行。boss-agent 本机状态页另新增 `Codex Remote Control` 摘要:读取 `codexRemoteControlEnabled / codexRemoteControlCommand / codexRemoteControlArgs`,默认展示 `codex remote-control start --json` 作为官方 daemon 远控入口;状态页只展示能力,不因刷新自动启动 daemon。2026-05-31 起runner 会把 `turn/plan/updated``turn/diff/updated``item/started|completed``thread/started` 归一成 `executionProgress.steps / branch / artifacts / agents`,把 `item/*/requestApproval``item/autoApprovalReview/*``guardianWarning``serverRequest/resolved``item/fileChange/patchUpdated` 归一成 `executionProgress.approvals / warnings / fileChanges`,把 `thread/status/changed``thread/realtime/started|transcript|outputAudio|itemAdded|error|closed` 归一成 `executionProgress.threadStatus / realtime`,把 `model/rerouted``thread/tokenUsage/updated``mcpServer/startupStatus/updated``remoteControl/status/changed` 归一成 `executionProgress.modelRoute / tokenUsage / mcpServers / remoteControl`,并把 `thread/goal/*``thread/settings/updated``thread/compacted``account/updated``account/rateLimits/updated``model/verification``warning``configWarning``deprecationNotice``ThreadItem.collabToolCall``ThreadItem.contextCompaction``mcpToolCall``dynamicToolCall``webSearch``imageView``imageGeneration``hook/started|completed``windowsSandbox/setupCompleted``enteredReviewMode``exitedReviewMode``commandExecution``ThreadItem.plan``ThreadItem.reasoning.summary` 归一成线程配置、账号状态、模型校验、安全提醒、线程协作、上下文压缩、工具活动、图片产物、钩子生命周期、Windows 沙箱准备状态、计划步骤和思考摘要;新版 `ThreadItem.collabToolCall.receiverThreadIds / agentsStates` 只归一为目标数量和 agent 状态集合。2026-06-03 起runner 还会把 `item/agentMessage/delta``item/plan/delta``item/reasoning/summaryPartAdded|summaryTextDelta|textDelta``item/mcpToolCall/progress``command/exec/outputDelta``item/commandExecution/outputDelta|terminalInteraction``item/fileChange/outputDelta` 归一成 `executionProgress.streamEvents` 计数。服务端 complete/progress 回写会与本地 Git/GitHub 进度合并,且不保存 SDP、音频 base64、raw realtime item、remote installationId、cwd、turnId、配置路径、collab 源/目标线程 ID、receiverThreadIds、collab prompt、agentsStates 私有消息、tool arguments/result/contentItems、web URL token、命令正文/输出、raw reasoning content、reasoning item id、原始 delta、terminal input、file output、imageGeneration revisedPrompt/result、hook sourcePath/statusMessage/entries、Windows sandbox sourcePath/samplePaths/本地绝对路径或未清洗的 MCP 错误。heartbeat 同时支持按 TTL 拉取 `model/list / skills/list / hooks/list / plugin/list / app/list / modelProvider/capabilities/read`,并把摘要保存在 `capabilities.codexAppServer.metadata`
- App Server heartbeat discovery 现在还会按 TTL 拉取 `experimentalFeature/list / collaborationMode/list / permissionProfile/list / mcpServerStatus/list`,写入 `capabilities.codexAppServer.metadata.experimentalFeatures / collaborationModes / permissionProfiles / mcpServers`。这些字段用于 APP/后台治理页展示 Codex 当前可用的实验特性、多 Agent/协作模式、权限 profile 和 MCP 服务健康MCP 请求固定使用 `detail=toolsAndAuthOnly`,服务端状态里不保存 resource URI、工具参数、permission profile 文件规则、本地路径或密钥。
- App Server heartbeat discovery 现在还会按 TTL 拉取 `account/read / account/rateLimits/read / config/read / configRequirements/read / externalAgentConfig/detect`,写入 `capabilities.codexAppServer.metadata.accountSummary / rateLimitSummary / appConfigSummary / configRequirements / externalAgentMigration`。这些字段用于 APP/后台展示账号、额度、App 配置、企业托管要求和外部 Agent 迁移候选摘要;当前只做观测,不通过 Boss 远程写 `config.toml` 或执行外部 Agent 导入,且不保存邮箱、完整 config、API key、本地路径或迁移描述。
- App Server heartbeat discovery 现在还会按 TTL 拉取 `thread/list / thread/loaded/list`,写入 `capabilities.codexAppServer.metadata.threadSummary`。该字段用于 APP/后台展示 Codex 当前可见线程数量、加载态、活跃态和非归档线程轻量目录;目录只保留 `id / name / sourceKind / status / updatedAt / loaded`,不保存 cwd、本地路径、turn 内容、用户正文或内部 prompt。
- App Server heartbeat discovery 现在还会按 TTL 对非归档可见线程拉取 `thread/turns/list`,写入 `capabilities.codexAppServer.metadata.threadTurnSummary`。该字段用于 APP/后台展示 Codex 当前线程 turn 运行态;请求固定 `itemsView=summary`,只保留 turn 计数、运行中 / 完成计数、最近状态、更新时间和最终 `agentMessage` 安全摘要不保存用户正文、reasoning 原文、命令输出、原始 items、内部 prompt 或系统提示词。
- App Server heartbeat discovery 现在还会把最终 `agentMessage` 安全摘要合并进 `projectCandidates.recentAssistantMessages`。服务端根据 `codexThreadRef` 将 Codex Desktop 自己产生的新回复反向同步到 Boss APP 对应会话、preview、lastMessageAt 和未读数;已有本地扫描候选的 folder/thread 映射优先App Server 只补充最新回复摘要。
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.threadActionSummary`。该字段用于 APP/后台展示当前协议下可接入的线程治理动作数量和分组,覆盖 archive / unarchive / fork / compact / rollback / rename / metadata / steer / interrupt / shell / unsubscribe它只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中调用这些写操作。
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.threadCollaborationSummary`。该字段用于 APP/后台展示 Boss Broker、协作事件 handler、协作模式数量和“非原生私聊”边界当前本机 `codex-cli 0.136.0-alpha.2` schema 未声明 `ThreadItem.collabToolCall`,所以线程协作继续走 Boss 服务端 `thread-collaboration` 入口和受控 App Server 注入/执行链路。
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.protocolDriftSummary`。该字段用于 APP/后台展示协议漂移状态,包含 `driftLevel``failedProbeCount``runtimeFailureMethods``docFollowupItems``fallbackStrategy`;其中 `runtimeFailureMethods` 只保留 method 名,不保存错误原文、线程 ID、用户正文或内部 prompt。
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.pluginGovernanceSummary`。该字段用于 APP/后台展示当前协议下可接入的插件治理动作数量和分组,覆盖 install / uninstall / read / skill-read / share它只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中调用插件安装、卸载或共享写操作。
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.accountGovernanceSummary / configGovernanceSummary`。这些字段用于 APP/后台展示当前协议下可接入的账号与配置治理动作数量和分组,覆盖 login / logout / token refresh / add credits nudge / config write / MCP reload / Skill config write它们只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中调用账号或配置写操作。
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.fileSystemGovernanceSummary / commandSessionSummary`。这些字段用于 APP/后台展示当前协议下可接入的文件系统与命令会话动作数量和分组,覆盖 file read/write/remove/watch 以及 command stdin / resize / terminate / stream它们只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中调用文件读写或命令控制操作。
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.externalAgentGovernanceSummary / marketplaceGovernanceSummary / experimentalFeatureGovernanceSummary`。这些字段用于 APP/后台展示当前协议下可接入的外部 Agent 迁移、Marketplace 和实验特性治理动作数量和分组,覆盖 external-agent import、marketplace add/remove/upgrade 和 experimental feature enablement它们只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中调用迁移导入、marketplace 写入或实验特性启用操作。
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.reviewGovernanceSummary / windowsSandboxGovernanceSummary / fuzzyFileSearchSummary`。这些字段用于 APP/后台展示当前协议下可接入的审查、Windows 沙箱和文件搜索事件能力,覆盖 review start、Windows sandbox readiness/setup 和 fuzzy file search updated/completed它们只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中调用审查启动、沙箱设置或文件搜索动作。
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.mcpGovernanceSummary / userInteractionGovernanceSummary / guardianGovernanceSummary`。这些字段用于 APP/后台展示当前协议下可接入的 MCP、用户交互和 Guardian 治理能力,覆盖 MCP OAuth/resource/tool/elicitation、tool requestUserInput、Guardian denied action approval 和 permission request approval它们只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中调用 MCP、用户输入或 Guardian 放行动作。
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.runtimeEventSummary / extensionEventSummary / threadLifecycleEventSummary`。这些字段用于 APP/后台展示当前协议下可接入的运行事件、扩展事件和线程生命周期事件能力,覆盖 process output/exited、raw response completed、skills changed、plugin installed、thread started/closed/archived/unarchived/name updated它们只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中主动触发进程、插件、Skill 或线程生命周期动作。
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.streamDeltaEventSummary`。该字段用于 APP/后台展示当前协议下可接入的流式增量事件能力,覆盖 agent delta、plan delta、reasoning delta、MCP progress、command output、terminal interaction 和 file output它只来自 runner 安全 catalog 和协议快照,不保存原始增量文本、命令输出、推理正文或文件输出。
- App Server heartbeat discovery 现在支持 `skills/extraRoots/set`:配置 `codexAppServerSkillExtraRoots` 或环境变量 `BOSS_CODEX_APP_SERVER_SKILL_EXTRA_ROOTS`runner 会先把共享 Skill 根下发给 App Server再刷新 `skills/list`,并写入 `capabilities.codexAppServer.metadata.skillExtraRootsSummary`。该字段用于 APP/后台展示企业共享 Skill 根是否已下发只保留数量、basename 和状态不保存根目录绝对路径、Skill 文件路径或配置原文。
- App Server heartbeat discovery 现在支持 `hooks/list`,写入 `capabilities.codexAppServer.metadata.hookSummary`。该字段用于 APP/后台展示本机 Codex hook 治理状态;只保留 workspace 数、hook 数、启用数、受管 / 可信 / 修改 / 未信任计数、warning / error 计数和事件 / handler 类型,不保存 hook key、command、sourcePath、statusMessage、hash、error message 或本地路径。
- 当前 Codex App Server runner 已新增第一版 Boss Inter-Thread Broker任务携带 `intentCategory=thread_collaboration``sourceCodexThreadRef``targetCodexThreadRef` 时,会先 `thread/read` 源线程,再通过 `thread/inject_items` 向目标线程注入受控摘要,最后 `turn/start` 目标线程;服务端入口是 `POST /api/v1/projects/[projectId]/thread-collaboration`,负责权限、源/目标线程校验和任务排队。这不是假设官方线程 P2P而是 Boss 自己做线程协作编排。
- 当前 Codex App Server runner 已新增 Boss 用户消息镜像:普通 `conversation_reply` 任务携带 `mirrorBossUserMessageToCodexDesktop=true``sourceMessageBody` 和目标 `codexThreadRef` 时,会先 `thread/resume`,再 `thread/inject_items` 写入 `role=user` 的 Boss APP 用户原文,最后 `turn/start`;该链路用于让 APP 发起的对话进入 Codex Desktop 同一线程历史。执行结果只保存 `threadHistorySync` 安全摘要,不保存 App Server 原始 item、消息 ID、用户原文、系统提示词或内部调度字段。
- 当前 Codex App Server runner 已新增受控线程回滚:任务携带 `intentCategory=thread_rollback`、目标 `codexThreadRef``rollbackNumTurns` 时,会调用 `thread/rollback` 回滚目标线程最近 N 轮,不会启动新 turn也不会把 App Server 返回的 thread/turn/items 写回 APP。服务端入口是 `POST /api/v1/projects/[projectId]/thread-rollback`,只保存回滚轮数、原因和执行摘要;边界是只回滚 Codex 线程历史,不自动还原本地文件变更。
- 当前 Codex App Server runner 已新增受控线程压缩:任务携带 `intentCategory=thread_compact` 和目标 `codexThreadRef` 时,会调用 `thread/compact/start` 发起上下文压缩,不会启动普通 turn也不会把 contextCompaction item 的原始字段写回 APP。服务端入口是 `POST /api/v1/projects/[projectId]/thread-compact`,只保存压缩原因和执行摘要;边界是只压缩 Codex 线程上下文,不代表代码修改、文件恢复或版本发布完成。
- 当前 Codex App Server runner 已新增受控线程归档 / 恢复:任务携带 `intentCategory=thread_archive|thread_unarchive`、目标 `codexThreadRef``threadLifecycleAction` 时,会直接调用 `thread/archive``thread/unarchive`,不会先 resume 已归档线程,也不会启动普通 turn。服务端入口是 `POST /api/v1/projects/[projectId]/thread-archive`,只保存生命周期动作、原因和执行摘要;边界是只改变 Codex 线程生命周期状态,不代表代码修改、文件恢复或版本发布完成。
- 当前 Codex App Server runner 已新增受控线程改名:任务携带 `intentCategory=thread_rename`、目标 `codexThreadRef``threadRenameName` 时,会直接调用 `thread/name/set`,不会先 resume 线程,也不会启动普通 turn。服务端入口复用 `POST /api/v1/projects/[projectId]/rename``mode=thread` 分支;本地 Boss 会话改名先成功,随后异步创建 Codex 改名任务,设备离线或冲突只返回非致命 `codexThreadRenameError`
- 当前 Codex App Server runner 已新增受控线程目标同步:任务携带 `intentCategory=thread_goal_sync`、目标 `codexThreadRef``threadGoalObjective``threadGoalStatus` 时,会直接调用 `thread/goal/set`,不会启动普通 turn。服务端入口复用 `POST /api/v1/projects/[projectId]/goals`;本地 Boss 项目目标先成功,单线程且已绑定 Codex 线程时再异步创建 Codex goal 同步任务,设备离线或冲突只返回非致命 `codexThreadGoalError`
- 当前 Codex App Server runner 已新增受控线程 Git 元数据同步:任务携带 `intentCategory=thread_metadata_sync`、目标 `codexThreadRef``threadMetadataGitInfo` 时,会直接调用 `thread/metadata/update`,不会启动普通 turn。服务端入口是 `POST /api/v1/projects/[projectId]/thread-metadata`;当前只允许 patch `gitInfo.sha / branch / originUrl`,不开放任意 metadata 写入。
- 当前 Codex App Server runner 已新增受控线程分叉:任务携带 `intentCategory=thread_fork`、目标 `codexThreadRef``threadForkEphemeral` 时,会直接调用 `thread/fork`,不会启动普通 turn。服务端入口是 `POST /api/v1/projects/[projectId]/thread-fork`;当前只使用源 thread id 分叉,不允许远程覆盖 model、sandbox、instructions 或 config新 Codex 线程进入 Boss 会话列表仍依赖后续 discovery / 导入链路。
- 当前 boss-agent Mac OTA 已接入:`local-agent/boss-agent-ota-runner.mjs` 会用设备 token 调 Boss 服务端 `/api/v1/boss-agent/ota` 检查最新 Mac 运行包,`/api/v1/boss-agent/ota/apply` 会下载 `boss-agent-mac-latest.zip`、校验 sha256、暂存安装 wrapper并拉起本机安装器安装脚本会保留绑定配置并只更新版本号与本机 runtime 路径。安装器会优先沿用当前 LaunchAgent active config并保留所有 `config*.json`,避免多电脑场景中误绑定到默认设备配置。当前最新验证包为 `20260516221619`;构建脚本支持 `BOSS_AGENT_NOTARIZE=1` 的 Developer ID 公证路径。
- 当前 `local-agent` 还新增了两条统一电脑控制 runtime
- `local-agent/browser-control-task-runner.mjs`
- `local-agent/computer-use-task-runner.mjs`
- 当前本机 boss-agent 还新增 Codex Remote Control 显式控制入口:
- `POST http://127.0.0.1:4317/api/v1/boss-agent/codex-remote-control/start`
- `POST http://127.0.0.1:4317/api/v1/boss-agent/codex-remote-control/stop`
- 这两个入口只在本机 agent 上执行 `codex remote-control start|stop --json`,返回和日志都会清洗敏感字段;状态页刷新不会自动调用
- 云端已新增 `POST /api/v1/devices/[deviceId]/codex-remote-control` 作为受控排队入口,参数为 `action=start|stop``confirmed=true` 和可选 `reason`
- 云端入口要求登录态、目标设备在线、当前账号具备该设备 `computer.control` 权限;成功会排入 `device_maintenance / codex_remote_control` 任务,由目标设备 local-agent 认领执行,并写入 `task.authorized` 审计;未授权或离线会写入 `task.denied`
- 独立 PC 后台已在 `全局设备 / 电脑与 Codex 接入` 表格中接入 `启动远控 / 停止远控` 操作当前使用浏览器确认框做二次确认Android APP 设备详情页已复用同一 API 做 `启动远控 / 停止远控` 原生确认入口
- 当前 `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`
- `codexComputerUseEnabled / codexComputerUseCommand / codexComputerUseArgs / codexComputerUseWorkdir / codexComputerUseTimeoutMs / codexComputerUseFallbackToCua`
- `codexAppServerEnabled / codexAppServerCommand / codexAppServerArgs / codexAppServerWorkdir / codexAppServerTimeoutMs / codexAppServerFallbackToCli / codexAppServerTransport / codexAppServerUrl / codexAppServerAuthTokenFile / codexAppServerSkillExtraRoots / codexAppServerDiscoveryEnabled / codexAppServerDiscoveryTtlMs / codexAppServerDiscoveryLimit`
- `codexRemoteControlEnabled / codexRemoteControlCommand / codexRemoteControlArgs`
- `scripts/codex-app-server-protocol-snapshot.mjs`:生成本机 Codex App Server help、JSON Schema、TypeScript bindings、协议方法清单和 support matrix当前快照目录为 `docs/protocol-snapshots/codex-app-server/0.136.0-alpha.2/`
### 1.4 Caddy #### `POST /api/v1/master-agent/tasks/[taskId]/progress`
- 用途:设备端在执行中实时刷新同一张 `execution_progress`
- 权限:设备 token / 设备写鉴权
- 请求体:`deviceId`、可选 `status=queued|running`、可选 `requestId`、可选 `executionProgress`
- 当前行为:只更新任务进度卡和实时事件,不把任务置为 completed / failed最终成功或失败仍必须走 `POST /api/v1/master-agent/tasks/[taskId]/complete`
#### `POST /api/v1/projects/[projectId]/thread-collaboration`
- 用途:从当前线程发起一次受控线程协作,把源线程上下文注入目标 Codex 线程并让目标线程执行
- 权限:登录态;源项目和目标项目都需要 `project.view`,源项目需要 `master_agent.ask`
- 请求体:`targetProjectId``body``requestText`
- 行为:先在源项目追加用户消息,再创建 `conversation_reply` 任务,任务携带 `intentCategory=thread_collaboration`、源/目标 `threadId``codexThreadRef` 和目标 `codexFolderRef`
#### `POST /api/v1/projects/[projectId]/thread-rollback`
- 用途:对当前会话绑定的 Codex 线程发起一次受控历史回滚,适合误触发、错误继续、接管误操作后的线程级撤回
- 权限:登录态;目标项目需要 `project.view``master_agent.ask`
- 请求体:`numTurns`,可选 `reason`
- 行为:先在目标项目追加一条用户可见原因消息,再创建 `conversation_reply` 任务,任务携带 `intentCategory=thread_rollback`、目标 `threadId``codexThreadRef``codexFolderRef``rollbackNumTurns``rollbackReason`
- 边界:设备端通过 Codex App Server 调用 `thread/rollback`,只回滚线程历史;不会自动还原本地文件变更,也不会把 App Server 返回的 thread/turn/items 明文写回 APP
#### `POST /api/v1/projects/[projectId]/thread-compact`
- 用途:对当前会话绑定的 Codex 线程发起一次受控上下文压缩,适合长线程接近上下文上限、继续开发前需要清理上下文的场景
- 权限:登录态;目标项目需要 `project.view``master_agent.ask`
- 请求体:可选 `reason`
- 行为:先在目标项目追加一条用户可见原因消息,再创建 `conversation_reply` 任务,任务携带 `intentCategory=thread_compact`、目标 `threadId``codexThreadRef``codexFolderRef``compactReason`
- 边界:设备端通过 Codex App Server 调用 `thread/compact/start`,只发起上下文压缩;不会启动普通 turn不会把 contextCompaction item 的原始字段写回 APP也不代表代码修改、文件恢复或版本发布完成
#### `POST /api/v1/projects/[projectId]/thread-archive`
- 用途:对当前会话绑定的 Codex 线程发起受控归档或恢复,适合项目阶段性结束、误归档恢复或清理会话首页前先同步 Codex 线程生命周期
- 权限:登录态;目标项目需要 `project.view``master_agent.ask`
- 请求体:`action=archive|unarchive`,可选 `reason`
- 行为:先在目标项目追加一条用户可见原因消息,再创建 `conversation_reply` 任务,任务携带 `intentCategory=thread_archive|thread_unarchive`、目标 `threadId``codexThreadRef``codexFolderRef``threadLifecycleAction``threadLifecycleReason`
- 边界:设备端通过 Codex App Server 调用 `thread/archive``thread/unarchive`;不会启动普通 turn不会把 App Server 返回的 thread 原始字段写回 APP也不代表代码修改、文件恢复或版本发布完成
#### `POST /api/v1/projects/[projectId]/thread-metadata`
- 用途:对当前会话绑定的 Codex 线程发起受控 Git 元数据同步,适合把 Boss 当前已知的分支、提交和远端仓库信息写回 Codex 线程
- 权限:登录态;目标项目需要 `project.view``master_agent.ask`
- 请求体:`gitInfo`,可选字段 `sha``branch``originUrl`;字段值为字符串表示设置,为 `null` 表示清除,字段缺省表示不改;可选 `reason`
- 行为:先在目标项目追加一条用户可见原因消息,再创建 `conversation_reply` 任务,任务携带 `intentCategory=thread_metadata_sync`、目标 `threadId``codexThreadRef``codexFolderRef``threadMetadataGitInfo``threadMetadataReason`
- 边界:设备端通过 Codex App Server 调用 `thread/metadata/update`;不会启动普通 turn不会把 App Server 返回的 thread 原始字段写回 APP也不允许写入 Git 信息之外的任意 metadata
#### `POST /api/v1/projects/[projectId]/thread-fork`
- 用途:对当前会话绑定的 Codex 线程发起受控分叉,适合在不破坏原线程历史的情况下复制当前上下文继续试验
- 权限:登录态;目标项目需要 `project.view``master_agent.ask`
- 请求体:可选 `reason`,可选 `ephemeral`
- 行为:先在目标项目追加一条用户可见原因消息,再创建 `conversation_reply` 任务,任务携带 `intentCategory=thread_fork`、目标 `threadId``codexThreadRef``codexFolderRef``threadForkEphemeral``threadForkReason`
- 边界:设备端通过 Codex App Server 调用 `thread/fork`;不会启动普通 turn不会把 App Server 返回的 path、cwd、turns、instructions 写回 APP当前不允许远程覆盖 model、sandbox、instructions 或 config新线程进入 Boss 会话列表依赖后续 thread discovery / 导入链路
- 当前仓库已自带 browser smoke runtime、desktop Cua runtime 和旧 desktop smoke 兜底:
- `scripts/browser-control-smoke.mjs`
- `scripts/codex-computer-use-runtime.mjs`
- `scripts/cua-driver-computer-use-runtime.mjs`
- `scripts/computer-use-smoke.mjs`
- `scripts/browser-control-smoke.mjs` 当前已支持两段式最小真实动作:
- 能从目标 URL 拉取 HTML 标题并回写到 `replyBody / executionSummary`
- 在显式配置 opener 命令时可实际执行打开 URL
- `scripts/codex-computer-use-runtime.mjs` 当前通过 `codex app-server` 发起 Codex Computer Use 桌面控制,是 boss-agent 的默认桌面控制入口;失败时由 `local-agent/computer-use-task-runner.mjs` 自动回退 CUA
- `scripts/cua-driver-computer-use-runtime.mjs` 当前通过外部 `cua-driver` 执行 macOS 桌面 GUI 控制:先 `launch_app`,再按返回窗口做 `get_window_state`,需要写入文本时调用 `type_text` 并再次观测;发送、提交、删除、支付等高风险动作默认返回 `needs_user_action`,不静默下发
- `scripts/computer-use-smoke.mjs` 当前已支持识别常见桌面应用名macOS 下默认用 `osascript` 激活目标应用,并支持把用户请求中的引号文本输入到当前前台应用、按需回车发送;它保留为旧兜底和回归资产
- `config.example.json / config.cloud.json` 现默认把 browser smoke runtime 和 desktop Cua 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 配置状态;`codexAppServer` 能力上报会参考 feature flagstdio 模式校验 app-server 命令可执行性ws/unix 模式校验 `codexAppServerUrl` 是否已配置
### 1.5 Caddy
- 作用:反向代理和 HTTPS 自动续签 - 作用:反向代理和 HTTPS 自动续签
- 服务器服务名:`caddy.service` - 服务器服务名:`caddy.service`
- 配置文件:`deployment/Caddyfile` - 配置文件:`deployment/Caddyfile`
- 当前站点:`boss.hyzq.net` 服务客户 Web / App API`admin.boss.hyzq.net` 根路径内部 rewrite 到 `/admin-web/index.html`,浏览器地址栏保持 `https://admin.boss.hyzq.net/`,作为平台级 To B 独立后台入口;旧 `/admin` 页面不再渲染旧 UI只做兼容跳转到根路径
### 1.5 boss-server-debug skill ### 1.6 boss-server-debug skill
- 作用:跨 Codex 窗口稳定连接 `106.53.170.158` - 作用:跨 Codex 窗口稳定连接 `106.53.170.158`
- 路径:`$HOME/.codex/skills/boss-server-debug/SKILL.md` - 路径:`$HOME/.codex/skills/boss-server-debug/SKILL.md`
- 密码来源:优先读取 macOS Keychain - 密码来源:优先读取 macOS Keychain
### 1.6 Postfix + Dovecot ### 1.7 Postfix + Dovecot
- 作用:服务器侧邮件发送 / 接收基础设施 - 作用:服务器侧邮件发送 / 接收基础设施
- SMTP 端口:`25 / 465 / 587` - SMTP 端口:`25 / 465 / 587`
@@ -143,6 +289,7 @@
- `GET /me/security` - `GET /me/security`
- `GET /me/about` - `GET /me/about`
- `GET /me/storage` - `GET /me/storage`
- `GET /me/access`
- `GET /me/ai-accounts` - `GET /me/ai-accounts`
- `GET /me/ops` - `GET /me/ops`
- `GET /me/ops/audit` - `GET /me/ops/audit`
@@ -163,9 +310,212 @@
#### `GET /api/state` #### `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`:启用或停用子账号;停用时撤销该账号当前活跃会话,且禁止停用最高管理员账号
- `revoke_device`:吊销指定设备,立即清空设备 token、标记离线、写入 `device.revoked` 审计;旧 token 后续不能 heartbeat、领任务、同步 Skill、上传日志或拉取 boss-agent OTA
- `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 告警;运维故障和线程上下文风险会带出负责人和 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 聚合目录和授权记录
- `insights.taskSlaPanel`MasterAgentTask 的 SLA 面板包含状态分布、SLA 截止、空闲时间、尝试次数、是否可自动恢复和建议动作
- `audit`:风险、通知、风险时间线和 `permissionAuditLogs`
- `yudaoMapping`Boss 账本字段到后台概念的映射,用于后续数据库化或模块拆分
- 当前定位:供 `https://admin.boss.hyzq.net/ -> apps/boss-admin-web` 消费;旧 `/admin` UI 已下线,不再消费 `/api/v1/admin/overview` 和旧数据 provider
#### `GET/POST /api/v1/admin/backups`
- 用途:最高管理员做文件状态快照、查看可回退点和执行状态回退
- 权限:仅 `highest_admin`
- `GET` 返回:
- `status`:当前文件状态路径、备份目录、最近快照时间、可回退点数量和校验状态
- `snapshots[]`:快照 ID、创建时间、创建人、备注、大小、sha256 和 schema 版本
- `POST` 输入:
- `action=create_snapshot`:创建当前 `boss-state` 快照,可带 `reason`
- `action=restore_snapshot`:恢复到指定 `snapshotId`
- 当前行为:恢复前会自动创建 `pre-restore:<snapshotId>` 快照,避免误操作后无法回滚;文件状态写入层默认按 `BOSS_STATE_AUTO_BACKUP_INTERVAL_MS` 自动创建 `auto:writeState` 快照,并按 `BOSS_STATE_AUTO_BACKUP_KEEP` 保留;独立 PC 管理后台的“备份与回退”页已接入创建、刷新和恢复动作。
#### `POST /api/v1/admin/risks/scan`
- 用途:扫描当前风险 SLA幂等生成平台侧待跟进通知
- 权限:仅 `highest_admin`
- 当前行为:
- 扫描未关闭的 `opsFaults``threadContextAlerts`
- 同步检查运行态异常:在线设备 `Computer Use` 不可用会补 `BOSS.COMPUTER_USE.UNAVAILABLE` 运维故障,`boss-agent OTA` 失败日志会补 `BOSS_AGENT.OTA.FAILED` 运维故障
- 同步扫描 `MasterAgentTask` SLA基于 lease、最近进度、尝试次数和 recoverable 标记生成任务 SLA 告警
- 只对 `queued / claimed / executor_starting / recoverable_failed` 这类 pre-turn 安全阶段的可恢复任务自动重排队,避免已进入目标线程回复阶段的任务被重复执行
-`slaDueAt` 已早于当前时间时,写入 `adminNotifications[]`
- 任务 SLA 告警同样写入 `adminNotifications[]`,自动恢复会写入 `adminRiskTimeline[]``permissionAuditLogs[]`
- 同一个 `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/` - 目录:`src/lib/execution/`
- 当前默认实现: - 当前默认实现:
@@ -182,13 +532,14 @@
- 当前状态: - 当前状态:
- 已在生产代码中被 `boss-master-agent.ts``local-agent/server.mjs``master-agent task complete route` 使用 - 已在生产代码中被 `boss-master-agent.ts``local-agent/server.mjs``master-agent task complete route` 使用
- 当前仍服务 Boss 自身执行链 - 当前仍服务 Boss 自身执行链
- 当前已补 `browser_control / desktop_control` 两个新的 execution tool并已纳入统一权限与风险分级判断
- 当前已最小接入 `ClawBackendAdapter`,但默认关闭,仅在显式配置且可用性探测通过时才参与执行 - 当前已最小接入 `ClawBackendAdapter`,但默认关闭,仅在显式配置且可用性探测通过时才参与执行
- 如果历史 `backendOverride=claw-runtime` 当前不可用,运行时会自动回退到默认后端,并把原因回给前台 - 如果历史 `backendOverride=claw-runtime` 当前不可用,运行时会自动回退到默认后端,并把原因回给前台
- 当前仓库自带 `scripts/claw-runtime-smoke.mjs` 作为兼容 JSON 协议的 smoke runtime可用于本地和服务器验证 `ClawBackendAdapter` - 当前仓库自带 `scripts/claw-runtime-smoke.mjs` 作为兼容 JSON 协议的 smoke runtime可用于本地和服务器验证 `ClawBackendAdapter`
- 当前已最小接入 `OmxTeamBackendAdapter`但默认关闭Web 群聊详情页和原生群资料页已经可以在 `Boss Native``OMX Team` 间切换编排后端OMX 不可用时会自动回退到默认后端并返回明确原因 - 当前已最小接入 `OmxTeamBackendAdapter`但默认关闭Web 群聊详情页和原生群资料页已经可以在 `Boss Native``OMX Team` 间切换编排后端OMX 不可用时会自动回退到默认后端并返回明确原因
- 当前仓库自带 `scripts/omx-team-smoke.mjs`,可用于本地和服务器验证 `OmxTeamBackendAdapter``dispatch_execution` JSON 协议 - 当前仓库自带 `scripts/omx-team-smoke.mjs`,可用于本地和服务器验证 `OmxTeamBackendAdapter``dispatch_execution` JSON 协议
### 3.1.2 线程状态文档与进展事件 ### 3.1.3 线程状态文档与进展事件
- 状态字段: - 状态字段:
- `threadStatusDocuments` - `threadStatusDocuments`
@@ -198,7 +549,8 @@
- 让 Web / Android 前台能直接查看线程的当前目标、阶段、进度、架构、阻塞、建议下一步 - 让 Web / Android 前台能直接查看线程的当前目标、阶段、进度、架构、阻塞、建议下一步
- 当前同步策略: - 当前同步策略:
- `heartbeat / thread reply` 平时优先写轻量进展事件 - `heartbeat / thread reply` 平时优先写轻量进展事件
- 首次理解、状态变薄、长时间未刷新或主 Agent 真正接手时,才补排隐藏全量理解任务 - 只有单线程接管、全局接管或用户明确要求同步项目目标 / 版本记录时,才补排隐藏全量理解任务
- 关闭接管会同步清理仍在 queued/running 的项目理解同步任务,避免取消接管后继续主动打扰线程
### 3.2 认证相关 ### 3.2 认证相关
@@ -208,13 +560,13 @@
- 输入: - 输入:
- `account` - `account`
- `purpose`: `login | register | forgot-password` - `purpose`: `login | register | forgot-password`
- 当前行为:在邮件验证码正式切换前,固定验证码为 `000000` - 当前行为:在邮件验证码正式切换前,fixed 模式仍返回固定验证码,但所有验证码登录都必须先通过 `send-code` 生成有效记录
- 当前说明Web 侧已经支持 email 模式email 模式下会通过本机 `sendmail` 调用 `Postfix` 发信;服务器默认仍保持 fixed - 当前说明Web 侧已经支持 email 模式email 模式下会通过本机 `sendmail` 调用 `Postfix` 发信;服务器默认仍保持 fixed
- 当前保护60 秒冷却,同一账号 15 分钟窗口内超过 5 次会被限流 - 当前保护60 秒冷却,同一账号 15 分钟窗口内超过 5 次会被限流
- 当前前置校验: - 当前前置校验:
- `purpose=login | forgot-password` 时要求账号已存在 - `purpose=login | forgot-password` 时要求账号已存在
- `purpose=register` 时要求账号尚未注册 - `purpose=register` 时要求账号尚未注册
- 当前 fixed 模式:登录可直接输入 `000000`,不再依赖先申请验证码;注册和重置密码`send-code` 申请链路 - 当前 fixed 模式:登录注册和重置密码都必须先`send-code` 申请链路,再消费账本里的有效验证码
#### `POST /api/auth/login` #### `POST /api/auth/login`
@@ -224,16 +576,16 @@
- `password` - `password`
- `code` - `code`
- 当前行为: - 当前行为:
- 当前已临时切到免验证模式,点击登录会直接创建 `17600003315` 的最高管理员会话 - 默认不再允许临时免验证登录,只有显式配置 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 时才开启开发兜底
- 原生 Android 端登录后会持久化 `boss_session + restore token`,用于 30 天登录保持和 OTA / 覆盖安装后的会话恢复 - 原生 Android 端登录后会持久化 `boss_session + restore token`,用于 30 天登录保持和 OTA / 覆盖安装后的会话恢复
- 当前阶段不会因为账号、密码或验证码为空而拒绝登录 - 正常模式要求 `password``code` 校验通过
- 校验通过后会写入 `boss_session` Cookie - 校验通过后会写入 `boss_session` Cookie
- 当请求头带 `x-boss-native-app: 1` 时,还会额外返回 `restoreToken` - 当请求头带 `x-boss-native-app: 1` 时,还会额外返回 `restoreToken`
- 当前 `boss_session` 默认保持 30 天 - 当前 `boss_session` 默认保持 30 天
- 连续失败 5 次后会锁定 10 分钟 - 连续失败 5 次后会锁定 10 分钟
- 当前密码存储:新注册 / 重置密码使用 `scrypt`;历史 `sha256` 会在下次密码登录时自动迁移 - 当前密码存储:新注册 / 重置密码使用 `scrypt`;历史 `sha256` 会在下次密码登录时自动迁移
- 当前默认管理员账号:`17600003315` - 当前默认管理员账号:`krisolo`
- 当前默认测试密码`boss123456` - 当前默认测试密码由线上初始化配置管理,文档不再明文记录
#### `GET /api/auth/session` #### `GET /api/auth/session`
@@ -248,6 +600,26 @@
- 当请求头带 `x-boss-native-app: 1` 时,还会返回: - 当请求头带 `x-boss-native-app: 1` 时,还会返回:
- `restoreToken` - `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` #### `POST /api/auth/restore`
- 用途:原生 APP 使用 `restore token` 恢复 `boss_session` - 用途:原生 APP 使用 `restore token` 恢复 `boss_session`
@@ -300,6 +672,10 @@
- 更新设备状态 - 更新设备状态
-`pairingCode` 合法,则 claim 设备绑定草稿并返回 token -`pairingCode` 合法,则 claim 设备绑定草稿并返回 token
- 若携带 `projectCandidates[]`,则会同步生成或刷新对应设备的 `deviceImportDraft` - 若携带 `projectCandidates[]`,则会同步生成或刷新对应设备的 `deviceImportDraft`
- 当前保护:
- 已存在设备必须携带有效 `token` 或未过期 enrollment 的 `pairingCode`
- 未准备 enrollment 的新 `deviceId` 不能通过心跳自注册
- 已吊销设备返回 `DEVICE_REVOKED`,不会更新 `lastSeenAt / status / projects / projectCandidates`
#### `POST /api/projects/[projectId]/goals/[goalId]/toggle` #### `POST /api/projects/[projectId]/goals/[goalId]/toggle`
@@ -385,13 +761,24 @@
- 普通单线程项目当前会在写入用户消息后,继续创建 `taskType=conversation_reply` 的主 Agent 任务 - 普通单线程项目当前会在写入用户消息后,继续创建 `taskType=conversation_reply` 的主 Agent 任务
- 返回体会附带 `task.taskId / taskType / status`,给 Web 和原生 Android 保持等待真实回写使用 - 返回体会附带 `task.taskId / taskType / status`,给 Web 和原生 Android 保持等待真实回写使用
- `projectId=master-agent``kind=text` 时,会先返回 `masterReplyState + task`,真实回复随后异步回写到账本 - `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``task queue -> local-agent -> codex exec -> complete`
- 如果当前主控是 `Master Codex Node`但节点离线或执行立即失败,主 Agent 当前会优先尝试已配置的 `OpenAI API / 阿里百炼 Qwen` 账号,避免聊天直接只剩失败日志 - 如果当前主控是 `Master Codex Node`主 Agent 会先使用授权范围内的 Codex 设备池:设备在线且 `Codex App Server / CLI / GUI` 至少一条模型通道在线才可作为模型通道;首选设备不可用会切下一台,执行中失败会把同一任务重排到下一台,全部 Codex 设备不可用后才切到已配置的 API 备用链
-本机节点未接通,可切到 `OpenAI API``阿里百炼 Qwen` 备用账号 - Codex 设备池和 API Key 都不可用,主 Agent 会在对话里提示“当前没有可用的模型渠道”,不再暴露内部 master 节点账号话术
- 群聊项目当前会带上 `collaborationGate`,用于标明当前是否需要先经主 Agent / 用户审批 - 群聊项目当前会带上 `collaborationGate`,用于标明当前是否需要先经主 Agent / 用户审批
- 群聊文本消息当前还会返回 `dispatchPlan / dispatchRecommendation`,用于展示主 Agent 推荐的线程下发方案 - 群聊文本消息当前还会返回 `dispatchPlan / dispatchRecommendation`,用于展示主 Agent 推荐的线程下发方案
- 如果群里已经有一条待确认推荐,接口会直接返回 `409`,要求先确认或拒绝当前推荐,避免审批消息叠加 - 如果群里已经有一条待确认推荐,接口会直接返回 `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` #### `GET /api/v1/projects/[projectId]/agent-controls`
- 用途:读取当前对话级别的 `modelOverride / reasoningEffortOverride / backendOverride` - 用途:读取当前对话级别的 `modelOverride / reasoningEffortOverride / backendOverride`
@@ -465,8 +852,9 @@
- `mode`: `thread | group` - `mode`: `thread | group`
- `name` - `name`
- 当前行为: - 当前行为:
- `mode=thread` 时同步更新线程显示名和会话标题 - `mode=thread` 时同步更新 Boss 线程显示名和会话标题;如果该会话已绑定 `codexThreadRef`,会追加创建 `intentCategory=thread_rename``conversation_reply` 任务,由本机 App Server runner 调用 `thread/name/set` 同步 Codex 线程名称
- `mode=group` 时更新群聊名称 - `mode=group` 时更新群聊名称
- 边界Codex 线程改名任务不会启动普通 turn不会把线程原始历史写回 APP设备离线、并发冲突或 App Server 不可用时,本地 Boss 改名仍保留,响应会带非致命 `codexThreadRenameError`
#### `POST /api/v1/projects/[projectId]/group-chat` #### `POST /api/v1/projects/[projectId]/group-chat`
@@ -660,6 +1048,7 @@
#### `GET /api/v1/storage/config` #### `GET /api/v1/storage/config`
- 用途:读取当前登录用户的附件存储配置 - 用途:读取当前登录用户的附件存储配置
- 当前入口Web `我的 > 附件与存储` 与 Android `StorageSettingsActivity`
- 返回: - 返回:
- `mode`: `server_file | oss` - `mode`: `server_file | oss`
- `ossProvider` - `ossProvider`
@@ -691,6 +1080,14 @@
- 用途:新增项目目标 - 用途:新增项目目标
- 输入: - 输入:
- `text` - `text`
- 当前行为:
- 本地 Boss 项目目标先落盘
- 如果目标项目是已绑定 `codexThreadRef` 的单线程会话,会追加创建 `intentCategory=thread_goal_sync``conversation_reply` 任务,由本机 App Server runner 调用 `thread/goal/set` 同步 Codex 线程 goal
- 设备离线、并发冲突或 App Server 不可用时,本地目标仍保留,响应会带非致命 `codexThreadGoalError`
- 返回:
- `goal`
- `codexThreadGoalTask`(可选)
- `codexThreadGoalError`(可选)
#### `GET /api/v1/threads/[threadId]/context-budget` #### `GET /api/v1/threads/[threadId]/context-budget`
@@ -848,6 +1245,20 @@
- 当前归档:发布脚本还会额外保留 `public/downloads/boss-android-v{versionName}-{flavor}.apk` - 当前归档:发布脚本还会额外保留 `public/downloads/boss-android-v{versionName}-{flavor}.apk`
- 当前保护:要求有效 `boss_session` - 当前保护:要求有效 `boss_session`
#### `GET /api/v1/boss-agent/ota`
- 用途:被控电脑上的 boss-agent 用设备 token 检查 Mac agent 运行包 OTA
- 输入:`deviceId``currentVersion`
- 返回:`hasUpdate` 与最新 `boss-agent-mac-latest.zip` 的版本、大小、sha256、下载地址
- 当前保护:要求 `x-boss-device-token`
#### `GET /api/v1/boss-agent/ota/package`
- 用途:下载当前已发布的最新 boss-agent macOS 运行包
- 当前来源:`public/downloads/boss-agent-mac-latest.zip`
- 当前元数据:`public/downloads/boss-agent-mac-latest.json`
- 当前保护:要求 `x-boss-device-token``deviceId`
#### `GET /api/v1/ops/summary` #### `GET /api/v1/ops/summary`
- 用途:读取运维 `fault / repair ticket / verification` 聚合数据 - 用途:读取运维 `fault / repair ticket / verification` 聚合数据
@@ -883,6 +1294,7 @@
- 用途:由 local-agent 认领分配给本机的主 Agent 任务 - 用途:由 local-agent 认领分配给本机的主 Agent 任务
- 当前保护:要求 `x-boss-device-token` 或匹配登录会话 - 当前保护:要求 `x-boss-device-token` 或匹配登录会话
- 当前可靠性claim 会写入 `attemptCount / maxAttempts / claimedAt / lastClaimedAt / leaseExpiresAt`;运行中任务租约过期后可重试认领,超过最大次数会转为 `timed_out` 并更新进度卡;被吊销设备不能继续认领
#### `POST /api/v1/master-agent/tasks/[taskId]/complete` #### `POST /api/v1/master-agent/tasks/[taskId]/complete`
@@ -902,7 +1314,64 @@
- `taskType=conversation_reply` 时,会把目标 Codex 线程的原始回复写回普通单线程会话 - `taskType=conversation_reply` 时,会把目标 Codex 线程的原始回复写回普通单线程会话
- `taskType=dispatch_execution` 时,会把线程原始结果镜像回群聊,再追加一条主 Agent 汇总,并更新对应执行单状态 - `taskType=dispatch_execution` 时,会把线程原始结果镜像回群聊,再追加一条主 Agent 汇总,并更新对应执行单状态
- `failed` 时写入 relay 失败消息,并更新 AI 账号健康状态 - `failed` 时写入 relay 失败消息,并更新 AI 账号健康状态
- 如果任务带有 `externalReplyTarget.provider=telegram`,完成后会尝试调用 Telegram Bot API 把 `replyBody` 回推到原始聊天
- 终态任务 `completed / failed / timed_out / canceled` 的迟到重复 complete 会直接返回当前任务,不再覆盖终态或重复写消息
- 对群聊分发推荐失败的情况,消息入口当前会额外写入一条 `system_notice`,把“没有真实线程”或“成员引用失效”明确回显给用户 - 对群聊分发推荐失败的情况,消息入口当前会额外写入一条 `system_notice`,把“没有真实线程”或“成员引用失效”明确回显给用户
#### `POST /api/v1/master-agent/tasks/[taskId]/cancel`
- 用途:取消仍在 `queued / running / needs_user_action` 的主 Agent 任务
- 权限:任务请求账号、`highest_admin`,或具备目标设备 `device.manage` 的账号
- 当前行为:任务转为 `canceled`,写入 `canceledAt / canceledBy / cancelReason`,清除租约;如果之后设备端迟到回写成功,服务端不会覆盖取消终态
#### `GET /api/v1/master-agent/tasks/[taskId]/control-state`
- 用途:设备端在执行中轮询任务控制状态,用于把 APP / Web 的取消动作同步到正在运行的本地 runtime
- 权限:目标设备 token或具备目标设备写权限的会话
- 返回:`taskId / status / canceled / cancelReason / canceledAt`
- 安全边界:不返回 `requestText / executionPrompt / sourceMessageBody`,避免把用户消息、内部提示词或调度字段泄露给设备外的调用方
- 当前 App Server 行为:`local-agent``turn/start` 后轮询该接口;如果返回 `canceled=true`,会调用当前 App Server 连接的 `turn/interrupt`,把 Codex 活跃 turn 真实中断
#### `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` 或匹配登录会话 - 当前保护:要求 `x-boss-device-token` 或匹配登录会话
#### `GET /api/v1/master-agent/prompt-policy` #### `GET /api/v1/master-agent/prompt-policy`
@@ -1080,6 +1549,7 @@
- local-agent 会周期性请求 `POST /api/v1/master-agent/tasks/claim` - local-agent 会周期性请求 `POST /api/v1/master-agent/tasks/claim`
- 认领到任务后会执行本机 `codex exec` - 认领到任务后会执行本机 `codex exec`
- `conversation_reply` 当前会优先走 `codex exec resume <targetCodexThreadRef>`,把任务恢复到真实 Codex 线程;只有缺失真实线程引用时才退回 `--ephemeral` - `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 协议 - `dispatch_execution` 当前默认也走 `codex exec resume`,但当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议
- `codex exec resume` 前当前还会做目标线程绑定预检若目标线程缺失、已归档、cwd 不匹配或为只读会话,会直接失败并返回标准化错误,不继续把任务派进错误线程 - `codex exec resume` 前当前还会做目标线程绑定预检若目标线程缺失、已归档、cwd 不匹配或为只读会话,会直接失败并返回标准化错误,不继续把任务派进错误线程
- 如果历史 `worker / explorer` 子线程需要转回可开发线程,除了数据库权限本身,还必须显式补发新的解锁指令覆盖其旧的“只读勘察 / 不改文件”上下文;否则前台看起来像可写,实际执行仍可能被旧上下文限制 - 如果历史 `worker / explorer` 子线程需要转回可开发线程,除了数据库权限本身,还必须显式补发新的解锁指令覆盖其旧的“只读勘察 / 不改文件”上下文;否则前台看起来像可写,实际执行仍可能被旧上下文限制
@@ -1094,15 +1564,41 @@
- `data/boss-state.json` - `data/boss-state.json`
状态存储默认仍走文件模式。PostgreSQL 仅作为显式量产切换路径存在:必须设置 `BOSS_STATE_STORE=postgres`,涉及真实连接 / 写入的维护命令还必须设置 `BOSS_DATABASE_URL``scripts/boss-state-store-maintenance.mjs` 当前支持:
- `validate-schema`:校验 `scripts/postgres-state-schema.sql` 是否包含 `boss_state_snapshots``snapshot_key` 主键、`state JSONB` 和更新时间索引
- `backup-file / export-file`:在文件模式下导出当前状态文件备份
- `migrate-file-to-postgres --dry-run`:只校验文件状态和 schema不连接数据库正式迁移会 upsert 到 `boss_state_snapshots`
- `export-postgres-backup`:从 PostgreSQL 导出带元数据和 sha256 的 JSON 备份包
- `restore-postgres-backup`:把备份包或原始状态 JSON 恢复回 PostgreSQL可先用 `--dry-run` 验证
- `rollback-postgres-to-file`:把 PostgreSQL 当前快照回写到文件,用于数据库切换失败后的文件模式回退
状态文件当前带有迁移前置元数据:
- `schemaVersion`:当前 BossState schema 版本
- `migratedAt`:最近一次从旧 schema 迁移到当前 schema 的时间
读取状态时会先经过 `migrateBossState`,用于从无版本或旧版本 JSON 补齐当前结构,并规范化授权和 Skill 生命周期相关数组。这个机制只为后续正式 DB 迁移提供稳定 schema 边界,不表示数据库化已经完成。
关键对象: 关键对象:
- `schemaVersion`
- `migratedAt`
- `user` - `user`
- `devices` - `devices`
- `projects` - `projects`
- `verificationCodes` - `verificationCodes`
- `verificationDispatches` - `verificationDispatches`
- `adminCompanies`
- `adminNotifications`
- `adminRiskTimeline`
- `authAccounts` - `authAccounts`
- `authSessions` - `authSessions`
- `accountDeviceGrants`
- `accountProjectGrants`
- `accountSkillGrants`
- `skillCatalog`
- `skillLifecycleRequests`
- `aiAccounts` - `aiAccounts`
- `aiAccountSwitchHistory` - `aiAccountSwitchHistory`
- `userAttachmentStorageConfigs` - `userAttachmentStorageConfigs`
@@ -1130,8 +1626,8 @@
不要误以为已经存在: 不要误以为已经存在:
- 正式数据库 - 已直接切换完成的正式数据库
- 正式鉴权中间件 - 企业 SSO / IdP
- 多家对象存储适配(当前只有服务器文件存储和阿里 OSS - 多家对象存储适配(当前只有服务器文件存储和阿里 OSS
- 完整的附件详情页与富预览器 - 完整的附件详情页与富预览器
- 完整的多端用户会话系统与刷新令牌体系 - 完整的多端会话风控平台(当前已有 restore token 轮换、CSRF 基础防护和 MFA 开关)

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
# Boss 当前运行与部署状态 # Boss 当前运行与部署状态
更新时间:`2026-04-03` 更新时间:`2026-06-04`
## 1. 本地状态 ## 1. 本地状态
@@ -20,15 +20,47 @@
- 登录接口:`POST http://127.0.0.1:3000/api/auth/login` - 登录接口:`POST http://127.0.0.1:3000/api/auth/login`
- 登录态接口:`GET http://127.0.0.1:3000/api/auth/session` - 登录态接口:`GET http://127.0.0.1:3000/api/auth/session`
- 登录恢复接口:`POST http://127.0.0.1:3000/api/auth/restore` - 登录恢复接口:`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` - 登出接口:`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`
- 管理后台状态备份与回退接口:`GET/POST http://127.0.0.1:3000/api/v1/admin/backups`,仅 `highest_admin` 可用;支持创建状态快照、列出快照和恢复到指定快照,恢复前会自动创建 pre-restore 快照。文件状态写入层已默认开启自动快照,可用 `BOSS_STATE_AUTO_BACKUP_INTERVAL_MS``BOSS_STATE_AUTO_BACKUP_KEEP` 调整频率与保留数量
- OTA 包下载接口:`GET http://127.0.0.1:3000/api/v1/user/ota/package` - OTA 包下载接口:`GET http://127.0.0.1:3000/api/v1/user/ota/package`
- boss-agent Mac OTA 接口:`GET http://127.0.0.1:3000/api/v1/boss-agent/ota?deviceId=...&currentVersion=...``GET http://127.0.0.1:3000/api/v1/boss-agent/ota/package`
- 本地 agent 健康检查:`http://127.0.0.1:4317/health`。当前这台开发机的 `launchd` 常驻已经恢复,`/health` 可在数十毫秒内返回,并且在手动 heartbeat 执行期间也不会再被 Codex 线程扫描卡死 - 本地 agent 健康检查:`http://127.0.0.1:4317/health`。当前这台开发机的 `launchd` 常驻已经恢复,`/health` 可在数十毫秒内返回,并且在手动 heartbeat 执行期间也不会再被 Codex 线程扫描卡死
- 2026-06-07 已补量产可靠性降载:`local-agent` 的 reliable outbox 会优先保留 `task.complete`,按任务合并重复 `task.progress`,并对同类 `app.log` 做去重和上限保护;`/health` 默认只返回轻量摘要,完整 runtime 只允许通过 `/health?verbose=1` 做诊断Android SSE 已新增 `message-patch-v1` 能力声明,服务端只对支持该能力的客户端下发 `projectMessagesPatch`,旧客户端继续使用完整 `projectMessagesPayload`
- 2026-06-07 已补任务 SLA 企业治理:新增 `src/lib/master-agent-task-sla.ts` 统一计算 MasterAgentTask 的 `ok / watch / breached / recoverable / terminal` 状态、SLA 截止时间、空闲时间、尝试次数和建议动作;`GET /api/v1/admin/backoffice` 会返回 `insights.taskSlaPanel`,独立 Web 管理后台的平台风险页和企业风险页都会展示任务 SLA 面板;`POST /api/v1/admin/risks/scan` 会对 SLA 超时、可恢复失败和终态失败幂等写入 `adminNotifications`,并把可安全重试的 pre-turn recoverable 任务自动重排队,写入 `adminRiskTimeline``permissionAuditLogs`
- 本地 Skill 扫描接口:`http://127.0.0.1:4317/api/v1/skills` - 本地 Skill 扫描接口:`http://127.0.0.1:4317/api/v1/skills`
- 本地 agent 手动 heartbeat`POST http://127.0.0.1:4317/api/v1/heartbeat` - 本地 agent 手动 heartbeat`POST http://127.0.0.1:4317/api/v1/heartbeat`
- `launchd` 已安装:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist` - `launchd` 已安装:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist`
- 当前执行底座抽象层已落地在 `src/lib/execution/`,并已补齐 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现 - 当前执行底座抽象层已落地在 `src/lib/execution/`,并已补齐 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现
- 当前生产主链仍然沿用 `local-agent -> codex exec resume -> /api/v1/master-agent/tasks/[taskId]/complete`,执行底座重构以“先抽象、不改行为”为准 - 当前生产主链仍然沿用 `local-agent -> codex exec resume -> /api/v1/master-agent/tasks/[taskId]/complete`,执行底座重构以“先抽象、不改行为”为准
- 当前 Codex server 调研结论已记录在 `docs/architecture/codex_server_progress_card_cn.md`:长期优先方向更新为 `Codex App Server / Remote Control -> Inter-Thread Broker -> CodexMcpBackendAdapter -> codex exec resume` 的分层 provider 策略;当前 boss-agent 默认打开 `Codex App Server` runner 作为 Codex 绑定入口Boss 仍保留 `codex exec resume` 兜底,并继续用 `execution_progress` 结构化进度卡作为 APP 可见执行态。本机 `codex-cli 0.136.0-alpha.2` 协议快照已生成到 `docs/protocol-snapshots/codex-app-server/0.136.0-alpha.2/`,确认 151 个 method并支持 WebSocket auth、`thread/inject_items``turn/steer``turn/interrupt``thread/archive``thread/unarchive``thread/fork``thread/compact/start``thread/rollback``thread/name/set``thread/metadata/update``thread/shellCommand``thread/unsubscribe``thread/realtime/*``thread/started|closed|archived|unarchived|name/updated``process/outputDelta|exited``rawResponseItem/completed``item/agentMessage/delta``item/plan/delta``item/reasoning/*Delta``item/mcpToolCall/progress``command/exec/outputDelta``item/commandExecution/terminalInteraction``item/fileChange/outputDelta``thread/goal/*``thread/settings/updated``thread/compacted``ThreadItem.contextCompaction``account/*``model/verification``configWarning``deprecationNotice``command/exec``command/exec/write``command/exec/resize``command/exec/terminate``model/list``skills/changed``skills/extraRoots/set``hooks/list``plugin/installed``plugin/install``plugin/uninstall``plugin/read``plugin/skill/read``plugin/share/*``config/value/write``config/batchWrite``config/mcpServer/reload``skills/config/write``fs/*``externalAgentConfig/import``marketplace/add|remove|upgrade``experimentalFeature/enablement/set``review/start``windowsSandbox/readiness|setupStart``fuzzyFileSearch/session*``mcpServer/oauth*``mcpServer/resource/read``mcpServer/tool/call``mcpServer/elicitation/request``item/tool/requestUserInput``thread/approveGuardianDeniedAction`
- 当前本机 `codex remote-control` 已确认为官方 App Server daemon 远控管理入口boss-agent 本机状态页会展示 `Codex Remote Control` 托管摘要和启动命令,默认只观测不启动。本批已新增云端受控入口 `POST /api/v1/devices/[deviceId]/codex-remote-control`,要求 `action=start|stop``confirmed=true`、设备在线和当前账号具备该设备 `computer.control` 权限;成功会排入 `device_maintenance / codex_remote_control` 任务,由目标设备 local-agent 本机执行 `codex remote-control start|stop --json` 并回写任务小结,同时写入 `task.authorized` 审计;未授权或设备离线写入 `task.denied`。独立 PC 后台的 `全局设备 / 电脑与 Codex 接入` 表格已接入 `启动远控 / 停止远控` 操作Android APP 设备详情页已同步接入原生二次确认入口。
- 2026-06-04 重新生成 0.136.0-alpha.2 协议快照后manifest 识别 151 个 method并新增 `itemTypes` 支持矩阵。当前本机 schema 已确认 `app/list``app/list/updated``configRequirements/read``mcpServerStatus/list``ThreadItem.contextCompaction`;官方 App Server 文档列出的 `collaborationMode/list``thread/turns/list``ThreadItem.collabToolCall` 在本机生成 schema 中仍未声明,所以 Boss 只把它们作为运行时兼容/官方文档跟进项,不把“线程间对话”写成无监管 P2P。
- 当前 App Server 能力发现已新增治理摘要local-agent 会在 heartbeat discovery 中拉取 `experimentalFeature/list / collaborationMode/list / permissionProfile/list / mcpServerStatus/list`,并把实验特性、协作模式、权限 Profile 与 MCP 服务状态写入设备 `codexAppServer.metadata`Web 与原生 Android 设备详情页都会显示“治理”摘要。该链路只保留安全摘要,不保存 MCP resource URI、permission profile 文件规则、本地路径、token 或工具参数。
- 当前 App Server 能力发现已新增账号与配置摘要local-agent 会在 heartbeat discovery 中拉取 `account/read / account/rateLimits/read / config/read / configRequirements/read / externalAgentConfig/detect`并把账号登录方式、套餐、额度使用率、App 配置计数、托管要求数量和外部 Agent 迁移候选数量写入设备 `codexAppServer.metadata`Web 设备详情页会显示“账号 / 配置”摘要,原生 Android 设备详情页会显示“账号”摘要。该链路只读不写,不保存账号邮箱、完整 config、API key、本地路径或迁移描述。
- 当前 App Server 能力发现已新增线程可见性摘要local-agent 会在 heartbeat discovery 中拉取 `thread/list / thread/loaded/list`,并把线程总数、已加载线程数、活跃线程数、归档线程数、最新更新时间和非归档线程轻量目录写入设备 `codexAppServer.metadata.threadSummary`Web 与原生 Android 设备详情页都会显示“线程”摘要。该链路不保存 cwd、本地路径、turn 内容、用户正文或内部 prompt。
- 当前 App Server 能力发现已新增 turn 运行态摘要local-agent 会在 heartbeat discovery 中对非归档可见线程拉取 `thread/turns/list`,请求固定 `itemsView=summary`,并把总轮次、运行中轮次、完成轮次、最新 turn 更新时间、每个线程的最近 turn 状态和最终 `agentMessage` 安全摘要写入设备 `codexAppServer.metadata.threadTurnSummary`Web 与原生 Android 设备详情页都会显示“轮次”摘要。该链路不保存用户正文、reasoning 原文、命令输出、原始 items、内部 prompt 或系统提示词。
- 当前 App Server discovery 还会把最终 `agentMessage` 合并进 heartbeat `projectCandidates.recentAssistantMessages`。服务端已有 `codexThreadRef` 匹配时会把 Codex Desktop 自己产生的新回复反向同步到 Boss APP 对应会话,并刷新 preview、lastMessageAt 和未读数;已有本地扫描候选的 folder/thread 映射优先保留App Server 只补充最新回复摘要。
- 当前 App Server 能力发现已新增线程操作能力摘要local-agent 会把已验证进入当前协议快照的 archive / unarchive / fork / compact / rollback / rename / metadata / steer / interrupt / shell / unsubscribe 写入设备 `codexAppServer.metadata.threadActionSummary`Web 与原生 Android 设备详情页都会显示“线程操作”。该字段只读,不在 heartbeat 中调用任何线程写 API。
- 当前 App Server 能力发现已新增线程协作口径摘要local-agent 会写入 `codexAppServer.metadata.threadCollaborationSummary`Web 与原生 Android 设备详情页都会显示 Boss Broker 可用性、协作事件 handler 可用性、协作模式数量和“非原生私聊”状态。该字段用于提醒产品和运维:当前可做的是 Boss 受控线程协作,不是 Codex 原生线程互聊。
- 当前 App Server 能力发现已新增协议漂移摘要local-agent 会写入 `codexAppServer.metadata.protocolDriftSummary`Web 与原生 Android 设备详情页都会显示“协议漂移:兼容/告警 · 失败探针 N 个 · 文档跟进 N 项 · Boss Broker 兜底”。该字段把运行时 discovery 失败 method、官方文档跟进项和当前兜底策略拆开展示避免 Codex Server 更新后只靠原始日志判断协议是否漂移。
- 当前 App Server 能力发现已新增插件治理能力摘要local-agent 会把已验证进入当前协议快照的 install / uninstall / read / skill-read / share 写入设备 `codexAppServer.metadata.pluginGovernanceSummary`;设备详情页会显示“插件治理”。该字段只读,不在 heartbeat 中调用任何插件写 API。
- 当前 App Server 能力发现已新增账号与配置治理能力摘要local-agent 会把已验证进入当前协议快照的 login / logout / token refresh / add credits nudge / config write / MCP reload / Skill config write 写入设备 `codexAppServer.metadata.accountGovernanceSummary / configGovernanceSummary`;设备详情页会显示“账号治理 / 配置治理”。这些字段只读,不在 heartbeat 中调用任何账号或配置写 API。
- 当前 App Server 能力发现已新增文件系统与命令会话治理能力摘要local-agent 会把已验证进入当前协议快照的文件读写、目录、元数据、复制、删除、监听、命令 stdin、PTY resize、terminate 和输出流能力写入设备 `codexAppServer.metadata.fileSystemGovernanceSummary / commandSessionSummary`;设备详情页会显示“文件治理 / 命令会话”。这些字段只读,不在 heartbeat 中调用任何文件或命令控制 API。
- 当前 App Server 能力发现已新增外部 Agent 迁移、Marketplace 和实验特性治理能力摘要local-agent 会把已验证进入当前协议快照的 external-agent 导入、marketplace 添加 / 移除 / 升级、实验特性 enablement 写入设备 `codexAppServer.metadata.externalAgentGovernanceSummary / marketplaceGovernanceSummary / experimentalFeatureGovernanceSummary`;设备详情页会显示“迁移治理 / 市场治理 / 实验特性治理”。这些字段只读,不在 heartbeat 中调用任何迁移导入、marketplace 写入或实验特性启用 API。
- 当前 App Server 能力发现已新增审查、Windows 沙箱和文件搜索事件能力摘要local-agent 会把已验证进入当前协议快照的 review start、Windows sandbox readiness / setup start / setup completed、fuzzy file search updated / completed 写入设备 `codexAppServer.metadata.reviewGovernanceSummary / windowsSandboxGovernanceSummary / fuzzyFileSearchSummary`;设备详情页会显示“审查治理 / Windows 沙箱 / 文件搜索事件”。这些字段只读,不在 heartbeat 中调用任何审查启动、沙箱设置或文件搜索动作。
- 当前 App Server 能力发现已新增 MCP、用户交互和 Guardian 治理能力摘要local-agent 会把已验证进入当前协议快照的 MCP OAuth / resource / tool / elicitation、tool requestUserInput、Guardian denied action approval 和 permission request approval 写入设备 `codexAppServer.metadata.mcpGovernanceSummary / userInteractionGovernanceSummary / guardianGovernanceSummary`设备详情页会显示“MCP 治理 / 用户交互 / Guardian 治理”。这些字段只读,不在 heartbeat 中调用任何 MCP、用户输入或 Guardian 放行动作。
- 当前 App Server 能力发现已新增运行事件、扩展事件和线程生命周期事件能力摘要local-agent 会把已验证进入当前协议快照的 process output / exited、raw response completed、skills changed、plugin installed、thread started / closed / archived / unarchived / name updated 写入设备 `codexAppServer.metadata.runtimeEventSummary / extensionEventSummary / threadLifecycleEventSummary`;设备详情页会显示“运行事件 / 扩展事件 / 线程生命周期”。这些字段只读,不在 heartbeat 中主动触发进程、插件、Skill 或线程生命周期动作。
- 当前 App Server 能力发现已新增流式增量事件能力摘要local-agent 会把已验证进入当前协议快照的 agent delta、plan delta、reasoning delta、MCP tool progress、command output、terminal interaction 和 file output 写入设备 `codexAppServer.metadata.streamDeltaEventSummary`;设备详情页会显示“流式增量”。同批已把执行中的 delta 事件接入 `executionProgress.streamEvents`APP 进度卡只展示各类片段计数,不保存原始增量文本、命令输出、终端输入、推理正文或文件输出。
- 当前 App Server 能力发现已支持共享 Skill 根目录下发:配置 `codexAppServerSkillExtraRoots` / `BOSS_CODEX_APP_SERVER_SKILL_EXTRA_ROOTS`local-agent 会先调用 `skills/extraRoots/set`,再刷新 `skills/list`,并把 `skillExtraRootsSummary` 写入设备 `codexAppServer.metadata`;设备详情页会显示“共享 Skill 根”。该链路只保存数量、basename 和状态不保存根目录绝对路径、Skill 文件路径或配置原文。
- 当前 App Server 能力发现已新增 Hook 治理摘要local-agent 会在 heartbeat discovery 中拉取 `hooks/list`,并把 hook 数、启用数、受管 / 可信 / 修改 / 未信任计数、warning / error 计数写入设备 `codexAppServer.metadata.hookSummary`设备详情页会显示“Hook”。该链路不保存 hook key、command、sourcePath、statusMessage、hash、error message 或本地路径。
- 当前量产 B+ 架构开发文档已新增:`docs/architecture/enterprise_ai_ops_architecture_cn.md`。该文档把 PPT 中的主 Agent / 业务 Agent / 老板端 / 经理端 / 员工端 / 治理层 / 系统层 / 设备层 / 执行层 / 接入层整理成后续产品架构约束并明确数据库备份、业务回退、Codex 协议扩展和 Skill 治理方向;它是规划文档,不代表当前全部已落地
- 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有显式配置 `BOSS_CLAW_*` 且可用性探测通过时,`master-agent` 当前对话中才会出现并允许选择 `claw-runtime` - 当前 `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 前台给出明确原因 - 如果历史上已经保存过 `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` 验证整条链 - 当前仓库已自带 `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 不可用时会自动回退到默认后端并明确提示原因 - 当前 `oh-my-codex` 已以最小 `OmxTeamBackendAdapter` 形式接入执行底座,但默认关闭;当前已经接到 Web 群聊详情页 / 原生群资料页的编排后端选择卡,可在 `Boss Native``OMX Team` 间切换OMX 不可用时会自动回退到默认后端并明确提示原因
@@ -40,6 +72,15 @@
- 当前 `conversation_reply / dispatch_execution` 的线程执行结果会先经过 `RemoteRuntimeAdapter` 标准化;如果线程返回的是固定模式的内部环境提示(如“当前会话环境只读 / cwd …”),会直接转成失败,不再把原文写回会话消息 - 当前 `conversation_reply / dispatch_execution` 的线程执行结果会先经过 `RemoteRuntimeAdapter` 标准化;如果线程返回的是固定模式的内部环境提示(如“当前会话环境只读 / cwd …”),会直接转成失败,不再把原文写回会话消息
- 当前设备模型已支持同一台 Mac / Windows 同时接入 Codex `GUI + CLI` 双能力Web / Android 设备详情页都会展示两种能力状态,并允许切换默认执行模式 - 当前设备模型已支持同一台 Mac / Windows 同时接入 Codex `GUI + CLI` 双能力Web / Android 设备详情页都会展示两种能力状态,并允许切换默认执行模式
- 当前同项目 `GUI / CLI` 并行写入风险已接入项目/文件夹级冲突控制:默认阻断,用户只能对当前异常项目/文件夹选择 `禁止 / 允许本次 / 永久放行` - 当前同项目 `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/cua-driver-computer-use-runtime.mjs` 作为 browser / desktop 起步执行器
- 当前这条电脑控制链先只按 macOS 交付:`browser_control / desktop_control` 任务会写入 `controlPlatform=macos``computerUseProvider`,其中浏览器控制默认 `openai-computer-use`,桌面 GUI 控制默认 `codex-computer-use``local-agent` 会先调用 Codex Computer Use失败后自动回退 `cua-driver-computer-use``local-agent` 下发 runtime stdin 时也会携带同一组字段,桌面 dialog guard 只保留 macOS adapterWindows 分支不进入当前生产链路
- 当前这两条控制链的 `control_summary` 已能回写结构化目标信息browser 会保留 `targetUrl`desktop 会保留 `targetApp`Android 聊天窗口会在控制结果卡片里直接显示执行目标
- 当前 `scripts/browser-control-smoke.mjs` 已提升到“最小真实浏览器探测”:如果目标 URL 可访问,会抓取页面 `<title>` 并回写结果;`scripts/codex-computer-use-runtime.mjs` 会通过 Codex App Server 发起 Codex Computer Use 执行;`scripts/cua-driver-computer-use-runtime.mjs` 作为 fallback 接入 `cua-driver` 的 macOS 窗口级控制能力,默认执行 `launch_app -> get_window_state`,并支持安全范围内的引号文本写入;涉及发送、提交、删除、支付等动作时默认返回确认卡,不直接执行高风险提交
- 当前 boss-agent 已补 Mac OTA`scripts/package-boss-agent-mac-runtime.sh` 会生成 `dist/boss-agent-mac-runtime-{version}.zip`,并同步发布 `public/downloads/boss-agent-mac-latest.zip/json`;本机 `local-agent` 默认每 5 分钟检查一次,可在 boss-agent 状态页手动“检查更新 / 下载并安装”。安装采用“下载校验 -> 写入暂存 wrapper -> 拉起 install.command”的安全路径失败不会覆盖当前运行版本。正式分发脚本已支持 `BOSS_AGENT_NOTARIZE=1 + BOSS_AGENT_NOTARY_PROFILE` 的 Developer ID 公证路径,本地开发默认仍可 ad-hoc / Apple Development 签名。
- 当前最新 boss-agent Mac 包版本为 `20260516221619`,已部署到 `https://boss.hyzq.net/api/v1/boss-agent/ota` 并在局域网 MacBook Air `macbook-air` 上完成真实 OTA 下载、sha256 校验、暂存、覆盖安装和 up-to-date 检查:安装后 `config.installed.json` 仍保持 `deviceId=macbook-air`、账号 `krisolo`、版本 `20260516221619``launchd` 状态为 running。
- 当前安装器已做多电脑绑定保护:`install.command` 会保留所有 `config*.json` 并优先沿用当前 launchd active config底层 `scripts/install-local-launchagent.sh` 在无显式参数时也会优先读取现有 LaunchAgent 的配置路径,再回退自定义设备配置,避免多台 Mac 重装/OTA 时误切到默认 `config.cloud.json`
- 当前 Cua runtime 已补上 launchd 友好的可执行文件发现:除 `PATH` 外会主动查找 `~/.local/bin/cua-driver``/Applications/CuaDriver.app/Contents/MacOS/cua-driver`;如果 `launch_app` 对已运行 App 返回 not found会兜底走 `list_apps -> list_windows -> get_window_state` 复用现有窗口
- 当前本机 `local-agent` 默认 heartbeat 已把 `browserAutomation / computerUse` 两项能力视为“已接通起步版 runtime”因此 Boss 前台设备能力会直接显示这两条链路在线;`codexAppServer` 能力只有在显式打开 App Server runner 后才会上报在线stdio 模式会校验本机 `codex` 命令可执行ws/unix 模式会校验已配置 `codexAppServerUrl`;如果后续需要临时关闭,可在 `local-agent/config.cloud.json` 里单独下掉对应 connected 标记或 runtime 命令
本地已知运行方式: 本地已知运行方式:
@@ -90,19 +131,32 @@ cd /Users/kris/code/boss
- `npm start`、服务器 `systemd` 与远端 `npm run build` 当前都显式设置了 `BOSS_RUNTIME_ROOT`,避免 `process.cwd()` 在 standalone / 服务器构建阶段误扫描整个仓库 - `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` - `next.config.ts` 当前已把 `deployment / docs / design / local-agent / prompts / scripts / android` 等目录排除出 standalone tracing服务器端构建不会再把非运行时资产卷进 `.next/standalone`
- `data/boss-state.json` 的写入已经改成串行事务队列、原子替换和 `.bak` 备份恢复,`heartbeat` 与 APP 日志并发写入已复核通过 - `data/boss-state.json` 的写入已经改成串行事务队列、原子替换和 `.bak` 备份恢复,`heartbeat` 与 APP 日志并发写入已复核通过
- `data/boss-state.json` 当前额外具备自动历史快照:每次写入后按 `BOSS_STATE_AUTO_BACKUP_INTERVAL_MS` 节流写入 `data/backups/state-snapshot-*.json`,元数据标记 `actorAccount=system / reason=auto:writeState`,管理后台可直接作为回退点查看和恢复
- `BossState` 当前新增 `schemaVersion / migratedAt` 元数据和 `migrateBossState` 迁移入口;读取旧的无版本状态时会补齐当前 schema并规范化 `accountDeviceGrants / accountProjectGrants / accountSkillGrants / skillLifecycleRequests / permissionAuditLogs`
- 这只是正式数据库迁移前置层,当前生产读写仍然是 `data/boss-state.json`,尚未完成 PostgreSQL / Redis / 其他 DB 落地
- 当前登录成功后会写入 `boss_session` Cookie`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 路由都要求有效会话 - 当前登录成功后会写入 `boss_session` Cookie`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 路由都要求有效会话
- 当前 `boss_session` 默认保持 30 天,`Set-Cookie` 已验证为 `Max-Age=2592000` - 当前 `boss_session` 默认保持 30 天,`Set-Cookie` 已验证为 `Max-Age=2592000`
- 原生 Android 客户端当前会把登录返回的 `boss_session / restore token / account` 落到 `SharedPreferences`,并在 APP 启动时通过 `/api/auth/restore` 自动补回会话;已本地验证“登录 -> 取 restore token -> restore 接口恢复”链路 - 原生 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 权限、套用权限模板、撤销授权;停用公司会禁用该租户普通子账号并撤销会话,停用 / 回收 / 重置账号也会撤销该账号当前活跃会话,吊销设备会清空设备 token、置离线并阻断 heartbeat / 任务认领 / Skill 同步 / 日志上报 / boss-agent OTA普通账号访问返回 `403`
- 当前旧 Web `/admin` 管理 UI 已下线:`src/components/admin/boss-admin-app.tsx` 和旧 data provider 已移除,`/admin` 现在只做兼容跳转到根路径 `/`
- 当前企业级后台独立化第一批已部署到云:`apps/boss-admin-web` 作为 Vue + Vite + Ant Design Vue 独立 PC 后台,静态产物位于 `/admin-web/index.html``admin.boss.hyzq.net` 根路径由 Caddy 内部 rewrite 到该静态入口,不再跳转到 `/enterprise-admin`
- 当前后台风险处理接口已落地:`POST /api/v1/admin/risks/actions``highest_admin` 可用,支持对 `ops_fault` 指派负责人、设置 SLA、确认、关闭、创建或复用修复工单`thread_context_alert` 指派负责人、设置 SLA、确认和关闭`POST /api/v1/admin/risks/scan` 会扫描超时 SLA 并幂等写入 `adminNotifications`,会把 Computer Use 不可用、boss-agent OTA 失败等运行态异常补成可治理 `opsFaults`,也会扫描 MasterAgentTask SLA 并对安全阶段可恢复失败自动重排队;管理后台总览会展示开放风险通知和任务 SLA 面板;不支持的风险类型会明确返回 `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 偶发卡在登录提示页 - 登录成功后的客户端跳转当前已做稳态兜底:会先确认 `/api/auth/session` 已可读,再 `replace``/conversations`,并补一次 `window.location.replace` 防止真机 WebView 偶发卡在登录提示页
- `POST /api/auth/send-code` 当前已增加 60 秒冷却和 15 分钟窗口限流 - `POST /api/auth/send-code` 当前已增加 60 秒冷却和 15 分钟窗口限流
- `POST /api/auth/send-code` 当前还会先按用途校验账号状态:登录 / 忘记密码必须是已存在账号,注册必须是未注册账号 - `POST /api/auth/send-code` 当前还会先按用途校验账号状态:登录 / 忘记密码必须是已存在账号,注册必须是未注册账号
- 当前账号连续登录失败 5 次后会锁定 10 分钟 - 当前账号连续登录失败 5 次后会锁定 10 分钟
- 当前登录页已临时切到免验证模式;点击“登录”会直接创建最高管理员会话,不再校验账号密码或验证码 - 当前登录页默认要求账号密码或验证码校验;临时开发兜底只有显式设置 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 时才会开启
- 新注册和重置密码当前已切到 `scrypt` 哈希;历史 `sha256` 密码会在下一次密码登录时自动迁移 - 新注册和重置密码当前已切到 `scrypt` 哈希;历史 `sha256` 密码会在下一次密码登录时自动迁移
- `launchd` 会保持 `com.hyzq.boss.local-agent` 常驻,所以本地 agent 被手动结束后会自动重启 - `launchd` 会保持 `com.hyzq.boss.local-agent` 常驻,所以本地 agent 被手动结束后会自动重启
- `launchd` 默认加载 `local-agent/config.cloud.json`,控制面指向 `https://boss.hyzq.net` - `launchd` 默认加载 `local-agent/config.cloud.json`,控制面指向 `https://boss.hyzq.net`
- `local-agent/config.example.json` 仍保留给本地 `127.0.0.1:3000` 回环开发 - `local-agent/config.example.json` 仍保留给本地 `127.0.0.1:3000` 回环开发
- 本地 `launchd` 当前已把 `mac-studio` 作为 `17600003315` 的绑定 Codex 节点上报 - 本地 `launchd` 当前已把 `mac-studio` 作为 `krisolo` 的绑定 Codex 节点上报
- 本地 agent 当前会递归扫描 `~/.codex/skills`,并把本机 Skill 同步到云端设备维度 - 本地 agent 当前会递归扫描 `~/.codex/skills`,并把本机 Skill 同步到云端设备维度
- 根布局当前会挂载 APP 日志桥,路由切换、运行时错误、消息发送和 OTA 操作会通过 `/api/v1/app-logs` 实时同步到服务器;日志绑定已改成按当前登录会话解析设备 - 根布局当前会挂载 APP 日志桥,路由切换、运行时错误、消息发送和 OTA 操作会通过 `/api/v1/app-logs` 实时同步到服务器;日志绑定已改成按当前登录会话解析设备
- 根布局当前还会挂载原生运行时桥:维护 APP 内导航历史、拦截 Android 返回键、防止根页直接退回桌面,并在 OTA / 同签名覆盖安装后自动尝试恢复登录态 - 根布局当前还会挂载原生运行时桥:维护 APP 内导航历史、拦截 Android 返回键、防止根页直接退回桌面,并在 OTA / 同签名覆盖安装后自动尝试恢复登录态
@@ -123,6 +177,9 @@ cd /Users/kris/code/boss
- 当前原生聊天页已把待审批推荐前移到主消息流:`ProjectDetailActivity` 会直接显示 `确认下发 / 拒绝` 操作,且刷新后仍能恢复最近一条待确认推荐 - 当前原生聊天页已把待审批推荐前移到主消息流:`ProjectDetailActivity` 会直接显示 `确认下发 / 拒绝` 操作,且刷新后仍能恢复最近一条待确认推荐
- 当前 `approval_required` 群聊在已经存在一条 `pending_user_confirmation` 推荐时,会拒绝继续创建新的推荐并返回 `409`,前台会提示用户先确认或拒绝当前推荐 - 当前 `approval_required` 群聊在已经存在一条 `pending_user_confirmation` 推荐时,会拒绝继续创建新的推荐并返回 `409`,前台会提示用户先确认或拒绝当前推荐
- 当前普通单线程聊天也已补上真实执行链:`POST /api/v1/projects/[projectId]/messages` 不再只写用户消息,而是会追加 `conversation_reply` 任务;绑定设备上的 `local-agent` 认领后会继续恢复到真实 Codex 线程,再把线程原始回复回写到该聊天窗口 - 当前普通单线程聊天也已补上真实执行链:`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 推荐”,不再因刷新丢失确认入口 - 当前 Web 群聊详情页也已补上待确认推荐的刷新恢复:服务端会在页面渲染时读取最近一条 `pending_user_confirmation` 的 dispatch plan聊天输入区会继续显示“等待你确认主 Agent 推荐”,不再因刷新丢失确认入口
- 当前 `AI 账号` 页面已分成三条显式接入链:`登录 OpenAI 平台账号API Key``接入阿里百炼备用账号``绑定 Master Codex Node`OpenAI API 登录成功后会立即切成当前主控,阿里百炼账号会作为备用链路保存 - 当前 `AI 账号` 页面已分成三条显式接入链:`登录 OpenAI 平台账号API Key``接入阿里百炼备用账号``绑定 Master Codex Node`OpenAI API 登录成功后会立即切成当前主控,阿里百炼账号会作为备用链路保存
- 当前 `登录 OpenAI 平台账号` 已升级成浏览器辅助登录流:原生 Android 会先进入 `OpenAiOnboardingActivity`,自动打开 `OpenAI Platform` 登录页;用户登录后可直接跳到 `API Keys` 页面,回 APP 粘贴 key 完成接入 - 当前 `登录 OpenAI 平台账号` 已升级成浏览器辅助登录流:原生 Android 会先进入 `OpenAiOnboardingActivity`,自动打开 `OpenAI Platform` 登录页;用户登录后可直接跳到 `API Keys` 页面,回 APP 粘贴 key 完成接入
@@ -133,13 +190,13 @@ cd /Users/kris/code/boss
- 当前 `我的 > 主 Agent 提示词 / 记忆` 页面已接通:管理员全局主提示词只读展示、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 跨项目项目记忆都可以在 Web 端查看和编辑;当前对话设置按登录账号隔离,管理员全局主提示词不可覆盖 - 当前 `我的 > 主 Agent 提示词 / 记忆` 页面已接通:管理员全局主提示词只读展示、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 跨项目项目记忆都可以在 Web 端查看和编辑;当前对话设置按登录账号隔离,管理员全局主提示词不可覆盖
- 当前 Web 端 `master-agent` 会话页右上角也已补齐微信式三点菜单,支持直接进入 `提示词 / 模型 / 推理强度 / 记忆 / 刷新` - 当前 Web 端 `master-agent` 会话页右上角也已补齐微信式三点菜单,支持直接进入 `提示词 / 模型 / 推理强度 / 记忆 / 刷新`
- 当前 `approval_required` 群聊在 Web 端已统一用单一状态快照驱动:如果存在新的待确认推荐,会自动折叠旧的拒绝态;如果上次推荐已拒绝,会明确展示“重新生成新的推荐”的恢复入口 - 当前 `approval_required` 群聊在 Web 端已统一用单一状态快照驱动:如果存在新的待确认推荐,会自动折叠旧的拒绝态;如果上次推荐已拒绝,会明确展示“重新生成新的推荐”的恢复入口
- 当前如果主控身份`Master Codex Node`但该节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API / 阿里百炼 Qwen` 备用账号,不再把失败日志直接原样回给用户 - 当前如果主控身份是 `Master Codex Node`主 Agent 会先按授权范围构建 Codex 设备池:设备必须在线,且 `Codex App Server / CLI / GUI` 至少一条模型通道在线,`codexAppServer.metadata.accountSummary.signedIn=false` 会被视为不可用;首选设备不可用时会自动切到下一台可用 Codex 设备,执行中失败也会先重排到下一台设备,全部 Codex 设备不可用时才尝试已配置的 API 备用链;如果 Codex 设备池和 API Key 都不可用APP 会提示“当前没有可用的模型渠道”
- 当前原生 Android 的聊天发送已收短客户端等待窗口;`master-agent` 单聊依赖服务端快速入队和消息流里的“主 Agent 思考中 / 回复超时 / 重试等待”状态,不再要求客户端长时间同步阻塞 - 当前原生 Android 的聊天发送已收短客户端等待窗口;`master-agent` 单聊依赖服务端快速入队和消息流里的“主 Agent 思考中 / 回复超时 / 重试等待”状态,不再要求客户端长时间同步阻塞
- 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口 - 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口
- Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链 - Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链
- 当前设备导入前台的状态表达已经统一为:`等待候选线程 / 等待勾选 / 建议生成中 / 建议已生成 / 已导入`,并会回显最终导入的线程名 - 当前设备导入前台的状态表达已经统一为:`等待候选线程 / 等待勾选 / 建议生成中 / 建议已生成 / 已导入`,并会回显最终导入的线程名
- 当前已导入设备也支持自动同步项目理解:绑定设备 heartbeat 发现活跃线程有新活动、或线程回写了新的执行结果时,系统会直接为这台设备上已导入的线程排隐藏的 `conversation_reply` 主 Agent 任务,回写最新的项目目标、当前进度、技术架构和下一步建议 - 当前已导入设备的项目理解同步已经收窄到“显式接管 / 用户主动要求同步”边界:绑定设备 heartbeat 或线程回写默认只追加轻量 `threadProgressEvent`,不会在未接管状态下主动向 Codex 线程发起隐藏对话
- 当前自动同步链路已经拆成两层heartbeat / thread reply 默认只追加轻量 `threadProgressEvent`;只有在线程首次理解、文档信息过薄、距离上次全量刷新太久或主 Agent 真的要接手时,才补排隐藏的全量理解任务并更新 `ThreadStatusDocument` - 当前全量理解链路只在单线程接管有效、全局接管有效,或用户明确要求“同步/核对项目目标和版本记录”时排 `conversation_reply` 任务;关闭接管会同步清理仍在 queued/running 的项目理解同步任务,避免取消接管后继续主动打扰线程
- 当前群资料页已补上“修复群成员”入口:当群里存在失效线程引用、`master-agent` 这类不可下发成员,或真实线程成员少于 2 个时,前台会明确提示并允许重新选择真实线程成员 - 当前群资料页已补上“修复群成员”入口:当群里存在失效线程引用、`master-agent` 这类不可下发成员,或真实线程成员少于 2 个时,前台会明确提示并允许重新选择真实线程成员
- 当前原生聊天页也已前移“修复群成员”入口:脏群会在消息流上方直接显示 `去修复` 按钮,并跳转到群资料页完成成员替换 - 当前原生聊天页也已前移“修复群成员”入口:脏群会在消息流上方直接显示 `去修复` 按钮,并跳转到群资料页完成成员替换
- 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `deviceImportDraft`,不再绕过勾选/审核阶段直接自动导入聊天窗口 - 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `deviceImportDraft`,不再绕过勾选/审核阶段直接自动导入聊天窗口
@@ -148,7 +205,8 @@ cd /Users/kris/code/boss
- 当前设备导入 `review` 已补 owner/admin 鉴权,并已切成真实异步审核:`review` 会先排队 `device_import_resolution` master task前台进入“主 Agent 审核中”并自动刷新;导入草稿在 `apply` 后再次 heartbeat 也不会从 `applied` 回退成 `resolved` - 当前设备导入 `review` 已补 owner/admin 鉴权,并已切成真实异步审核:`review` 会先排队 `device_import_resolution` master task前台进入“主 Agent 审核中”并自动刷新;导入草稿在 `apply` 后再次 heartbeat 也不会从 `applied` 回退成 `resolved`
- 原生会话页当前的刷新失败策略已改成按当前 tab 独立判错:`会话` 不会再因为 `设备 / OTA / 设置` 的旁路请求失败而整体提示“刷新失败” - 原生会话页当前的刷新失败策略已改成按当前 tab 独立判错:`会话` 不会再因为 `设备 / OTA / 设置` 的旁路请求失败而整体提示“刷新失败”
- 会话页、设备页、技能页和项目详情页当前都通过 `/api/v1/events` 的 SSE 自动刷新 - 会话页、设备页、技能页和项目详情页当前都通过 `/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 账号` 页当前已补上显式 `登录指引`:手机端不会直接弹出 ChatGPT OAuth`主 GPT` 需要先在绑定电脑上的 Codex / ChatGPT Plus 会话里登录,再回手机端点“测试连接 / 校验连接”
- `AI 账号` 页当前已升级成双入口:首页会显式展示 `登录 OpenAI 平台账号``绑定电脑上的 Codex 节点` - `AI 账号` 页当前已升级成双入口:首页会显式展示 `登录 OpenAI 平台账号``绑定电脑上的 Codex 节点`
- `登录 OpenAI 平台账号` 当前通过填写 `OpenAI API Key` 完成;校验成功后会立即设为当前主控 - `登录 OpenAI 平台账号` 当前通过填写 `OpenAI API Key` 完成;校验成功后会立即设为当前主控
@@ -157,6 +215,8 @@ cd /Users/kris/code/boss
- 因此 `POST /api/v1/accounts/onboard/openai-api` 在公网环境下已经能返回明确中文网络错误,但在服务器出网恢复前,还不能完成真实 OpenAI 平台账号探针与调用 - 因此 `POST /api/v1/accounts/onboard/openai-api` 在公网环境下已经能返回明确中文网络错误,但在服务器出网恢复前,还不能完成真实 OpenAI 平台账号探针与调用
- `POST /api/v1/accounts/[accountId]/validate` 当前对 `master_codex_node` 不再只看 `nodeId`,还会同时校验绑定设备是否在线;设备离线时返回 `degraded` 和清晰的人类可读提示 - `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 -> 项目消息账本` - 主 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`,真实回复随后再回写消息账本 - 主 Agent 单聊当前已改成“快速入队 + 异步回流”:`POST /api/v1/projects/master-agent/messages` 会先返回 `masterReplyState + task`,真实回复随后再回写消息账本
- 当前对话级 `agentControls` 已经生效:`master-agent` 会话支持 `modelOverride / reasoningEffortOverride`,并会优先作用到实际 OpenAI 回复和 Master Codex Node 执行 prompt - 当前对话级 `agentControls` 已经生效:`master-agent` 会话支持 `modelOverride / reasoningEffortOverride`,并会优先作用到实际 OpenAI 回复和 Master Codex Node 执行 prompt
- 当前对话级 `agentControls` 也已支持 `backendOverride``master-agent` 会话可在 `Claw Runtime` 可用时显式选择 `claw-runtime`,由 `ExecutionBackendSelector` 在当前对话里优先尝试对应后端;不可用时保存接口会直接拒绝,并返回人类可读原因 - 当前对话级 `agentControls` 也已支持 `backendOverride``master-agent` 会话可在 `Claw Runtime` 可用时显式选择 `claw-runtime`,由 `ExecutionBackendSelector` 在当前对话里优先尝试对应后端;不可用时保存接口会直接拒绝,并返回人类可读原因
@@ -170,18 +230,19 @@ cd /Users/kris/code/boss
- `npm run aab:release` 当前会先准备本机 release keystore再构建 signed release AAB 并发布到 `public/downloads/boss-android-latest.aab` - `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-v{versionName}-{flavor}.aab`
- AAB 归档元数据会写入 `public/downloads/boss-android-latest-aab.json` - AAB 归档元数据会写入 `public/downloads/boss-android-latest-aab.json`
- 当前默认管理员账号:`17600003315` - 当前默认管理员账号:`krisolo`
- 当前默认测试密码`boss123456` - 当前默认测试密码由线上初始化配置管理,文档不再明文记录
- 登录页当前是临时免验证入口;Web 登录页和原生 Android 登录页都会直接创建会话 - Web 登录页和原生 Android 登录页默认都必须通过账号密码或验证码校验后才会创建会话
- 当前已生成 Android debug APK`android/app/build/outputs/apk/debug/app-debug.apk` - 当前已生成 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` - 当前已生成 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 构建还会额外生成带版本号的 APK`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
- 当前最新 release 构建版本:`2.5.11``versionCode=24` - 当前最新 release 构建版本:`2.5.11``versionCode=24`
- 当前 release keystore 位于本机 `android/keystores/boss-release.keystore`,签名参数位于 `android/signing/release-signing.properties` - 当前 release keystore 位于本机 `android/keystores/boss-release.keystore`,签名参数位于 `android/signing/release-signing.properties`
- 真机开发约束:用户已明确切换到当前连接的 OPPO `PHZ110`ADB serial `U84XJRIB7D65ZH45`);除非用户再次要求切换设备,后续 Android 开发、ADB 安装、交互回归与问题复现统一使用这台 OPPO不再回退到原 `PLB110`
- Android 真机无线调试当前可恢复使用但系统层面没有“永久保持无线调试开启”的官方稳定开关重启、切网、ADB server 重启或重新切换 USB 调试后,都可能自动失效 - Android 真机无线调试当前可恢复使用但系统层面没有“永久保持无线调试开启”的官方稳定开关重启、切网、ADB server 重启或重新切换 USB 调试后,都可能自动失效
- 如果要尽量稳定,当前推荐做法是:同一局域网下先走 USB 启用,再执行 `adb tcpip 5555``adb connect <phone-ip>:5555`;同时固定同一 SSID、避免切热点/VPN、开启“保持唤醒”并保留 USB 作为长时间调试兜底 - 如果要尽量稳定,当前推荐做法是:同一局域网下先走 USB 启用,再执行 `adb tcpip 5555``adb connect <phone-ip>:5555`;同时固定同一 SSID、避免切热点/VPN、开启“保持唤醒”并保留 USB 作为长时间调试兜底
- `2.0.1` 已在本机连接的华为真机上复核通过,修复了 `Theme.SplashScreen` 导致的 `AppCompatActivity` 启动闪退 - `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.0` 已完成签名包覆盖安装到本机连接的华为真机,并确认 `com.hyzq.boss` 可以成功拉起进程
- `2.1.1` 已补上原生 OTA 下载安装引导、`REQUEST_INSTALL_PACKAGES` 权限声明,以及根页默认入口/返回逻辑收口 - `2.1.1` 已补上原生 OTA 下载安装引导、`REQUEST_INSTALL_PACKAGES` 权限声明,以及根页默认入口/返回逻辑收口
- `2.2.0` 已把原生 UI 回退到微信式交互:会话首页改为简单聊天列表,项目详情页改为聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口,设备页和我的页根面改为简单列表 - `2.2.0` 已把原生 UI 回退到微信式交互:会话首页改为简单聊天列表,项目详情页改为聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口,设备页和我的页根面改为简单列表
@@ -200,16 +261,38 @@ cd /Users/kris/code/boss
- `2.5.5` 已给 `approval_required` 群聊补齐“确认 / 拒绝”两条审批动作;拒绝后会把群审批状态写成 `rejected`,并追加系统提示,不再继续下发到线程 - `2.5.5` 已给 `approval_required` 群聊补齐“确认 / 拒绝”两条审批动作;拒绝后会把群审批状态写成 `rejected`,并追加系统提示,不再继续下发到线程
- `2.5.11` 对应这一轮的主链收口Android 会话首页改为直接读取 `/api/v1/conversations`,会把这台 Mac 上已导入的 Codex 线程对话直接平铺出来;`master-agent` 对“操作真实线程”的请求会先生成推荐下发方案,确认后再把任务派到真实线程执行;线程无绑定或设备离线时,确认接口会给清晰失败原因,避免假成功状态 - `2.5.11` 对应这一轮的主链收口Android 会话首页改为直接读取 `/api/v1/conversations`,会把这台 Mac 上已导入的 Codex 线程对话直接平铺出来;`master-agent` 对“操作真实线程”的请求会先生成推荐下发方案,确认后再把任务派到真实线程执行;线程无绑定或设备离线时,确认接口会给清晰失败原因,避免假成功状态
- 当前附件分析任务已带受控 `task token` 下载链接和文本摘录:本地开发环境会跟随请求 origin 生成链接,生产环境默认走 `https://boss.hyzq.net` - 当前附件分析任务已带受控 `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 页面统一纳入状态栏安全区处理 - `2.5.x` 当前已补上会话首页独立建群入口:可以不从单线程聊天内部出发,直接在会话首页右上角 `+` 建立新群聊;同时已把多个原生自定义 top bar 页面统一纳入状态栏安全区处理
- 当前 `local-agent` 已能回写带 `dispatchExecutionId / targetProjectId / targetThreadId / rawThreadReply` 的任务完成载荷,群聊分发执行结果不再只停留在主 Agent 队列 - 当前 `local-agent` 已能回写带 `dispatchExecutionId / targetProjectId / targetThreadId / rawThreadReply` 的任务完成载荷,群聊分发执行结果不再只停留在主 Agent 队列
- 当前 `local-agent``conversation_reply` 任务会优先使用 `codex exec resume <targetCodexThreadRef>`,只有缺失真实线程引用时才退回 `--ephemeral` - 当前 `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` 已新增 `Codex App Server` providerboss-agent 默认配置 `codexAppServerEnabled=true``conversation_reply / dispatch_execution` 会先通过 `codex app-server` 的 stdio JSON-RPC 恢复或创建线程,也可配置 `codexAppServerTransport=ws + codexAppServerUrl=ws://127.0.0.1:<port>``codexAppServerTransport=unix + codexAppServerUrl=unix:///absolute/path.sock` 连接同机长驻 App Server长驻连接可通过 `codexAppServerAuthTokenFile``BOSS_CODEX_APP_SERVER_AUTH_TOKEN_FILE` 提供 bearer token。随后 runner 下发 `turn/start` 并收集流式 agent 回复;如果单个 JSON-RPC 请求返回 `-32001 / retry later`runner 会先做指数退避重试;如果任务携带 `targetCodexTurnId`,会改用 `turn/steer` 干预活跃 turn如果 App Server 在 turn 启动前失败,默认允许回退到 `codex exec resume`,如果 turn 已经启动则不再回退,避免同一轮用户消息被重复执行。桌面控制另有 `codexComputerUseEnabled=true`,默认先走 Codex Computer Use再回退 CUA Driver。
- 当前已新增 Boss 自有 Inter-Thread Broker 第一版:服务端入口 `POST /api/v1/projects/[projectId]/thread-collaboration` 会创建带源/目标 Codex 线程引用的协作任务App Server runner 执行 `thread/read(source) -> thread/inject_items(target) -> turn/start(target)`,用于让一个线程的结论受控进入另一个线程,不依赖官方任意线程 P2P 互聊能力
- 当前已新增 App Server 版 Boss 用户消息镜像:普通 `conversation_reply` 任务携带 `mirrorBossUserMessageToCodexDesktop=true`App Server runner 会在 `thread/resume` 后、`turn/start` 前调用 `thread/inject_items`,把 Boss APP 用户原文写成目标 Codex 线程的 `role=user` Responses item任务结果只回写 `threadHistorySync.threadId / injectedItemCount / source`,不回写消息 ID、用户原文、内部调度 prompt 或系统约束。CLI rollout 写入仍作为 App Server 不可用前的兼容兜底。
- 当前已新增 Codex App Server 受控线程回滚:服务端入口 `POST /api/v1/projects/[projectId]/thread-rollback` 会创建 `intentCategory=thread_rollback` 任务App Server runner 执行 `thread/rollback(target, numTurns)`,只回写“已回滚最近 N 轮”的用户可见摘要,不启动新 turn不保存 App Server 返回的 thread/turn/items。该能力只回滚 Codex 线程历史,不自动还原本地文件变更。
- 当前已新增 Codex App Server 受控线程压缩:服务端入口 `POST /api/v1/projects/[projectId]/thread-compact` 会创建 `intentCategory=thread_compact` 任务App Server runner 执行 `thread/compact/start(target)`,只回写“已发起上下文压缩”的用户可见摘要,不启动普通 turn不保存 contextCompaction item 原始字段。该能力只压缩 Codex 线程上下文,不代表代码修改、文件恢复或版本发布完成。
- 当前已新增 Codex App Server 受控线程归档 / 恢复:服务端入口 `POST /api/v1/projects/[projectId]/thread-archive` 会创建 `intentCategory=thread_archive|thread_unarchive` 任务App Server runner 直接执行 `thread/archive(target)``thread/unarchive(target)`,不先 resume 已归档线程,不启动普通 turn不保存 App Server 返回的 thread 原始字段。该能力只改变 Codex 线程生命周期状态,不代表代码修改、文件恢复或版本发布完成。
- 当前已新增 Codex App Server 受控线程改名:服务端入口复用 `POST /api/v1/projects/[projectId]/rename``mode=thread` 分支;本地 Boss 线程标题更新后会创建 `intentCategory=thread_rename` 任务App Server runner 直接执行 `thread/name/set(target, name)`,不先 resume 线程,不启动普通 turn不保存 App Server 线程原始字段。设备离线或冲突时,本地改名仍成功,响应只返回非致命同步错误。
- 当前已新增 Codex App Server 受控线程目标同步:服务端入口复用 `POST /api/v1/projects/[projectId]/goals`;本地 Boss 项目目标更新后,如果该项目是已绑定 `codexThreadRef` 的单线程,会创建 `intentCategory=thread_goal_sync` 任务App Server runner 直接执行 `thread/goal/set(target, objective, status, tokenBudget?)`,不启动普通 turn不保存 App Server 原始 goal payload。设备离线或冲突时本地项目目标仍成功响应只返回非致命同步错误。
- 当前已新增 Codex App Server 受控线程 Git 元数据同步:服务端入口 `POST /api/v1/projects/[projectId]/thread-metadata` 会创建 `intentCategory=thread_metadata_sync` 任务App Server runner 直接执行 `thread/metadata/update(target, gitInfo)`,不启动普通 turn不保存 App Server 原始 thread payload。当前只允许同步 `gitInfo.sha / branch / originUrl`,用于让 Boss 线程治理和 Codex 线程的分支/提交信息保持一致。
- 当前已新增 Codex App Server 受控线程分叉:服务端入口 `POST /api/v1/projects/[projectId]/thread-fork` 会创建 `intentCategory=thread_fork` 任务App Server runner 直接执行 `thread/fork(target)`,不启动普通 turn不保存 App Server 返回的 path、cwd、turns 或 instructionSources。当前不允许远程覆盖 model、sandbox、instructions 或 config新 Codex 线程进入 Boss 会话列表仍通过现有 thread discovery / 导入链路完成。
- 当前 `local-agent``dispatch_execution` 任务会按 `orchestrationBackendId` 分流:默认走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行并回写 `rawThreadReply / replyBody` - 当前 `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 原生聊天页会显示“进度 / 线程状态 / 实时状态 / 线程配置 / 线程协作 / 工具活动 / 思考摘要 / 账号状态 / 运行状态 / 安全提醒 / 审批状态 / 文件变更 / 分支详情 / 生成结果 / 后台智能体”。2026-05-31 起Codex App Server 的 `turn/plan/updated``turn/diff/updated``item/started|completed``thread/started` 会直接映射为进度步骤、变更统计、生成产物和后台智能体;第二批已把 `item/*/requestApproval``item/autoApprovalReview/*``guardianWarning``serverRequest/resolved``item/fileChange/patchUpdated` 映射为审批、安全提醒和文件变更摘要;第三批已把 `thread/status/changed``thread/realtime/*` 安全映射为线程状态和实时状态摘要;第四批已把 `model/rerouted``thread/tokenUsage/updated``mcpServer/startupStatus/updated``remoteControl/status/changed` 安全映射为运行状态摘要;第五批已把 `thread/goal/*``thread/settings/updated``thread/compacted` 映射为线程配置摘要;第六批已把 `account/updated``account/rateLimits/updated``model/verification``warning``configWarning``deprecationNotice` 映射为账号状态、模型校验和安全提醒摘要;第七批已把 `ThreadItem.collabToolCall``ThreadItem.contextCompaction` 映射为线程协作和上下文压缩摘要;第八批已把 `mcpToolCall``dynamicToolCall``webSearch``imageView``enteredReviewMode``exitedReviewMode``commandExecution` 映射为工具活动摘要;第九批已把 `ThreadItem.plan``ThreadItem.reasoning.summary` 映射为计划步骤与思考摘要;第十批已把 `ThreadItem.imageGeneration` 映射为图像生成工具活动和图片产物;第十一批已把 `hook/started|completed` 映射为钩子生命周期工具活动;第十二批已把 `windowsSandbox/setupCompleted` 映射为 Windows 沙箱准备状态摘要;第十七批已把新版 `ThreadItem.collabToolCall.receiverThreadIds / agentsStates` 安全映射为线程协作目标数量和 agent 状态集合。所有进度均通过 `POST /api/v1/master-agent/tasks/[taskId]/progress` 实时刷新;字段白名单会剥离 cwd、turnId、配置文件路径、内部 prompt、collab 源/目标线程 ID、receiverThreadIds、agentsStates 私有消息、共享 Skill 根绝对路径、tool arguments/result、web URL token、命令正文/输出、raw reasoning content、reasoning item id、图像生成 revisedPrompt/result、hook sourcePath/statusMessage/entries、Windows sandbox sourcePath/samplePaths/本地绝对路径和未清洗密钥complete 回写仍会携带最终进度兜底
- 当前 `local-agent` heartbeat 已新增 Codex App Server capability discovery按 TTL 拉取模型、provider 能力、Skill、Hook、Plugin、App 摘要,并附加只读线程操作、插件治理、账号治理、配置治理、文件治理、命令会话、外部 Agent 迁移、Marketplace、实验特性、审查、Windows 沙箱、文件搜索事件、MCP、用户交互、Guardian、运行事件、扩展事件、线程生命周期和流式增量能力 catalog写入 `capabilities.codexAppServer.metadata`Web 设备详情会展示 App Server 连接状态、模型数量、默认/快速/深度模型、扩展数量、Hook 治理摘要、线程操作摘要、插件治理摘要、账号治理摘要、配置治理摘要、文件治理摘要、命令会话摘要、迁移治理摘要、市场治理摘要、实验特性治理摘要、审查治理摘要、Windows 沙箱摘要、文件搜索事件摘要、MCP 治理摘要、用户交互摘要、Guardian 治理摘要、运行事件摘要、扩展事件摘要、线程生命周期摘要和流式增量摘要;原生 Android 设备详情当前已展示 App Server 连接态、模型、扩展、治理、账号、线程、轮次、线程操作、线程协作和协议漂移这些核心摘要
- 当前 `MasterAgentTask` 已具备服务端租约和取消基础状态机claim 会写入 `attemptCount / maxAttempts / leaseExpiresAt`,运行中任务租约过期后可被重新认领,超过重试上限会转 `timed_out``POST /api/v1/master-agent/tasks/[taskId]/cancel` 会把任务转 `canceled`,迟到的成功 complete 不会覆盖终态
- 当前 App Server 执行中的任务取消已补真实中断链路:服务端新增 `GET /api/v1/master-agent/tasks/[taskId]/control-state` 给设备端按 token 轮询取消状态;`local-agent` 在 turn 启动后按 `masterAgentInterruptPollIntervalMs` 检查该接口,发现任务已取消会在同一个 App Server 连接上调用 `turn/interrupt`,并把 interrupted 作为干净取消结果处理,不再等长任务自然结束或把取消刷成失败日志
- 当前 `local-agent``browser_control / desktop_control` 已从占位骨架升级成外部 runtime 桥:当本机配置了 `browserControlEnabled + browserControlCommand``computerUseEnabled + computerUseCommand` 时,会把标准化 JSON 请求透传给外部进程,并解析单行 JSON 结果;未启用时会 fail closed返回明确的 runtime disabled 错误,不再假装执行成功
- 远程电脑控制链路当前已有可复用压测基线:`npm run stress:remote-control` 可按参数压测 `local-agent -> MasterAgentTask -> browser_control / desktop_control runtime -> complete 回写` 全链路;`npm run stress:remote-control:ci` 固定 120 条链路任务和 360 条 runtime 并发任务,并用 p95 延迟预算判断是否退化。压测报告可通过 `--report-json=PATH` 落盘,便于后续接入真实 macOS AX / Windows UIA helper 后复用同一套稳定性判断。
- 当前历史脏群如果不再包含真实线程成员,群聊消息不会再表现成“无响应”;服务端会在群内追加明确 `system_notice`,提示先重新添加线程成员 - 当前历史脏群如果不再包含真实线程成员,群聊消息不会再表现成“无响应”;服务端会在群内追加明确 `system_notice`,提示先重新添加线程成员
- 当前设备导入决议已经升级成真正通过 `local-agent -> codex exec -> /complete` 回写的主 Agent 决议链Web 和 Android 前台都会在 `pending_resolution` 阶段显示审核任务状态,并在任务完成后自动刷新出正式导入建议 - 当前设备导入决议已经升级成真正通过 `local-agent -> codex exec -> /complete` 回写的主 Agent 决议链Web 和 Android 前台都会在 `pending_resolution` 阶段显示审核任务状态,并在任务完成后自动刷新出正式导入建议
- 当前 `local-agent` 已改成先启动本地 `4317` 健康监听,再异步跑首次 heartbeat 和 task poll避免控制面短时阻塞时本地健康探针不可用 - 当前 `local-agent` 已改成先启动本地 `4317` 健康监听,再异步跑首次 heartbeat 和 task poll避免控制面短时阻塞时本地健康探针不可用
- 当前 heartbeat 上报 `browserAutomation / computerUse / codexAppServer` 能力时,不再只看静态 connected 布尔值browser/computer 会参考 runtime 配置状态Codex App Server 会参考 `codexAppServerEnabled`stdio 模式校验本机 app-server 命令可执行性ws/unix 模式校验 `codexAppServerUrl`
- Codex 项目/线程扫描当前已搬到 worker 线程执行,避免 `.codex/logs_1.sqlite``state_5.sqlite` 的同步扫描阻塞主线程健康接口 - Codex 项目/线程扫描当前已搬到 worker 线程执行,避免 `.codex/logs_1.sqlite``state_5.sqlite` 的同步扫描阻塞主线程健康接口
- 当前 `local-agent` 的任务完成回写已通过 `RemoteRuntimeAdapter` 标准化,`conversation_reply / dispatch_execution` 的完成载荷会先做统一归一化,再进入主 Agent 完成路由 - 当前 `local-agent` 的任务完成回写已通过 `RemoteRuntimeAdapter` 标准化,`conversation_reply / dispatch_execution` 的完成载荷会先做统一归一化,再进入主 Agent 完成路由
- 原生 Android 当前对 `master-agent` 聊天不再依赖长时间同步等待;发送后会先显示“主 Agent 思考中”,右上角改成微信式 `...` 菜单,菜单项包含 `模型 / 推理强度 / 会话信息 / 刷新` - 原生 Android 当前对 `master-agent` 聊天不再依赖长时间同步等待;发送后会先显示“主 Agent 思考中”,右上角改成微信式 `...` 菜单,菜单项包含 `模型 / 推理强度 / 会话信息 / 刷新`
- 原生 Android 当前已新增 `TelegramIntegrationActivity`:可从 `我的 > Telegram 接入` 查看当前 Bot 状态、配置 Bot Token / Webhook Secret / Webhook URL、私聊白名单、群聊白名单、群聊触发策略和群 / Topic 到 Boss 项目的路由;群聊可配置为只接受 `@Bot` 或直接回复当前 Bot 的消息,并可直接测试连接或保存配置
## 2. 服务器状态 ## 2. 服务器状态
@@ -236,7 +319,8 @@ cd /Users/kris/code/boss
- `boss-web` 当前通过 `npm start` 启动 - `boss-web` 当前通过 `npm start` 启动
- 实际监听端口为 `3000` - 实际监听端口为 `3000`
- `boss-web.service` 显式设置了 `BOSS_STATE_FILE=/opt/boss/data/boss-state.json` - `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` 作为平台级 To B 独立后台入口并把根路径内部 rewrite 到 `/admin-web/index.html`
- 服务器上存在 `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` - `Postfix` 监听 `25 / 465 / 587`
- `Dovecot` 监听 `993` - `Dovecot` 监听 `993`
- 当前部署脚本在远端重启服务后会自动执行一遍本机 health check - 当前部署脚本在远端重启服务后会自动执行一遍本机 health check
@@ -256,10 +340,12 @@ cd /Users/kris/code/boss
- 服务器本机 `dig +short boss.hyzq.net` 返回 `106.53.170.158` - 服务器本机 `dig +short boss.hyzq.net` 返回 `106.53.170.158`
- 服务器本机访问 `http://boss.hyzq.net` 会被 `308` 跳转到 `https://boss.hyzq.net` - 服务器本机访问 `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` - 服务器本机执行 `curl --resolve boss.hyzq.net:443:127.0.0.1 https://boss.hyzq.net -I` 返回 `307` 并跳转到 `/auth/login`
- 当前 `admin.boss.hyzq.net` 用于平台级 To B 独立后台入口,站点根路径直接承载新 PC 后台;`/admin` 不再渲染旧 UI只保留跳转到根路径的兼容入口
同时也确认了这些事实: 同时也确认了这些事实:
- 当前本机网络 `dig +short boss.hyzq.net` 仍返回 `198.18.1.188` - 当前本机网络 `dig +short boss.hyzq.net` 仍返回 `198.18.1.188`
- 当前本机网络 `dig +short admin.boss.hyzq.net` 已返回 `106.53.170.158`
- 当前本机网络 `curl -I http://boss.hyzq.net` 返回 `308` - 当前本机网络 `curl -I http://boss.hyzq.net` 返回 `308`
- 当前本机网络 `curl -I https://boss.hyzq.net` 返回 `HTTP/2 307`,并跳转到 `/auth/login` - 当前本机网络 `curl -I https://boss.hyzq.net` 返回 `HTTP/2 307`,并跳转到 `/auth/login`
- 当前本机网络 `curl https://boss.hyzq.net/api/health` 返回 `{"ok":true,"service":"boss-web",...}` - 当前本机网络 `curl https://boss.hyzq.net/api/health` 返回 `{"ok":true,"service":"boss-web",...}`
@@ -282,13 +368,13 @@ cd /Users/kris/code/boss
## 4. 当前未完成或仅为 MVP 的部分 ## 4. 当前未完成或仅为 MVP 的部分
- 当前服务器默认仍是 `fixed`,验证码`000000` - 当前服务器默认仍是 `fixed`验证码登录必须先通过 `send-code` 生成账本记录;不能只靠固定码直接登录
- 当前虽然已经补齐 OTA 版本中心、检查更新、执行升级和 APK 包下载链路,但仍是文件型状态驱动的 MVP不是原生增量更新基础设施 - 当前虽然已经补齐 OTA 版本中心、检查更新、执行升级和 APK 包下载链路,但仍是文件型状态驱动的 MVP不是原生增量更新基础设施
- 当前“OTA / 重装后不掉登录”覆盖原生 Android 客户端的 `SharedPreferences` 恢复与同签名覆盖安装;如果用户先卸载 APP 再全新安装,仍可能丢失本地原生存储 - 当前“OTA / 重装后不掉登录”覆盖原生 Android 客户端的 `SharedPreferences` 恢复与同签名覆盖安装;如果用户先卸载 APP 再全新安装,仍可能丢失本地原生存储
- 数据存储仍是文件型,而不是数据库 - 数据存储默认仍是文件型,但已经有 PostgreSQL store adapter、schema 和维护脚本生产切换前需先执行备份、dry-run 迁移和回滚演练
- 设备发现、项目扫描和额度采集仍是静态配置驱动的 MVP - 设备发现、项目扫描和额度采集仍是静态配置驱动的 MVP
- APP 实时日志当前已能同步到主 Agent 会话,但还没有单独的日志检索、分页和告警升级规则 - APP 实时日志当前已能同步到主 Agent 会话,但还没有单独的日志检索、分页和告警升级规则
- Skill 清单当前按设备同步和展示已经可用,但还没有“安装 / 卸载 Skill”这种远程管理能力 - Skill 清单当前按设备同步和展示已经可用;远程治理已贯通最高管理员创建 lifecycle 请求、设备端认领、local-agent 执行安装 / 更新 / 卸载 / 回滚 / 版本锁、执行后同步 Skill 清单和完成回写。当前仍属于文件型状态与 Git 来源驱动的 MVP生产使用前需要配置设备侧 source allowlist / trusted sources、校验和策略和失败告警。
- 服务器侧主 Agent 实时回复依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;如果设备离线,只能保留任务或走 API 容灾账号 - 服务器侧主 Agent 实时回复依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;如果设备离线,只能保留任务或走 API 容灾账号
- 设备导入主链的后端状态机已经跑通,并且已经分成两条: - 设备导入主链的后端状态机已经跑通,并且已经分成两条:
- 新接入设备继续走 `import draft -> 勾选 -> review -> apply` - 新接入设备继续走 `import draft -> 勾选 -> review -> apply`
@@ -296,6 +382,10 @@ cd /Users/kris/code/boss
- 本机 `mac-studio` 当前已经验证可通过 `local-agent` 直接从 `~/.codex/state_5.sqlite / logs_1.sqlite / session_index.jsonl / .codex-global-state.json` 扫描真实 Codex 线程,并通过 heartbeat 自动导入到会话列表 - 本机 `mac-studio` 当前已经验证可通过 `local-agent` 直接从 `~/.codex/state_5.sqlite / logs_1.sqlite / session_index.jsonl / .codex-global-state.json` 扫描真实 Codex 线程,并通过 heartbeat 自动导入到会话列表
- 线程发现当前会优先保留每个 Codex 文件夹下的主工作线程;如果同文件夹里存在 `worker / explorer` 子线程,会优先过滤这些子线程,避免把子代理线程误当成独立聊天窗口 - 线程发现当前会优先保留每个 Codex 文件夹下的主工作线程;如果同文件夹里存在 `worker / explorer` 子线程,会优先过滤这些子线程,避免把子代理线程误当成独立聊天窗口
- `local-agent` 当前还会在 `codex exec resume` 前再次校验目标线程绑定;如果目标线程在 Codex 本地状态库里不存在、已归档、cwd 不匹配或是 `read-only` 会话,会直接 fail closed不再把内部环境提示原文回写到聊天 - `local-agent` 当前还会在 `codex exec resume` 前再次校验目标线程绑定;如果目标线程在 Codex 本地状态库里不存在、已归档、cwd 不匹配或是 `read-only` 会话,会直接 fail closed不再把内部环境提示原文回写到聊天
- 2026-06-06 起Boss 已开始落地 B+ 可靠性外壳目标架构是“Boss Cloud 控制面 + 本地 Boss Edge 执行面 + 可靠性外壳”。第一阶段不新增独立服务器进程,先让当前 `local-agent` 具备 Edge 行为:`MasterAgentTask` 保留旧 `status`,新增 `phase` 表达 `queued / claimed / executor_starting / turn_started / awaiting_reply / completing / completed / recoverable_failed / terminal_failed / timed_out / canceled / needs_user_action`APP 进度卡优先按 phase 推导步骤状态,避免任务真实推进后仍显示卡在第一步。
- 同批已新增本地 durable outbox`local-agent``task.progress / task.complete / app.log` 先写本地 outbox再发送云端网络失败、云端 5xx/429 或进程重启后会在 heartbeat 前自动重放。outbox 写入在单进程内串行化,避免 progress、complete 和 heartbeat 重放并发覆盖。云端 complete 继续保持幂等,迟到 complete 不覆盖终态。
- 同批已收紧自动重试边界:租约过期但尚未进入 Codex turn 的任务会进入 `recoverable_failed` 并安全重排;已经进入 `turn_started / awaiting_reply / completing` 的任务不会自动重复下发,避免同一 Codex 线程重复执行。Codex App Server 能力从单一 `connected` 提升为 `available / degraded / unavailable` 健康分级,调度不再只看布尔 connected。
- 企业安全第一批已产品化:`GET/POST /api/v1/admin/backups` 在原有文件快照基础上新增业务投影、checksum 校验、恢复预览和 dry-run恢复前仍自动创建 pre-restore 快照创建、校验、预览、dry-run 和实际恢复动作都会写入审计日志。`GET /api/v1/admin/backoffice` 新增 `dataSafetySummary / taskRiskSummary`,平台后台和企业后台可展示备份健康、当前文件 MVP 阶段 RPO/RTO 说明、卡住任务和可恢复任务;`GET/POST /api/v1/master-agent/tasks/[taskId]/recovery` 支持查看任务恢复诊断并仅允许最高管理员重试 recoverable 的 turn 前任务Android 进度卡新增 `phase` 人话解释和最后更新时间。
- 如果历史上误把 `worker / explorer` 子线程当成开发线程继续复用,即使后来把数据库权限改回可写,这类线程也可能仍然带着“只读勘察 / 不改文件”的历史上下文;恢复开发时应优先切回主交接线程,或先对该子线程补发明确的解锁指令 - 如果历史上误把 `worker / explorer` 子线程当成开发线程继续复用,即使后来把数据库权限改回可写,这类线程也可能仍然带着“只读勘察 / 不改文件”的历史上下文;恢复开发时应优先切回主交接线程,或先对该子线程补发明确的解锁指令
- 会话首页当前已经不再简单平铺所有线程;如果某个设备导入了大量同文件夹线程,首页会优先显示项目归档项,降低会话页噪音 - 会话首页当前已经不再简单平铺所有线程;如果某个设备导入了大量同文件夹线程,首页会优先显示项目归档项,降低会话页噪音
- 已绑定生产设备的自动导入链现在还会在 heartbeat 时清理已经不再出现在最新 `projectCandidates[]` 里的旧线程会话,避免旧导入结果长期残留 - 已绑定生产设备的自动导入链现在还会在 heartbeat 时清理已经不再出现在最新 `projectCandidates[]` 里的旧线程会话,避免旧导入结果长期残留
@@ -303,7 +393,9 @@ cd /Users/kris/code/boss
- 原生 Android 的二级深层页虽然仍保留 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等能力,但它们已经退出主 UI 正面;后续如再加入口,需继续遵守“一级微信式,复杂能力下沉”的规则 - 原生 Android 的二级深层页虽然仍保留 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等能力,但它们已经退出主 UI 正面;后续如再加入口,需继续遵守“一级微信式,复杂能力下沉”的规则
- Android 本地 Gradle 验证当前必须串行执行;如果并发跑 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug`,会导致中间产物互踩并出现假失败 - Android 本地 Gradle 验证当前必须串行执行;如果并发跑 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug`,会导致中间产物互踩并出现假失败
- 聊天附件当前已经支持真实上传、消息落账本、受保护下载和原生打开;默认后端为服务器文件存储,可按用户切到阿里 OSS 私有桶 - 聊天附件当前已经支持真实上传、消息落账本、受保护下载和原生打开;默认后端为服务器文件存储,可按用户切到阿里 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` 才会进入 PostgreSQL 路径,真实连接 / 写入还必须同时配置 `BOSS_DATABASE_URL`。schema 见 `scripts/postgres-state-schema.sql`,生产切换前需先跑 `validate-schema`、文件备份、`migrate-file-to-postgres --dry-run`、PostgreSQL 备份导出和恢复演练。
- 认证已补 CSRF 基础防护、restore token 轮换、账号锁定和子账号 MFA 开关;后续仍可继续补更完整的企业 IdP / SSO
- 邮件对外正式投递仍缺少 DNS / 信誉相关的最终收口,例如 SPF、DKIM、DMARC、MX 与退信策略 - 邮件对外正式投递仍缺少 DNS / 信誉相关的最终收口,例如 SPF、DKIM、DMARC、MX 与退信策略
- 外部真实邮箱的 end-to-end 收件链路还没有在生产账号上完成最终验收 - 外部真实邮箱的 end-to-end 收件链路还没有在生产账号上完成最终验收
@@ -313,15 +405,22 @@ cd /Users/kris/code/boss
```bash ```bash
curl -sS http://127.0.0.1:3000/api/health 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/auth/session
curl -sS http://127.0.0.1:3000/api/v1/conversations 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/projects/master-agent
curl -sS http://127.0.0.1:3000/api/v1/devices/mac-studio/skills 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 validate-schema
BOSS_STATE_STORE=postgres BOSS_DATABASE_URL="$BOSS_DATABASE_URL" node scripts/boss-state-store-maintenance.mjs migrate-file-to-postgres --dry-run
BOSS_STATE_STORE=postgres BOSS_DATABASE_URL="$BOSS_DATABASE_URL" node scripts/boss-state-store-maintenance.mjs export-postgres-backup --output /tmp/boss-postgres-backup.json --dry-run
BOSS_STATE_STORE=postgres BOSS_DATABASE_URL="$BOSS_DATABASE_URL" node scripts/boss-state-store-maintenance.mjs restore-postgres-backup --input data/boss-state.json --dry-run
curl -I http://127.0.0.1:3000/api/v1/user/ota/package 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/health
curl -sS http://127.0.0.1:4317/api/v1/skills curl -sS http://127.0.0.1:4317/api/v1/skills
curl -sS -X POST http://127.0.0.1:4317/api/v1/heartbeat curl -sS -X POST http://127.0.0.1:4317/api/v1/heartbeat
npm run stress:remote-control:ci
npm run stress:remote-control -- --chain-tasks=120 --runtime-tasks=360 --runtime-concurrency=36 --timeout-ms=60000 --report-json=/tmp/boss-remote-control-stress.json
``` ```
服务器: 服务器:

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,463 @@
# Boss 企业 AI 运营中枢量产架构开发文档
更新时间:`2026-05-17`
## 1. 文档定位
这份文档把 `outputs/boss-product-intro-image2-full-raster.pptx` 里的产品架构、此前确认的量产 B+ 方案、以及 Codex App Server 最新开放协议思路统一成后续开发约束。
当前结论Boss 不能只做一个“手机控制 Codex”的工具而要升级成企业级 AI 运营中枢。Boss 负责组织、权限、任务、审计、数据安全、回退和跨设备协作Codex、Computer Use、Skill、业务系统和第三方 Agent 都只是可替换执行能力。
本文件描述的是量产目标架构,不代表当前所有能力都已经落地。当前运行真相仍以 `docs/architecture/current_runtime_and_deploy_status_cn.md` 为准。
## 2. 来源材料
- 产品 PPT`outputs/boss-product-intro-image2-full-raster.pptx`
- PPT 抽图校对目录:`outputs/pptx-architecture-read/slides`
- Codex App Server 官方文档:`https://developers.openai.com/codex/app-server`
- 当前 Boss 运行文档:`docs/architecture/current_runtime_and_deploy_status_cn.md`
- 当前 API 与服务清单:`docs/architecture/api_and_service_inventory_cn.md`
## 3. 产品总目标
Boss 的产品目标是把主 Agent、业务 Agent、组织角色、真实电脑、企业系统和 Skill 连接成可执行的企业管理系统。
PPT 中的核心判断需要进入产品开发主线:
- AI 已经能对话,但企业执行还没有被完整接管。
- 真正的成本不在模型本身,而在重复沟通、人工汇总、跨系统搬运和不可追踪的执行过程。
- Boss 的价值不是再做一个聊天机器人,而是让经营目标变成可拆解、可审批、可执行、可追踪、可复盘、可回退的闭环。
- 企业级 AI 必须先可控,再谈自动化。
## 4. 采用方案 BBoss 企业控制面 + 可插拔执行协议
量产版本默认采用方案 B。
方案 B 的定义:
- Boss 是企业级控制面和数据事实源。
- Codex App Server、Codex MCP、Codex CLI、Computer Use、CUA Driver、Browser Automation、业务系统 API、Skill Runtime 都作为执行 provider 接入。
- 所有 provider 的原始事件必须先归一化为 Boss 自有事件和消息模型,再进入 APP、Web 管理后台、审计日志和回退系统。
- UI、权限、审计、备份、任务 SLA、风险处置和企业账号体系不能直接依赖某一个 provider 的私有字段。
选择方案 B 的原因:
- 企业客户最关心的是权限、审批、审计、稳定性、数据边界和可回退,不是某个单一执行引擎的能力展示。
- Codex 协议未来会持续变化Boss 必须通过适配层快速跟进,而不是把协议字段写死到业务模型里。
- Boss 还需要支持我们自研的 Computer Use、未来的企业系统 API、Telegram/飞书/微信入口、Skill 分发和多租户后台,单独围绕 Codex 建模会限制长期扩展。
## 5. 方案 C 的优劣势
方案 C 指把 Codex App Server 作为更核心的数据和执行事实源,让 Boss 尽量贴近 Codex 原生 Thread、Turn、Item、Approval、Skill、Plugin 和 App Server event。
优势:
- 最接近 Codex 原生体验线程、实时事件、审批、Skill、命令执行和文件变更可以更快跟随官方能力。
- APP 与 Codex 桌面端的同线程实时同步理论上更顺,重复实现更少。
- Codex 新增功能时Boss 可以更快暴露给用户。
劣势:
- 业务核心会强绑定 Codex 协议,协议变更会直接冲击 Boss 的权限、审计、回退和消息账本。
- 企业级多租户、子账号授权、跨公司隔离、平台总后台、Skill 分配和数据留存不能完全交给 Codex 原生模型。
- 非 Codex 执行能力会变成二等能力,比如自研 Computer Use、业务系统 API、Telegram/飞书入口和后续企业 OA 集成。
- 数据自动备份和业务级回退会受限于 Codex 本地会话存储语义,不能满足 To B 生产级治理。
最终策略:
- 不采用方案 C 作为总架构。
- 在执行层内部吸收方案 C 的优点,优先新增 `CodexAppServerBackendAdapter`
- Boss 数据模型保持独立Codex App Server 只作为强执行 provider 和实时事件来源。
## 6. 多层级协作关系
PPT 第 3、4、5、8、9 页确定的协作链路必须成为产品模型的骨架。
### 6.1 组织层级
| 层级 | 角色 | 主要职责 | 可见范围 |
| --- | --- | --- | --- |
| 平台最高管理员 | Boss 平台运营方 | 创建企业、开通老板账号、查看全局风险、处理服务异常、管理套餐和授权 | 全平台治理数据,不默认看企业业务内容明细 |
| 企业老板端 | 企业超级管理员 | 看全局目标、成本、现金流、风险和最终结果;通过主 Agent 问询公司执行状态 | 本企业全局 |
| 经理端 | 部门或项目负责人 | 接收目标、拆解任务、审批异常、追踪团队进度、协调资源 | 授权团队、部门、项目 |
| 员工端 | 具体执行人员 | 处理具体任务、补充判断、上传材料、确认结果 | 自己任务和授权上下文 |
| 系统端 | Boss 控制面 | 维护账号、设备、权限、SOP、审批、审计、日志、备份和回退 | 按租户和权限隔离 |
### 6.2 Agent 层级
| 层级 | Agent | 职责 |
| --- | --- | --- |
| 调度层 | 主 Agent | 理解目标、判断权限、拆解任务、分配资源、汇总结果、协调多线程/多设备/多业务 Agent |
| 执行层 | 业务 Agent | 按 SOP 执行业务流程如销售、客服、财务、HR、项目、行政、运维 |
| 设备层 | 本地 Agent | 接入真实电脑、Codex、Computer Use、浏览器、文件、系统权限和本地 Skill |
| Provider 层 | 执行 provider | Codex App Server、Codex CLI/MCP、CUA Driver、Browser Automation、业务系统 API、Skill Runtime |
### 6.3 主 Agent 与业务 Agent 分工
主 Agent 负责“为什么做、谁来做、能不能做”:
- 理解业务目标和约束条件。
- 拆解任务、里程碑和依赖关系。
- 判断账号、设备、项目、Skill 和数据访问权限。
- 选择合适业务 Agent、设备 Agent 或执行 provider。
- 在关键节点请求用户、经理或审批人确认。
- 汇总执行结果、风险、阻塞和下一步动作。
业务 Agent 负责“按 SOP 把事情做完”:
- 销售 Agent线索跟进、商机管理、合同执行、回款跟踪。
- 客服 Agent客户咨询、工单处理、服务跟进、满意度管理。
- 财务 Agent费用报销、预算管理、账务处理、财务分析。
- HR Agent招聘管理、入职管理、考勤管理、绩效管理。
- 项目 Agent项目计划、进度跟踪、风险管理、交付验收。
- 行政 Agent采购管理、资产管理、会议管理、用品管理。
- 运维 Agent巡检、告警、故障复盘和修复建议。
## 7. 标准执行闭环
所有企业动作都应尽量落到同一条闭环:
1. 用户提出经营目标或执行请求。
2. 主 Agent 将自然语言目标拆成任务、里程碑、依赖和风险。
3. 系统检查账号、设备、项目、Skill、SOP 和数据权限。
4. 经理或授权人确认边界、资源、预算和高风险动作。
5. 业务 Agent 或设备 Agent 按 SOP 执行。
6. 员工只处理例外、补充材料、做判断和确认结果。
7. 系统回写过程记录、权限记录、结果记录和异常记录。
8. 主 Agent 形成复盘、版本记录、项目目标更新和下一步建议。
这条闭环必须沉淀四类记录:
- 权限记录:谁在何时对哪些内容拥有什么操作权限。
- 过程记录:任务流转、操作步骤、审批意见和执行过程。
- 结果记录:关键输出、指标达成、交付物和数据回写。
- 异常记录:异常识别、处理过程、原因分析和改进措施。
## 8. 治理能力必须前置
PPT 第 8 页强调“AI 执行必须先可控,再谈自动化”。量产版本的功能优先级必须体现这一点。
| 治理能力 | 产品要求 |
| --- | --- |
| RBAC 角色权限 | 老板、经理、员工、子账号、设备、项目、Skill 和数据权限必须可裁剪 |
| 审批与确认 | 高风险任务必须进入人工确认,不允许 AI 越权操作 |
| 审计日志 | 记录任务来源、执行过程、结果回写、异常原因和审批链 |
| 账号与设备治理 | 管理 AI 账号、真实电脑、本地 Agent、Skill 能力和授权状态 |
| 数据边界 | 按企业、部门、项目、账号隔离上下文,越权数据不展示 |
| SLA 与风险处置 | 异常任务、离线设备、执行失败、超时任务必须进入风险台 |
## 9. 量产数据安全与自动备份机制
当前文件型 `data/boss-state.json` 只能支撑 MVP量产必须迁移到数据库和可回退的数据架构。
### 9.1 数据事实源
量产推荐:
- PostgreSQL 作为主业务库。
- 对象存储保存附件、截图、执行产物、日志归档和备份包。
- Redis 或队列系统只做缓存、锁和异步任务,不作为最终事实源。
- 每一个重要状态变化都写入 append-only 事件账本。
核心原则:
- 普通业务表保存当前状态。
- 事件账本保存“发生过什么”。
- 审计表保存“谁允许了什么”。
- 快照表保存“某个时刻可以回到哪里”。
### 9.2 自动备份
必须具备:
- PostgreSQL WAL 归档和定时全量备份。
- 每日全量备份、小时级增量备份、关键变更前即时快照。
- 跨区域或独立对象存储备份。
- 备份加密、备份校验和定期恢复演练。
- 按企业租户拆分可导出数据包,便于企业级交付和迁移。
建议目标:
- RPO普通业务不超过 15 分钟,关键企业客户可配置到 5 分钟。
- RTO普通故障 1 小时内恢复,关键演示或生产客户 15 分钟内恢复核心链路。
### 9.3 业务级回退
量产回退不能只依赖数据库备份。必须提供业务级回退能力:
| 场景 | 回退方式 |
| --- | --- |
| 消息误删 | 软删除 + 会话级恢复 |
| 项目目标或版本记录误改 | 版本化保存 + 一键恢复上一版 |
| 权限误授权 | 授权变更事件可撤销,撤销后同步清理会话和任务上下文 |
| Skill 安装或升级失败 | 安装前备份、版本锁、回滚到上一可用版本 |
| 主 Agent 错误接管 | 关闭接管、取消 queued/running 主动任务、恢复原线程控制权 |
| Codex 开发任务误操作 | 执行前 checkpoint、Git 分支隔离、diff 审批、必要时 revert |
| Computer Use 错误点击 | 高风险动作前确认、动作录像/截图留档、支持人工中止 |
| 企业配置误改 | 配置快照 + 审计原因 + 指定时间点恢复 |
## 10. Codex 协议扩展策略
Codex App Server 官方文档明确了它面向深度产品集成,包含 authentication、conversation history、approvals 和 streamed agent events。它当前基于 JSON-RPC 形态暴露 Thread、Turn、Item、Approval、Skill、Plugin、App、MCP、文件系统和模型列表等能力。
Boss 后续要把 Codex App Server 当作优先升级方向,但不能把业务模型绑死在它的字段上。
### 10.1 Provider 抽象
新增或强化统一执行 provider 接口:
```text
Boss Task
-> ExecutionBackendSelector
-> CodexAppServerBackendAdapter
-> CodexMcpBackendAdapter
-> CodexCliExecBackendAdapter
-> NativeComputerUseBackendAdapter
-> BrowserAutomationBackendAdapter
-> BusinessSystemApiBackendAdapter
```
每个 provider 必须声明:
- `providerId`
- `protocolVersion`
- `capabilities`
- `riskPolicy`
- `approvalModes`
- `streamingModes`
- `rollbackSupport`
- `healthCheck`
- `fallbackProviderId`
### 10.2 事件归一化
Codex App Server 的原始事件不能直接进入前台 UI。必须先归一化为 Boss 事件:
| Codex 原始概念 | Boss 归一化概念 |
| --- | --- |
| Thread | ProjectConversation / CodexThreadRef |
| Turn | ExecutionTurn / UserRequest |
| Item | ExecutionItem / MessageSegment / ProgressStep |
| Approval Request | ApprovalCard |
| Command Execution | ExecutionStep |
| File Change | ChangeSet |
| Skill | SkillCapability |
| Plugin/App | ExternalCapability |
| Error | RiskEvent / TaskFailure |
### 10.3 版本兼容机制
每次 Codex 协议升级时必须走以下流程:
1. 拉取或生成当前 Codex App Server TypeScript / JSON Schema。
2. 保存到 `docs/protocol-snapshots/codex-app-server/<version>/`
3. 自动生成 provider capability manifest。
4. 跑协议兼容测试,确认 Thread、Turn、Item、Approval、Skill、Plugin 和 model/list 是否仍能映射。
5. 新能力先挂 feature flag不直接进入生产默认链路。
6. 对关键企业租户先做灰度,再全量开启。
### 10.4 禁止写死的内容
以下内容不得写死进业务逻辑或 UI
- Codex CLI 输出 envelope。
- Codex Desktop 私有数据库字段。
- 某一个 Codex 版本的 stderr/stdout 文案。
- 某一个 App Server event 的完整原始字段。
- 本地线程文件路径。
- Codex 具体模型列表。
- Skill 的本地绝对路径。
允许写死的是 Boss 自己的领域模型、权限模型、审计模型和回退模型。
## 11. 进度卡与实时协作
PPT 强调从目标到结果的可追踪闭环Codex App Server 又提供 streamed agent events。Boss 的聊天窗口必须把“正在做什么”表达为结构化进度,而不是刷屏输出过程噪音。
建议统一进度卡结构:
- 进度:计划、执行中、完成、失败、等待审批。
- 分支详情Git 分支、diff 摘要、测试状态、生成产物。
- 生成结果文档、APK、图片、代码文件、报告。
- 后台智能体:主 Agent、业务 Agent、Codex、explorer、Computer Use provider。
- 风险:权限不足、设备离线、测试失败、审批等待、回退点。
执行过程中的低价值输出默认折叠,只显示最终结果和关键节点。用户需要查看细节时再展开过程日志。
## 12. Skill 治理与共享
Skill 是 Boss 企业扩展性的关键能力,但不能只做本机目录同步。
量产 Skill 治理需要支持:
- 平台级 Skill 市场。
- 企业级私有 Skill 仓库。
- 按公司、部门、账号、设备、项目授权 Skill。
- Skill 安装、升级、回滚、版本锁。
- Skill 依赖、来源、checksum、签名和安全等级。
- Skill 使用审计和失败率统计。
- 多电脑共享 Skill但执行时仍按设备本地能力和权限裁剪。
主 Agent 在选择 Skill 时,只能看到当前用户、当前企业、当前设备和当前项目被授权的 Skill。
## 13. 当前落地进度与量产剩余清单
本节用于承接当前开发状态。这里的“已落地”只表示代码和本地回归已具备,不代表已经完成生产灰度、客户验收或长期稳定性验证。
### 13.1 已落地的量产底座
| 方向 | 当前状态 | 关键文件 / 接口 |
| --- | --- | --- |
| 多租户与 RBAC | 已具备最高管理员、企业管理员、成员账号、公司归属、设备 / 项目 / Skill 授权和审计日志 | `src/lib/boss-permissions.ts``GET/POST /api/v1/admin/access` |
| 设备撤权 | 已支持 `revoke_device`:清空设备 token、置离线、写 `device.revoked` 审计,并阻断 heartbeat、任务认领、Skill 同步、日志上报、boss-agent OTA | `src/lib/boss-data.ts``src/app/api/device-heartbeat/route.ts``src/app/api/v1/admin/access/route.ts` |
| 设备心跳安全 | 已禁止无 token 的已存在设备续命,禁止未准备 enrollment 的新设备自注册,吊销设备不会刷新 `lastSeenAt / status / projects / projectCandidates` | `POST /api/device-heartbeat` |
| 主 Agent 任务租约 | 已支持 `attemptCount / maxAttempts / leaseExpiresAt`,运行中任务租约过期可重试认领,超过上限转 `timed_out` | `claimNextMasterAgentTask()``POST /api/v1/master-agent/tasks/claim` |
| 主 Agent 任务取消 | 已支持取消 `queued / running / needs_user_action`,写 `canceledAt / canceledBy / cancelReason`,迟到 complete 不覆盖终态 | `POST /api/v1/master-agent/tasks/[taskId]/cancel` |
| Codex 桌面同步 | 已支持 APP 用户消息镜像到 Codex Desktop rollout并通过本机刷新桥提示桌面端感知更新 | `local-agent`、Codex Desktop Refresh Bridge |
| Codex App Server 接入 | 已有第一批 provider runnerturn 启动前失败可回退 CLIturn 启动后不重复执行 | `local-agent/codex-app-server-runner.mjs` |
| 电脑控制 provider | macOS 链路优先 Codex Computer Use失败后回退 CUA Driverbrowser / desktop 任务统一走 `MasterAgentTask` 和进度卡 | `local-agent/computer-use-task-runner.mjs``scripts/codex-computer-use-runtime.mjs``scripts/cua-driver-computer-use-runtime.mjs` |
| 状态快照与回退 | 文件状态已有自动快照、手动创建快照和恢复前 pre-restore 快照 | `src/lib/boss-state-backups.ts``GET/POST /api/v1/admin/backups` |
| PostgreSQL 切换前置 | 已有 Postgres JSONB store、schema 校验、dry-run 迁移、Postgres 备份导出和恢复脚本 | `src/lib/boss-state-store.ts``scripts/boss-state-store-maintenance.mjs` |
| boss-agent Mac OTA | 已支持 Mac agent 包检查、下载、校验和覆盖安装,并保留绑定配置 | `src/lib/boss-agent-ota.ts``local-agent/boss-agent-ota-runner.mjs` |
| Skill 生命周期治理 | 已支持 install / update / uninstall / rollback / version_lock 请求、设备端 claim / complete、source allowlist、checksum、更新前备份和失败恢复 | `GET/POST /api/v1/admin/skills/requests``skill-requests/claim` |
### 13.2 量产 P0上线前必须补齐
P0 的定义:不补齐会影响企业客户数据安全、权限边界、稳定演示或生产事故恢复。
1. 数据库正式切换:把 `BOSS_STATE_STORE=postgres` 从可选适配层推进到生产主路径,补正式 PostgreSQL 表拆分、迁移脚本、灰度开关和回滚剧本。
2. 事件账本:新增 append-only event ledger覆盖账号、设备、权限、任务、审批、Skill、备份、Computer Use 动作和主 Agent 接管。
3. 备份恢复演练自动化:把文件快照和 Postgres 备份纳入后台“恢复演练”流程,记录演练时间、耗时、校验结果和负责人。
4. 任务调度服务化:把 `MasterAgentTask` 从状态文件轮询升级为数据库任务队列或可靠队列,支持 lease、retry、cancel、dead-letter 和 worker 心跳。
5. 企业 SSO / IdP补 OIDC/SAML 之一支持企业管理员配置登录策略、MFA 强制、离职回收和会话全量吊销。
6. 设备绑定与正版授权boss-agent 绑定二维码、授权到期、许可证校验、设备换绑、离线宽限期和吊销恢复流程需要闭环。
7. 审计不可抵赖:关键操作需要稳定审计 ID、操作者、来源 IP、UA、前后快照、关联任务和导出能力。
8. 高风险审批Computer Use、代码提交、部署、权限变更、批量 Skill 下发必须进入审批卡,不允许只靠主 Agent 自行判断。
9. 生产监控与告警:补服务端 metrics、错误率、任务积压、设备离线、API 失败、OTA 失败、备份失败和客户维度 SLA 告警。
10. 客户数据隔离验收:对所有 Web / APP / API 投影视图做租户隔离回归确保子账号看不到未授权设备、项目、线程、Skill、日志和附件。
### 13.3 量产 P1首批企业客户试点必须补齐
P1 的定义:不影响最小上线,但会影响试点客户规模化复制、运营效率和长期留存。
1. 管理后台企业化:平台总后台和企业自管后台进一步拆清,补租户套餐、授权席位、设备额度、用量统计、客户健康分和客户成功视图。
2. Skill 市场:支持平台 Skill、企业私有 Skill、版本签名、依赖扫描、灰度发布、回滚统计和失败率看板。
3. 业务 Agent 目录把销售、客服、财务、HR、项目、行政、运维 Agent 做成可配置目录,并绑定 SOP、权限和数据源。
4. 进度卡增强:把 `execution_progress` 扩展为统一 task timeline支持步骤、截图、文件变更、测试结果、审批记录和可展开过程日志。
5. Codex 协议快照:建立 `docs/protocol-snapshots/codex-app-server/`,自动比较 protocol version、capabilities、model/list、approval、skill、plugin 变化。
6. 多入口一致会话Telegram 已有最小链路,后续补飞书、企业微信或微信生态入口,并统一权限、审计、通知和会话状态。
7. 企业知识库与长期记忆:把项目目标、版本记录、任务结果、回退点、客户 SOP 和主 Agent 记忆纳入统一版本化记录。
8. Computer Use 证据链:关键桌面动作需要截图 / 屏幕录制 / AX tree 摘要 / 操作序列留档,便于复盘和客户信任。
### 13.4 当前验证基线
截至本次文档更新,以下本地验证命令作为当前量产底座的最小回归基线:
```bash
npx tsx --test tests/device-revocation-auth.test.ts tests/master-agent-task-reliability.test.ts tests/device-import-draft.test.ts tests/ai-account-validation.test.ts tests/device-import-candidate-id-regression.test.ts tests/master-agent-task-claim-route.test.ts tests/device-execution-conflict.test.ts tests/browser-desktop-control-summary-message.test.ts tests/skill-lifecycle-route.test.ts tests/state-store-maintenance-script.test.ts tests/boss-state-store.test.ts
npm run lint
npm run build
```
本次验证结果49 项核心测试通过,`npm run lint` 通过,`npm run build` 通过。
## 14. 90 天量产试点路径
PPT 第 10 页给出的 90 天路径进入后续 To B 交付方法论。
### 阶段 10-30 天
目标:选择 1-2 个高频流程,搭好企业账号、权限、设备和数据接入基础。
交付:
- 梳理老板、经理、员工权限。
- 选择高频、规则清晰、跨系统搬运多的流程。
- 接入关键系统和数据,如 CRM、ERP、财务、OA、知识库。
- 接入真实电脑和本地 Agent。
### 阶段 231-60 天
目标:部署主 Agent 和 2-3 个业务 Agent跑真实任务。
交付:
- 主 Agent 统筹,业务 Agent 执行具体流程。
- 建立审批、审计、异常处理和权限边界。
- 跑通真实任务并持续优化 SOP。
- 形成可复制配置模板。
### 阶段 361-90 天
目标:接入经营看板,评估效率和复制条件。
交付:
- 实时展示任务进度、效率指标和业务价值。
- 评估从发起到结果的整体响应时间。
- 评估人工汇总减少、流程稳定性和复制条件。
- 输出下一部门复制方案。
成功标准:
- 可持续执行。
- 可审批。
- 可追踪。
- 可复制。
## 15. 产品开发优先级
第一优先级:稳定和治理。
- 数据库正式替换文件状态。
- append-only 事件账本。
- 自动备份和恢复演练。
- 业务级回退。
- 主 Agent 任务取消、接管关闭和主动任务清理。当前已有任务 cancel / timed_out 底座,仍需数据库队列化和 dead-letter。
- 设备离线、执行失败、审批超时进入风险台。
第二优先级Codex App Server 深度接入。
- App Server provider adapter。当前已有第一批 runner仍需协议快照、能力清单和 streamed events 完整映射。
- Thread / Turn / Item 映射。
- Approval card 映射。
- streamed events 进入进度卡。
- skills/list、model/list、plugin/list 进入能力清单。
- 协议快照和兼容测试。
第三优先级:企业协作网络。
- 老板端、经理端、员工端权限视图。
- 业务 Agent 目录和 SOP。
- 部门/项目维度执行闭环。
- Skill 企业分配和跨设备同步。
- 平台总后台风险与客户成功视图。
第四优先级:前瞻扩展。
- Telegram、飞书、微信、Web、APP 多入口一致会话。
- 自研 Computer Use 与 Codex Computer Use 并存。
- 多 provider 智能路由。
- 企业知识库和长期记忆。
- 自动复盘、流程优化和策略建议。
## 16. 非协商性原则
- 不允许把系统提示词、内部 prompt、设备 token、API key、工作目录调度说明写进用户可见消息。
- 不允许主 Agent 在用户取消接管后继续主动向线程发起任务。
- 不允许没有审计记录的高风险操作。
- 不允许没有回退点的批量权限、Skill、数据或代码变更。
- 不允许把 Codex 当前某个版本的协议字段直接作为 Boss 业务事实源。
- 不允许把过程噪音当作未读消息。
- 不允许让企业子账号看到未授权设备、项目、Skill 或线程上下文。
## 17. 下一步落地清单
1. 输出 PostgreSQL 正式 schema 设计账号、企业、设备、项目、线程、消息、任务、审批、事件账本、审计、备份、Skill。
2.`MasterAgentTask` 调度从文件状态轮询迁到可靠队列或数据库任务表,并保留现有 lease / retry / cancel 语义。
3. 新增 `docs/protocol-snapshots/codex-app-server/` 目录规范和兼容测试。
4.`execution_progress` 进度卡扩展成统一 task timeline。
5. 把项目目标、版本记录、任务结果和回退点纳入同一套版本化记录。
6. 为平台总后台增加恢复演练、租户风险、设备离线、主 Agent 失败和任务积压看板。
7. 为 Skill 治理增加签名、依赖扫描、灰度发布和失败率统计。
8. 为 boss-agent 增加企业正版授权、授权到期提醒、离线宽限和设备换绑流程。

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