feat: ship enterprise control and desktop governance
This commit is contained in:
12
.gitignore
vendored
12
.gitignore
vendored
@@ -19,11 +19,23 @@
|
||||
|
||||
# production
|
||||
/build
|
||||
apps/boss-admin-web/dist/
|
||||
apps/boss-admin-web/node_modules/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.playwright-cli/
|
||||
.playwright-mcp/
|
||||
.superpowers/
|
||||
output/
|
||||
admin-redesign*.png
|
||||
main-*.js
|
||||
android/.project
|
||||
android/.settings/
|
||||
android/app/.classpath
|
||||
android/app/.project
|
||||
android/app/.settings/
|
||||
data/*.json
|
||||
data/*.json.bak
|
||||
android/.gradle/
|
||||
|
||||
50
README.md
50
README.md
@@ -10,8 +10,9 @@
|
||||
2. `docs/architecture/repo_map_cn.md`
|
||||
3. `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
4. `docs/architecture/api_and_service_inventory_cn.md`
|
||||
5. `docs/architecture/boss_server_connection_and_deploy_cn.md`
|
||||
6. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md`
|
||||
5. `docs/architecture/rbac_skill_regression_matrix_cn.md`
|
||||
6. `docs/architecture/boss_server_connection_and_deploy_cn.md`
|
||||
7. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md`
|
||||
|
||||
## 当前有效目录
|
||||
|
||||
@@ -53,17 +54,33 @@
|
||||
- `POST http://127.0.0.1:3000/api/auth/restore` 正常,已验证可用原生 restore token 恢复登录态
|
||||
- `POST http://127.0.0.1:3000/api/v1/projects/master-agent/messages` 正常,已验证可通过 `Mac Studio local-agent -> 本机 Master Codex Node -> 回写项目账本` 返回真实主 Agent 回复
|
||||
- `POST http://127.0.0.1:3000/api/v1/projects/[projectId]/messages` 正常,普通单线程会话当前会返回 `conversation_reply` 任务,并等待绑定设备上的真实 Codex 线程回写
|
||||
- `POST http://127.0.0.1:3000/api/v1/integrations/telegram/webhook` 正常,已支持 Telegram Bot 私聊消息直连 Boss 主 Agent;快速回复会立即回 Telegram,异步任务完成后也会自动回推
|
||||
- `GET/POST http://127.0.0.1:3000/api/v1/integrations/telegram` 正常,已支持最高管理员读取和保存 Telegram 接入配置,返回默认脱敏视图;保存 webhook 模式时会自动调用 Telegram `setWebhook`,切回 polling/关闭时会自动调用 `deleteWebhook`;Web `/me/telegram` 与原生 Android `我的 > Telegram 接入` 都已接入这条配置链路,并支持把群 / Topic 路由到指定 Boss 项目
|
||||
- `POST http://127.0.0.1:3000/api/auth/logout` 正常,退出后访问受保护 `/api/v1/*` 会返回 `401`
|
||||
- `GET/POST http://127.0.0.1:3000/api/v1/auth/sessions` 正常,已支持查看当前账号登录会话、最高管理员查看全部活跃会话,以及撤销单个登录端;返回内容不会暴露 `sessionToken / restoreToken`
|
||||
- 当前多用户 / RBAC 第一阶段已经落地:`BossState` 新增 `accountDeviceGrants / accountProjectGrants / accountSkillGrants / skillCatalog / permissionAuditLogs`,所有会话、设备、项目详情、消息读写、设备 Skill 和 `/api/state` 都会按当前登录账号过滤;最高管理员仍保持全局可见
|
||||
- `GET/POST http://127.0.0.1:3000/api/v1/admin/access` 正常,仅最高管理员可用;当前支持创建/更新子账号、公司启用/停用、账号/设备归属、批量导入预览、批量导入、重置子账号密码、离职回收、授予设备/项目/Skill 权限、套用权限模板和撤销授权,返回账号时不会暴露 `passwordHash`
|
||||
- `GET http://127.0.0.1:3000/api/v1/admin/overview`、`POST http://127.0.0.1:3000/api/v1/admin/risks/scan` 和 `POST http://127.0.0.1:3000/api/v1/admin/notifications/dispatch` 正常,仅最高管理员可用;风险扫描会把超时 SLA 幂等写入 `adminNotifications`,派发结果和处置动作写入 `adminRiskTimeline`
|
||||
- `GET/POST http://127.0.0.1:3000/api/v1/admin/skills/requests` 正常,仅最高管理员可用;当前支持对指定设备创建 `install / update / uninstall / rollback / version_lock` 请求,local-agent 会通过设备 token 认领、执行本机 Skill 文件操作或 Git 操作,并把完成状态和最新 Skill 清单回写
|
||||
- 当前 Web `/me/access` 和原生 Android `我的 > 用户与权限` 已接入授权管理:最高管理员可在前台创建子账号、授予设备/项目/Skill 权限、套用 `只读观察员 / 项目开发者 / 设备操作者` 模板、查看同名 Skill 跨设备聚合和撤销授权;`admin/member` 不显示该入口
|
||||
- 当前主 Agent 执行上下文已接入授权快照:主 Agent 生成提示词和任务时只带当前账号可见的设备、项目、线程状态文档、进展事件和 Skill,并在 `MasterAgentTask` 上记录 `authorizedDeviceIds / authorizedProjectIds / authorizedSkillIds / requiredPermissions`
|
||||
- 多用户 / RBAC / Skill / 主 Agent 权限和多设备控制的集中状态、回归矩阵与缺口清单见 `docs/architecture/rbac_skill_regression_matrix_cn.md`
|
||||
- `GET http://127.0.0.1:3000/api/v1/user/ota/package` 正常,当前会返回最新 APK 包
|
||||
- 当前这台开发机的 `launchd` 常驻 `local-agent` 已恢复:`GET http://127.0.0.1:4317/health` 现在可在数十毫秒内返回,且在手动 heartbeat 执行期间仍能正常回包
|
||||
- 当前 Boss 已新增 `src/lib/execution/` 执行底座抽象层;当前生产主链仍然沿用 `local-agent -> codex exec resume`,只是执行责任已开始通过 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现收束
|
||||
- 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有在显式配置 `BOSS_CLAW_*` 且可用性探测通过时,`master-agent` 当前对话里才会出现并允许选择 `claw-runtime`
|
||||
- 当前已新增最小 `Telegram Gateway`:Boss 服务器可直接作为 Telegram Bot webhook 入口,把 Telegram 私聊或受控群聊文本桥接进 `master-agent` 或指定 Boss 项目,并在 `master-agent task complete` 后自动把结果回推给 Telegram 用户;Android 原生端已提供 `TelegramIntegrationActivity`,可查看 Bot 状态、配置 webhook、白名单、群聊触发策略和群 / Topic 路由
|
||||
- 如果历史上已经保存过 `backendOverride=claw-runtime`,但当前 `Claw Runtime` 不可用,运行时会自动回退到默认后端,并在前台显示明确原因
|
||||
- 当前 `oh-my-codex` 已以最小 `OmxTeamBackendAdapter` 形式接入执行底座,但默认关闭;当前已经接到 Web 群聊详情页 / 原生群资料页的编排后端选择卡,可在 `Boss Native` 与 `OMX Team` 间切换,OMX 不可用时会自动回退到默认后端并明确提示原因
|
||||
- 当前仓库已自带一个本地 OMX smoke runtime:`scripts/omx-team-smoke.mjs`。在还没有真实 `oh-my-codex` 可执行文件时,可以先用它验证 `OmxTeamBackendAdapter -> selector -> dispatch_execution -> 回写群聊账本` 这条链
|
||||
- 当前仓库已自带一个本地 smoke runtime:`scripts/claw-runtime-smoke.mjs`。在还没有真实 `claw-code` 可执行文件时,可以先用它验证 `ClawBackendAdapter -> backendOverride -> 异步回流` 整条链
|
||||
- 当前已新增“Boss 统一电脑控制中枢”第一批能力:主 Agent 已能把聊天输入区分为 `discussion / development / browser / desktop` 四类意图,并能把 `browser_control / desktop_control` 作为正式任务排入 `MasterAgentTask` 队列;本机 `local-agent` 已补上 `browser-control-task-runner / computer-use-task-runner` 外部 runtime 桥,可通过 `browserControl* / computerUse*` 配置接入真实 Browser Automation 与 Computer Use 执行器,未启用时会 fail closed,不再假装执行成功
|
||||
- 当前 browser/desktop 控制结果已经会作为 `control_summary` 正式写回会话账本,并保留目标 URL / 应用名;Android 原生端会以单独控制结果卡片展示,便于把“执行什么”和“执行结果”与普通聊天正文区分开
|
||||
- 当前 `scripts/browser-control-smoke.mjs` 已经能对目标 URL 做一次真实最小探测:抓取页面标题并写回聊天结果;`scripts/computer-use-smoke.mjs` 已支持 macOS `osascript` 应用激活、引号文本输入、可选回车发送、`open -a` 兜底打开和执行 artifact 落盘,作为后续接完整 Computer Use 前的稳定过渡层
|
||||
- 当前默认本机配置已把 `browserAutomation / computerUse` 两项能力直接上报为在线起步态,所以 Boss App 里这台 Mac 会显示“可做浏览器控制 / 桌面控制”;如果某条链路要临时收起,只需要改 `local-agent/config.cloud.json`
|
||||
- `GET http://127.0.0.1:4317/api/v1/skills` 正常,已返回本机扫描到的 Codex Skill
|
||||
- `POST http://127.0.0.1:4317/api/v1/heartbeat` 正常,且会顺带触发 `thread-context` 上报
|
||||
- `local-agent` 当前每 5 秒轮询一次本机 Skill lifecycle 请求;默认打开 `skillLifecycleEnabled=true`。远程 `install` 或带 `sourceUrl` 的更新必须命中 `skillLifecycleAllowedSources` 或 `skillLifecycleTrustedSources`,为空时只允许既有本地 Skill 的 `update / rollback / uninstall / version_lock`;请求携带 `checksum / expectedChecksum` 时会校验 `manifest.json` 或 `SKILL.md` 的 sha256,失败会清理半安装目录或尽量恢复备份。卸载 / 更新 / 回滚前会在 `skillsDir/.boss-skill-backups` 保留备份,卸载仍限制在 `skillsDir` 目录内,版本锁写入 `.boss-skill-locks.json`
|
||||
- `launchd` 已加载:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist`
|
||||
|
||||
服务器:
|
||||
@@ -103,13 +120,14 @@ Android APK:
|
||||
- `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
|
||||
- 当前最新 release 构建版本:`2.5.11`(`versionCode=24`)
|
||||
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
|
||||
- 真机开发约束:除非用户明确要求切换设备,后续 Android 开发、ADB 安装、交互回归与问题复现统一只使用 `PLB110`;如果 `PLB110` 当前不在线,应先恢复这台设备连接,不自动切到其他手机
|
||||
- 真机开发约束:用户已明确切换到当前连接的 OPPO `PHZ110`(ADB serial `U84XJRIB7D65ZH45`);除非用户再次要求切换设备,后续 Android 开发、ADB 安装、交互回归与问题复现统一使用这台 OPPO,不再回退到原 `PLB110`
|
||||
- OPPO 权限回归建议命令:`adb -s U84XJRIB7D65ZH45 devices -l`、`./gradlew :app:assembleDebug`、`adb -s U84XJRIB7D65ZH45 install -r android/app/build/outputs/apk/debug/app-debug.apk`、`adb -s U84XJRIB7D65ZH45 shell am start -W -n com.hyzq.boss/.MainActivity -e initial_tab me`,再从 `我的 > 用户与权限` 确认最高管理员可进入权限页。
|
||||
- Android 真机无线调试如果要尽量稳定,优先使用“同一局域网 + 初次 USB 启用后执行 `adb tcpip 5555` + `adb connect <phone-ip>:5555`”这条链路;它通常比只依赖系统“无线调试配对码”更稳
|
||||
- Android 系统层面对“无线调试”没有真正的永久不掉线开关;重启手机、切 Wi‑Fi、切热点、ADB server 重启、USB 调试被重新切换后,都可能导致无线调试自动失效
|
||||
- 真机调试时建议固定同一 SSID、避免代理/VPN 改路、开发者选项里开启“保持唤醒”,并在需要长时间稳定调试时优先保留 USB 兜底;如果必须完全避免自动断开,不要只依赖无线调试
|
||||
- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、主 Agent 提示词 / 记忆、技能、运维中心、关于
|
||||
- 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab,会话首页是简单聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口
|
||||
- 当前会话首页右上角已切回 `+` 入口:直接从首页发起独立群聊;设备页右上角仍是 `+添加`
|
||||
- 当前会话首页右上角已切回 `+` 入口:直接从首页发起独立群聊;设备页右上角 `+添加` 仅最高管理员可见,子账号只保留刷新。
|
||||
- 当前会话首页已升级成“项目聚合 + 线程下钻”的结构:如果某个 Codex 文件夹只导入了 1 个线程,会话列表直接显示这个线程;如果同一文件夹导入了多个线程,会话首页只显示该文件夹归档项,点进去再看这个项目下的全部线程
|
||||
- 当前会话首页的数据源已分成两层:`/api/v1/conversations` 继续保留平铺线程视图给群聊创建、转发等内部能力使用;首页和原生根页改走 `/api/v1/conversations/home`,文件夹归档详情走 `/api/v1/conversation-folders/[folderKey]`
|
||||
- 当前会话搜索仍然保留线程可达性:如果命中单线程项目,会直接进入该线程;如果命中多线程项目里的某条线程,结果会显示 `项目 / 线程`,点击后先进入项目文件夹页并定位到对应线程,不会把首页重新打平成线程列表
|
||||
@@ -148,6 +166,7 @@ Android APK:
|
||||
- 当前设备导入 `review` 已补 owner/admin 鉴权,并改成真正的异步审核链:`review` 只负责排队 `device_import_resolution` 任务并返回 queued 状态,等 local-agent 完成回写后才把决议写回草稿和会话账本
|
||||
- 当前原生 APP 会话页的“刷新失败”已按当前 tab 的主数据源独立判错:`会话` 只看会话请求本身,`设备` 只看设备请求,`我的` 只在 `settings + ota` 同时失败时才提示刷新失败
|
||||
- 当前 `设备` 和 `我的` 根页已收口为简单列表;`运维与修复 / AI 账号 / 技能` 保留在一级 `我的`,`审计对话` 作为置顶会话保留在会话首页
|
||||
- 当前原生 `我的` 根页已开始按登录角色过滤入口:`member` 只显示个人安全、设置、已授权 Skill 和关于;`admin / highest_admin` 才显示运维、AI 账号、附件存储和 Telegram 管理入口;`用户与权限` 仅 `highest_admin` 可见
|
||||
- 原生客户端当前直接调用 `https://boss.hyzq.net` 的 Boss API,不再打开 WebView
|
||||
- `2.0.1` 已修复华为真机上因 `Theme.SplashScreen` 与 `AppCompatActivity` 不兼容导致的启动闪退
|
||||
- `2.1.0` 已在本机连接的华为真机上完成签名包覆盖安装与启动复核,原生三栏入口和子活动页声明已全部接通
|
||||
@@ -160,6 +179,7 @@ Android APK:
|
||||
- `2.5.1` 继续收口微信式原生 UI:聊天页普通态顶部已隐藏刷新按钮,只保留右上角“信息”;发起群聊页顶部说明和选择区已压成更轻的会话式密度,候选线程继续复用微信式会话卡片
|
||||
- `2.5.2` 继续补齐深层原生页:`项目目标 / 版本迭代记录 / 会话信息 / 群资料` 已进一步向设计图收口;附件消息卡片的分析状态和动作文案也压成了更轻的微信式层级
|
||||
- `2.5.4` 已把 `我的` 根页收口成微信式资料区 + 白底菜单列表,并同步把 `设置 / 账号与安全 / AI 账号 / 技能 / 运维与修复` 的顶部说明从重 `soft panel` 降成轻量列表说明
|
||||
- `2.5.11` 已补齐第一批遗漏功能:聊天长按“删除”接通服务端账本删除与实时刷新;原生 `我的 > 附件与存储` 可直接切换服务器文件存储 / 阿里 OSS;后台通知覆盖所有会话里的主 Agent 回复;browser/desktop runtime 未配置时改为明确失败而不是占位成功
|
||||
- `2.5.5` 已补上群资料页的“修复群成员”主链:历史脏群会明确提示失效成员,并允许重新选择真实线程成员写回群资料;`approval_required` 群聊也已补齐“确认 / 拒绝”两条审批动作
|
||||
|
||||
## 本地启动
|
||||
@@ -189,6 +209,7 @@ npm start
|
||||
- 登录页:[http://127.0.0.1:3000/auth/login](http://127.0.0.1:3000/auth/login)
|
||||
- 会话页:[http://127.0.0.1:3000/conversations](http://127.0.0.1:3000/conversations)
|
||||
- 设备页:[http://127.0.0.1:3000/devices](http://127.0.0.1:3000/devices)
|
||||
- 平台总后台:[http://127.0.0.1:3000/admin](http://127.0.0.1:3000/admin),生产计划独立入口为 `https://admin.boss.hyzq.net`
|
||||
|
||||
## 设备端本地服务
|
||||
|
||||
@@ -222,6 +243,8 @@ device-agent 当前职责:
|
||||
- 轮询云端 `/api/v1/master-agent/tasks/claim`,并用当前电脑已登录的 `codex` 账号执行主 Agent 任务
|
||||
- 将主 Agent 执行结果回写到云端 `/api/v1/master-agent/tasks/[taskId]/complete`
|
||||
- 对普通单线程会话,认领到的 `conversation_reply` 任务会直接恢复到目标 Codex 线程,并把线程原始回复回写到对应聊天窗口
|
||||
- 对已绑定 `codexThreadRef` 的普通单线程会话,`local-agent` 会在执行 `codex exec resume` 前先把 Boss App 里的用户消息镜像进目标 Codex Desktop 线程 rollout,避免 APP 和桌面版同线程历史割裂;定位 rollout 时优先用 `state_5.sqlite`,不可用时回退扫描 `~/.codex/sessions`,并尽量刷新线程活跃时间。镜像成功后会优先调用本机常驻 `Codex Desktop Bridge` endpoint,再打开 `codex://threads/{threadId}` 并发送一次安全刷新提示,让桌面版切到目标线程后重新读取记录;endpoint 不可用时回退原命令式刷新。刷新桥默认对短暂失败重试 2 次、间隔 120ms,并保留 deep link 与尝试次数,便于追踪桌面同步是否真正触发。bridge 同时提供 `GET /api/v1/codex-desktop/events` SSE 和 recent 缓冲,后续 Codex Desktop 插件可直接订阅安全元数据事件;`scripts/codex-desktop-event-consumer.mjs` 可作为本机订阅 smoke
|
||||
- `scripts/codex-desktop-integration-probe.mjs` 可探测本机 Codex Desktop 能力,bridge 也提供 `GET /api/v1/codex-desktop/capabilities`;探测只读 `Info.plist` 和 app 资源,明确不修改 Codex.app 签名包体
|
||||
- 对群聊线程分发任务,认领到的 `dispatch_execution` 任务会把原始线程结果和主 Agent 汇总一起回写到群聊消息账本
|
||||
- `local-agent` 对 `conversation_reply` 当前会优先使用 `codex exec resume <targetCodexThreadRef>`,只有缺失真实线程引用时才退回 `--ephemeral`
|
||||
- `local-agent` 对 `dispatch_execution` 当前会按 `orchestrationBackendId` 分流:默认继续走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行
|
||||
@@ -250,6 +273,8 @@ device-agent 当前职责:
|
||||
- APK 发布脚本:`scripts/publish-apk-to-public.sh`
|
||||
- `systemd` 配置:`deployment/systemd/boss-web.service`
|
||||
- `Caddy` 配置:`deployment/Caddyfile`
|
||||
- 平台总后台域名解析:`admin.boss.hyzq.net` 需要在 DNSPod 添加 `A` 记录到 `106.53.170.158`,Caddy 已预留独立站点并把根路径跳到 `/admin`
|
||||
- 服务器 Caddy 还有 `gptpluscontrol-boss-caddy-reconcile.timer` 周期性重写:如果改域名入口,必须同步更新 `/home/ubuntu/build/gptpluscontrol/deploy/server/caddy.boss_hyzq_net.gptpluscontrol.conf`,否则会再次生成重复站点块
|
||||
- 邮件配置:`deployment/mail/`
|
||||
- Android 原生入口:`android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
- Android API 客户端:`android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
@@ -344,21 +369,21 @@ npm run aab:release
|
||||
- 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;因此会话页会自动出现这台设备当前真实运行的 Codex 线程窗口
|
||||
- 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;如果某个项目下存在多个线程,会话首页会先显示项目归档项,而不是把所有线程平铺在首页
|
||||
- 对已经绑定的生产设备,如果某些自动导入线程已经不再出现在最新 `projectCandidates[]` 中,服务端会在下一次 heartbeat 自动清理这些过时会话,避免旧线程长期滞留首页
|
||||
- 登录页当前已临时切到免验证模式,点击“登录”会直接进入会话首页
|
||||
- 认证现在已经有最小会话链路:登录后会写入 `boss_session` Cookie,默认保持 30 天,`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 接口都要求有效会话
|
||||
- 认证现在已经有最小会话链路:登录后会写入 `boss_session` Cookie,默认保持 30 天,`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 接口都要求有效会话;临时免验证登录默认关闭,仅在显式设置 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 时启用
|
||||
- 新增 `GET /api/auth/session`、`POST /api/auth/logout` 与 `POST /api/auth/restore`
|
||||
- 当前同一账号已经支持多个登录端并存;Web 与原生 Android 的 `我的 > 账号与安全` 可查看和撤销登录会话,最高管理员可以管理所有活跃会话
|
||||
- 原生 Android 客户端当前会把 `boss_session / restore token / account` 存到 `SharedPreferences`,用于重启后恢复会话
|
||||
- 验证码新增防刷与防重放:60 秒冷却、15 分钟窗口限流,登录连续失败 5 次后会锁定 10 分钟
|
||||
- `POST /api/auth/send-code` 现在会先按用途校验账号状态:登录 / 忘记密码要求账号已存在,注册要求账号尚未注册
|
||||
- 当前登录页已临时放开成“一键进入”,账号密码和验证码输入暂时不作为拦截条件
|
||||
- `POST /api/auth/send-code` 与固定验证码 `000000` 仍保留给注册 / 重置密码和后续认证收口,不作为当前登录页前置条件
|
||||
- 当前登录页默认走账号密码或验证码校验,不再把开发兜底作为生产默认能力
|
||||
- `POST /api/auth/send-code` 当前仍支持 fixed 模式,但验证码登录也必须先申请验证码并消费账本里的有效记录;不能只靠固定码直接登录
|
||||
- 新注册和重置密码现在使用 `scrypt` 哈希;历史 `sha256` 密码会在下一次密码登录时自动迁移
|
||||
- 原生 Android 当前把 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等复杂能力下沉到二级或更深层入口,不再把线程预算 / 转发 / 运维说明堆在主聊天页和一级我的页
|
||||
- 原生 OTA 当前除了整包下载和系统安装器拉起,还会在关于页保留本地下载状态;离开关于页再回来时,仍能看到进行中 / 失败 / 待授权 / 可安装状态
|
||||
- Android 本地 Gradle 验证当前必须串行执行,避免并发 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug` 相互踩坏中间产物
|
||||
- 当前默认最高管理员账号:`17600003315`
|
||||
- 当前默认测试密码:`boss123456`
|
||||
- 当前本机 Codex 节点 `mac-studio` 已绑定到 `17600003315`
|
||||
- 当前默认最高管理员账号:`krisolo`
|
||||
- 当前默认测试密码由线上初始化配置管理,文档不再明文记录
|
||||
- 当前本机 Codex 节点 `mac-studio` 已绑定到 `krisolo`
|
||||
- 主 Agent 对话当前真实执行链路是:`Boss Web -> 写入用户消息 -> 返回 queued/running -> master-agent task queue -> local-agent / OpenAI API -> complete task -> project ledger`
|
||||
- `master-agent` 单聊当前已改成“快速入队 + 异步回流”:发送后会立即返回任务包和 `masterReplyState`,前台先显示“主 Agent 思考中”,真实回复稍后自动回写到账本
|
||||
- 原生 Android 当前会把 `master-agent` 的等待态保留在消息流里:发送后常驻显示“主 Agent 思考中”,超时后改成“主 Agent 回复超时 + 重试等待”,收到新回复后会自动清掉,不再只靠 toast 提示
|
||||
@@ -369,7 +394,8 @@ npm run aab:release
|
||||
- 应用内 `GET /api/v1/user/ota` / `POST /api/v1/user/ota` / `GET /api/v1/user/ota/package` 现在已经支持 OTA 状态、检查更新、执行升级和 APK 包下载
|
||||
- `GET /api/v1/app-logs` 现在已支持登录态下按 `deviceId / projectId / level / category / source / cursor` 查询日志分页
|
||||
- 设备写接口 `POST /api/v1/app-logs`、`POST /api/v1/devices/[deviceId]/skills`、`POST /api/v1/workers/[workerId]/thread-context` 现在都要求有效设备 token 或匹配登录会话
|
||||
- 当前认证仍是 MVP:已有最小会话 Cookie,但还没有刷新令牌、跨端会话治理、吊销审计和 CSRF 防护
|
||||
- 当前认证已具备最小会话 Cookie、restore token 轮换、浏览器 CSRF 基础防护、子账号 MFA 开关、基础跨端会话治理和后台高危操作审计;后续仍可继续补企业 SSO / IdP
|
||||
- 当前状态存储默认继续使用 `data/boss-state.json`;已新增 `BOSS_STATE_STORE=postgres` 适配层,生产切换 PostgreSQL 时必须配置 `BOSS_DATABASE_URL`,并先使用 `scripts/boss-state-store-maintenance.mjs` 做备份、dry-run 迁移和回滚演练
|
||||
- 聊天附件当前已支持真实上传、消息落账本、受保护下载和原生打开;默认存储后端为服务器文件存储
|
||||
- 当前用户已可在 `我的 > 附件与存储` 切到阿里 OSS 私有桶,下载链会按附件快照生成签名地址,避免用户后续修改配置后旧附件失效
|
||||
- 图片 / PDF / 文本默认自动进入主 Agent 附件分析;视频 / Office / 大文件默认手动触发
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
@@ -14,6 +17,11 @@
|
||||
android:theme="@style/AppTheme"
|
||||
android:forceDarkAllowed="false">
|
||||
|
||||
<service
|
||||
android:name=".BossBackgroundRealtimeService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
android:name=".MainActivity"
|
||||
@@ -50,8 +58,11 @@
|
||||
<activity android:name=".DeviceImportDraftActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".SkillInventoryActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".SecurityActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".AccessManagementActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".SettingsActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".StorageSettingsActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".AiAccountsActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".TelegramIntegrationActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".OpenAiOnboardingActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".MasterAgentPromptActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".MasterAgentTakeoverActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
|
||||
@@ -15,6 +15,7 @@ import android.provider.Settings;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
@@ -76,11 +77,7 @@ public class AboutActivity extends BossScreenActivity {
|
||||
restoreDownloadUiState();
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(otaDownloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
|
||||
} else {
|
||||
registerReceiver(otaDownloadReceiver, filter);
|
||||
}
|
||||
ContextCompat.registerReceiver(this, otaDownloadReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED);
|
||||
reload();
|
||||
}
|
||||
|
||||
@@ -491,11 +488,9 @@ public class AboutActivity extends BossScreenActivity {
|
||||
persistDownloadUiState();
|
||||
refreshDownloadStateSection();
|
||||
|
||||
if (!getPackageManager().canRequestPackageInstalls()) {
|
||||
if (!canInstallDownloadedPackages()) {
|
||||
showMessage("请先允许 Boss 安装未知来源应用,然后重新打开安装包。");
|
||||
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
openUnknownAppSourcesSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -566,7 +561,7 @@ public class AboutActivity extends BossScreenActivity {
|
||||
return OtaDownloadStateMapper.failed(fileName);
|
||||
}
|
||||
if (downloadedApkUri != null) {
|
||||
if (!getPackageManager().canRequestPackageInstalls()) {
|
||||
if (!canInstallDownloadedPackages()) {
|
||||
return OtaDownloadStateMapper.waitingInstallPermission(fileName);
|
||||
}
|
||||
return OtaDownloadStateMapper.readyToInstall(fileName);
|
||||
@@ -580,9 +575,7 @@ public class AboutActivity extends BossScreenActivity {
|
||||
downloadLatestApk();
|
||||
break;
|
||||
case OPEN_INSTALL_PERMISSION:
|
||||
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
openUnknownAppSourcesSettings();
|
||||
break;
|
||||
case INSTALL_APK:
|
||||
installDownloadedApk();
|
||||
@@ -598,11 +591,9 @@ public class AboutActivity extends BossScreenActivity {
|
||||
showMessage("当前没有可安装的更新包");
|
||||
return;
|
||||
}
|
||||
if (!getPackageManager().canRequestPackageInstalls()) {
|
||||
if (!canInstallDownloadedPackages()) {
|
||||
showMessage("请先开启安装未知来源应用权限");
|
||||
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
openUnknownAppSourcesSettings();
|
||||
return;
|
||||
}
|
||||
Intent installIntent = new Intent(Intent.ACTION_VIEW);
|
||||
@@ -622,6 +613,19 @@ public class AboutActivity extends BossScreenActivity {
|
||||
return "boss-android-latest.apk";
|
||||
}
|
||||
|
||||
private boolean canInstallDownloadedPackages() {
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.O
|
||||
|| getPackageManager().canRequestPackageInstalls();
|
||||
}
|
||||
|
||||
private void openUnknownAppSourcesSettings() {
|
||||
Intent intent = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
? new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()))
|
||||
: new Intent(Settings.ACTION_SECURITY_SETTINGS);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void restoreDownloadUiState() {
|
||||
android.content.SharedPreferences prefs = getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE);
|
||||
activeDownloadId = prefs.getLong(KEY_ACTIVE_DOWNLOAD_ID, -1L);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -133,9 +133,7 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
} else {
|
||||
appendContent(buildApiSection(
|
||||
isPrimaryRole(currentRole) ? "主要API配置" : "备用API配置",
|
||||
isPrimaryRole(currentRole)
|
||||
? "主链路只在这里配置 OAuth 登录或 API 接入。"
|
||||
: "主链路异常时自动切到这里,不抢占当前主控。",
|
||||
isPrimaryRole(currentRole) ? "主链路" : "备用链路",
|
||||
accounts,
|
||||
currentRole
|
||||
));
|
||||
@@ -170,7 +168,7 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
if (currentRole == null) {
|
||||
return "主要API与备用API";
|
||||
}
|
||||
return "OAuth 登录与 API 接入";
|
||||
return "OAuth / API";
|
||||
}
|
||||
|
||||
private LinearLayout buildOverviewSection(@Nullable JSONArray accounts) {
|
||||
@@ -198,9 +196,9 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
private String overviewSummaryForRole(@Nullable JSONArray accounts, String targetRole) {
|
||||
int count = countAccountsForRole(accounts, targetRole);
|
||||
if (count <= 0) {
|
||||
return "暂未配置,点进去添加。";
|
||||
return "暂未配置";
|
||||
}
|
||||
return "已配置 " + count + " 条,点进去查看和编辑。";
|
||||
return "已配置 " + count + " 条";
|
||||
}
|
||||
|
||||
private int countAccountsForRole(@Nullable JSONArray accounts, String targetRole) {
|
||||
@@ -282,7 +280,7 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
currentFastModelOverride,
|
||||
currentDeepModelOverride
|
||||
),
|
||||
"切换后会和主Agent对话框保持同步。",
|
||||
"与主Agent对话同步",
|
||||
"切换",
|
||||
v -> showMasterAgentModePicker()
|
||||
));
|
||||
@@ -290,7 +288,7 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
this,
|
||||
"快速反应模型",
|
||||
"当前:" + MasterAgentModePresets.resolveFastModel(currentFastModelOverride),
|
||||
"快速问答默认使用低推理强度。",
|
||||
"低推理强度",
|
||||
"配置",
|
||||
v -> showMasterAgentModeModelPicker(true)
|
||||
));
|
||||
@@ -298,7 +296,7 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
this,
|
||||
"深度思考模型",
|
||||
"当前:" + MasterAgentModePresets.resolveDeepModel(currentDeepModelOverride),
|
||||
"复杂任务默认使用高推理强度。",
|
||||
"高推理强度",
|
||||
"配置",
|
||||
v -> showMasterAgentModeModelPicker(false)
|
||||
));
|
||||
@@ -306,7 +304,7 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
section.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"OAuth 登录",
|
||||
isPrimaryRole(targetRole) ? "设置主要 OAuth 登录。" : "设置备用 OAuth 登录。",
|
||||
isPrimaryRole(targetRole) ? "主要 OAuth" : "备用 OAuth",
|
||||
configuredMethodAccountsSummary(accounts, targetRole, true),
|
||||
null,
|
||||
v -> openRoleProviderChooser(targetRole, true)
|
||||
@@ -314,7 +312,7 @@ public class AiAccountsActivity extends BossScreenActivity {
|
||||
section.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"API 接入",
|
||||
isPrimaryRole(targetRole) ? "设置主要 API 接入。" : "设置备用 API 接入。",
|
||||
isPrimaryRole(targetRole) ? "主要 API" : "备用 API",
|
||||
configuredMethodAccountsSummary(accounts, targetRole, false),
|
||||
null,
|
||||
v -> openRoleProviderChooser(targetRole, false)
|
||||
|
||||
@@ -66,6 +66,53 @@ public class BossApiClient {
|
||||
return response;
|
||||
}
|
||||
|
||||
public ApiResponse loginWithPassword(String account, String password) throws IOException, JSONException {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("account", account);
|
||||
body.put("password", password);
|
||||
body.put("method", "password");
|
||||
ApiResponse response = request("POST", "/api/auth/login", body, false);
|
||||
if (response.ok()) {
|
||||
rememberIdentity(response.json);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
public ApiResponse sendVerificationCode(String account, String purpose) throws IOException, JSONException {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("account", account);
|
||||
body.put("purpose", purpose);
|
||||
return request("POST", "/api/auth/send-code", body, false);
|
||||
}
|
||||
|
||||
public ApiResponse registerAccount(
|
||||
String account,
|
||||
String password,
|
||||
String confirmPassword,
|
||||
String code
|
||||
) throws IOException, JSONException {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("account", account);
|
||||
body.put("password", password);
|
||||
body.put("confirmPassword", confirmPassword);
|
||||
body.put("code", code);
|
||||
return request("POST", "/api/auth/register", body, false);
|
||||
}
|
||||
|
||||
public ApiResponse resetPassword(
|
||||
String account,
|
||||
String password,
|
||||
String confirmPassword,
|
||||
String code
|
||||
) throws IOException, JSONException {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("account", account);
|
||||
body.put("password", password);
|
||||
body.put("confirmPassword", confirmPassword);
|
||||
body.put("code", code);
|
||||
return request("POST", "/api/auth/forgot-password", body, false);
|
||||
}
|
||||
|
||||
public ApiResponse restoreSession() throws IOException, JSONException {
|
||||
if (getRestoreToken().isEmpty()) {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_RESTORE_TOKEN"));
|
||||
@@ -83,6 +130,17 @@ public class BossApiClient {
|
||||
return request("GET", "/api/auth/session", null, true);
|
||||
}
|
||||
|
||||
public ApiResponse getAuthSessions() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/auth/sessions", null);
|
||||
}
|
||||
|
||||
public ApiResponse revokeAuthSession(String sessionId) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("action", "revoke_session");
|
||||
payload.put("sessionId", sessionId);
|
||||
return requestWithRestore("POST", "/api/v1/auth/sessions", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getConversations() throws IOException, JSONException {
|
||||
return requestWithRestoreRaw(
|
||||
"GET",
|
||||
@@ -107,6 +165,12 @@ public class BossApiClient {
|
||||
return requestWithRestore("GET", "/api/v1/conversation-folders/" + encode(folderKey), null);
|
||||
}
|
||||
|
||||
public ApiResponse markConversationRead(String projectId) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("action", "mark_read");
|
||||
return requestWithRestore("POST", "/api/v1/conversations/" + encode(projectId) + "/actions", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getProjectDetail(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null);
|
||||
}
|
||||
@@ -254,6 +318,16 @@ public class BossApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse decideDialogGuardIntervention(String interventionId, String decision) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("decision", decision);
|
||||
return requestWithRestore(
|
||||
"POST",
|
||||
"/api/v1/dialog-guard/interventions/" + encode(interventionId) + "/decision",
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse retryDispatchPlan(String projectId, String planId) throws IOException, JSONException {
|
||||
return requestWithRestoreRaw(
|
||||
"POST",
|
||||
@@ -299,6 +373,18 @@ public class BossApiClient {
|
||||
return requestWithRestore("PATCH", "/api/v1/projects/" + encode(projectId) + "/dispatch-reminder", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getAttachmentStorageConfig() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/storage/config", null);
|
||||
}
|
||||
|
||||
public ApiResponse saveAttachmentStorageConfig(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("PATCH", "/api/v1/storage/config", payload);
|
||||
}
|
||||
|
||||
public ApiResponse validateAttachmentStorageConfig(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/storage/config/validate", payload);
|
||||
}
|
||||
|
||||
public ApiResponse sendProjectMessage(String projectId, String body, String kind) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("body", body);
|
||||
@@ -312,6 +398,14 @@ public class BossApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse deleteProjectMessage(String projectId, String messageId) throws IOException, JSONException {
|
||||
return requestWithRestore(
|
||||
"DELETE",
|
||||
"/api/v1/projects/" + encode(projectId) + "/messages?messageId=" + encode(messageId),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse uploadAttachment(
|
||||
String projectId,
|
||||
String fileName,
|
||||
@@ -484,6 +578,14 @@ public class BossApiClient {
|
||||
return requestWithRestore("GET", "/api/v1/accounts", null);
|
||||
}
|
||||
|
||||
public ApiResponse getAdminAccess() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/admin/access", null);
|
||||
}
|
||||
|
||||
public ApiResponse updateAdminAccess(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/admin/access", payload);
|
||||
}
|
||||
|
||||
public ApiResponse createAccount(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/accounts", payload);
|
||||
}
|
||||
@@ -546,6 +648,14 @@ public class BossApiClient {
|
||||
return requestWithRestore("POST", "/api/v1/settings", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getTelegramIntegration() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/integrations/telegram", null);
|
||||
}
|
||||
|
||||
public ApiResponse updateTelegramIntegration(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/integrations/telegram", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getOtaStatus() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/user/ota", null);
|
||||
}
|
||||
@@ -569,7 +679,7 @@ public class BossApiClient {
|
||||
}
|
||||
|
||||
public String getAccountLabel() {
|
||||
return prefs.getString(KEY_ACCOUNT, "17600003315");
|
||||
return prefs.getString(KEY_ACCOUNT, "krisolo");
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
@@ -614,9 +724,9 @@ public class BossApiClient {
|
||||
int readTimeoutMs
|
||||
) throws IOException, JSONException {
|
||||
ApiResponse response = requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
|
||||
if (response.statusCode == 401 && !getRestoreToken().isEmpty()) {
|
||||
ApiResponse restored = restoreSession();
|
||||
if (restored.ok()) {
|
||||
if (response.statusCode == 401) {
|
||||
ApiResponse recovered = !getRestoreToken().isEmpty() ? restoreSession() : autoLogin();
|
||||
if (recovered.ok()) {
|
||||
return requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
|
||||
}
|
||||
}
|
||||
@@ -709,7 +819,16 @@ public class BossApiClient {
|
||||
private ApiResponse executeConnection(HttpURLConnection connection, boolean expectProtected) throws IOException, JSONException {
|
||||
int statusCode = connection.getResponseCode();
|
||||
captureSessionCookie(connection.getHeaderFields());
|
||||
JSONObject json = readJson(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
|
||||
JsonBody jsonBody = readJsonBody(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
|
||||
JSONObject json = jsonBody.json;
|
||||
if (!jsonBody.validJson) {
|
||||
int normalizedStatusCode = expectProtected && statusCode < 400 ? 401 : statusCode;
|
||||
json = new JSONObject()
|
||||
.put("ok", false)
|
||||
.put("message", "NON_JSON_RESPONSE")
|
||||
.put("statusCode", statusCode);
|
||||
statusCode = normalizedStatusCode;
|
||||
}
|
||||
|
||||
if (statusCode == 401 && !expectProtected) {
|
||||
clearSession();
|
||||
@@ -822,8 +941,12 @@ public class BossApiClient {
|
||||
}
|
||||
|
||||
private JSONObject readJson(InputStream stream) throws IOException, JSONException {
|
||||
return readJsonBody(stream).json;
|
||||
}
|
||||
|
||||
private JsonBody readJsonBody(InputStream stream) throws IOException, JSONException {
|
||||
if (stream == null) {
|
||||
return new JSONObject();
|
||||
return new JsonBody(new JSONObject(), true);
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||
@@ -834,9 +957,13 @@ public class BossApiClient {
|
||||
}
|
||||
String raw = builder.toString().trim();
|
||||
if (raw.isEmpty()) {
|
||||
return new JSONObject();
|
||||
return new JsonBody(new JSONObject(), true);
|
||||
}
|
||||
try {
|
||||
return new JsonBody(new JSONObject(raw), true);
|
||||
} catch (JSONException error) {
|
||||
return new JsonBody(new JSONObject(), false);
|
||||
}
|
||||
return new JSONObject(raw);
|
||||
}
|
||||
|
||||
private String readText(InputStream stream) throws IOException {
|
||||
@@ -870,9 +997,13 @@ public class BossApiClient {
|
||||
|
||||
private void captureSessionCookie(Map<String, List<String>> headers) {
|
||||
if (headers == null) return;
|
||||
List<String> setCookieHeaders = headers.get("Set-Cookie");
|
||||
if (setCookieHeaders == null) {
|
||||
setCookieHeaders = headers.get("set-cookie");
|
||||
List<String> setCookieHeaders = null;
|
||||
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
|
||||
String headerName = entry.getKey();
|
||||
if (headerName != null && "set-cookie".equalsIgnoreCase(headerName)) {
|
||||
setCookieHeaders = entry.getValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (setCookieHeaders == null) return;
|
||||
|
||||
@@ -965,6 +1096,16 @@ public class BossApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
private static class JsonBody {
|
||||
final JSONObject json;
|
||||
final boolean validJson;
|
||||
|
||||
JsonBody(JSONObject json, boolean validJson) {
|
||||
this.json = json;
|
||||
this.validJson = validJson;
|
||||
}
|
||||
}
|
||||
|
||||
public static class DownloadedAttachment {
|
||||
public final int statusCode;
|
||||
public final String fileName;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,68 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Application;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
|
||||
public final class BossApplication extends Application {
|
||||
private final BossAppVisibilityTracker visibilityTracker = new BossAppVisibilityTracker();
|
||||
private BossNotificationRouter notificationRouter;
|
||||
private int startedActivityCount;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
|
||||
super.onCreate();
|
||||
notificationRouter = new BossNotificationRouter(this, visibilityTracker);
|
||||
registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
|
||||
@Override
|
||||
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {}
|
||||
|
||||
@Override
|
||||
public void onActivityStarted(Activity activity) {
|
||||
startedActivityCount += 1;
|
||||
if (startedActivityCount == 1) {
|
||||
visibilityTracker.onAppForegrounded();
|
||||
BossBackgroundRealtimeService.stop(BossApplication.this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResumed(Activity activity) {}
|
||||
|
||||
@Override
|
||||
public void onActivityPaused(Activity activity) {}
|
||||
|
||||
@Override
|
||||
public void onActivityStopped(Activity activity) {
|
||||
startedActivityCount = Math.max(0, startedActivityCount - 1);
|
||||
if (startedActivityCount == 0) {
|
||||
visibilityTracker.onAppBackgrounded();
|
||||
BossBackgroundRealtimeService.start(BossApplication.this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {}
|
||||
|
||||
@Override
|
||||
public void onActivityDestroyed(Activity activity) {}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTerminate() {
|
||||
BossBackgroundRealtimeService.stop(this);
|
||||
super.onTerminate();
|
||||
}
|
||||
|
||||
BossAppVisibilityTracker visibilityTracker() {
|
||||
return visibilityTracker;
|
||||
}
|
||||
|
||||
BossNotificationRouter notificationRouter() {
|
||||
return notificationRouter;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Typeface;
|
||||
import android.os.Build;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.SpannedString;
|
||||
@@ -155,8 +156,10 @@ public final class BossMarkdown {
|
||||
ensureBlockSeparation(builder, false);
|
||||
int start = builder.length();
|
||||
appendInlineStyled(builder, TextUtils.isEmpty(text) ? "引用" : text, palette);
|
||||
builder.setSpan(new QuoteSpan(palette.quoteColor, BossUi.dp(palette.context, 3), BossUi.dp(palette.context, 8)),
|
||||
start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
QuoteSpan quoteSpan = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|
||||
? new QuoteSpan(palette.quoteColor, BossUi.dp(palette.context, 3), BossUi.dp(palette.context, 8))
|
||||
: new QuoteSpan(palette.quoteColor);
|
||||
builder.setSpan(quoteSpan, start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.append('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
@@ -25,9 +26,15 @@ import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.SwitchCompat;
|
||||
import androidx.core.widget.ImageViewCompat;
|
||||
import android.content.res.ColorStateList;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public final class BossUi {
|
||||
private static final int[] AVATAR_BG_COLORS = {
|
||||
Color.parseColor("#1EC76F"),
|
||||
@@ -371,7 +378,64 @@ public final class BossUi {
|
||||
LinearLayout row = buildListRow(context, title, subtitle, meta, badge, listener);
|
||||
row.setBackgroundColor(Color.WHITE);
|
||||
row.setElevation(0f);
|
||||
row.setPadding(dp(context, 16), dp(context, 13), dp(context, 16), dp(context, 13));
|
||||
return row;
|
||||
}
|
||||
|
||||
public static LinearLayout buildWechatSwitchRow(
|
||||
Context context,
|
||||
String title,
|
||||
@Nullable String subtitle,
|
||||
SwitchCompat switchView
|
||||
) {
|
||||
LinearLayout row = new LinearLayout(context);
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
row.setGravity(Gravity.CENTER_VERTICAL);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
row.setLayoutParams(params);
|
||||
row.setBackgroundColor(Color.WHITE);
|
||||
row.setPadding(dp(context, 18), dp(context, 15), dp(context, 18), dp(context, 15));
|
||||
|
||||
LinearLayout textWrap = new LinearLayout(context);
|
||||
textWrap.setOrientation(LinearLayout.VERTICAL);
|
||||
textWrap.setLayoutParams(new LinearLayout.LayoutParams(
|
||||
0,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
1f
|
||||
));
|
||||
|
||||
TextView titleView = new TextView(context);
|
||||
titleView.setText(title);
|
||||
titleView.setTextSize(17);
|
||||
titleView.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
titleView.setTextColor(context.getColor(R.color.boss_text_primary));
|
||||
textWrap.addView(titleView);
|
||||
|
||||
if (!TextUtils.isEmpty(subtitle)) {
|
||||
TextView subtitleView = new TextView(context);
|
||||
subtitleView.setText(subtitle);
|
||||
subtitleView.setTextSize(14);
|
||||
subtitleView.setTextColor(context.getColor(R.color.boss_text_muted));
|
||||
subtitleView.setPadding(0, dp(context, 4), 0, 0);
|
||||
textWrap.addView(subtitleView);
|
||||
}
|
||||
|
||||
row.addView(textWrap);
|
||||
|
||||
ViewParent currentParent = switchView.getParent();
|
||||
if (currentParent instanceof ViewGroup) {
|
||||
((ViewGroup) currentParent).removeView(switchView);
|
||||
}
|
||||
LinearLayout.LayoutParams switchParams = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
switchParams.leftMargin = dp(context, 12);
|
||||
switchView.setLayoutParams(switchParams);
|
||||
row.addView(switchView);
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -501,11 +565,11 @@ public final class BossUi {
|
||||
);
|
||||
params.leftMargin = dp(context, 12);
|
||||
params.rightMargin = dp(context, 12);
|
||||
params.bottomMargin = dp(context, 12);
|
||||
params.bottomMargin = dp(context, 1);
|
||||
card.setLayoutParams(params);
|
||||
card.setPadding(dp(context, 14), dp(context, 14), dp(context, 14), dp(context, 14));
|
||||
card.setBackground(createRoundedBackground(Color.WHITE, dp(context, 18)));
|
||||
card.setElevation(dp(context, 1));
|
||||
card.setPadding(dp(context, 14), dp(context, 13), dp(context, 14), dp(context, 13));
|
||||
card.setBackgroundColor(Color.WHITE);
|
||||
card.setElevation(0f);
|
||||
if (listener != null) {
|
||||
card.setClickable(true);
|
||||
card.setFocusable(true);
|
||||
@@ -944,7 +1008,34 @@ public final class BossUi {
|
||||
}
|
||||
|
||||
public static LinearLayout buildEmptyCard(Context context, String text) {
|
||||
return buildCard(context, "暂无内容", text, "下拉或点击顶部刷新按钮重试。");
|
||||
LinearLayout card = new LinearLayout(context);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
params.leftMargin = dp(context, 16);
|
||||
params.rightMargin = dp(context, 16);
|
||||
params.topMargin = dp(context, 28);
|
||||
card.setLayoutParams(params);
|
||||
card.setGravity(Gravity.CENTER_HORIZONTAL);
|
||||
card.setPadding(dp(context, 16), dp(context, 18), dp(context, 16), dp(context, 18));
|
||||
|
||||
TextView titleView = new TextView(context);
|
||||
titleView.setText("暂无内容");
|
||||
titleView.setTextSize(16);
|
||||
titleView.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
titleView.setTextColor(context.getColor(R.color.boss_text_primary));
|
||||
card.addView(titleView);
|
||||
|
||||
TextView bodyView = new TextView(context);
|
||||
bodyView.setText(text);
|
||||
bodyView.setTextSize(13);
|
||||
bodyView.setGravity(Gravity.CENTER);
|
||||
bodyView.setTextColor(context.getColor(R.color.boss_text_muted));
|
||||
bodyView.setPadding(0, dp(context, 8), 0, 0);
|
||||
card.addView(bodyView);
|
||||
return card;
|
||||
}
|
||||
|
||||
public static TextView buildHintPill(Context context, String text) {
|
||||
@@ -965,6 +1056,7 @@ public final class BossUi {
|
||||
return pill;
|
||||
}
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
public static LinearLayout buildMessageBubble(
|
||||
Context context,
|
||||
String senderLabel,
|
||||
@@ -1006,6 +1098,410 @@ public final class BossUi {
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
public static LinearLayout buildMasterAgentMessageBubble(
|
||||
Context context,
|
||||
String senderLabel,
|
||||
String body,
|
||||
@Nullable String meta,
|
||||
@Nullable String kindLabel
|
||||
) {
|
||||
LinearLayout wrapper = buildMessageBubble(context, senderLabel, body, meta, false, kindLabel);
|
||||
View bubble = findMessageBodyContainer(wrapper);
|
||||
if (bubble != null) {
|
||||
GradientDrawable background = createRoundedBackground(Color.parseColor("#EAF5FF"), dp(context, 18));
|
||||
background.setStroke(dp(context, 1), Color.parseColor("#D1E8FF"));
|
||||
bubble.setBackground(background);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
public static LinearLayout buildThreadProcessFoldCard(
|
||||
Context context,
|
||||
int itemCount,
|
||||
@Nullable String preview,
|
||||
@Nullable String detail
|
||||
) {
|
||||
LinearLayout card = new LinearLayout(context);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
params.leftMargin = dp(context, 12);
|
||||
params.rightMargin = dp(context, 12);
|
||||
params.bottomMargin = dp(context, 10);
|
||||
card.setLayoutParams(params);
|
||||
card.setPadding(dp(context, 14), dp(context, 12), dp(context, 14), dp(context, 12));
|
||||
GradientDrawable background = createRoundedBackground(Color.parseColor("#F7F8F7"), dp(context, 16));
|
||||
background.setStroke(dp(context, 1), Color.parseColor("#E4E9E5"));
|
||||
card.setBackground(background);
|
||||
card.setClickable(true);
|
||||
card.setFocusable(true);
|
||||
|
||||
LinearLayout header = new LinearLayout(context);
|
||||
header.setOrientation(LinearLayout.HORIZONTAL);
|
||||
header.setGravity(Gravity.CENTER_VERTICAL);
|
||||
header.setLayoutParams(new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
));
|
||||
|
||||
TextView titleView = new TextView(context);
|
||||
titleView.setTextSize(14);
|
||||
titleView.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
titleView.setTextColor(context.getColor(R.color.boss_green));
|
||||
titleView.setLayoutParams(new LinearLayout.LayoutParams(
|
||||
0,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
1f
|
||||
));
|
||||
header.addView(titleView);
|
||||
|
||||
TextView arrowView = new TextView(context);
|
||||
arrowView.setTextSize(16);
|
||||
arrowView.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
arrowView.setTextColor(context.getColor(R.color.boss_text_muted));
|
||||
arrowView.setPadding(dp(context, 8), 0, 0, 0);
|
||||
header.addView(arrowView);
|
||||
card.addView(header);
|
||||
|
||||
TextView previewView = new TextView(context);
|
||||
previewView.setText(TextUtils.isEmpty(preview) ? "线程正在输出过程内容" : preview);
|
||||
previewView.setTextSize(13);
|
||||
previewView.setLineSpacing(0f, 1.24f);
|
||||
previewView.setTextColor(context.getColor(R.color.boss_text_muted));
|
||||
previewView.setPadding(0, dp(context, 6), 0, 0);
|
||||
previewView.setMaxLines(2);
|
||||
previewView.setEllipsize(TextUtils.TruncateAt.END);
|
||||
card.addView(previewView);
|
||||
|
||||
TextView detailView = new TextView(context);
|
||||
detailView.setText(detail);
|
||||
detailView.setTextSize(13);
|
||||
detailView.setLineSpacing(0f, 1.28f);
|
||||
detailView.setTextColor(context.getColor(R.color.boss_text_primary));
|
||||
detailView.setPadding(0, dp(context, 10), 0, 0);
|
||||
detailView.setVisibility(View.GONE);
|
||||
card.addView(detailView);
|
||||
|
||||
final boolean[] expanded = new boolean[] {false};
|
||||
View.OnClickListener toggleListener = ignored -> {
|
||||
expanded[0] = !expanded[0];
|
||||
titleView.setText((expanded[0] ? "收起本轮工作过程" : "查看本轮工作过程") + "(" + itemCount + " 条)");
|
||||
arrowView.setText(expanded[0] ? "⌃" : "⌄");
|
||||
detailView.setVisibility(expanded[0] ? View.VISIBLE : View.GONE);
|
||||
previewView.setVisibility(expanded[0] ? View.GONE : View.VISIBLE);
|
||||
card.setContentDescription((expanded[0] ? "已展开" : "已折叠") + "本轮工作过程");
|
||||
};
|
||||
card.setOnClickListener(toggleListener);
|
||||
titleView.setText("查看本轮工作过程(" + itemCount + " 条)");
|
||||
arrowView.setText("⌄");
|
||||
card.setContentDescription("已折叠本轮工作过程");
|
||||
return card;
|
||||
}
|
||||
|
||||
public static LinearLayout buildControlSummaryCard(
|
||||
Context context,
|
||||
String title,
|
||||
String body,
|
||||
@Nullable String meta,
|
||||
@Nullable String badge
|
||||
) {
|
||||
LinearLayout card = buildCard(context, title, body, meta, null);
|
||||
card.setPadding(dp(context, 16), dp(context, 14), dp(context, 16), dp(context, 14));
|
||||
card.setBackground(createRoundedBackground(Color.parseColor("#F7FAF7"), dp(context, 18)));
|
||||
GradientDrawable background = (GradientDrawable) card.getBackground();
|
||||
background.setStroke(dp(context, 1), Color.parseColor("#DCEBDD"));
|
||||
if (!TextUtils.isEmpty(badge)) {
|
||||
View badgeView = buildHintPill(context, badge);
|
||||
if (badgeView.getParent() != null) {
|
||||
((ViewGroup) badgeView.getParent()).removeView(badgeView);
|
||||
}
|
||||
card.addView(badgeView, 0);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
public static LinearLayout buildExecutionProgressCard(
|
||||
Context context,
|
||||
@Nullable JSONObject progress,
|
||||
@Nullable String meta
|
||||
) {
|
||||
LinearLayout card = new LinearLayout(context);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
params.leftMargin = dp(context, 12);
|
||||
params.rightMargin = dp(context, 12);
|
||||
params.bottomMargin = dp(context, 12);
|
||||
card.setLayoutParams(params);
|
||||
card.setPadding(dp(context, 18), dp(context, 16), dp(context, 18), dp(context, 16));
|
||||
GradientDrawable background = createRoundedBackground(Color.WHITE, dp(context, 22));
|
||||
background.setStroke(dp(context, 1), Color.parseColor("#E5E9E7"));
|
||||
card.setBackground(background);
|
||||
card.setElevation(dp(context, 1));
|
||||
|
||||
LinearLayout titleRow = new LinearLayout(context);
|
||||
titleRow.setOrientation(LinearLayout.HORIZONTAL);
|
||||
titleRow.setGravity(Gravity.CENTER_VERTICAL);
|
||||
titleRow.setLayoutParams(new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
));
|
||||
TextView title = sectionTitle(context, "进度");
|
||||
title.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
|
||||
titleRow.addView(title);
|
||||
TextView pin = new TextView(context);
|
||||
pin.setText("◆");
|
||||
pin.setTextSize(18);
|
||||
pin.setTextColor(Color.parseColor("#9AA09D"));
|
||||
titleRow.addView(pin);
|
||||
card.addView(titleRow);
|
||||
|
||||
JSONArray steps = progress == null ? null : progress.optJSONArray("steps");
|
||||
if (steps == null || steps.length() == 0) {
|
||||
card.addView(progressLine(context, "等待执行进度回写", "running"));
|
||||
} else {
|
||||
for (int i = 0; i < steps.length(); i += 1) {
|
||||
JSONObject step = steps.optJSONObject(i);
|
||||
if (step == null) {
|
||||
continue;
|
||||
}
|
||||
String text = step.optString("text", "").trim();
|
||||
if (TextUtils.isEmpty(text)) {
|
||||
continue;
|
||||
}
|
||||
card.addView(progressLine(context, text, step.optString("status", "pending")));
|
||||
}
|
||||
}
|
||||
|
||||
card.addView(divider(context));
|
||||
card.addView(sectionTitle(context, "分支详情"));
|
||||
JSONObject branch = progress == null ? null : progress.optJSONObject("branch");
|
||||
if (branch != null) {
|
||||
String changeText = formatChangeText(branch);
|
||||
if (!TextUtils.isEmpty(changeText)) {
|
||||
card.addView(changeRow(context, branch));
|
||||
}
|
||||
String gitStatus = branch.optString("gitStatus", "").trim();
|
||||
card.addView(detailRow(context, "⌘", TextUtils.isEmpty(gitStatus) ? "Git 操作" : gitStatus, "", false));
|
||||
String ghStatus = branch.optString("githubCliStatus", "").trim();
|
||||
if ("unavailable".equals(ghStatus)) {
|
||||
card.addView(detailRow(context, "○", "GitHub CLI 不可用", "", false, true));
|
||||
} else if ("available".equals(ghStatus)) {
|
||||
card.addView(detailRow(context, "○", "GitHub CLI 可用", "", false));
|
||||
}
|
||||
} else {
|
||||
card.addView(detailRow(context, "⌘", "Git 操作", "等待执行器回写", false));
|
||||
}
|
||||
|
||||
JSONArray artifacts = progress == null ? null : progress.optJSONArray("artifacts");
|
||||
if (artifacts != null && artifacts.length() > 0) {
|
||||
card.addView(divider(context));
|
||||
card.addView(sectionTitle(context, "生成结果"));
|
||||
int shown = 0;
|
||||
for (int i = 0; i < artifacts.length(); i += 1) {
|
||||
JSONObject artifact = artifacts.optJSONObject(i);
|
||||
String label = artifact == null ? "" : artifact.optString("label", "").trim();
|
||||
if (TextUtils.isEmpty(label)) {
|
||||
continue;
|
||||
}
|
||||
String icon = "image".equals(artifact.optString("kind", "")) ? "◌" : "▣";
|
||||
card.addView(detailRow(context, icon, label, "", false));
|
||||
shown += 1;
|
||||
if (shown >= 6) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (artifacts.length() > shown) {
|
||||
card.addView(detailRow(context, "", "再显示 " + (artifacts.length() - shown) + " 个", "", false, true));
|
||||
}
|
||||
}
|
||||
|
||||
JSONArray agents = progress == null ? null : progress.optJSONArray("agents");
|
||||
if (agents != null && agents.length() > 0) {
|
||||
card.addView(divider(context));
|
||||
card.addView(sectionTitle(context, "后台智能体"));
|
||||
for (int i = 0; i < agents.length(); i += 1) {
|
||||
JSONObject agent = agents.optJSONObject(i);
|
||||
String name = agent == null ? "" : agent.optString("name", "").trim();
|
||||
if (TextUtils.isEmpty(name)) {
|
||||
continue;
|
||||
}
|
||||
String role = agent.optString("role", "").trim();
|
||||
card.addView(detailRow(
|
||||
context,
|
||||
"♙",
|
||||
TextUtils.isEmpty(role) ? name : name + "(" + role + ")",
|
||||
"",
|
||||
false
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(meta)) {
|
||||
TextView metaView = secondaryText(context, meta);
|
||||
metaView.setPadding(0, dp(context, 10), 0, 0);
|
||||
card.addView(metaView);
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
private static View progressLine(Context context, String text, String status) {
|
||||
LinearLayout row = new LinearLayout(context);
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
row.setGravity(Gravity.TOP);
|
||||
row.setPadding(0, dp(context, 10), 0, 0);
|
||||
|
||||
TextView check = new TextView(context);
|
||||
LinearLayout.LayoutParams checkParams = new LinearLayout.LayoutParams(dp(context, 28), dp(context, 28));
|
||||
check.setLayoutParams(checkParams);
|
||||
check.setGravity(Gravity.CENTER);
|
||||
check.setText("✓");
|
||||
check.setTextSize(15);
|
||||
check.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
int color = "failed".equals(status) ? Color.parseColor("#E44B4B") :
|
||||
"running".equals(status) ? Color.parseColor("#1EC76F") : Color.parseColor("#9AA09D");
|
||||
check.setTextColor(Color.WHITE);
|
||||
check.setBackground(createRoundedBackground(color, dp(context, 14)));
|
||||
row.addView(check);
|
||||
|
||||
TextView body = new TextView(context);
|
||||
LinearLayout.LayoutParams bodyParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f);
|
||||
bodyParams.leftMargin = dp(context, 12);
|
||||
body.setLayoutParams(bodyParams);
|
||||
body.setText(text);
|
||||
body.setTextSize(18);
|
||||
body.setLineSpacing(0f, 1.22f);
|
||||
body.setTextColor(context.getColor(R.color.boss_text_primary));
|
||||
row.addView(body);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static View divider(Context context) {
|
||||
View divider = new View(context);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
dp(context, 1)
|
||||
);
|
||||
params.topMargin = dp(context, 16);
|
||||
params.bottomMargin = dp(context, 14);
|
||||
divider.setLayoutParams(params);
|
||||
divider.setBackgroundColor(Color.parseColor("#ECEFEE"));
|
||||
return divider;
|
||||
}
|
||||
|
||||
private static TextView sectionTitle(Context context, String text) {
|
||||
TextView view = new TextView(context);
|
||||
view.setText(text);
|
||||
view.setTextSize(17);
|
||||
view.setTypeface(Typeface.DEFAULT_BOLD);
|
||||
view.setTextColor(Color.parseColor("#8B918F"));
|
||||
return view;
|
||||
}
|
||||
|
||||
private static TextView secondaryText(Context context, String text) {
|
||||
TextView view = new TextView(context);
|
||||
view.setText(text);
|
||||
view.setTextSize(12);
|
||||
view.setTextColor(context.getColor(R.color.boss_text_muted));
|
||||
return view;
|
||||
}
|
||||
|
||||
private static View detailRow(Context context, String iconText, String label, String value, boolean valueIsChange) {
|
||||
return detailRow(context, iconText, label, value, valueIsChange, false);
|
||||
}
|
||||
|
||||
private static View detailRow(Context context, String iconText, String label, String value, boolean valueIsChange, boolean muted) {
|
||||
LinearLayout row = new LinearLayout(context);
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
row.setGravity(Gravity.CENTER_VERTICAL);
|
||||
row.setPadding(0, dp(context, 8), 0, 0);
|
||||
|
||||
TextView icon = new TextView(context);
|
||||
icon.setText(iconText);
|
||||
icon.setGravity(Gravity.CENTER);
|
||||
icon.setTextSize(18);
|
||||
icon.setTextColor(muted ? Color.parseColor("#9AA09D") : context.getColor(R.color.boss_text_primary));
|
||||
row.addView(icon, new LinearLayout.LayoutParams(dp(context, 34), LinearLayout.LayoutParams.WRAP_CONTENT));
|
||||
|
||||
TextView labelView = new TextView(context);
|
||||
labelView.setText(label);
|
||||
labelView.setTextSize(17);
|
||||
labelView.setTextColor(muted ? Color.parseColor("#8B918F") : context.getColor(R.color.boss_text_primary));
|
||||
row.addView(labelView, new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
|
||||
|
||||
if (!TextUtils.isEmpty(value)) {
|
||||
TextView valueView = new TextView(context);
|
||||
valueView.setText(value);
|
||||
valueView.setTextSize(16);
|
||||
valueView.setTextColor(valueIsChange ? Color.parseColor("#00A94F") : context.getColor(R.color.boss_text_muted));
|
||||
row.addView(valueView);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private static View changeRow(Context context, JSONObject branch) {
|
||||
LinearLayout row = new LinearLayout(context);
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
row.setGravity(Gravity.CENTER_VERTICAL);
|
||||
row.setPadding(0, dp(context, 8), 0, 0);
|
||||
|
||||
TextView icon = new TextView(context);
|
||||
icon.setText("⊞");
|
||||
icon.setGravity(Gravity.CENTER);
|
||||
icon.setTextSize(18);
|
||||
icon.setTextColor(context.getColor(R.color.boss_text_primary));
|
||||
row.addView(icon, new LinearLayout.LayoutParams(dp(context, 34), LinearLayout.LayoutParams.WRAP_CONTENT));
|
||||
|
||||
TextView labelView = new TextView(context);
|
||||
labelView.setText("变更");
|
||||
labelView.setTextSize(17);
|
||||
labelView.setTextColor(context.getColor(R.color.boss_text_primary));
|
||||
row.addView(labelView, new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
|
||||
|
||||
int additions = branch.optInt("additions", 0);
|
||||
if (additions > 0) {
|
||||
TextView additionsView = new TextView(context);
|
||||
additionsView.setText("+" + String.format(Locale.US, "%,d", additions));
|
||||
additionsView.setTextSize(16);
|
||||
additionsView.setTextColor(Color.parseColor("#00A94F"));
|
||||
row.addView(additionsView);
|
||||
}
|
||||
int deletions = branch.optInt("deletions", 0);
|
||||
if (deletions > 0) {
|
||||
TextView deletionsView = new TextView(context);
|
||||
deletionsView.setText("-" + String.format(Locale.US, "%,d", deletions));
|
||||
deletionsView.setTextSize(16);
|
||||
deletionsView.setTextColor(Color.parseColor("#C52828"));
|
||||
LinearLayout.LayoutParams deletionParams = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
deletionParams.leftMargin = dp(context, 8);
|
||||
row.addView(deletionsView, deletionParams);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private static String formatChangeText(JSONObject branch) {
|
||||
int additions = branch.optInt("additions", 0);
|
||||
int deletions = branch.optInt("deletions", 0);
|
||||
StringBuilder builder = new StringBuilder();
|
||||
if (additions > 0) {
|
||||
builder.append("+").append(String.format(Locale.US, "%,d", additions));
|
||||
}
|
||||
if (deletions > 0) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" ");
|
||||
}
|
||||
builder.append("-").append(String.format(Locale.US, "%,d", deletions));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
public static LinearLayout buildAttachmentMessageCard(
|
||||
Context context,
|
||||
String senderLabel,
|
||||
@@ -1160,6 +1656,23 @@ public final class BossUi {
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
public static LinearLayout buildMasterAgentForwardSingleBubble(
|
||||
Context context,
|
||||
String senderLabel,
|
||||
String body,
|
||||
@Nullable String meta,
|
||||
@Nullable String sourceLabel
|
||||
) {
|
||||
LinearLayout wrapper = buildForwardSingleBubble(context, senderLabel, body, meta, sourceLabel, false);
|
||||
View bubble = findMessageBodyContainer(wrapper);
|
||||
if (bubble != null) {
|
||||
GradientDrawable background = createRoundedBackground(Color.parseColor("#EAF5FF"), dp(context, 18));
|
||||
background.setStroke(dp(context, 1), Color.parseColor("#D1E8FF"));
|
||||
bubble.setBackground(background);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
public static LinearLayout buildForwardBundleCard(
|
||||
Context context,
|
||||
String senderLabel,
|
||||
@@ -1235,6 +1748,14 @@ public final class BossUi {
|
||||
return buildMessageBubble(context, effectiveSender, body, "发送中", true, null);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static View findMessageBodyContainer(LinearLayout wrapper) {
|
||||
if (wrapper == null || wrapper.getChildCount() < 2) {
|
||||
return null;
|
||||
}
|
||||
return wrapper.getChildAt(1);
|
||||
}
|
||||
|
||||
public static void applyMessageSelectionState(Context context, View messageView, boolean selected) {
|
||||
if (messageView == null) {
|
||||
return;
|
||||
@@ -1248,6 +1769,61 @@ public final class BossUi {
|
||||
}
|
||||
}
|
||||
|
||||
public static View wrapIncomingMessageWithSourceAvatar(
|
||||
Context context,
|
||||
View messageView,
|
||||
@Nullable String avatarLabel,
|
||||
@Nullable String sourceName
|
||||
) {
|
||||
if (messageView == null || TextUtils.isEmpty(avatarLabel)) {
|
||||
return messageView;
|
||||
}
|
||||
ViewParent parent = messageView.getParent();
|
||||
if (parent instanceof ViewGroup) {
|
||||
((ViewGroup) parent).removeView(messageView);
|
||||
}
|
||||
|
||||
LinearLayout row = new LinearLayout(context);
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
row.setGravity(Gravity.TOP);
|
||||
LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
row.setLayoutParams(rowParams);
|
||||
|
||||
TextView avatar = buildAvatarCircle(
|
||||
context,
|
||||
avatarLabel,
|
||||
Color.parseColor("#E5F6EC"),
|
||||
context.getColor(R.color.boss_green),
|
||||
36
|
||||
);
|
||||
LinearLayout.LayoutParams avatarParams = new LinearLayout.LayoutParams(dp(context, 36), dp(context, 36));
|
||||
avatarParams.leftMargin = dp(context, 8);
|
||||
avatarParams.rightMargin = dp(context, 8);
|
||||
avatarParams.topMargin = dp(context, 16);
|
||||
avatar.setLayoutParams(avatarParams);
|
||||
if (!TextUtils.isEmpty(sourceName)) {
|
||||
avatar.setContentDescription("来自 " + sourceName);
|
||||
}
|
||||
row.addView(avatar);
|
||||
|
||||
LinearLayout.LayoutParams contentParams = new LinearLayout.LayoutParams(
|
||||
0,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
1f
|
||||
);
|
||||
if (messageView.getLayoutParams() instanceof LinearLayout.LayoutParams) {
|
||||
LinearLayout.LayoutParams previous = (LinearLayout.LayoutParams) messageView.getLayoutParams();
|
||||
contentParams.topMargin = previous.topMargin;
|
||||
contentParams.bottomMargin = previous.bottomMargin;
|
||||
}
|
||||
messageView.setLayoutParams(contentParams);
|
||||
row.addView(messageView);
|
||||
return row;
|
||||
}
|
||||
|
||||
public static TextView buildMessagePlaceholder(Context context, String text) {
|
||||
TextView placeholder = new TextView(context);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||
@@ -1327,9 +1903,6 @@ public final class BossUi {
|
||||
@Nullable String meta,
|
||||
boolean outgoing
|
||||
) {
|
||||
if (outgoing && !TextUtils.isEmpty(meta)) {
|
||||
return meta;
|
||||
}
|
||||
if (TextUtils.isEmpty(meta)) {
|
||||
return senderLabel;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import java.util.Map;
|
||||
public class ConversationInfoActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
public static final String EXTRA_TAKEOVER_ENABLED = "takeover_enabled";
|
||||
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
|
||||
private String projectId;
|
||||
@@ -178,17 +179,9 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
takeoverInheritedFromGlobal = agentControls != null && agentControls.optBoolean("takeoverInheritedFromGlobal", false);
|
||||
configureScreen("会话信息", buildSubtitle(threadMeta, participantCount));
|
||||
|
||||
appendContent(BossUi.buildSimpleProfileHeader(
|
||||
this,
|
||||
projectName,
|
||||
"单线程会话",
|
||||
buildHeaderDetail(project, threadMeta, participantCount)
|
||||
));
|
||||
|
||||
appendThreadStatusSummary(threadStatusPayload);
|
||||
appendTakeoverControl();
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"发起群聊",
|
||||
"选择其他线程加入新群",
|
||||
@@ -197,7 +190,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
v -> openGroupCreate()
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"线程详情",
|
||||
"查看当前线程聊天与项目",
|
||||
@@ -206,7 +199,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
v -> openProject(projectId, projectName)
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"线程状态",
|
||||
"状态文档和最近进展事件",
|
||||
@@ -215,7 +208,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
v -> openThreadStatus()
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"参与线程",
|
||||
participantCount <= 0 ? "暂无参与线程" : "共 " + participantCount + " 个",
|
||||
@@ -225,7 +218,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
));
|
||||
|
||||
if (participants == null || participants.length() == 0) {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"暂无参与线程",
|
||||
"下拉刷新后重试",
|
||||
@@ -237,7 +230,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
for (int i = 0; i < participants.length(); i++) {
|
||||
JSONObject participant = participants.optJSONObject(i);
|
||||
if (participant == null) continue;
|
||||
appendContent(buildParticipantRow(participant));
|
||||
appendConversationInfoItem(buildParticipantRow(participant));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,86 +239,34 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
|
||||
private void appendTakeoverControl() {
|
||||
SwitchCompat takeoverSwitch = new SwitchCompat(this);
|
||||
takeoverSwitch.setText("开启");
|
||||
takeoverSwitch.setShowText(false);
|
||||
takeoverSwitch.setText(null);
|
||||
takeoverSwitch.setChecked(takeoverEnabled);
|
||||
takeoverSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> saveTakeoverSetting(isChecked));
|
||||
appendContent(BossUi.buildFormCell(
|
||||
appendConversationInfoItem(BossUi.buildWechatSwitchRow(
|
||||
this,
|
||||
"主 Agent 协同接管",
|
||||
takeoverInheritedFromGlobal
|
||||
? "当前跟随全局默认开启。主 Agent 会协同推进,但不会抢走你直接控制线程开发的能力。"
|
||||
: "为这个线程单独开启主 Agent 协同推进。不会抢走你直接控制线程开发的能力。",
|
||||
? "跟随全局默认开启"
|
||||
: "为此线程单独开启",
|
||||
takeoverSwitch
|
||||
));
|
||||
}
|
||||
|
||||
private void appendThreadStatusSummary(@Nullable JSONObject threadStatusPayload) {
|
||||
if (threadStatusPayload == null) {
|
||||
return;
|
||||
private void appendConversationInfoItem(android.view.View view) {
|
||||
android.view.ViewGroup.LayoutParams currentParams = view.getLayoutParams();
|
||||
LinearLayout.LayoutParams params;
|
||||
if (currentParams instanceof LinearLayout.LayoutParams) {
|
||||
params = (LinearLayout.LayoutParams) currentParams;
|
||||
} else {
|
||||
params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
}
|
||||
JSONObject document = threadStatusPayload.optJSONObject("threadStatusDocument");
|
||||
if (document == null) {
|
||||
return;
|
||||
}
|
||||
JSONArray recentProgressEvents = threadStatusPayload.optJSONArray("recentProgressEvents");
|
||||
int eventCount = recentProgressEvents == null ? 0 : recentProgressEvents.length();
|
||||
String body = buildThreadStatusSummaryBody(document, eventCount);
|
||||
String meta = buildThreadStatusSummaryMeta(document, eventCount);
|
||||
appendContent(BossUi.buildCard(this, "线程状态摘要", body, meta));
|
||||
}
|
||||
|
||||
private String buildThreadStatusSummaryBody(JSONObject document, int eventCount) {
|
||||
return joinNonEmptyLines(
|
||||
formatSummaryLine("当前目标", document.optString("projectGoal", "")),
|
||||
formatSummaryLine("当前进度", document.optString("currentProgress", "")),
|
||||
formatSummaryLine("当前阻塞", document.optString("currentBlockers", "")),
|
||||
formatSummaryLine("建议下一步", document.optString("recommendedNextStep", "")),
|
||||
eventCount > 0 ? "最近进展:" + eventCount + " 条" : ""
|
||||
);
|
||||
}
|
||||
|
||||
private String buildThreadStatusSummaryMeta(JSONObject document, int eventCount) {
|
||||
return joinNonEmptyParts(
|
||||
projectFolderName,
|
||||
eventCount > 0 ? "最近 " + eventCount + " 条进展" : "暂无进展",
|
||||
document.optString("updatedAt", "").isEmpty() ? "" : "更新于 " + document.optString("updatedAt", "")
|
||||
);
|
||||
}
|
||||
|
||||
private String formatSummaryLine(String label, String value) {
|
||||
String trimmed = value == null ? "" : value.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return label + ":" + trimmed;
|
||||
}
|
||||
|
||||
private String joinNonEmptyLines(String... values) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (String value : values) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append('\n');
|
||||
}
|
||||
builder.append(value.trim());
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String joinNonEmptyParts(String... values) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (String value : values) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(value.trim());
|
||||
}
|
||||
return builder.toString();
|
||||
params.bottomMargin = BossUi.dp(this, 8);
|
||||
view.setLayoutParams(params);
|
||||
appendContent(view);
|
||||
}
|
||||
|
||||
private LinearLayout buildParticipantRow(JSONObject participant) {
|
||||
@@ -445,6 +386,10 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
Intent result = new Intent();
|
||||
result.putExtra(EXTRA_PROJECT_NAME, projectName);
|
||||
result.putExtra(EXTRA_TAKEOVER_ENABLED, enabled);
|
||||
setResult(RESULT_OK, result);
|
||||
showMessage(enabled ? "已开启主 Agent 协同接管" : "已关闭主 Agent 协同接管");
|
||||
reload();
|
||||
});
|
||||
@@ -517,25 +462,6 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
return folder + " · " + suffix;
|
||||
}
|
||||
|
||||
private String buildHeaderDetail(JSONObject project, @Nullable JSONObject threadMeta, int count) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
String threadId = resolveThreadId(project, threadMeta);
|
||||
if (!threadId.isEmpty()) {
|
||||
builder.append(threadId);
|
||||
}
|
||||
if (!projectFolderName.isEmpty()) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(projectFolderName);
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(count <= 0 ? "暂无参与线程" : count + " 个参与线程");
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String resolveThreadId(JSONObject project, @Nullable JSONObject threadMeta) {
|
||||
if (threadMeta != null) {
|
||||
String threadId = threadMeta.optString("threadId", "");
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Color;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.text.Editable;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
@@ -23,6 +29,7 @@ import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.recyclerview.widget.DiffUtil;
|
||||
@@ -45,14 +52,17 @@ import java.util.function.Supplier;
|
||||
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
public static final String EXTRA_INITIAL_TAB = "initial_tab";
|
||||
private static final int REQUEST_POST_NOTIFICATIONS = 2101;
|
||||
private static final String UI_PREFS = "boss_native_client";
|
||||
private static final String KEY_LAST_ROOT_TAB = "last_root_tab";
|
||||
private static final String KEY_NOTIFICATION_PERMISSION_REQUESTED = "notification_permission_requested";
|
||||
private static final long ROOT_BACK_EXIT_WINDOW_MS = 1_500L;
|
||||
private static final long CONVERSATION_AUTO_REFRESH_MS = 12_000L;
|
||||
private static final long REALTIME_REFRESH_DEBOUNCE_MS = 350L;
|
||||
private static final long REALTIME_REFRESH_THROTTLE_MS = 900L;
|
||||
|
||||
private final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
private final ExecutorService sessionExecutor = Executors.newSingleThreadExecutor();
|
||||
private final Handler uiHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
private BossApiClient apiClient;
|
||||
@@ -64,7 +74,16 @@ public class MainActivity extends AppCompatActivity {
|
||||
private View mainTopBar;
|
||||
private TextView loginTitle;
|
||||
private TextView loginHint;
|
||||
private EditText loginAccountInput;
|
||||
private EditText loginPasswordInput;
|
||||
private EditText loginConfirmPasswordInput;
|
||||
private EditText loginCodeInput;
|
||||
private View loginCodeRow;
|
||||
private Button loginSendCodeButton;
|
||||
private Button loginButton;
|
||||
private Button loginModeButton;
|
||||
private Button registerModeButton;
|
||||
private Button forgotModeButton;
|
||||
private ProgressBar loginProgress;
|
||||
|
||||
private ImageButton backButton;
|
||||
@@ -92,6 +111,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
private String activeTab = "conversations";
|
||||
private String preferredEntryTab = "conversations";
|
||||
private @Nullable String requestedInitialTab;
|
||||
private String authMode = "login";
|
||||
private boolean userSelectedTab = false;
|
||||
private long lastRootBackPressedAt = 0L;
|
||||
private @Nullable JSONObject sessionData;
|
||||
@@ -107,9 +127,11 @@ public class MainActivity extends AppCompatActivity {
|
||||
private boolean conversationQuickActionsVisible = false;
|
||||
private boolean conversationAutoRefreshArmed = false;
|
||||
private boolean conversationAutoRefreshEnabled = false;
|
||||
private boolean conversationRootUsesGroupedHomeFeed = false;
|
||||
private boolean rootTabRefreshInFlight = false;
|
||||
private boolean pendingRootTabRefresh = false;
|
||||
private boolean realtimeRefreshScheduled = false;
|
||||
private boolean notificationPermissionRequestScheduled = false;
|
||||
private final java.util.HashMap<String, Long> recentRealtimeEventTimestamps = new java.util.HashMap<>();
|
||||
private final Set<String> selectedConversationProjectIds = new LinkedHashSet<>();
|
||||
private @Nullable RootPagerAdapter rootPagerAdapter;
|
||||
@@ -142,8 +164,21 @@ public class MainActivity extends AppCompatActivity {
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
apiClient = new BossApiClient(this);
|
||||
realtimeClient = new BossRealtimeClient(apiClient, new BossRealtimeClient.Listener() {
|
||||
apiClient = createApiClient();
|
||||
realtimeClient = createRealtimeClient(apiClient);
|
||||
bindViews();
|
||||
bindActions();
|
||||
configureBackNavigation();
|
||||
applyInitialTab(getIntent());
|
||||
bootstrapSession();
|
||||
}
|
||||
|
||||
BossApiClient createApiClient() {
|
||||
return new BossApiClient(this);
|
||||
}
|
||||
|
||||
BossRealtimeClient createRealtimeClient(BossApiClient client) {
|
||||
return new BossRealtimeClient(client, new BossRealtimeClient.Listener() {
|
||||
@Override
|
||||
public void onRealtimeEvent(BossRealtimeEvent event) {
|
||||
handleRealtimeEvent(event);
|
||||
@@ -154,10 +189,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
runOnUiThread(() -> handleRealtimeConnectionChanged(connected));
|
||||
}
|
||||
});
|
||||
bindViews();
|
||||
bindActions();
|
||||
applyInitialTab(getIntent());
|
||||
bootstrapSession();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -171,32 +202,44 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
private void configureBackNavigation() {
|
||||
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
if (handleRootBackPressed()) {
|
||||
return;
|
||||
}
|
||||
setEnabled(false);
|
||||
getOnBackPressedDispatcher().onBackPressed();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private boolean handleRootBackPressed() {
|
||||
if (contentPanel.getVisibility() == View.VISIBLE && conversationSearchMode) {
|
||||
exitConversationSearchMode(true);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (contentPanel.getVisibility() == View.VISIBLE && conversationQuickActionsVisible) {
|
||||
hideConversationQuickActions(true);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (contentPanel.getVisibility() == View.VISIBLE && !"conversations".equals(activeTab)) {
|
||||
setActiveTab("conversations", false);
|
||||
persistLastRootTab("conversations");
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (contentPanel.getVisibility() == View.VISIBLE) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastRootBackPressedAt < ROOT_BACK_EXIT_WINDOW_MS) {
|
||||
moveTaskToBack(true);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
lastRootBackPressedAt = now;
|
||||
showMessage("再按一次返回,应用进入后台");
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
super.onBackPressed();
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -204,6 +247,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
cancelConversationAutoRefresh();
|
||||
cancelRealtimeRefreshSchedule();
|
||||
stopRealtimeUpdates();
|
||||
sessionExecutor.shutdownNow();
|
||||
executor.shutdownNow();
|
||||
super.onDestroy();
|
||||
}
|
||||
@@ -214,6 +258,17 @@ public class MainActivity extends AppCompatActivity {
|
||||
conversationAutoRefreshEnabled = true;
|
||||
updateConversationAutoRefresh();
|
||||
updateRealtimeSubscription();
|
||||
maybeRequestNotificationPermission();
|
||||
if (
|
||||
contentPanel != null &&
|
||||
contentPanel.getVisibility() == View.VISIBLE &&
|
||||
"conversations".equals(activeTab) &&
|
||||
apiClient != null &&
|
||||
apiClient.hasSessionHints() &&
|
||||
!rootTabRefreshInFlight
|
||||
) {
|
||||
refreshConversationsData();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -232,7 +287,16 @@ public class MainActivity extends AppCompatActivity {
|
||||
mainTopBar = findViewById(R.id.main_top_bar);
|
||||
loginTitle = findViewById(R.id.login_title);
|
||||
loginHint = findViewById(R.id.login_hint);
|
||||
loginAccountInput = findViewById(R.id.login_account_input);
|
||||
loginPasswordInput = findViewById(R.id.login_password_input);
|
||||
loginConfirmPasswordInput = findViewById(R.id.login_confirm_password_input);
|
||||
loginCodeInput = findViewById(R.id.login_code_input);
|
||||
loginCodeRow = findViewById(R.id.login_code_row);
|
||||
loginSendCodeButton = findViewById(R.id.login_send_code_button);
|
||||
loginButton = findViewById(R.id.login_button);
|
||||
loginModeButton = findViewById(R.id.login_mode_button);
|
||||
registerModeButton = findViewById(R.id.register_mode_button);
|
||||
forgotModeButton = findViewById(R.id.forgot_mode_button);
|
||||
loginProgress = findViewById(R.id.login_progress);
|
||||
backButton = findViewById(R.id.back_button);
|
||||
topTitle = findViewById(R.id.top_title);
|
||||
@@ -259,12 +323,17 @@ public class MainActivity extends AppCompatActivity {
|
||||
loginTitle.setText(WechatSurfaceMapper.loginTitle());
|
||||
loginHint.setText(WechatSurfaceMapper.loginHintText());
|
||||
loginButton.setText(WechatSurfaceMapper.loginButtonLabel());
|
||||
setAuthMode("login", WechatSurfaceMapper.loginHintText());
|
||||
BossWindowInsets.applyStatusBarInset(loginShell);
|
||||
BossWindowInsets.applyStatusBarInset(mainTopBar);
|
||||
}
|
||||
|
||||
private void bindActions() {
|
||||
loginButton.setOnClickListener(v -> performAutoLogin());
|
||||
loginButton.setOnClickListener(v -> performPrimaryAuthAction());
|
||||
loginSendCodeButton.setOnClickListener(v -> sendAuthVerificationCode());
|
||||
loginModeButton.setOnClickListener(v -> setAuthMode("login", "请输入账号和密码登录。"));
|
||||
registerModeButton.setOnClickListener(v -> setAuthMode("register", "注册后会自动登录并进入会话。"));
|
||||
forgotModeButton.setOnClickListener(v -> setAuthMode("forgot", "通过验证码重置密码后再登录。"));
|
||||
backButton.setVisibility(View.GONE);
|
||||
backButton.setOnClickListener(v -> {
|
||||
if (conversationSearchMode) {
|
||||
@@ -348,7 +417,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
setLoginLoading(true, "正在恢复上次登录状态...");
|
||||
executor.execute(() -> {
|
||||
sessionExecutor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse sessionResponse = apiClient.getSession();
|
||||
if (!sessionResponse.ok()) {
|
||||
@@ -365,15 +434,52 @@ public class MainActivity extends AppCompatActivity {
|
||||
} catch (Exception ignored) {
|
||||
// Fall back to login panel.
|
||||
}
|
||||
runOnUiThread(() -> setLoginLoading(false, WechatSurfaceMapper.loginHintText()));
|
||||
runOnUiThread(() -> setLoginLoading(false, "登录已过期,请重新输入账号密码。"));
|
||||
});
|
||||
}
|
||||
|
||||
private void performAutoLogin() {
|
||||
setLoginLoading(true, "正在创建会话...");
|
||||
executor.execute(() -> {
|
||||
private void performPrimaryAuthAction() {
|
||||
String account = inputText(loginAccountInput);
|
||||
String password = inputText(loginPasswordInput);
|
||||
String confirmPassword = inputText(loginConfirmPasswordInput);
|
||||
String code = inputText(loginCodeInput);
|
||||
if (account.isEmpty()) {
|
||||
setLoginLoading(false, "请先填写账号。");
|
||||
return;
|
||||
}
|
||||
if (password.isEmpty()) {
|
||||
setLoginLoading(false, "请先填写密码。");
|
||||
return;
|
||||
}
|
||||
if (!"login".equals(authMode) && confirmPassword.isEmpty()) {
|
||||
setLoginLoading(false, "请再次确认密码。");
|
||||
return;
|
||||
}
|
||||
if (!"login".equals(authMode) && !password.equals(confirmPassword)) {
|
||||
setLoginLoading(false, "两次输入的密码不一致。");
|
||||
return;
|
||||
}
|
||||
if (!"login".equals(authMode) && code.isEmpty()) {
|
||||
setLoginLoading(false, "请先填写验证码。");
|
||||
return;
|
||||
}
|
||||
|
||||
if ("register".equals(authMode)) {
|
||||
performRegisterAndLogin(account, password, confirmPassword, code);
|
||||
return;
|
||||
}
|
||||
if ("forgot".equals(authMode)) {
|
||||
performPasswordReset(account, password, confirmPassword, code);
|
||||
return;
|
||||
}
|
||||
performPasswordLogin(account, password);
|
||||
}
|
||||
|
||||
private void performPasswordLogin(String account, String password) {
|
||||
setLoginLoading(true, "正在登录...");
|
||||
sessionExecutor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.autoLogin();
|
||||
BossApiClient.ApiResponse response = apiClient.loginWithPassword(account, password);
|
||||
if (response.ok()) {
|
||||
JSONObject session = response.json.optJSONObject("session");
|
||||
runOnUiThread(() -> {
|
||||
@@ -389,6 +495,78 @@ public class MainActivity extends AppCompatActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void performRegisterAndLogin(String account, String password, String confirmPassword, String code) {
|
||||
setLoginLoading(true, "正在注册...");
|
||||
sessionExecutor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse registerResponse = apiClient.registerAccount(
|
||||
account,
|
||||
password,
|
||||
confirmPassword,
|
||||
code
|
||||
);
|
||||
if (!registerResponse.ok()) {
|
||||
runOnUiThread(() -> setLoginLoading(false, "注册失败:" + registerResponse.message()));
|
||||
return;
|
||||
}
|
||||
BossApiClient.ApiResponse loginResponse = apiClient.loginWithPassword(account, password);
|
||||
if (loginResponse.ok()) {
|
||||
JSONObject session = loginResponse.json.optJSONObject("session");
|
||||
runOnUiThread(() -> {
|
||||
showContent();
|
||||
refreshAllData(session);
|
||||
});
|
||||
return;
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
setAuthMode("login", "注册成功,请用刚才的账号密码登录。");
|
||||
setLoginLoading(false, "注册成功,请用刚才的账号密码登录。");
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> setLoginLoading(false, "注册链路异常:" + error.getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void performPasswordReset(String account, String password, String confirmPassword, String code) {
|
||||
setLoginLoading(true, "正在重置密码...");
|
||||
sessionExecutor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.resetPassword(account, password, confirmPassword, code);
|
||||
if (response.ok()) {
|
||||
runOnUiThread(() -> {
|
||||
clearSecretInputs();
|
||||
setAuthMode("login", "密码已重置,请使用新密码登录。");
|
||||
});
|
||||
return;
|
||||
}
|
||||
runOnUiThread(() -> setLoginLoading(false, "重置失败:" + response.message()));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> setLoginLoading(false, "重置链路异常:" + error.getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void sendAuthVerificationCode() {
|
||||
String account = inputText(loginAccountInput);
|
||||
if (account.isEmpty()) {
|
||||
setLoginLoading(false, "请先填写账号。");
|
||||
return;
|
||||
}
|
||||
String purpose = "forgot".equals(authMode) ? "forgot-password" : "register";
|
||||
setLoginLoading(true, "正在发送验证码...");
|
||||
sessionExecutor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.sendVerificationCode(account, purpose);
|
||||
runOnUiThread(() -> setLoginLoading(false, response.ok()
|
||||
? "验证码已发送,请查看对应邮箱或短信。"
|
||||
: "验证码发送失败:" + response.message()));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> setLoginLoading(false, "验证码链路异常:" + error.getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void refreshCurrentTab() {
|
||||
if (rootTabRefreshInFlight) {
|
||||
pendingRootTabRefresh = true;
|
||||
@@ -413,9 +591,11 @@ public class MainActivity extends AppCompatActivity {
|
||||
JSONObject session = ensureActiveSession();
|
||||
BossApiClient.ApiResponse conversations = null;
|
||||
boolean conversationsOk = false;
|
||||
boolean usedGroupedHomeFeed = false;
|
||||
try {
|
||||
conversations = apiClient.getConversationHome();
|
||||
conversationsOk = conversations.ok();
|
||||
usedGroupedHomeFeed = conversationsOk;
|
||||
} catch (Exception ignored) {
|
||||
conversationsOk = false;
|
||||
}
|
||||
@@ -425,6 +605,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (fallbackConversations.ok()) {
|
||||
conversations = fallbackConversations;
|
||||
conversationsOk = true;
|
||||
usedGroupedHomeFeed = false;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
conversationsOk = false;
|
||||
@@ -433,18 +614,24 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
BossApiClient.ApiResponse finalConversations = conversations;
|
||||
final boolean finalConversationsOk = conversationsOk;
|
||||
final boolean finalUsedGroupedHomeFeed = usedGroupedHomeFeed;
|
||||
runOnUiThread(() -> {
|
||||
sessionData = session;
|
||||
JSONArray refreshedConversations = finalConversations == null
|
||||
? null
|
||||
: WechatSurfaceMapper.normalizeConversationHomeFeed(
|
||||
finalConversations.json.optJSONArray("conversations")
|
||||
);
|
||||
: finalUsedGroupedHomeFeed
|
||||
? finalConversations.json.optJSONArray("conversations")
|
||||
: WechatSurfaceMapper.normalizeConversationHomeFeed(
|
||||
finalConversations.json.optJSONArray("conversations")
|
||||
);
|
||||
conversationsData = WechatSurfaceMapper.resolveRefreshValue(
|
||||
conversationsData,
|
||||
refreshedConversations,
|
||||
finalConversationsOk
|
||||
);
|
||||
if (finalConversationsOk) {
|
||||
conversationRootUsesGroupedHomeFeed = finalUsedGroupedHomeFeed;
|
||||
}
|
||||
maybeApplyPreferredEntry();
|
||||
renderCurrentTab();
|
||||
startRefreshing(false);
|
||||
@@ -612,7 +799,11 @@ public class MainActivity extends AppCompatActivity {
|
||||
return false;
|
||||
}
|
||||
JSONObject conversationItem = event.payload.optJSONObject("conversationItem");
|
||||
if (conversationItem == null) {
|
||||
JSONObject threadConversationItem = event.payload.optJSONObject("threadConversationItem");
|
||||
JSONObject patchItem = conversationRootUsesGroupedHomeFeed
|
||||
? (conversationItem != null ? conversationItem : threadConversationItem)
|
||||
: (threadConversationItem != null ? threadConversationItem : conversationItem);
|
||||
if (patchItem == null) {
|
||||
return false;
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
@@ -622,7 +813,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
conversationsData = WechatSurfaceMapper.mergeConversationHomeItem(
|
||||
conversationsData,
|
||||
conversationItem,
|
||||
patchItem,
|
||||
affectedProjectId
|
||||
);
|
||||
renderCurrentTab();
|
||||
@@ -631,8 +822,11 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
private void scheduleRealtimeRefresh() {
|
||||
realtimeRefreshScheduled = false;
|
||||
refreshCurrentTab();
|
||||
if (realtimeRefreshScheduled) {
|
||||
return;
|
||||
}
|
||||
realtimeRefreshScheduled = true;
|
||||
uiHandler.postDelayed(realtimeRefreshRunnable, REALTIME_REFRESH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private void cancelRealtimeRefreshSchedule() {
|
||||
@@ -729,6 +923,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
BossApiClient.ApiResponse ota = null;
|
||||
BossApiClient.ApiResponse settings = null;
|
||||
boolean conversationsOk = false;
|
||||
boolean usedGroupedHomeFeed = false;
|
||||
boolean devicesOk = false;
|
||||
boolean otaOk = false;
|
||||
boolean settingsOk = false;
|
||||
@@ -736,6 +931,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
try {
|
||||
conversations = apiClient.getConversationHome();
|
||||
conversationsOk = conversations.ok();
|
||||
usedGroupedHomeFeed = conversationsOk;
|
||||
} catch (Exception ignored) {
|
||||
conversationsOk = false;
|
||||
}
|
||||
@@ -745,6 +941,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (fallbackConversations.ok()) {
|
||||
conversations = fallbackConversations;
|
||||
conversationsOk = true;
|
||||
usedGroupedHomeFeed = false;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
conversationsOk = false;
|
||||
@@ -775,6 +972,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
BossApiClient.ApiResponse finalOta = ota;
|
||||
BossApiClient.ApiResponse finalSettings = settings;
|
||||
final boolean finalConversationsOk = conversationsOk;
|
||||
final boolean finalUsedGroupedHomeFeed = usedGroupedHomeFeed;
|
||||
final boolean finalDevicesOk = devicesOk;
|
||||
final boolean finalOtaOk = otaOk;
|
||||
final boolean finalSettingsOk = settingsOk;
|
||||
@@ -782,14 +980,19 @@ public class MainActivity extends AppCompatActivity {
|
||||
sessionData = finalSession;
|
||||
JSONArray refreshedConversations = finalConversations == null
|
||||
? null
|
||||
: WechatSurfaceMapper.normalizeConversationHomeFeed(
|
||||
finalConversations.json.optJSONArray("conversations")
|
||||
);
|
||||
: finalUsedGroupedHomeFeed
|
||||
? finalConversations.json.optJSONArray("conversations")
|
||||
: WechatSurfaceMapper.normalizeConversationHomeFeed(
|
||||
finalConversations.json.optJSONArray("conversations")
|
||||
);
|
||||
conversationsData = WechatSurfaceMapper.resolveRefreshValue(
|
||||
conversationsData,
|
||||
refreshedConversations,
|
||||
finalConversationsOk
|
||||
);
|
||||
if (finalConversationsOk) {
|
||||
conversationRootUsesGroupedHomeFeed = finalUsedGroupedHomeFeed;
|
||||
}
|
||||
devicesData = WechatSurfaceMapper.resolveRefreshValue(
|
||||
devicesData,
|
||||
finalDevices == null ? null : finalDevices.json.optJSONArray("devices"),
|
||||
@@ -865,6 +1068,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
private void showLogin(String hint) {
|
||||
loginPanel.setVisibility(View.VISIBLE);
|
||||
contentPanel.setVisibility(View.GONE);
|
||||
setAuthMode("login", hint);
|
||||
setLoginLoading(false, hint);
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
@@ -875,15 +1079,76 @@ public class MainActivity extends AppCompatActivity {
|
||||
setActiveTab(activeTab, false);
|
||||
updateConversationAutoRefresh();
|
||||
updateRealtimeSubscription();
|
||||
scheduleNotificationPermissionRequest();
|
||||
}
|
||||
|
||||
private void setLoginLoading(boolean loading, String hint) {
|
||||
loginProgress.setVisibility(loading ? View.VISIBLE : View.GONE);
|
||||
loginButton.setEnabled(!loading);
|
||||
loginButton.setText(loading ? "处理中..." : WechatSurfaceMapper.loginButtonLabel());
|
||||
loginSendCodeButton.setEnabled(!loading);
|
||||
loginModeButton.setEnabled(!loading);
|
||||
registerModeButton.setEnabled(!loading);
|
||||
forgotModeButton.setEnabled(!loading);
|
||||
loginButton.setText(loading ? "处理中..." : primaryAuthButtonLabel());
|
||||
loginHint.setText(hint);
|
||||
}
|
||||
|
||||
private void setAuthMode(String mode, String hint) {
|
||||
authMode = ("register".equals(mode) || "forgot".equals(mode)) ? mode : "login";
|
||||
boolean codeMode = !"login".equals(authMode);
|
||||
loginTitle.setText(authTitle());
|
||||
loginButton.setText(primaryAuthButtonLabel());
|
||||
loginPasswordInput.setHint("forgot".equals(authMode) ? "新密码" : "密码");
|
||||
loginConfirmPasswordInput.setVisibility(codeMode ? View.VISIBLE : View.GONE);
|
||||
loginCodeRow.setVisibility(codeMode ? View.VISIBLE : View.GONE);
|
||||
loginHint.setText(hint);
|
||||
tintAuthModeButtons();
|
||||
}
|
||||
|
||||
private String authTitle() {
|
||||
if ("register".equals(authMode)) {
|
||||
return "注册账号";
|
||||
}
|
||||
if ("forgot".equals(authMode)) {
|
||||
return "找回密码";
|
||||
}
|
||||
return "登录 Boss";
|
||||
}
|
||||
|
||||
private String primaryAuthButtonLabel() {
|
||||
if ("register".equals(authMode)) {
|
||||
return "注册并登录";
|
||||
}
|
||||
if ("forgot".equals(authMode)) {
|
||||
return "重置密码";
|
||||
}
|
||||
return "登录";
|
||||
}
|
||||
|
||||
private void tintAuthModeButtons() {
|
||||
int selectedColor = getColor(R.color.boss_green);
|
||||
int mutedColor = getColor(R.color.boss_text_muted);
|
||||
loginModeButton.setTextColor("login".equals(authMode) ? selectedColor : mutedColor);
|
||||
registerModeButton.setTextColor("register".equals(authMode) ? selectedColor : mutedColor);
|
||||
forgotModeButton.setTextColor("forgot".equals(authMode) ? selectedColor : mutedColor);
|
||||
}
|
||||
|
||||
private String inputText(EditText input) {
|
||||
return input == null || input.getText() == null ? "" : input.getText().toString().trim();
|
||||
}
|
||||
|
||||
private void clearSecretInputs() {
|
||||
if (loginPasswordInput != null) {
|
||||
loginPasswordInput.setText("");
|
||||
}
|
||||
if (loginConfirmPasswordInput != null) {
|
||||
loginConfirmPasswordInput.setText("");
|
||||
}
|
||||
if (loginCodeInput != null) {
|
||||
loginCodeInput.setText("");
|
||||
}
|
||||
}
|
||||
|
||||
private void setActiveTab(String tab, boolean fromUser) {
|
||||
if (!"conversations".equals(tab)) {
|
||||
exitConversationSelectionMode();
|
||||
@@ -942,14 +1207,22 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
private void updateTabStyles() {
|
||||
styleTab(tabConversations, "conversations".equals(activeTab));
|
||||
styleTab(tabDevices, "devices".equals(activeTab));
|
||||
styleTab(tabMe, "me".equals(activeTab));
|
||||
styleTab(tabConversations, "conversations".equals(activeTab), R.drawable.ic_boss_tab_chat);
|
||||
styleTab(tabDevices, "devices".equals(activeTab), R.drawable.ic_boss_tab_devices);
|
||||
styleTab(tabMe, "me".equals(activeTab), R.drawable.ic_boss_tab_me);
|
||||
}
|
||||
|
||||
private void styleTab(Button button, boolean active) {
|
||||
button.setBackgroundResource(active ? R.drawable.bg_tab_active : R.drawable.bg_tab_inactive);
|
||||
button.setTextColor(getColor(active ? R.color.boss_green : R.color.boss_text_muted));
|
||||
private void styleTab(Button button, boolean active, int iconRes) {
|
||||
int color = getColor(active ? R.color.boss_green : R.color.boss_text_muted);
|
||||
button.setBackgroundColor(Color.TRANSPARENT);
|
||||
button.setTextColor(color);
|
||||
button.setTextSize(12);
|
||||
button.setAllCaps(false);
|
||||
button.setGravity(android.view.Gravity.CENTER);
|
||||
button.setCompoundDrawablesWithIntrinsicBounds(0, iconRes, 0, 0);
|
||||
button.setCompoundDrawablePadding(BossUi.dp(this, 3));
|
||||
button.setCompoundDrawableTintList(ColorStateList.valueOf(color));
|
||||
button.setPadding(0, BossUi.dp(this, 5), 0, BossUi.dp(this, 3));
|
||||
}
|
||||
|
||||
private void configureTopAction(WechatSurfaceMapper.RootTopAction action) {
|
||||
@@ -970,7 +1243,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
topSearchInput.setVisibility(View.GONE);
|
||||
backButton.setVisibility(View.GONE);
|
||||
searchButton.setVisibility(View.GONE);
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, false);
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, false, false, currentSessionRole());
|
||||
refreshButton.setVisibility(View.VISIBLE);
|
||||
configureTopAction(action);
|
||||
}
|
||||
@@ -1005,7 +1278,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
refreshButton.setEnabled(true);
|
||||
return;
|
||||
}
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode);
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode, currentSessionRole());
|
||||
configureTopAction(action);
|
||||
refreshButton.setEnabled(!"refresh".equals(action.actionKey) || !refreshing);
|
||||
refreshButton.setAlpha(refreshing && "refresh".equals(action.actionKey) ? 0.45f : 1f);
|
||||
@@ -1036,7 +1309,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
toggleConversationQuickActions();
|
||||
return;
|
||||
}
|
||||
String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode).actionKey;
|
||||
String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode, currentSessionRole()).actionKey;
|
||||
if ("add_device".equals(actionKey)) {
|
||||
startActivity(new Intent(this, DeviceEnrollmentActivity.class));
|
||||
return;
|
||||
@@ -1181,11 +1454,19 @@ public class MainActivity extends AppCompatActivity {
|
||||
String matchedProjectId = item.optString("searchMatchProjectId", "").trim();
|
||||
String matchedProjectLabel = item.optString("searchMatchLabel", "").trim();
|
||||
if (!matchedProjectId.isEmpty() && !matchedProjectLabel.isEmpty()) {
|
||||
exitConversationSearchMode(true);
|
||||
openProject(matchedProjectId, matchedProjectLabel);
|
||||
exitConversationSearchMode(true);
|
||||
return;
|
||||
}
|
||||
openConversationFolder(
|
||||
folderKey,
|
||||
resolveConversationFolderName(item, row),
|
||||
item.optString("searchMatchProjectId", ""),
|
||||
item.optJSONArray("searchMatchProjectIds"),
|
||||
item.optString("searchMatchLabel", "")
|
||||
);
|
||||
exitConversationSearchMode(true);
|
||||
return;
|
||||
}
|
||||
openConversationFolder(
|
||||
folderKey,
|
||||
@@ -1543,6 +1824,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
private void prepareConversationQuickActionMenu() {
|
||||
quickActionAddDevice.setVisibility("highest_admin".equals(currentSessionRole()) ? View.VISIBLE : View.GONE);
|
||||
conversationQuickActionsMenu.setVisibility(View.VISIBLE);
|
||||
conversationQuickActionsMenu.setAlpha(0f);
|
||||
conversationQuickActionsMenu.setTranslationY(-BossUi.dp(this, 6));
|
||||
@@ -1553,6 +1835,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
conversationQuickActionsMenu.setAlpha(0f);
|
||||
conversationQuickActionsMenu.setTranslationY(-BossUi.dp(this, 6));
|
||||
conversationQuickActionsMenu.setVisibility(View.GONE);
|
||||
quickActionAddDevice.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
static boolean matchesConversationQuery(JSONObject item, String rawQuery) {
|
||||
@@ -1706,7 +1989,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
(roleLabel.isEmpty() ? "主控账号已启用安全保护" : roleLabel + " · 主控账号已启用安全保护")
|
||||
));
|
||||
|
||||
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItems()) {
|
||||
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItemsForRole(currentSessionRole())) {
|
||||
screenContent.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
item.title,
|
||||
@@ -1856,6 +2139,33 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private void maybeRequestNotificationPermission() {
|
||||
notificationPermissionRequestScheduled = false;
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
return;
|
||||
}
|
||||
if (contentPanel == null || contentPanel.getVisibility() != View.VISIBLE) {
|
||||
return;
|
||||
}
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
||||
return;
|
||||
}
|
||||
android.content.SharedPreferences prefs = getSharedPreferences(UI_PREFS, Context.MODE_PRIVATE);
|
||||
if (prefs.getBoolean(KEY_NOTIFICATION_PERMISSION_REQUESTED, false)) {
|
||||
return;
|
||||
}
|
||||
prefs.edit().putBoolean(KEY_NOTIFICATION_PERMISSION_REQUESTED, true).apply();
|
||||
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, REQUEST_POST_NOTIFICATIONS);
|
||||
}
|
||||
|
||||
private void scheduleNotificationPermissionRequest() {
|
||||
if (notificationPermissionRequestScheduled) {
|
||||
return;
|
||||
}
|
||||
notificationPermissionRequestScheduled = true;
|
||||
uiHandler.postDelayed(this::maybeRequestNotificationPermission, 450L);
|
||||
}
|
||||
|
||||
void handleRealtimeConnectionChanged(boolean connected) {
|
||||
if (!connected
|
||||
&& shouldMaintainConversationAutoRefresh()
|
||||
@@ -1868,14 +2178,27 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
private void openMeEntry(String key) {
|
||||
if (!WechatSurfaceMapper.canOpenMeEntryForRole(key, currentSessionRole())) {
|
||||
showMessage("当前账号没有权限打开这个入口。");
|
||||
return;
|
||||
}
|
||||
Intent intent;
|
||||
switch (key) {
|
||||
case "security":
|
||||
intent = new Intent(this, SecurityActivity.class);
|
||||
break;
|
||||
case "access":
|
||||
intent = new Intent(this, AccessManagementActivity.class);
|
||||
break;
|
||||
case "ai_accounts":
|
||||
intent = new Intent(this, AiAccountsActivity.class);
|
||||
break;
|
||||
case "storage":
|
||||
intent = new Intent(this, StorageSettingsActivity.class);
|
||||
break;
|
||||
case "telegram":
|
||||
intent = new Intent(this, TelegramIntegrationActivity.class);
|
||||
break;
|
||||
case "settings":
|
||||
intent = new Intent(this, SettingsActivity.class);
|
||||
break;
|
||||
@@ -1895,6 +2218,13 @@ public class MainActivity extends AppCompatActivity {
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private String currentSessionRole() {
|
||||
if (sessionData == null) {
|
||||
return "member";
|
||||
}
|
||||
return sessionData.optString("role", "member");
|
||||
}
|
||||
|
||||
private void openSkillInventoryFromMe() {
|
||||
String targetDeviceId = resolveSkillTargetDeviceId();
|
||||
if (targetDeviceId == null || targetDeviceId.isEmpty()) {
|
||||
|
||||
@@ -14,6 +14,30 @@ import java.util.Set;
|
||||
public final class ProjectChatUiState {
|
||||
private ProjectChatUiState() {}
|
||||
|
||||
public static final class MessageDisplayItem {
|
||||
public static final String TYPE_MESSAGE = "message";
|
||||
public static final String TYPE_PROCESS_GROUP = "process_group";
|
||||
|
||||
public final String type;
|
||||
@Nullable
|
||||
public final JSONObject message;
|
||||
public final List<JSONObject> processMessages;
|
||||
|
||||
private MessageDisplayItem(String type, @Nullable JSONObject message, List<JSONObject> processMessages) {
|
||||
this.type = type;
|
||||
this.message = message;
|
||||
this.processMessages = Collections.unmodifiableList(new ArrayList<>(processMessages));
|
||||
}
|
||||
|
||||
private static MessageDisplayItem message(JSONObject message) {
|
||||
return new MessageDisplayItem(TYPE_MESSAGE, message, Collections.emptyList());
|
||||
}
|
||||
|
||||
private static MessageDisplayItem processGroup(List<JSONObject> processMessages) {
|
||||
return new MessageDisplayItem(TYPE_PROCESS_GROUP, null, processMessages);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class SelectionState {
|
||||
public final boolean multiSelecting;
|
||||
public final Set<String> selectedMessageIds;
|
||||
@@ -31,6 +55,7 @@ public final class ProjectChatUiState {
|
||||
public final boolean showMultiSelectBar;
|
||||
public final boolean showRefresh;
|
||||
public final boolean showHeaderAction;
|
||||
public final boolean copyEnabled;
|
||||
public final boolean forwardEnabled;
|
||||
public final String backLabel;
|
||||
public final String title;
|
||||
@@ -42,6 +67,7 @@ public final class ProjectChatUiState {
|
||||
boolean showMultiSelectBar,
|
||||
boolean showRefresh,
|
||||
boolean showHeaderAction,
|
||||
boolean copyEnabled,
|
||||
boolean forwardEnabled,
|
||||
String backLabel,
|
||||
String title,
|
||||
@@ -52,6 +78,7 @@ public final class ProjectChatUiState {
|
||||
this.showMultiSelectBar = showMultiSelectBar;
|
||||
this.showRefresh = showRefresh;
|
||||
this.showHeaderAction = showHeaderAction;
|
||||
this.copyEnabled = copyEnabled;
|
||||
this.forwardEnabled = forwardEnabled;
|
||||
this.backLabel = backLabel;
|
||||
this.title = title;
|
||||
@@ -81,6 +108,77 @@ public final class ProjectChatUiState {
|
||||
return nearBottom || forced;
|
||||
}
|
||||
|
||||
public static List<MessageDisplayItem> buildMessageDisplayItems(@Nullable JSONArray messages) {
|
||||
ArrayList<MessageDisplayItem> items = new ArrayList<>();
|
||||
if (messages == null || messages.length() == 0) {
|
||||
return items;
|
||||
}
|
||||
ArrayList<JSONObject> pendingProcessMessages = new ArrayList<>();
|
||||
for (int i = 0; i < messages.length(); i++) {
|
||||
JSONObject message = messages.optJSONObject(i);
|
||||
if (message == null) {
|
||||
continue;
|
||||
}
|
||||
if (isThreadProcessMessage(message)) {
|
||||
pendingProcessMessages.add(message);
|
||||
continue;
|
||||
}
|
||||
flushProcessGroup(items, pendingProcessMessages);
|
||||
items.add(MessageDisplayItem.message(message));
|
||||
}
|
||||
flushProcessGroup(items, pendingProcessMessages);
|
||||
return items;
|
||||
}
|
||||
|
||||
public static boolean hasThreadProcessFoldCandidates(@Nullable JSONArray messages, int startIndex) {
|
||||
if (messages == null || messages.length() == 0) {
|
||||
return false;
|
||||
}
|
||||
int firstIndex = Math.max(0, startIndex);
|
||||
for (int i = firstIndex; i < messages.length(); i++) {
|
||||
JSONObject message = messages.optJSONObject(i);
|
||||
if (message != null && isThreadProcessMessage(message)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static String processGroupPreview(@Nullable MessageDisplayItem item) {
|
||||
if (item == null || item.processMessages.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
JSONObject latestMessage = item.processMessages.get(item.processMessages.size() - 1);
|
||||
return truncate(latestMessage.optString("body", ""), 52);
|
||||
}
|
||||
|
||||
public static String processGroupDetail(@Nullable MessageDisplayItem item) {
|
||||
if (item == null || item.processMessages.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < item.processMessages.size(); i++) {
|
||||
JSONObject message = item.processMessages.get(i);
|
||||
String body = compactBody(message.optString("body", ""));
|
||||
if (body.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append("\n\n");
|
||||
}
|
||||
builder.append(i + 1).append(". ").append(body);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static void flushProcessGroup(List<MessageDisplayItem> items, List<JSONObject> pendingProcessMessages) {
|
||||
if (pendingProcessMessages.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
items.add(MessageDisplayItem.processGroup(pendingProcessMessages));
|
||||
pendingProcessMessages.clear();
|
||||
}
|
||||
|
||||
public static String threadExecutionConflictTitle(@Nullable JSONObject conflict) {
|
||||
if (conflict == null) {
|
||||
return "当前线程命中冲突保护";
|
||||
@@ -149,6 +247,10 @@ public final class ProjectChatUiState {
|
||||
return state != null && state.multiSelecting && state.selectedMessageIds.size() >= 2;
|
||||
}
|
||||
|
||||
public static boolean canCopySelection(@Nullable SelectionState state) {
|
||||
return state != null && state.multiSelecting && !state.selectedMessageIds.isEmpty();
|
||||
}
|
||||
|
||||
public static SelectionState reconcileSelection(
|
||||
@Nullable SelectionState current,
|
||||
@Nullable List<String> availableMessageIds
|
||||
@@ -181,6 +283,7 @@ public final class ProjectChatUiState {
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
canCopySelection(selectionState),
|
||||
canForwardSelection(selectionState),
|
||||
"取消",
|
||||
"已选 " + selectedCount + " 条",
|
||||
@@ -194,6 +297,7 @@ public final class ProjectChatUiState {
|
||||
!conversationInfoReady,
|
||||
conversationInfoReady,
|
||||
false,
|
||||
false,
|
||||
"返回",
|
||||
isBlank(defaultTitle) ? "项目详情" : defaultTitle,
|
||||
isBlank(defaultSubtitle) ? "原生页面" : defaultSubtitle
|
||||
@@ -420,6 +524,13 @@ public final class ProjectChatUiState {
|
||||
if ("completed".equals(taskStatus) || "failed".equals(taskStatus)) {
|
||||
return new ReplyWaitSpec(false, null);
|
||||
}
|
||||
JSONObject replyMessage = response.optJSONObject("replyMessage");
|
||||
if (replyMessage != null) {
|
||||
String replyMessageId = replyMessage.optString("id", "").trim();
|
||||
if (!replyMessageId.isEmpty()) {
|
||||
return new ReplyWaitSpec(true, replyMessageId);
|
||||
}
|
||||
}
|
||||
JSONObject message = response.optJSONObject("message");
|
||||
return new ReplyWaitSpec(true, message == null ? null : message.optString("id", ""));
|
||||
}
|
||||
@@ -444,6 +555,14 @@ public final class ProjectChatUiState {
|
||||
return !isBlank(latestMessageId) && !baselineMessageId.trim().equals(latestMessageId);
|
||||
}
|
||||
|
||||
public static boolean shouldAutoRefreshConversation(
|
||||
boolean shouldMaintainAutoRefresh,
|
||||
boolean realtimeConnected,
|
||||
boolean trackedMasterReplyTimedOut
|
||||
) {
|
||||
return shouldMaintainAutoRefresh && (!realtimeConnected || trackedMasterReplyTimedOut);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String latestMessageId(@Nullable JSONArray messages) {
|
||||
if (messages == null || messages.length() == 0) {
|
||||
@@ -457,10 +576,110 @@ public final class ProjectChatUiState {
|
||||
return messageId.isEmpty() ? null : messageId;
|
||||
}
|
||||
|
||||
private static boolean isThreadProcessMessage(@Nullable JSONObject message) {
|
||||
if (message == null) {
|
||||
return false;
|
||||
}
|
||||
String kind = message.optString("kind", "").trim();
|
||||
if ("thread_process".equals(kind)) {
|
||||
return true;
|
||||
}
|
||||
if (!isBlank(kind)
|
||||
&& !"text".equals(kind)
|
||||
&& !"conversation_reply".equals(kind)
|
||||
&& !"thread_reply".equals(kind)) {
|
||||
return false;
|
||||
}
|
||||
String sender = message.optString("sender", "").trim().toLowerCase(java.util.Locale.ROOT);
|
||||
String senderLabel = message.optString("senderLabel", "").trim();
|
||||
if ("user".equals(sender)
|
||||
|| "master".equals(sender)
|
||||
|| "ops".equals(sender)
|
||||
|| "audit".equals(sender)
|
||||
|| senderLabel.contains("主 Agent")
|
||||
|| senderLabel.contains("审计")
|
||||
|| senderLabel.contains("你")) {
|
||||
return false;
|
||||
}
|
||||
String body = compactBody(message.optString("body", ""));
|
||||
if (body.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (isStructuredNumberedProcessBody(body)) {
|
||||
return true;
|
||||
}
|
||||
if (containsAny(body, FOLD_BLOCK_MARKERS)) {
|
||||
return false;
|
||||
}
|
||||
return hasProcessProgressMarker(body);
|
||||
}
|
||||
|
||||
private static boolean isBlank(@Nullable String value) {
|
||||
return value == null || value.trim().isEmpty();
|
||||
}
|
||||
|
||||
private static String compactBody(@Nullable String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.replaceAll("\\n{2,}", "\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
private static boolean containsAny(String body, String[] markers) {
|
||||
String normalizedBody = body.toLowerCase(java.util.Locale.ROOT);
|
||||
for (String marker : markers) {
|
||||
if (normalizedBody.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isStructuredNumberedProcessBody(String body) {
|
||||
String[] rawLines = body
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.split("\n");
|
||||
ArrayList<String> numberedLines = new ArrayList<>();
|
||||
for (String rawLine : rawLines) {
|
||||
String normalizedLine = compactBody(rawLine);
|
||||
if (normalizedLine.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedLine.matches("^\\d+[.)、]\\s*.+$")) {
|
||||
numberedLines.add(normalizedLine);
|
||||
}
|
||||
}
|
||||
if (numberedLines.size() < 2) {
|
||||
return false;
|
||||
}
|
||||
String merged = android.text.TextUtils.join(" ", numberedLines)
|
||||
.toLowerCase(java.util.Locale.ROOT);
|
||||
return containsAny(merged, PROCESS_PROGRESS_NUMBERED_HINTS);
|
||||
}
|
||||
|
||||
private static boolean hasProcessProgressMarker(String body) {
|
||||
String normalizedBody = body.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
if (isStructuredNumberedProcessBody(body)) {
|
||||
return true;
|
||||
}
|
||||
for (String marker : PROCESS_PROGRESS_PREFIXES) {
|
||||
if (normalizedBody.startsWith(marker.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (String marker : PROCESS_PROGRESS_CONTAINS) {
|
||||
if (normalizedBody.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String truncate(@Nullable String value, int maxLength) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (normalized.length() <= maxLength) {
|
||||
@@ -468,4 +687,83 @@ public final class ProjectChatUiState {
|
||||
}
|
||||
return normalized.substring(0, maxLength) + "…";
|
||||
}
|
||||
|
||||
private static final String[] PROCESS_PROGRESS_PREFIXES = new String[] {
|
||||
"我先",
|
||||
"我现在",
|
||||
"我会先",
|
||||
"我发现",
|
||||
"我准备",
|
||||
"接下来",
|
||||
"正在",
|
||||
"先看",
|
||||
"先读",
|
||||
"我把",
|
||||
"我再",
|
||||
"目前在",
|
||||
"现在在",
|
||||
"补一组",
|
||||
"处理一下",
|
||||
"先确认",
|
||||
"准备",
|
||||
"同步一下",
|
||||
"我这边已经"
|
||||
};
|
||||
|
||||
private static final String[] PROCESS_PROGRESS_CONTAINS = new String[] {
|
||||
"我继续",
|
||||
"我已经在",
|
||||
"正在跑",
|
||||
"正在检查",
|
||||
"正在处理",
|
||||
"正在同步",
|
||||
"我会直接",
|
||||
"我先把",
|
||||
"先补",
|
||||
"再接"
|
||||
};
|
||||
|
||||
private static final String[] PROCESS_PROGRESS_NUMBERED_HINTS = new String[] {
|
||||
"先",
|
||||
"再",
|
||||
"接下来",
|
||||
"然后",
|
||||
"检查",
|
||||
"确认",
|
||||
"处理",
|
||||
"同步",
|
||||
"补",
|
||||
"排查",
|
||||
"推进",
|
||||
"回你",
|
||||
"回传",
|
||||
"会把",
|
||||
"我会"
|
||||
};
|
||||
|
||||
private static final String[] FOLD_BLOCK_MARKERS = new String[] {
|
||||
"失败",
|
||||
"报错",
|
||||
"错误",
|
||||
"阻塞",
|
||||
"不能",
|
||||
"无法",
|
||||
"崩溃",
|
||||
"超时",
|
||||
"exception",
|
||||
"error",
|
||||
"fatal",
|
||||
"结论",
|
||||
"最终",
|
||||
"总结",
|
||||
"已完成",
|
||||
"已经完成",
|
||||
"验证通过",
|
||||
"测试通过",
|
||||
"已修复",
|
||||
"修好了",
|
||||
"已部署",
|
||||
"已安装",
|
||||
"可以直接"
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class SecurityActivity extends BossScreenActivity {
|
||||
@@ -22,7 +23,11 @@ public class SecurityActivity extends BossScreenActivity {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getSession();
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> renderSecurity(response.json.optJSONObject("session")));
|
||||
BossApiClient.ApiResponse sessionsResponse = apiClient.getAuthSessions();
|
||||
JSONArray sessions = sessionsResponse.ok()
|
||||
? sessionsResponse.json.optJSONArray("sessions")
|
||||
: new JSONArray();
|
||||
runOnUiThread(() -> renderSecurity(response.json.optJSONObject("session"), sessions));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
@@ -32,7 +37,7 @@ public class SecurityActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void renderSecurity(@Nullable JSONObject session) {
|
||||
private void renderSecurity(@Nullable JSONObject session, @Nullable JSONArray sessions) {
|
||||
replaceContent();
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
@@ -55,6 +60,33 @@ public class SecurityActivity extends BossScreenActivity {
|
||||
));
|
||||
}
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"登录会话",
|
||||
"当前可管理 " + (sessions == null ? 0 : sessions.length()) + " 个登录端",
|
||||
"点击非当前会话可撤销;撤销当前会话会回到登录页。",
|
||||
null,
|
||||
null
|
||||
));
|
||||
if (sessions != null) {
|
||||
for (int index = 0; index < sessions.length(); index += 1) {
|
||||
JSONObject item = sessions.optJSONObject(index);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
buildSessionTitle(item),
|
||||
item.optString("account", "-")
|
||||
+ " · " + BossUi.formatRoleLabel(item.optString("role", "-")),
|
||||
"最近 " + item.optString("lastSeenAt", "-")
|
||||
+ " · 到期 " + item.optString("expiresAt", "-"),
|
||||
item.optBoolean("current", false) ? "当前" : null,
|
||||
v -> confirmRevokeSession(item.optString("sessionId", ""), item.optBoolean("current", false))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
appendContent(BossUi.buildMenuRow(this, "打开设备页", "查看已绑定设备与状态", null, v -> {
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.putExtra(MainActivity.EXTRA_INITIAL_TAB, "devices");
|
||||
@@ -68,6 +100,56 @@ public class SecurityActivity extends BossScreenActivity {
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private String buildSessionTitle(JSONObject session) {
|
||||
String method = "code".equals(session.optString("loginMethod", "password")) ? "验证码登录" : "账号密码登录";
|
||||
String name = session.optString("displayName", session.optString("account", "登录端"));
|
||||
return name + " · " + method;
|
||||
}
|
||||
|
||||
private void confirmRevokeSession(String sessionId, boolean current) {
|
||||
if (sessionId == null || sessionId.isEmpty()) {
|
||||
showMessage("会话 ID 缺失,无法撤销。");
|
||||
return;
|
||||
}
|
||||
new androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
.setTitle(current ? "退出当前会话" : "撤销登录会话")
|
||||
.setMessage(current ? "撤销当前会话后需要重新登录。" : "只撤销这一端的登录态,不影响其他会话。")
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton(current ? "退出" : "撤销", (dialog, which) -> revokeSession(sessionId, current))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void revokeSession(String sessionId, boolean current) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.revokeAuthSession(sessionId);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
if (current) {
|
||||
apiClient.logout();
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage("会话已撤销");
|
||||
if (current) {
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
finish();
|
||||
} else {
|
||||
reload();
|
||||
}
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("撤销失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void logout() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,101 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class WechatSurfaceMapper {
|
||||
private static final String[] PROCESS_PREVIEW_PREFIXES = new String[] {
|
||||
"我先",
|
||||
"我现在",
|
||||
"我会先",
|
||||
"我发现",
|
||||
"我准备",
|
||||
"接下来",
|
||||
"正在",
|
||||
"先看",
|
||||
"先读",
|
||||
"我把",
|
||||
"我再",
|
||||
"目前在",
|
||||
"现在在",
|
||||
"补一组",
|
||||
"处理一下",
|
||||
"先确认",
|
||||
"准备",
|
||||
"同步一下",
|
||||
"我这边已经"
|
||||
};
|
||||
|
||||
private static final String[] PROCESS_PREVIEW_CONTAINS = new String[] {
|
||||
"我继续",
|
||||
"我已经在",
|
||||
"正在跑",
|
||||
"正在检查",
|
||||
"正在处理",
|
||||
"正在同步",
|
||||
"我会直接",
|
||||
"我先把",
|
||||
"先补",
|
||||
"再接"
|
||||
};
|
||||
|
||||
private static final String[] PROCESS_PREVIEW_NUMBERED_HINTS = new String[] {
|
||||
"先",
|
||||
"再",
|
||||
"接下来",
|
||||
"然后",
|
||||
"检查",
|
||||
"确认",
|
||||
"处理",
|
||||
"同步",
|
||||
"补",
|
||||
"排查",
|
||||
"推进",
|
||||
"回你",
|
||||
"回传",
|
||||
"会把",
|
||||
"我会"
|
||||
};
|
||||
|
||||
private static final String[] PROCESS_PREVIEW_BLOCK_MARKERS = new String[] {
|
||||
"失败",
|
||||
"报错",
|
||||
"错误",
|
||||
"阻塞",
|
||||
"不能",
|
||||
"无法",
|
||||
"崩溃",
|
||||
"超时",
|
||||
"exception",
|
||||
"error",
|
||||
"fatal",
|
||||
"结论",
|
||||
"最终",
|
||||
"总结",
|
||||
"已完成",
|
||||
"已经完成",
|
||||
"验证通过"
|
||||
};
|
||||
|
||||
private static final String[] LEAKED_TITLE_PREFIXES = new String[] {
|
||||
"你当前接手的项目根目录是",
|
||||
"你现在接手的项目根目录是",
|
||||
"你现在以目标线程身份直接回复用户",
|
||||
"你正在向主 Agent 同步当前项目状态",
|
||||
"只回复对用户真正有用的内容",
|
||||
"只输出 JSON"
|
||||
};
|
||||
|
||||
private static final String[] LEAKED_TITLE_CONTAINS = new String[] {
|
||||
"不要发送内部字段",
|
||||
"不要自称主 Agent",
|
||||
"不要解释系统如何分发",
|
||||
"不要输出 JSON",
|
||||
"项目名称:",
|
||||
"线程名称:",
|
||||
"文件夹:",
|
||||
"同步原因:",
|
||||
"当前消息:",
|
||||
"用户当前消息:"
|
||||
};
|
||||
|
||||
private static final List<String> ROOT_TAB_LABELS = Arrays.asList(
|
||||
"会话",
|
||||
"设备",
|
||||
@@ -21,8 +116,11 @@ public final class WechatSurfaceMapper {
|
||||
private static final List<MeMenuItem> ROOT_ME_MENU_ITEMS = Arrays.asList(
|
||||
new MeMenuItem("security", "账号与安全", "修改登录密码、设备安全与身份校验"),
|
||||
new MeMenuItem("settings", "设置", "默认首页、提醒方式与危险操作确认"),
|
||||
new MeMenuItem("access", "用户与权限", "分配子账号、设备、项目与 Skill 权限"),
|
||||
new MeMenuItem("ops", "运维与修复", "查看运维会话、修复回放与 standby 切换"),
|
||||
new MeMenuItem("ai_accounts", "AI 账号", "管理主 GPT、备用 GPT 与 API 容灾"),
|
||||
new MeMenuItem("storage", "附件与存储", "配置附件上传位置、服务器文件与阿里 OSS"),
|
||||
new MeMenuItem("telegram", "Telegram 接入", "配置 Telegram Bot、Webhook 与白名单"),
|
||||
new MeMenuItem("skills", "技能", "按设备查看 Skill 清单"),
|
||||
new MeMenuItem("about", "关于", "当前版本、OTA 状态与更新内容")
|
||||
);
|
||||
@@ -59,14 +157,20 @@ public final class WechatSurfaceMapper {
|
||||
JSONObject avatar = source.optJSONObject("avatar");
|
||||
boolean isGroup = source.optBoolean("isGroup", groupAvatarMembers.size() > 1);
|
||||
String conversationType = source.optString("conversationType", "");
|
||||
String threadTitle = trimLocalWorkspacePrefix(
|
||||
source.optString("threadTitle", source.optString("title", source.optString("projectTitle", "")))
|
||||
String folderLabel = normalizeConversationTitle(source.optString("folderLabel", ""));
|
||||
String threadTitle = sanitizeConversationTitle(
|
||||
source.optString("threadTitle", source.optString("title", source.optString("projectTitle", ""))),
|
||||
folderLabel,
|
||||
source.optString("projectTitle", "")
|
||||
);
|
||||
String projectId = source.optString("projectId", "").trim();
|
||||
if ("folder_archive".equals(conversationType)) {
|
||||
threadTitle = source.optString(
|
||||
"projectTitle",
|
||||
source.optString("threadTitle", source.optString("title", source.optString("folderLabel", "")))
|
||||
);
|
||||
} else if (isPinnedSystemProject(projectId)) {
|
||||
threadTitle = source.optString("projectTitle", threadTitle);
|
||||
}
|
||||
String pinnedLabel = source.optString("topPinnedLabel", "");
|
||||
return new ConversationRow(
|
||||
@@ -188,10 +292,36 @@ public final class WechatSurfaceMapper {
|
||||
return titles;
|
||||
}
|
||||
|
||||
public static String[] rootMeMenuTitlesForRole(String role) {
|
||||
List<MeMenuItem> items = rootMeMenuItemsForRoleList(role);
|
||||
String[] titles = new String[items.size()];
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
titles[i] = items.get(i).title;
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
|
||||
public static MeMenuItem[] rootMeMenuItems() {
|
||||
return ROOT_ME_MENU_ITEMS.toArray(new MeMenuItem[0]);
|
||||
}
|
||||
|
||||
public static MeMenuItem[] rootMeMenuItemsForRole(String role) {
|
||||
List<MeMenuItem> items = rootMeMenuItemsForRoleList(role);
|
||||
return items.toArray(new MeMenuItem[0]);
|
||||
}
|
||||
|
||||
public static boolean canOpenMeEntryForRole(String key, String role) {
|
||||
if (key == null || key.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (MeMenuItem item : rootMeMenuItemsForRoleList(role)) {
|
||||
if (item.key.equals(key)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static MeMenuItem findMeMenuItem(String key) {
|
||||
for (MeMenuItem item : ROOT_ME_MENU_ITEMS) {
|
||||
if (item.key.equals(key)) {
|
||||
@@ -201,6 +331,34 @@ public final class WechatSurfaceMapper {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<MeMenuItem> rootMeMenuItemsForRoleList(String role) {
|
||||
if ("highest_admin".equals(role)) {
|
||||
return ROOT_ME_MENU_ITEMS;
|
||||
}
|
||||
List<MeMenuItem> visible = new ArrayList<>();
|
||||
for (MeMenuItem item : ROOT_ME_MENU_ITEMS) {
|
||||
if (!isHighestAdminOnlyMeEntry(item.key) && (isAdministratorRole(role) || !isAdministratorOnlyMeEntry(item.key))) {
|
||||
visible.add(item);
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
private static boolean isAdministratorRole(String role) {
|
||||
return "highest_admin".equals(role) || "admin".equals(role);
|
||||
}
|
||||
|
||||
private static boolean isAdministratorOnlyMeEntry(String key) {
|
||||
return "ops".equals(key)
|
||||
|| "ai_accounts".equals(key)
|
||||
|| "storage".equals(key)
|
||||
|| "telegram".equals(key);
|
||||
}
|
||||
|
||||
private static boolean isHighestAdminOnlyMeEntry(String key) {
|
||||
return "access".equals(key);
|
||||
}
|
||||
|
||||
public static String[] projectQuickActions() {
|
||||
return PROJECT_QUICK_ACTIONS.toArray(new String[0]);
|
||||
}
|
||||
@@ -249,6 +407,10 @@ public final class WechatSurfaceMapper {
|
||||
return "cancel_on_detach";
|
||||
}
|
||||
|
||||
private static boolean isPinnedSystemProject(String projectId) {
|
||||
return "master-agent".equals(projectId) || "audit-collab".equals(projectId);
|
||||
}
|
||||
|
||||
private static String buildContextStatusLabel(JSONObject source) {
|
||||
if (source.optBoolean("mustFinishBeforeCompaction", false)) {
|
||||
return "必须收尾";
|
||||
@@ -322,7 +484,14 @@ public final class WechatSurfaceMapper {
|
||||
}
|
||||
|
||||
public static RootTopAction rootTopAction(String activeTab, boolean refreshing, boolean selectionMode) {
|
||||
return rootTopAction(activeTab, refreshing, selectionMode, "highest_admin");
|
||||
}
|
||||
|
||||
public static RootTopAction rootTopAction(String activeTab, boolean refreshing, boolean selectionMode, String role) {
|
||||
if ("devices".equals(activeTab)) {
|
||||
if (!"highest_admin".equals(role)) {
|
||||
return new RootTopAction("刷新", false, true, "refresh", "refresh");
|
||||
}
|
||||
return new RootTopAction("添加设备", false, true, "add", "add_device");
|
||||
}
|
||||
if ("conversations".equals(activeTab)) {
|
||||
@@ -714,9 +883,156 @@ public final class WechatSurfaceMapper {
|
||||
if (preview.matches("^已从设备.+导入线程《.+》[。.]?$")) {
|
||||
return "已导入线程";
|
||||
}
|
||||
if (isLikelyProcessPreview(preview)) {
|
||||
return "";
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
private static boolean isLikelyProcessPreview(String value) {
|
||||
String preview = value == null ? "" : value
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.replaceAll("\\n{2,}", "\n")
|
||||
.trim();
|
||||
if (preview.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (containsMarker(preview, PROCESS_PREVIEW_BLOCK_MARKERS)) {
|
||||
return false;
|
||||
}
|
||||
if (isStructuredNumberedProcessPreview(preview)) {
|
||||
return true;
|
||||
}
|
||||
String normalized = preview.toLowerCase(java.util.Locale.ROOT);
|
||||
for (String marker : PROCESS_PREVIEW_PREFIXES) {
|
||||
if (normalized.startsWith(marker.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (String marker : PROCESS_PREVIEW_CONTAINS) {
|
||||
if (normalized.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isStructuredNumberedProcessPreview(String value) {
|
||||
String[] rawLines = value
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.split("\n");
|
||||
ArrayList<String> numberedLines = new ArrayList<>();
|
||||
for (String rawLine : rawLines) {
|
||||
String normalizedLine = rawLine == null ? "" : rawLine.trim();
|
||||
if (normalizedLine.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedLine.matches("^\\d+[.)、]\\s*.+$")) {
|
||||
numberedLines.add(normalizedLine);
|
||||
}
|
||||
}
|
||||
if (numberedLines.size() < 2) {
|
||||
return false;
|
||||
}
|
||||
String merged = android.text.TextUtils.join(" ", numberedLines)
|
||||
.toLowerCase(java.util.Locale.ROOT);
|
||||
return containsMarker(merged, PROCESS_PREVIEW_NUMBERED_HINTS);
|
||||
}
|
||||
|
||||
private static boolean containsMarker(String value, String[] markers) {
|
||||
String normalized = value == null ? "" : value.toLowerCase(java.util.Locale.ROOT);
|
||||
for (String marker : markers) {
|
||||
if (normalized.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String normalizeConversationTitle(String value) {
|
||||
String source = value == null ? "" : value.replace("\u0000", "");
|
||||
String[] lines = source.split("\\r?\\n");
|
||||
for (String line : lines) {
|
||||
if (line == null) {
|
||||
continue;
|
||||
}
|
||||
String trimmed = line.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
return trimmed.replaceAll("\\s+", " ");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String stripTrailingConversationTitleNoise(String value) {
|
||||
return value == null ? "" : value.replaceAll("['\"}\\]]{2,}$", "").trim();
|
||||
}
|
||||
|
||||
private static boolean looksLikeLeakedConversationTitle(String value) {
|
||||
String normalized = normalizeConversationTitle(value);
|
||||
if (normalized.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (String marker : LEAKED_TITLE_PREFIXES) {
|
||||
if (normalized.startsWith(marker)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (String marker : LEAKED_TITLE_CONTAINS) {
|
||||
if (normalized.contains(marker)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String extractWorkspaceProjectName(String value) {
|
||||
String normalized = normalizeConversationTitle(value).replace('\\', '/');
|
||||
if (normalized.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String[] patterns = new String[] {
|
||||
".*/Users/[^/]+/code/([^/\\s\"'`,。;!?]+).*",
|
||||
".*/home/[^/]+/code/([^/\\s\"'`,。;!?]+).*",
|
||||
".*[A-Za-z]:/Users/[^/]+/code/([^/\\s\"'`,。;!?]+).*"
|
||||
};
|
||||
for (String pattern : patterns) {
|
||||
if (normalized.matches(pattern)) {
|
||||
return normalized.replaceFirst(pattern, "$1").split("/")[0].trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String sanitizeConversationTitle(String value, String... fallbackCandidates) {
|
||||
String normalized = normalizeConversationTitle(value);
|
||||
String trimmed = stripTrailingConversationTitleNoise(trimLocalWorkspacePrefix(normalized));
|
||||
if (!trimmed.isEmpty() && !looksLikeLeakedConversationTitle(normalized) && !looksLikeLeakedConversationTitle(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
String extractedProject = extractWorkspaceProjectName(normalized);
|
||||
if (!extractedProject.isEmpty() && !looksLikeLeakedConversationTitle(extractedProject)) {
|
||||
return extractedProject;
|
||||
}
|
||||
|
||||
for (String fallbackCandidate : fallbackCandidates) {
|
||||
String extractedFallback = extractWorkspaceProjectName(fallbackCandidate);
|
||||
if (!extractedFallback.isEmpty() && !looksLikeLeakedConversationTitle(extractedFallback)) {
|
||||
return extractedFallback;
|
||||
}
|
||||
String normalizedFallback = stripTrailingConversationTitleNoise(
|
||||
trimLocalWorkspacePrefix(normalizeConversationTitle(fallbackCandidate))
|
||||
);
|
||||
if (!normalizedFallback.isEmpty() && !looksLikeLeakedConversationTitle(normalizedFallback)) {
|
||||
return normalizedFallback;
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static String trimLocalWorkspacePrefix(String value) {
|
||||
String label = value == null ? "" : value.trim();
|
||||
if (label.isEmpty()) {
|
||||
|
||||
@@ -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>
|
||||
10
android/app/src/main/res/drawable/ic_boss_arrow_down.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_arrow_down.xml
Normal 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>
|
||||
10
android/app/src/main/res/drawable/ic_boss_tab_chat.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_tab_chat.xml
Normal 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>
|
||||
10
android/app/src/main/res/drawable/ic_boss_tab_devices.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_tab_devices.xml
Normal 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>
|
||||
10
android/app/src/main/res/drawable/ic_boss_tab_me.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_tab_me.xml
Normal 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>
|
||||
@@ -52,11 +52,96 @@
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/login_account_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="28dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:hint="账号"
|
||||
android:imeOptions="actionNext"
|
||||
android:inputType="text"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:singleLine="true"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textColorHint="@color/boss_text_muted"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/login_password_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:hint="密码"
|
||||
android:imeOptions="actionDone"
|
||||
android:inputType="textPassword"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:singleLine="true"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textColorHint="@color/boss_text_muted"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/login_confirm_password_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:hint="确认密码"
|
||||
android:imeOptions="actionNext"
|
||||
android:inputType="textPassword"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:singleLine="true"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textColorHint="@color/boss_text_muted"
|
||||
android:textSize="16sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/login_code_row"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="horizontal"
|
||||
android:visibility="gone">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/login_code_input"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:hint="验证码"
|
||||
android:imeOptions="actionDone"
|
||||
android:inputType="number"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:singleLine="true"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textColorHint="@color/boss_text_muted"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/login_send_code_button"
|
||||
android:layout_width="104dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginLeft="10dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:text="获取验证码"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/login_progress"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:layout_marginTop="18dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<Button
|
||||
@@ -72,6 +157,46 @@
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/login_mode_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:text="账号登录"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/register_mode_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:text="注册账号"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/forgot_mode_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:text="忘记密码"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
|
||||
@@ -188,51 +313,50 @@
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="72dp"
|
||||
android:layout_height="64dp"
|
||||
android:background="@color/boss_surface"
|
||||
android:elevation="10dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingRight="12dp">
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:paddingLeft="10dp"
|
||||
android:paddingRight="10dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/tab_conversations"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginRight="6dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_tab_active"
|
||||
android:background="@android:color/transparent"
|
||||
android:text="会话"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/tab_devices"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginLeft="6dp"
|
||||
android:layout_marginRight="6dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_tab_inactive"
|
||||
android:background="@android:color/transparent"
|
||||
android:text="设备"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/tab_me"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginLeft="6dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_tab_inactive"
|
||||
android:background="@android:color/transparent"
|
||||
android:text="我的"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -80,54 +80,90 @@
|
||||
android:tint="@color/boss_text_primary" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/screen_refresh_layout"
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
<LinearLayout
|
||||
android:layout_weight="1"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false">
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/screen_refresh_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
android:layout_height="match_parent">
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_quick_actions_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="12dp">
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_quick_actions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal" />
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/project_chat_scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="ifContentScrolls">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:id="@+id/project_chat_quick_actions_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="0dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="20dp" />
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_quick_actions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal" />
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/project_chat_scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="ifContentScrolls">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="0dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="20dp" />
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/project_chat_scroll_bottom"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_gravity="bottom|left"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="@drawable/bg_chat_scroll_bottom_button"
|
||||
android:contentDescription="回到底部"
|
||||
android:elevation="8dp"
|
||||
android:padding="12dp"
|
||||
android:scaleType="center"
|
||||
android:src="@drawable/ic_boss_arrow_down"
|
||||
android:tint="@color/boss_text_primary"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_mention_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:elevation="8dp"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_composer_row"
|
||||
@@ -197,9 +233,22 @@
|
||||
android:visibility="gone">
|
||||
|
||||
<Button
|
||||
android:id="@+id/project_chat_multi_forward"
|
||||
android:layout_width="match_parent"
|
||||
android:id="@+id/project_chat_multi_copy"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:text="复制"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/project_chat_multi_forward"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:text="转发"
|
||||
android:textAllCaps="false"
|
||||
|
||||
9
android/app/src/main/res/values-v29/styles.xml
Normal file
9
android/app/src/main/res/values-v29/styles.xml
Normal 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>
|
||||
@@ -13,7 +13,6 @@
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:windowBackground">@color/boss_bg_app</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,22 @@ public class BossApiClientDispatchPlansTest {
|
||||
assertEquals("no-cache", connection.getRequestProperty("Pragma"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void protectedHtmlResponseReturnsJsonErrorInsteadOfThrowing() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/auth/session"),
|
||||
200,
|
||||
"<!DOCTYPE html><html><body>login</body></html>",
|
||||
""
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getSession();
|
||||
|
||||
assertEquals(401, response.statusCode);
|
||||
assertEquals("NON_JSON_RESPONSE", response.message());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void confirmDispatchPlanWritesApprovedTargetProjectIds() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/confirm"));
|
||||
@@ -114,6 +130,19 @@ public class BossApiClientDispatchPlansTest {
|
||||
assertEquals("{}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decideDialogGuardInterventionUsesContractEndpointAndDecisionBody() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/dialog-guard/interventions/intervention-1/decision"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.decideDialogGuardIntervention("intervention-1", "allow_once");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/dialog-guard/interventions/intervention-1/decision", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals("{\"decision\":\"allow_once\"}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retryDispatchPlanUsesProjectScopedRetryEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/retry"));
|
||||
@@ -282,6 +311,153 @@ public class BossApiClientDispatchPlansTest {
|
||||
assertEquals(20000, connection.readTimeoutValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void deleteProjectMessageUsesProjectScopedDeleteEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1/messages"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.deleteProjectMessage("thread-1", "msg-1");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/thread-1/messages?messageId=msg-1", apiClient.lastPath);
|
||||
assertEquals("DELETE", connection.requestMethodValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void storageConfigMethodsUseDedicatedStorageEndpoints() throws Exception {
|
||||
RecordingConnection getConnection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/storage/config"));
|
||||
RecordingBossApiClient getClient = new RecordingBossApiClient(getConnection);
|
||||
getClient.getAttachmentStorageConfig();
|
||||
assertEquals("/api/v1/storage/config", getClient.lastPath);
|
||||
assertEquals("GET", getConnection.requestMethodValue);
|
||||
|
||||
RecordingConnection saveConnection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/storage/config"));
|
||||
RecordingBossApiClient saveClient = new RecordingBossApiClient(saveConnection);
|
||||
saveClient.saveAttachmentStorageConfig(new JSONObject().put("mode", "server_file"));
|
||||
assertEquals("/api/v1/storage/config", saveClient.lastPath);
|
||||
assertEquals("PATCH", saveConnection.requestMethodValue);
|
||||
assertEquals("{\"mode\":\"server_file\"}", saveConnection.requestBody());
|
||||
|
||||
RecordingConnection validateConnection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/storage/config/validate"));
|
||||
RecordingBossApiClient validateClient = new RecordingBossApiClient(validateConnection);
|
||||
validateClient.validateAttachmentStorageConfig(new JSONObject().put("mode", "oss"));
|
||||
assertEquals("/api/v1/storage/config/validate", validateClient.lastPath);
|
||||
assertEquals("POST", validateConnection.requestMethodValue);
|
||||
assertEquals("{\"mode\":\"oss\"}", validateConnection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void protectedRequestFallsBackToAutoLoginWhenNoRestoreTokenExists() throws Exception {
|
||||
SequencedBossApiClient apiClient = new SequencedBossApiClient(
|
||||
new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/projects/project-1"),
|
||||
401,
|
||||
"{\"ok\":false,\"message\":\"UNAUTHORIZED\"}",
|
||||
"{\"ok\":false,\"message\":\"UNAUTHORIZED\"}"
|
||||
),
|
||||
new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/projects/project-1"),
|
||||
200,
|
||||
"{\"ok\":true,\"project\":{\"id\":\"project-1\",\"name\":\"北区试产线\"}}",
|
||||
"{\"ok\":false}"
|
||||
)
|
||||
);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getProjectDetail("project-1");
|
||||
|
||||
assertEquals(1, apiClient.autoLoginCalls);
|
||||
assertEquals(2, apiClient.protectedRequestCount);
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("北区试产线", response.json.optJSONObject("project").optString("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void autoLoginCapturesSessionCookieFromMixedCaseHeaderNames() throws Exception {
|
||||
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/login"));
|
||||
connection.responseHeaders.put(
|
||||
"Set-cookie",
|
||||
Collections.singletonList("boss_session=session-from-mixed-case; Path=/; HttpOnly")
|
||||
);
|
||||
IdentityCapturingBossApiClient apiClient = new IdentityCapturingBossApiClient(connection, prefs);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.autoLogin();
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("boss_session=session-from-mixed-case", prefs.getString("session_cookie", ""));
|
||||
assertEquals("krisolo", prefs.getString("account", ""));
|
||||
assertEquals("Boss 超级管理员", prefs.getString("display_name", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loginWithPasswordPostsCredentialsAndCapturesNativeRestoreToken() throws Exception {
|
||||
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/auth/login"),
|
||||
200,
|
||||
"{\"ok\":true,\"account\":\"krisolo\",\"displayName\":\"Boss 超级管理员\",\"restoreToken\":\"restore-login\"}",
|
||||
"{\"ok\":false}"
|
||||
);
|
||||
connection.responseHeaders.put(
|
||||
"Set-cookie",
|
||||
Collections.singletonList("boss_session=session-from-login; Path=/; HttpOnly")
|
||||
);
|
||||
IdentityCapturingBossApiClient apiClient = new IdentityCapturingBossApiClient(connection, prefs);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.loginWithPassword("krisolo", "Admin_yqs_asd.");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/auth/login", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(
|
||||
"{\"account\":\"krisolo\",\"password\":\"Admin_yqs_asd.\",\"method\":\"password\"}",
|
||||
connection.requestBody()
|
||||
);
|
||||
assertEquals("boss_session=session-from-login", prefs.getString("session_cookie", ""));
|
||||
assertEquals("restore-login", prefs.getString("restore_token", ""));
|
||||
assertEquals("krisolo", prefs.getString("account", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void authRegistrationAndPasswordResetUseDedicatedNativeRoutes() throws Exception {
|
||||
ScriptedBossApiClient apiClient = new ScriptedBossApiClient(
|
||||
new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/send-code")),
|
||||
new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/register")),
|
||||
new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/forgot-password"))
|
||||
);
|
||||
|
||||
BossApiClient.ApiResponse codeResponse = apiClient.sendVerificationCode("new-user", "register");
|
||||
assertEquals(200, codeResponse.statusCode);
|
||||
assertEquals("/api/auth/send-code", apiClient.lastPath);
|
||||
assertEquals("{\"account\":\"new-user\",\"purpose\":\"register\"}", apiClient.lastConnection.requestBody());
|
||||
|
||||
BossApiClient.ApiResponse registerResponse = apiClient.registerAccount(
|
||||
"new-user",
|
||||
"New_password_123",
|
||||
"New_password_123",
|
||||
"123456"
|
||||
);
|
||||
assertEquals(200, registerResponse.statusCode);
|
||||
assertEquals("/api/auth/register", apiClient.lastPath);
|
||||
assertEquals(
|
||||
"{\"account\":\"new-user\",\"password\":\"New_password_123\",\"confirmPassword\":\"New_password_123\",\"code\":\"123456\"}",
|
||||
apiClient.lastConnection.requestBody()
|
||||
);
|
||||
|
||||
BossApiClient.ApiResponse resetResponse = apiClient.resetPassword(
|
||||
"new-user",
|
||||
"Reset_password_123",
|
||||
"Reset_password_123",
|
||||
"654321"
|
||||
);
|
||||
assertEquals(200, resetResponse.statusCode);
|
||||
assertEquals("/api/auth/forgot-password", apiClient.lastPath);
|
||||
assertEquals(
|
||||
"{\"account\":\"new-user\",\"password\":\"Reset_password_123\",\"confirmPassword\":\"Reset_password_123\",\"code\":\"654321\"}",
|
||||
apiClient.lastConnection.requestBody()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onboardOpenAiApiAccountUsesDedicatedRouteAndSetsActive() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"));
|
||||
@@ -308,7 +484,7 @@ public class BossApiClientDispatchPlansTest {
|
||||
public void rememberIdentityDoesNotOverwriteSessionIdentityFromAiAccountOnboardingResponse() throws Exception {
|
||||
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
|
||||
prefs.edit()
|
||||
.putString("account", "17600003315")
|
||||
.putString("account", "krisolo")
|
||||
.putString("display_name", "Boss 超级管理员")
|
||||
.apply();
|
||||
BossApiClient apiClient = new BossApiClient(prefs, "https://boss.hyzq.net");
|
||||
@@ -321,7 +497,7 @@ public class BossApiClientDispatchPlansTest {
|
||||
|
||||
apiClient.rememberIdentity(onboardingResponse);
|
||||
|
||||
assertEquals("17600003315", apiClient.getAccountLabel());
|
||||
assertEquals("krisolo", apiClient.getAccountLabel());
|
||||
assertEquals("Boss 超级管理员", apiClient.getDisplayName());
|
||||
}
|
||||
|
||||
@@ -359,7 +535,11 @@ public class BossApiClientDispatchPlansTest {
|
||||
private String lastPath = "";
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
this(connection, new InMemorySharedPreferences());
|
||||
}
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection, SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@@ -383,6 +563,7 @@ public class BossApiClientDispatchPlansTest {
|
||||
private static final class ScriptedBossApiClient extends BossApiClient {
|
||||
private final Map<String, RecordingConnection> connections;
|
||||
private String lastPath = "";
|
||||
private RecordingConnection lastConnection;
|
||||
|
||||
ScriptedBossApiClient(RecordingConnection... connections) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
@@ -399,6 +580,7 @@ public class BossApiClientDispatchPlansTest {
|
||||
if (connection == null) {
|
||||
throw new IllegalStateException("Missing scripted connection for " + path);
|
||||
}
|
||||
lastConnection = connection;
|
||||
return connection;
|
||||
}
|
||||
|
||||
@@ -413,6 +595,65 @@ public class BossApiClientDispatchPlansTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SequencedBossApiClient extends BossApiClient {
|
||||
private final java.util.ArrayDeque<RecordingConnection> protectedConnections = new java.util.ArrayDeque<>();
|
||||
private int autoLoginCalls;
|
||||
private int protectedRequestCount;
|
||||
|
||||
SequencedBossApiClient(RecordingConnection... protectedConnections) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
Collections.addAll(this.protectedConnections, protectedConnections);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse autoLogin() throws org.json.JSONException {
|
||||
autoLoginCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("account", "krisolo")
|
||||
.put("displayName", "Boss 超级管理员"));
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
if (!"/api/v1/projects/project-1".equals(path)) {
|
||||
throw new IllegalStateException("Unexpected path " + path);
|
||||
}
|
||||
protectedRequestCount += 1;
|
||||
RecordingConnection connection = protectedConnections.pollFirst();
|
||||
if (connection == null) {
|
||||
throw new IllegalStateException("No more scripted protected responses");
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
String encode(String value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
void rememberIdentity(JSONObject json) {
|
||||
// no-op for JVM unit test
|
||||
}
|
||||
}
|
||||
|
||||
private static final class IdentityCapturingBossApiClient extends BossApiClient {
|
||||
private final RecordingConnection connection;
|
||||
private String lastPath = "";
|
||||
|
||||
IdentityCapturingBossApiClient(RecordingConnection connection, SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
lastPath = path;
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConnection extends HttpURLConnection {
|
||||
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
|
||||
private final Map<String, String> requestHeaders = new HashMap<>();
|
||||
@@ -422,9 +663,15 @@ public class BossApiClientDispatchPlansTest {
|
||||
private final int responseCodeValue;
|
||||
private final String responseBody;
|
||||
private final String errorBody;
|
||||
private final Map<String, java.util.List<String>> responseHeaders = new HashMap<>();
|
||||
|
||||
RecordingConnection(URL url) {
|
||||
this(url, 200, "{\"ok\":true}", "{\"ok\":false}");
|
||||
this(
|
||||
url,
|
||||
200,
|
||||
"{\"ok\":true,\"account\":\"krisolo\",\"displayName\":\"Boss 超级管理员\"}",
|
||||
"{\"ok\":false}"
|
||||
);
|
||||
}
|
||||
|
||||
RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
|
||||
@@ -493,6 +740,11 @@ public class BossApiClientDispatchPlansTest {
|
||||
return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, java.util.List<String>> getHeaderFields() {
|
||||
return responseHeaders;
|
||||
}
|
||||
|
||||
String requestBody() {
|
||||
return requestBody.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.view.View;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
@@ -13,6 +16,7 @@ import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@@ -21,31 +25,41 @@ public class BossUiRootSurfaceTest {
|
||||
@Test
|
||||
public void renderMeRoot_usesWechatProfileHeaderAndFlatMenuRows() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"setActiveTab",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "me"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
|
||||
ReflectionHelpers.setField(
|
||||
activity,
|
||||
"sessionData",
|
||||
new JSONObject()
|
||||
.put("displayName", "Kris")
|
||||
.put("account", "17600003315")
|
||||
.put("account", "krisolo")
|
||||
.put("role", "highest_admin")
|
||||
);
|
||||
ReflectionHelpers.callInstanceMethod(activity, "renderMeRoot");
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
assertEquals("我的页应是资料头 + 6 条菜单", 7, content.getChildCount());
|
||||
LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
|
||||
assertEquals("我的页应是资料头 + 9 条菜单", 10, content.getChildCount());
|
||||
|
||||
View header = content.getChildAt(0);
|
||||
assertEquals("资料头不应保留浮层卡片感", 0f, header.getElevation(), 0.01f);
|
||||
assertTrue(viewTreeContainsText(header, "Kris"));
|
||||
assertTrue(viewTreeContainsText(header, "17600003315"));
|
||||
assertTrue(viewTreeContainsText(header, "krisolo"));
|
||||
assertTrue(viewTreeContainsText(header, "最高管理员"));
|
||||
assertTrue(viewTreeContainsText(header, "主控账号已启用安全保护"));
|
||||
|
||||
assertTrue(viewTreeContainsText(content, "账号与安全"));
|
||||
assertTrue(viewTreeContainsText(content, "设置"));
|
||||
assertTrue(viewTreeContainsText(content, "用户与权限"));
|
||||
assertTrue(viewTreeContainsText(content, "运维与修复"));
|
||||
assertTrue(viewTreeContainsText(content, "AI 账号"));
|
||||
assertTrue(viewTreeContainsText(content, "附件与存储"));
|
||||
assertTrue(viewTreeContainsText(content, "Telegram 接入"));
|
||||
assertTrue(viewTreeContainsText(content, "技能"));
|
||||
assertTrue(viewTreeContainsText(content, "关于"));
|
||||
|
||||
@@ -55,6 +69,44 @@ public class BossUiRootSurfaceTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openMeEntry_storageStartsAttachmentStorageSettings() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(
|
||||
activity,
|
||||
"sessionData",
|
||||
new JSONObject().put("role", "highest_admin")
|
||||
);
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"openMeEntry",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "storage")
|
||||
);
|
||||
|
||||
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
|
||||
assertNotNull(started);
|
||||
assertEquals(StorageSettingsActivity.class.getName(), started.getComponent().getClassName());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootTabs_useWechatIconLabelNavigation() {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
|
||||
Button conversations = activity.findViewById(R.id.tab_conversations);
|
||||
Button devices = activity.findViewById(R.id.tab_devices);
|
||||
Button me = activity.findViewById(R.id.tab_me);
|
||||
|
||||
assertEquals("会话", conversations.getText().toString());
|
||||
assertEquals("设备", devices.getText().toString());
|
||||
assertEquals("我的", me.getText().toString());
|
||||
assertNotNull("会话 tab 应显示顶部图标", conversations.getCompoundDrawables()[1]);
|
||||
assertNotNull("设备 tab 应显示顶部图标", devices.getCompoundDrawables()[1]);
|
||||
assertNotNull("我的 tab 应显示顶部图标", me.getCompoundDrawables()[1]);
|
||||
assertEquals("底栏文字应压成微信式小字号", 12f, conversations.getTextSize() / activity.getResources().getDisplayMetrics().scaledDensity, 0.5f);
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.shadows.ShadowDialog;
|
||||
import java.time.Duration;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@@ -145,7 +146,7 @@ public class ConversationFolderActivityTest {
|
||||
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
|
||||
assertEquals(1, activity.reloadCount);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageButton;
|
||||
@@ -15,6 +17,7 @@ import android.widget.ListView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.SwitchCompat;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
@@ -37,7 +40,7 @@ import java.util.concurrent.TimeUnit;
|
||||
@Config(sdk = 34)
|
||||
public class ConversationInfoActivityTest {
|
||||
@Test
|
||||
public void renderConversationUsesLightweightHeaderMenuAndThreadList() throws Exception {
|
||||
public void renderConversationOmitsProfileHeaderAndStartsWithUsefulSettings() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
@@ -55,22 +58,81 @@ public class ConversationInfoActivityTest {
|
||||
);
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "北区试产线回归"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "单线程会话"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(1), "线程状态摘要"));
|
||||
assertTrue(viewTreeContainsTextFragment(content.getChildAt(1), "当前进度:已经记录最近 2 条进展"));
|
||||
assertTrue(viewTreeContainsTextFragment(content.getChildAt(1), "建议下一步:继续同步 Android 只读页"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(2), "主 Agent 协同接管"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(3), "发起群聊"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(3), "选择其他线程加入新群"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(4), "线程详情"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(4), "查看当前线程聊天与项目"));
|
||||
assertFalse(viewTreeContainsText(content, "线程状态摘要"));
|
||||
assertFalse(viewTreeContainsTextFragment(content, "当前进度:已经记录最近 2 条进展"));
|
||||
assertFalse(viewTreeContainsTextFragment(content, "建议下一步:继续同步 Android 只读页"));
|
||||
assertFalse(viewTreeContainsText(content, "单线程会话"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "主 Agent 协同接管"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(1), "发起群聊"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(1), "选择其他线程加入新群"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(2), "线程详情"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(2), "查看当前线程聊天与项目"));
|
||||
assertTrue(viewTreeContainsText(content, "参与线程"));
|
||||
assertTrue(viewTreeContainsText(content, "硬件审计协作"));
|
||||
assertFalse(viewTreeContainsText(content, "从当前会话选择其他线程,创建新的独立群聊"));
|
||||
assertFalse(viewTreeContainsText(content, "以下线程参与当前会话,点击可查看对应项目详情。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void takeoverControlUsesWechatRowVisualSystem() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestConversationInfoActivity activity = Robolectric
|
||||
.buildActivity(TestConversationInfoActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderConversation",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildThreadStatusPayload())
|
||||
);
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
LinearLayout takeoverRow = (LinearLayout) content.getChildAt(0);
|
||||
SwitchCompat takeoverSwitch = findFirstSwitch(takeoverRow);
|
||||
|
||||
assertEquals(LinearLayout.HORIZONTAL, takeoverRow.getOrientation());
|
||||
assertEquals(BossUi.dp(activity, 18), takeoverRow.getPaddingLeft());
|
||||
assertEquals(BossUi.dp(activity, 18), takeoverRow.getPaddingRight());
|
||||
assertNotNull(takeoverSwitch);
|
||||
assertEquals("", String.valueOf(takeoverSwitch.getText()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void conversationInfoRowsUseConsistentSpacingAndTakeoverHasNoDividerLines() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestConversationInfoActivity activity = Robolectric
|
||||
.buildActivity(TestConversationInfoActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderConversation",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildThreadStatusPayload())
|
||||
);
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
int expectedBottomMargin = BossUi.dp(activity, 8);
|
||||
for (int index = 0; index < Math.min(content.getChildCount(), 6); index += 1) {
|
||||
View child = content.getChildAt(index);
|
||||
assertTrue(child.getLayoutParams() instanceof LinearLayout.LayoutParams);
|
||||
assertEquals(expectedBottomMargin, ((LinearLayout.LayoutParams) child.getLayoutParams()).bottomMargin);
|
||||
}
|
||||
|
||||
View takeoverRow = content.getChildAt(0);
|
||||
assertTrue(takeoverRow.getBackground() instanceof ColorDrawable);
|
||||
assertEquals(Color.WHITE, ((ColorDrawable) takeoverRow.getBackground()).getColor());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void threadDetailMenuRowStillOpensProjectDetail() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
@@ -235,6 +297,42 @@ public class ConversationInfoActivityTest {
|
||||
assertEquals(1, apiClient.autoLoginCalls);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void saveTakeoverSettingReturnsUpdatedResultState() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestConversationInfoActivity activity = Robolectric
|
||||
.buildActivity(TestConversationInfoActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(
|
||||
activity.getSharedPreferences("conversation-info-save-result-test", Context.MODE_PRIVATE),
|
||||
"https://boss.hyzq.net"
|
||||
);
|
||||
apiClient.failFirstLoad = false;
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
ReflectionHelpers.setField(activity, "reloadEnabled", true);
|
||||
ReflectionHelpers.setField(activity, "delegateReloadToSuper", true);
|
||||
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
|
||||
|
||||
activity.reload();
|
||||
ShadowLooper.shadowMainLooper().idle();
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"saveTakeoverSetting",
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, true)
|
||||
);
|
||||
ShadowLooper.shadowMainLooper().idle();
|
||||
|
||||
assertEquals(android.app.Activity.RESULT_OK, Shadows.shadowOf(activity).getResultCode());
|
||||
Intent resultIntent = Shadows.shadowOf(activity).getResultIntent();
|
||||
assertNotNull(resultIntent);
|
||||
assertTrue(resultIntent.getBooleanExtra(ConversationInfoActivity.EXTRA_TAKEOVER_ENABLED, false));
|
||||
assertEquals("北区试产线回归", resultIntent.getStringExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void matchingProjectMessagesUpdatedEventTriggersReload() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
@@ -393,6 +491,23 @@ public class ConversationInfoActivityTest {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static SwitchCompat findFirstSwitch(View root) {
|
||||
if (root instanceof SwitchCompat) {
|
||||
return (SwitchCompat) root;
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return null;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
SwitchCompat match = findFirstSwitch(group.getChildAt(index));
|
||||
if (match != null) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static class TestConversationInfoActivity extends ConversationInfoActivity {
|
||||
private boolean reloadEnabled;
|
||||
private boolean delegateReloadToSuper;
|
||||
@@ -474,7 +589,7 @@ public class ConversationInfoActivityTest {
|
||||
200,
|
||||
new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", new JSONObject().put("account", "17600003315"))
|
||||
.put("session", new JSONObject().put("account", "krisolo"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -297,7 +297,7 @@ public class DeviceDetailActivityTest {
|
||||
.put("id", "device-1")
|
||||
.put("name", "Mac Studio")
|
||||
.put("avatar", "M")
|
||||
.put("account", "17600003315")
|
||||
.put("account", "krisolo")
|
||||
.put("status", "online")
|
||||
.put("quota5h", 75)
|
||||
.put("quota7d", 88)
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.view.View;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class MainActivityBootstrapSessionTest {
|
||||
@Test
|
||||
public void bootstrapSession_withoutSessionHints_showsLoginFormAndDoesNotAutoLogin() throws Exception {
|
||||
TestBootstrapSessionMainActivity activity =
|
||||
Robolectric.buildActivity(TestBootstrapSessionMainActivity.class).setup().get();
|
||||
SharedPreferences prefs = activity.getSharedPreferences("test-bootstrap-session", Context.MODE_PRIVATE);
|
||||
prefs.edit().clear().apply();
|
||||
|
||||
Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(200));
|
||||
|
||||
View loginPanel = activity.findViewById(R.id.login_panel);
|
||||
View contentPanel = activity.findViewById(R.id.content_panel);
|
||||
android.widget.EditText accountInput = activity.findViewById(R.id.login_account_input);
|
||||
android.widget.EditText passwordInput = activity.findViewById(R.id.login_password_input);
|
||||
|
||||
assertEquals(0, activity.apiClient.autoLoginCalls);
|
||||
assertEquals(View.VISIBLE, loginPanel.getVisibility());
|
||||
assertEquals(View.GONE, contentPanel.getVisibility());
|
||||
assertNotNull(accountInput);
|
||||
assertNotNull(passwordInput);
|
||||
assertFalse(accountInput.getHint().toString().isEmpty());
|
||||
assertFalse(passwordInput.getHint().toString().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bootstrapSession_withSessionHints_prefersRestoreAndDoesNotAutoLogin() throws Exception {
|
||||
TestRestoreBootstrapSessionMainActivity activity =
|
||||
Robolectric.buildActivity(TestRestoreBootstrapSessionMainActivity.class).setup().get();
|
||||
|
||||
waitFor(() -> activity.apiClient.restoreCalls > 0 && activity.apiClient.homeCalls > 0);
|
||||
|
||||
View loginPanel = activity.findViewById(R.id.login_panel);
|
||||
View contentPanel = activity.findViewById(R.id.content_panel);
|
||||
JSONObject sessionData = ReflectionHelpers.getField(activity, "sessionData");
|
||||
|
||||
assertEquals(0, activity.apiClient.autoLoginCalls);
|
||||
assertEquals(1, activity.apiClient.getSessionCalls);
|
||||
assertEquals(1, activity.apiClient.restoreCalls);
|
||||
assertEquals(View.GONE, loginPanel.getVisibility());
|
||||
assertEquals(View.VISIBLE, contentPanel.getVisibility());
|
||||
assertNotNull(sessionData);
|
||||
assertEquals("krisolo", sessionData.optString("account", ""));
|
||||
}
|
||||
|
||||
private static void waitFor(BooleanSupplier condition) {
|
||||
long deadline = System.currentTimeMillis() + 5_000L;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(50));
|
||||
if (condition.getAsBoolean()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new AssertionError("Condition not met before timeout");
|
||||
}
|
||||
|
||||
public static class TestBootstrapSessionMainActivity extends MainActivity {
|
||||
RecordingBootstrapApiClient apiClient;
|
||||
|
||||
@Override
|
||||
BossApiClient createApiClient() {
|
||||
apiClient = new RecordingBootstrapApiClient(
|
||||
getSharedPreferences("test-bootstrap-session", Context.MODE_PRIVATE)
|
||||
);
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
BossRealtimeClient createRealtimeClient(BossApiClient client) {
|
||||
return new BossRealtimeClient(client, new BossRealtimeClient.Listener() {
|
||||
@Override
|
||||
public void onRealtimeEvent(BossRealtimeEvent event) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static class TestRestoreBootstrapSessionMainActivity extends MainActivity {
|
||||
RecordingRestoreBootstrapApiClient apiClient;
|
||||
|
||||
@Override
|
||||
BossApiClient createApiClient() {
|
||||
apiClient = new RecordingRestoreBootstrapApiClient(
|
||||
getSharedPreferences("test-bootstrap-session-restore", Context.MODE_PRIVATE)
|
||||
);
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
BossRealtimeClient createRealtimeClient(BossApiClient client) {
|
||||
return new BossRealtimeClient(client, new BossRealtimeClient.Listener() {
|
||||
@Override
|
||||
public void onRealtimeEvent(BossRealtimeEvent event) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingBootstrapApiClient extends BossApiClient {
|
||||
int autoLoginCalls;
|
||||
int homeCalls;
|
||||
int devicesCalls;
|
||||
int otaCalls;
|
||||
int settingsCalls;
|
||||
|
||||
RecordingBootstrapApiClient(SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasSessionHints() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse autoLogin() throws java.io.IOException, org.json.JSONException {
|
||||
autoLoginCalls += 1;
|
||||
JSONObject session = new JSONObject()
|
||||
.put("account", "krisolo")
|
||||
.put("displayName", "Boss 超级管理员")
|
||||
.put("restoreToken", "restore-auto");
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", session));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse restoreSession() throws java.io.IOException, org.json.JSONException {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_RESTORE_TOKEN"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getSession() throws java.io.IOException, org.json.JSONException {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_SESSION"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getConversationHome() throws java.io.IOException, org.json.JSONException {
|
||||
homeCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("conversations", new JSONArray().put(new JSONObject()
|
||||
.put("projectId", "master-agent")
|
||||
.put("conversationType", "master_agent")
|
||||
.put("projectTitle", "主 Agent")
|
||||
.put("threadTitle", "主 Agent 汇总")
|
||||
.put("lastMessagePreview", "最近会话已恢复")
|
||||
.put("latestReplyLabel", "刚刚"))));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getDevices() throws java.io.IOException, org.json.JSONException {
|
||||
devicesCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("devices", new JSONArray()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getOtaStatus() throws java.io.IOException, org.json.JSONException {
|
||||
otaCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("hasOta", false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getSettings() throws java.io.IOException, org.json.JSONException {
|
||||
settingsCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("settings", new JSONObject().put("preferredEntryPoint", "conversations"))
|
||||
.put("user", new JSONObject()));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingRestoreBootstrapApiClient extends BossApiClient {
|
||||
int autoLoginCalls;
|
||||
int getSessionCalls;
|
||||
int restoreCalls;
|
||||
int homeCalls;
|
||||
int devicesCalls;
|
||||
int otaCalls;
|
||||
int settingsCalls;
|
||||
|
||||
RecordingRestoreBootstrapApiClient(SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
prefs.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.putString("restore_token", "restore-test")
|
||||
.apply();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse autoLogin() throws java.io.IOException, org.json.JSONException {
|
||||
autoLoginCalls += 1;
|
||||
return ApiResponse.error(500, new JSONObject().put("ok", false).put("message", "AUTO_LOGIN_SHOULD_NOT_RUN"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getSession() throws java.io.IOException, org.json.JSONException {
|
||||
getSessionCalls += 1;
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "SESSION_EXPIRED"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse restoreSession() throws java.io.IOException, org.json.JSONException {
|
||||
restoreCalls += 1;
|
||||
JSONObject session = new JSONObject()
|
||||
.put("account", "krisolo")
|
||||
.put("displayName", "Boss 超级管理员")
|
||||
.put("restoreToken", "restore-test");
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", session));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getConversationHome() throws java.io.IOException, org.json.JSONException {
|
||||
homeCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("conversations", new JSONArray().put(new JSONObject()
|
||||
.put("projectId", "master-agent")
|
||||
.put("conversationType", "master_agent")
|
||||
.put("projectTitle", "主 Agent")
|
||||
.put("threadTitle", "主 Agent 汇总")
|
||||
.put("lastMessagePreview", "最近会话已恢复")
|
||||
.put("latestReplyLabel", "刚刚"))));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getDevices() throws java.io.IOException, org.json.JSONException {
|
||||
devicesCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("devices", new JSONArray()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getOtaStatus() throws java.io.IOException, org.json.JSONException {
|
||||
otaCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("hasOta", false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getSettings() throws java.io.IOException, org.json.JSONException {
|
||||
settingsCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("settings", new JSONObject().put("preferredEntryPoint", "conversations"))
|
||||
.put("user", new JSONObject()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.shadows.ShadowApplication;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@@ -18,6 +26,10 @@ public class MainActivityConversationAutoRefreshTest {
|
||||
org.robolectric.android.controller.ActivityController<MainActivity> controller =
|
||||
Robolectric.buildActivity(MainActivity.class).setup().resume();
|
||||
MainActivity activity = controller.get();
|
||||
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.apply();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
|
||||
assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
|
||||
@@ -35,4 +47,53 @@ public class MainActivityConversationAutoRefreshTest {
|
||||
controller.pause();
|
||||
assertFalse(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void returningToVisibleConversationRootRefreshesImmediatelyOnResume() {
|
||||
org.robolectric.android.controller.ActivityController<TestResumeRefreshMainActivity> controller =
|
||||
Robolectric.buildActivity(TestResumeRefreshMainActivity.class).setup().resume();
|
||||
TestResumeRefreshMainActivity activity = controller.get();
|
||||
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.apply();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
activity.conversationRefreshCount = 0;
|
||||
|
||||
controller.pause();
|
||||
controller.resume();
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void showContent_doesNotRequestNotificationPermissionInSameTapFrame() {
|
||||
ShadowApplication.getInstance().denyPermissions(Manifest.permission.POST_NOTIFICATIONS);
|
||||
org.robolectric.android.controller.ActivityController<MainActivity> controller =
|
||||
Robolectric.buildActivity(MainActivity.class).setup();
|
||||
MainActivity activity = controller.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
|
||||
assertNull(Shadows.shadowOf(activity).getLastRequestedPermission());
|
||||
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(java.time.Duration.ofMillis(500));
|
||||
|
||||
assertNotNull(Shadows.shadowOf(activity).getLastRequestedPermission());
|
||||
assertEquals(1, Shadows.shadowOf(activity).getLastRequestedPermission().requestedPermissions.length);
|
||||
assertEquals(
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
Shadows.shadowOf(activity).getLastRequestedPermission().requestedPermissions[0]
|
||||
);
|
||||
}
|
||||
|
||||
public static class TestResumeRefreshMainActivity extends MainActivity {
|
||||
int conversationRefreshCount;
|
||||
|
||||
@Override
|
||||
void refreshConversationsData() {
|
||||
conversationRefreshCount += 1;
|
||||
completeRealtimeTabRefresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.Manifest;
|
||||
import android.view.View;
|
||||
import android.view.inputmethod.InputMethodManager;
|
||||
import android.widget.EditText;
|
||||
@@ -25,6 +26,7 @@ import org.robolectric.annotation.Config;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.shadow.api.Shadow;
|
||||
import org.robolectric.shadows.ShadowInputMethodManager;
|
||||
import org.robolectric.shadows.ShadowApplication;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@@ -148,6 +150,7 @@ public class MainActivityConversationSearchTest {
|
||||
|
||||
@Test
|
||||
public void searchHitOnSingleThread_exitsSearchModeAndOpensProjectDetail() throws Exception {
|
||||
ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
@@ -180,6 +183,7 @@ public class MainActivityConversationSearchTest {
|
||||
|
||||
@Test
|
||||
public void searchHitInsideArchivedProject_opensMatchedThreadDetailAndClearsSearchState() throws Exception {
|
||||
ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
@@ -221,6 +225,7 @@ public class MainActivityConversationSearchTest {
|
||||
|
||||
@Test
|
||||
public void archivedProjectSearchByFolderName_stillOpensFolderPage() throws Exception {
|
||||
ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
|
||||
@@ -90,6 +90,7 @@ public class MainActivityConversationSelectionTest {
|
||||
public void topPlusAction_opensWechatStyleDropdownMenu() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
|
||||
ReflectionHelpers.setField(activity, "sessionData", new JSONObject().put("role", "highest_admin"));
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
@@ -106,6 +107,27 @@ public class MainActivityConversationSelectionTest {
|
||||
assertTrue(viewTreeContainsText(menu, "发起群聊"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void topPlusAction_hidesAddDeviceForSubAccount() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
|
||||
ReflectionHelpers.setField(activity, "sessionData", new JSONObject().put("role", "member"));
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
ImageButton actionButton = activity.findViewById(R.id.refresh_button);
|
||||
actionButton.performClick();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
View overlay = activity.findViewById(R.id.conversation_quick_actions_overlay);
|
||||
View menu = activity.findViewById(R.id.conversation_quick_actions_menu);
|
||||
assertEquals(View.VISIBLE, overlay.getVisibility());
|
||||
assertEquals(View.VISIBLE, menu.getVisibility());
|
||||
assertFalse(viewTreeContainsVisibleText(menu, "添加设备"));
|
||||
assertTrue(viewTreeContainsVisibleText(menu, "扫一扫"));
|
||||
assertTrue(viewTreeContainsVisibleText(menu, "发起群聊"));
|
||||
}
|
||||
|
||||
private static View getRecyclerChild(RecyclerView recyclerView, int position) {
|
||||
RecyclerView.Adapter adapter = recyclerView.getAdapter();
|
||||
int viewType = adapter.getItemViewType(position);
|
||||
@@ -188,6 +210,28 @@ public class MainActivityConversationSelectionTest {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsVisibleText(View root, String expectedText) {
|
||||
if (root.getVisibility() != View.VISIBLE) {
|
||||
return false;
|
||||
}
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
if (expectedText.contentEquals(text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof LinearLayout)) {
|
||||
return false;
|
||||
}
|
||||
LinearLayout group = (LinearLayout) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeContainsVisibleText(group.getChildAt(index), expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsContentDescription(View root, String expectedText) {
|
||||
CharSequence description = root.getContentDescription();
|
||||
if (expectedText.contentEquals(description)) {
|
||||
|
||||
@@ -31,7 +31,7 @@ public class MainActivityDevicesRootTest {
|
||||
.put("name", "Mac Studio")
|
||||
.put("status", "online")
|
||||
.put("platform", "macOS")
|
||||
.put("account", "17600003315")));
|
||||
.put("account", "krisolo")));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.hyzq.boss;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Looper;
|
||||
import org.json.JSONObject;
|
||||
import org.json.JSONArray;
|
||||
@@ -15,6 +16,7 @@ import org.robolectric.annotation.Config;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@@ -24,6 +26,15 @@ public class MainActivityRealtimeTest {
|
||||
public void conversationRealtimeEventRefreshesVisibleConversationTab() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"setActiveTab",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
activity.conversationRefreshCount = 0;
|
||||
activity.deviceRefreshCount = 0;
|
||||
activity.meRefreshCount = 0;
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
@@ -33,6 +44,8 @@ public class MainActivityRealtimeTest {
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
@@ -78,6 +91,15 @@ public class MainActivityRealtimeTest {
|
||||
public void deviceScopedConversationEventRefreshesVisibleConversationTab() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"setActiveTab",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
activity.conversationRefreshCount = 0;
|
||||
activity.deviceRefreshCount = 0;
|
||||
activity.meRefreshCount = 0;
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
@@ -87,6 +109,8 @@ public class MainActivityRealtimeTest {
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
@@ -108,6 +132,8 @@ public class MainActivityRealtimeTest {
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
@@ -129,13 +155,15 @@ public class MainActivityRealtimeTest {
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void distinctConversationEventsBackToBackBothRefreshVisibleConversationTab() throws Exception {
|
||||
public void distinctConversationEventsBackToBackCoalesceIntoSingleVisibleConversationRefresh() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
@@ -161,8 +189,10 @@ public class MainActivityRealtimeTest {
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
|
||||
assertEquals(2, activity.conversationRefreshCount);
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
}
|
||||
|
||||
@@ -176,6 +206,9 @@ public class MainActivityRealtimeTest {
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "devices"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
activity.conversationRefreshCount = 0;
|
||||
activity.deviceRefreshCount = 0;
|
||||
activity.meRefreshCount = 0;
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
@@ -187,6 +220,8 @@ public class MainActivityRealtimeTest {
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
assertEquals(1, activity.deviceRefreshCount);
|
||||
assertEquals(0, activity.meRefreshCount);
|
||||
}
|
||||
@@ -201,6 +236,9 @@ public class MainActivityRealtimeTest {
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "me"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
activity.conversationRefreshCount = 0;
|
||||
activity.deviceRefreshCount = 0;
|
||||
activity.meRefreshCount = 0;
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
@@ -213,6 +251,8 @@ public class MainActivityRealtimeTest {
|
||||
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
assertEquals(0, activity.meRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
assertEquals(1, activity.meRefreshCount);
|
||||
}
|
||||
|
||||
@@ -220,6 +260,15 @@ public class MainActivityRealtimeTest {
|
||||
public void burstConversationRealtimeEventsCoalesceIntoSingleFollowUpRefresh() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"setActiveTab",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
activity.conversationRefreshCount = 0;
|
||||
activity.deviceRefreshCount = 0;
|
||||
activity.meRefreshCount = 0;
|
||||
ReflectionHelpers.setField(activity, "rootTabRefreshInFlight", true);
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
@@ -253,6 +302,7 @@ public class MainActivityRealtimeTest {
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
|
||||
activity.completeRealtimeTabRefresh();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
waitFor(() -> activity.conversationRefreshCount == 1);
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
@@ -282,7 +332,7 @@ public class MainActivityRealtimeTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshConversationsData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception {
|
||||
public void refreshConversationsData_prefersGroupedHomeFeedOverFlatConversationFeed() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
@@ -294,18 +344,47 @@ public class MainActivityRealtimeTest {
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
activity.refreshConversationsData();
|
||||
waitFor(() -> apiClient.homeCalls > 0 || apiClient.conversationsCalls > 0);
|
||||
waitFor(() -> apiClient.homeCalls > 0);
|
||||
|
||||
assertEquals(1, apiClient.homeCalls);
|
||||
assertEquals(0, apiClient.conversationsCalls);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshConversationsData_prefersGroupedHomeFeedForRootList() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
SharedPreferences prefs = activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE);
|
||||
prefs.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.putString("restore_token", "restore-test")
|
||||
.apply();
|
||||
RecordingConversationSourceClient apiClient = new RecordingConversationSourceClient(
|
||||
prefs
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
activity.refreshConversationsData();
|
||||
waitFor(() -> apiClient.homeCalls > 0);
|
||||
|
||||
assertEquals(1, apiClient.homeCalls);
|
||||
assertEquals(0, apiClient.conversationsCalls);
|
||||
JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData");
|
||||
assertEquals(1, conversationsData.length());
|
||||
assertEquals("folder_archive", conversationsData.optJSONObject(0).optString("conversationType", ""));
|
||||
assertEquals("mac-studio:boss", conversationsData.optJSONObject(0).optString("projectId", ""));
|
||||
assertEquals(2, conversationsData.optJSONObject(0).optInt("threadCount", 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshConversationsData_groupsFlatFallbackFeedWhenHomeFeedFails() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
RecordingRejectedConversationSourceClient apiClient = new RecordingRejectedConversationSourceClient(
|
||||
RecordingRejectedHomeConversationSourceClient apiClient = new RecordingRejectedHomeConversationSourceClient(
|
||||
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
@@ -328,7 +407,7 @@ public class MainActivityRealtimeTest {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
RecordingIOExceptionConversationSourceClient apiClient = new RecordingIOExceptionConversationSourceClient(
|
||||
RecordingIOExceptionHomeConversationSourceClient apiClient = new RecordingIOExceptionHomeConversationSourceClient(
|
||||
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
@@ -347,7 +426,7 @@ public class MainActivityRealtimeTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshAllData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception {
|
||||
public void refreshAllData_prefersGroupedHomeFeedOverFlatConversationFeed() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
@@ -363,18 +442,51 @@ public class MainActivityRealtimeTest {
|
||||
"refreshAllData",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject())
|
||||
);
|
||||
waitFor(() -> apiClient.homeCalls > 0 || apiClient.conversationsCalls > 0);
|
||||
waitFor(() -> apiClient.homeCalls > 0);
|
||||
|
||||
assertEquals(1, apiClient.homeCalls);
|
||||
assertEquals(0, apiClient.conversationsCalls);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshAllData_prefersGroupedHomeFeedForRootList() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
SharedPreferences prefs = activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE);
|
||||
prefs.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.putString("restore_token", "restore-test")
|
||||
.apply();
|
||||
RecordingConversationSourceClient apiClient = new RecordingConversationSourceClient(
|
||||
prefs
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"refreshAllData",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject())
|
||||
);
|
||||
waitFor(() -> apiClient.homeCalls > 0);
|
||||
|
||||
assertEquals(1, apiClient.homeCalls);
|
||||
assertEquals(0, apiClient.conversationsCalls);
|
||||
JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData");
|
||||
assertEquals(1, conversationsData.length());
|
||||
assertEquals("folder_archive", conversationsData.optJSONObject(0).optString("conversationType", ""));
|
||||
assertEquals("mac-studio:boss", conversationsData.optJSONObject(0).optString("projectId", ""));
|
||||
assertEquals(2, conversationsData.optJSONObject(0).optInt("threadCount", 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshAllData_groupsFlatFallbackFeedWhenHomeFeedFails() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
RecordingRejectedConversationSourceClient apiClient = new RecordingRejectedConversationSourceClient(
|
||||
RecordingRejectedHomeConversationSourceClient apiClient = new RecordingRejectedHomeConversationSourceClient(
|
||||
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
@@ -401,7 +513,7 @@ public class MainActivityRealtimeTest {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
RecordingIOExceptionConversationSourceClient apiClient = new RecordingIOExceptionConversationSourceClient(
|
||||
RecordingIOExceptionHomeConversationSourceClient apiClient = new RecordingIOExceptionHomeConversationSourceClient(
|
||||
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
@@ -445,6 +557,13 @@ public class MainActivityRealtimeTest {
|
||||
int deviceRefreshCount;
|
||||
int meRefreshCount;
|
||||
|
||||
@Override
|
||||
BossApiClient createApiClient() {
|
||||
SharedPreferences prefs = getSharedPreferences("boss_native_client", Context.MODE_PRIVATE);
|
||||
prefs.edit().clear().apply();
|
||||
return new InertBootstrapApiClient(prefs);
|
||||
}
|
||||
|
||||
@Override
|
||||
void refreshConversationsData() {
|
||||
conversationRefreshCount += 1;
|
||||
@@ -464,7 +583,28 @@ public class MainActivityRealtimeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingRejectedConversationSourceClient extends BossApiClient {
|
||||
private static final class InertBootstrapApiClient extends BossApiClient {
|
||||
InertBootstrapApiClient(SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse autoLogin() throws IOException, org.json.JSONException {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "TEST_BOOTSTRAP_DISABLED"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse restoreSession() throws IOException, org.json.JSONException {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "TEST_BOOTSTRAP_DISABLED"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getSession() throws IOException, org.json.JSONException {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "TEST_BOOTSTRAP_DISABLED"));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingRejectedHomeConversationSourceClient extends BossApiClient {
|
||||
int homeCalls;
|
||||
int conversationsCalls;
|
||||
int sessionCalls;
|
||||
@@ -472,7 +612,7 @@ public class MainActivityRealtimeTest {
|
||||
int settingsCalls;
|
||||
int otaCalls;
|
||||
|
||||
RecordingRejectedConversationSourceClient(android.content.SharedPreferences prefs) {
|
||||
RecordingRejectedHomeConversationSourceClient(android.content.SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@@ -489,7 +629,7 @@ public class MainActivityRealtimeTest {
|
||||
conversationsCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("conversations", buildFlatConversations()));
|
||||
.put("conversations", RecordingConversationSourceClient.buildFlatConversations()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -498,7 +638,7 @@ public class MainActivityRealtimeTest {
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", new JSONObject()
|
||||
.put("account", "17600003315")
|
||||
.put("account", "krisolo")
|
||||
.put("displayName", "Boss 超级管理员")));
|
||||
}
|
||||
|
||||
@@ -526,32 +666,6 @@ public class MainActivityRealtimeTest {
|
||||
.put("ok", true)
|
||||
.put("hasOta", false));
|
||||
}
|
||||
|
||||
private static JSONArray buildFlatConversations() throws org.json.JSONException {
|
||||
return new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "thread-revert")
|
||||
.put("conversationType", "single_device")
|
||||
.put("projectTitle", "发布回滚")
|
||||
.put("threadTitle", "发布回滚")
|
||||
.put("folderLabel", "Boss")
|
||||
.put("folderKey", "mac-studio:boss")
|
||||
.put("lastMessagePreview", "最近:发布回滚")
|
||||
.put("latestReplyAt", "2026-04-06T10:00:00.000Z")
|
||||
.put("latestReplyLabel", "11:00")
|
||||
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 80).put("level", "watch")))
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "thread-ui")
|
||||
.put("conversationType", "single_device")
|
||||
.put("projectTitle", "Android UI 收尾")
|
||||
.put("threadTitle", "Android UI 收尾")
|
||||
.put("folderLabel", "Boss")
|
||||
.put("folderKey", "mac-studio:boss")
|
||||
.put("lastMessagePreview", "最近:Android UI 收尾")
|
||||
.put("latestReplyAt", "2026-04-06T09:59:00.000Z")
|
||||
.put("latestReplyLabel", "10:59")
|
||||
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 95).put("level", "safe")));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConversationSourceClient extends BossApiClient {
|
||||
@@ -588,7 +702,7 @@ public class MainActivityRealtimeTest {
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", new JSONObject()
|
||||
.put("account", "17600003315")
|
||||
.put("account", "krisolo")
|
||||
.put("displayName", "Boss 超级管理员")));
|
||||
}
|
||||
|
||||
@@ -619,13 +733,15 @@ public class MainActivityRealtimeTest {
|
||||
|
||||
private static JSONArray buildHomeConversations() throws org.json.JSONException {
|
||||
return new JSONArray().put(new JSONObject()
|
||||
.put("projectId", "folder-boss")
|
||||
.put("projectId", "mac-studio:boss")
|
||||
.put("conversationType", "folder_archive")
|
||||
.put("folderKey", "mac-studio:boss")
|
||||
.put("projectTitle", "Boss")
|
||||
.put("threadTitle", "Boss")
|
||||
.put("threadCount", 2)
|
||||
.put("folderLabel", "2 个线程 · 最近:发布回滚")
|
||||
.put("searchAliases", new JSONArray().put("发布回滚").put("Android UI 收尾"))
|
||||
.put("searchTargetProjectIds", new JSONArray().put("thread-revert").put("thread-ui"))
|
||||
.put("lastMessagePreview", "最近:发布回滚")
|
||||
.put("latestReplyLabel", "11:00"));
|
||||
}
|
||||
@@ -657,7 +773,7 @@ public class MainActivityRealtimeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingIOExceptionConversationSourceClient extends BossApiClient {
|
||||
private static final class RecordingIOExceptionHomeConversationSourceClient extends BossApiClient {
|
||||
int homeCalls;
|
||||
int conversationsCalls;
|
||||
int sessionCalls;
|
||||
@@ -665,7 +781,7 @@ public class MainActivityRealtimeTest {
|
||||
int settingsCalls;
|
||||
int otaCalls;
|
||||
|
||||
RecordingIOExceptionConversationSourceClient(android.content.SharedPreferences prefs) {
|
||||
RecordingIOExceptionHomeConversationSourceClient(android.content.SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@@ -680,7 +796,7 @@ public class MainActivityRealtimeTest {
|
||||
conversationsCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("conversations", buildFlatConversations()));
|
||||
.put("conversations", RecordingConversationSourceClient.buildFlatConversations()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -689,7 +805,7 @@ public class MainActivityRealtimeTest {
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", new JSONObject()
|
||||
.put("account", "17600003315")
|
||||
.put("account", "krisolo")
|
||||
.put("displayName", "Boss 超级管理员")));
|
||||
}
|
||||
|
||||
@@ -717,31 +833,5 @@ public class MainActivityRealtimeTest {
|
||||
.put("ok", true)
|
||||
.put("hasOta", false));
|
||||
}
|
||||
|
||||
private static JSONArray buildFlatConversations() throws org.json.JSONException {
|
||||
return new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "thread-revert")
|
||||
.put("conversationType", "single_device")
|
||||
.put("projectTitle", "发布回滚")
|
||||
.put("threadTitle", "发布回滚")
|
||||
.put("folderLabel", "Boss")
|
||||
.put("folderKey", "mac-studio:boss")
|
||||
.put("lastMessagePreview", "最近:发布回滚")
|
||||
.put("latestReplyAt", "2026-04-06T10:00:00.000Z")
|
||||
.put("latestReplyLabel", "11:00")
|
||||
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 80).put("level", "watch")))
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "thread-ui")
|
||||
.put("conversationType", "single_device")
|
||||
.put("projectTitle", "Android UI 收尾")
|
||||
.put("threadTitle", "Android UI 收尾")
|
||||
.put("folderLabel", "Boss")
|
||||
.put("folderKey", "mac-studio:boss")
|
||||
.put("lastMessagePreview", "最近:Android UI 收尾")
|
||||
.put("latestReplyAt", "2026-04-06T09:59:00.000Z")
|
||||
.put("latestReplyLabel", "10:59")
|
||||
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 95).put("level", "safe")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ public class MasterAgentTakeoverActivityTest {
|
||||
200,
|
||||
new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", new JSONObject().put("account", "17600003315"))
|
||||
.put("session", new JSONObject().put("account", "krisolo"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,14 @@ public class ProjectChatUiStateTest {
|
||||
assertTrue(ProjectChatUiState.canForwardSelection(next));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void copySelectionRequiresAtLeastOneMessage() {
|
||||
assertFalse(ProjectChatUiState.canCopySelection(ProjectChatUiState.emptySelection()));
|
||||
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
|
||||
assertTrue(ProjectChatUiState.canCopySelection(state));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void selectionPreservesInsertionOrder() {
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m2");
|
||||
@@ -104,6 +112,7 @@ public class ProjectChatUiStateTest {
|
||||
assertTrue(chromeState.showMultiSelectBar);
|
||||
assertFalse(chromeState.showRefresh);
|
||||
assertFalse(chromeState.showHeaderAction);
|
||||
assertTrue(chromeState.copyEnabled);
|
||||
assertTrue(chromeState.forwardEnabled);
|
||||
assertEquals("取消", chromeState.backLabel);
|
||||
assertEquals("已选 2 条", chromeState.title);
|
||||
@@ -120,6 +129,7 @@ public class ProjectChatUiStateTest {
|
||||
assertFalse(chromeState.showMultiSelectBar);
|
||||
assertFalse(chromeState.showRefresh);
|
||||
assertTrue(chromeState.showHeaderAction);
|
||||
assertFalse(chromeState.copyEnabled);
|
||||
assertFalse(chromeState.forwardEnabled);
|
||||
assertEquals("返回", chromeState.backLabel);
|
||||
assertEquals("北区试产线回归", chromeState.title);
|
||||
@@ -136,6 +146,7 @@ public class ProjectChatUiStateTest {
|
||||
assertFalse(chromeState.showMultiSelectBar);
|
||||
assertTrue(chromeState.showRefresh);
|
||||
assertFalse(chromeState.showHeaderAction);
|
||||
assertFalse(chromeState.copyEnabled);
|
||||
assertFalse(chromeState.forwardEnabled);
|
||||
assertEquals("返回", chromeState.backLabel);
|
||||
assertEquals("北区试产线回归", chromeState.title);
|
||||
@@ -196,9 +207,10 @@ public class ProjectChatUiStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void queuedReplyTaskStartsReplyWaitFromRequestMessageId() throws Exception {
|
||||
public void queuedReplyTaskStartsReplyWaitFromImmediateReplyWhenPresent() throws Exception {
|
||||
JSONObject response = new JSONObject()
|
||||
.put("message", new JSONObject().put("id", "msg-user-1"))
|
||||
.put("replyMessage", new JSONObject().put("id", "msg-master-ack-1"))
|
||||
.put("task", new JSONObject()
|
||||
.put("taskId", "task-1")
|
||||
.put("taskType", "conversation_reply")
|
||||
@@ -207,7 +219,7 @@ public class ProjectChatUiStateTest {
|
||||
ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterSend(response);
|
||||
|
||||
assertTrue(waitSpec.shouldWait);
|
||||
assertEquals("msg-user-1", waitSpec.baselineMessageId);
|
||||
assertEquals("msg-master-ack-1", waitSpec.baselineMessageId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -250,6 +262,318 @@ public class ProjectChatUiStateTest {
|
||||
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void replyWaitIgnoresDuplicateBaselineMessages() throws Exception {
|
||||
JSONObject project = new JSONObject()
|
||||
.put("messages", new JSONArray()
|
||||
.put(new JSONObject().put("id", "msg-user-1"))
|
||||
.put(new JSONObject().put("id", "msg-user-1")));
|
||||
|
||||
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, "msg-user-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void timedOutMasterRelayKeepsConversationPollingEvenWhenRealtimeConnected() {
|
||||
assertTrue(ProjectChatUiState.shouldAutoRefreshConversation(true, true, true));
|
||||
assertTrue(ProjectChatUiState.shouldAutoRefreshConversation(true, false, false));
|
||||
assertFalse(ProjectChatUiState.shouldAutoRefreshConversation(true, true, false));
|
||||
assertFalse(ProjectChatUiState.shouldAutoRefreshConversation(false, true, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void threadProcessMessagesAreCollapsedBeforeFinalResult() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "u1")
|
||||
.put("sender", "user")
|
||||
.put("body", "继续"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "我先看一下当前聊天渲染链路和消息结构。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p2")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "接下来我会补一组单元测试,再把折叠 UI 接上。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "这轮已经接好过程折叠,最终结果现在直接显示在主消息流里。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
|
||||
assertEquals("u1", items.get(0).message.optString("id"));
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
|
||||
assertEquals(2, items.get(1).processMessages.size());
|
||||
assertEquals("p1", items.get(1).processMessages.get(0).optString("id"));
|
||||
assertEquals("p2", items.get(1).processMessages.get(1).optString("id"));
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
|
||||
assertEquals("r1", items.get(2).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void errorMessagesStayVisibleInsteadOfBeingCollapsed() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "e1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "当前执行失败,构建报错,需要先补依赖。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(1, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
|
||||
assertEquals("e1", items.get(0).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void processGroupPreviewUsesLatestProgressLine() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("body", "我先检查项目结构。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p2")
|
||||
.put("sender", "device")
|
||||
.put("body", "接下来开始补聊天折叠按钮。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(1, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
|
||||
assertEquals("接下来开始补聊天折叠按钮。", ProjectChatUiState.processGroupPreview(items.get(0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitThreadProcessKindIsCollapsedEvenWhenCopyLooksLikeACompletionUpdate() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "thread_process")
|
||||
.put("body", "工程骨架已经建好了,我现在开始写核心代码。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p2")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "thread_process")
|
||||
.put("body", "编译错误已定位到导入问题,我已修复并正在重新构建确认。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "text")
|
||||
.put("body", "已完成折叠修复,过程消息会收进按钮里,未读只增加一次。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(2, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
|
||||
assertEquals(2, items.get(0).processMessages.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(1).type);
|
||||
assertEquals("r1", items.get(1).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void executionProgressCardsStayVisibleBetweenProcessGroups() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "thread_process")
|
||||
.put("body", "我先检查当前执行链路。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "progress-1")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent")
|
||||
.put("kind", "execution_progress")
|
||||
.put("body", "执行进度")
|
||||
.put("executionProgress", new JSONObject()
|
||||
.put("status", "running")
|
||||
.put("steps", new JSONArray()
|
||||
.put(new JSONObject().put("text", "接收对话任务").put("status", "done"))
|
||||
.put(new JSONObject().put("text", "等待目标线程回复").put("status", "running")))))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p2")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "thread_process")
|
||||
.put("body", "我继续执行验证。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(1).type);
|
||||
assertEquals("progress-1", items.get(1).message.optString("id"));
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(2).type);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void processGroupKeepsFinalResultVisibleWhenProcessMessagesCarryThreadProcessKind() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "u1")
|
||||
.put("sender", "user")
|
||||
.put("body", "继续推进"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "thread_process")
|
||||
.put("body", "我先检查聊天折叠链路,确认过程消息不会直接展开。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "text")
|
||||
.put("body", "这轮已经完成折叠修复,未读现在只会算最终结果。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
|
||||
assertEquals(1, items.get(1).processMessages.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
|
||||
assertEquals("r1", items.get(2).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void numberedProgressUpdatesAreCollapsedWhenMarkedAsThreadProcess() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "u1")
|
||||
.put("sender", "user")
|
||||
.put("body", "继续处理"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "thread_process")
|
||||
.put("body", "1. 先检查当前消息折叠链路。\\n2. 再确认 Android 端只把最终结果记成未读。\\n3. 处理完成后我会回你最终结果。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "text")
|
||||
.put("body", "这轮已经处理完成,最终结果已回写。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
|
||||
assertEquals(1, items.get(1).processMessages.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
|
||||
assertEquals("r1", items.get(2).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void numberedProgressUpdatesWithoutKindStillCollapseBeforeFinalResult() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "u1")
|
||||
.put("sender", "user")
|
||||
.put("body", "继续处理"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "1. 先检查当前消息折叠链路。\n2. 再确认 Android 端只把最终结果记成未读。\n3. 处理完成后我会回你最终结果。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "这轮已经处理完成,最终结果已回写。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
|
||||
assertEquals(1, items.get(1).processMessages.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
|
||||
assertEquals("r1", items.get(2).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void progressUpdatesStartingWithWoZheBianYiJingStillCollapseIntoProcessGroup() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "u1")
|
||||
.put("sender", "user")
|
||||
.put("body", "继续"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "我这边已经查了,adb 现在还只看到一台 USB 连着的 PHZ110,PLB110 的无线目标还没有被发现出来。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "text")
|
||||
.put("body", "无线调试已经接通,最新 debug 包也装好了。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
|
||||
assertEquals(1, items.get(1).processMessages.size());
|
||||
assertEquals("p1", items.get(1).processMessages.get(0).optString("id"));
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
|
||||
assertEquals("r1", items.get(2).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void realThreadPlanningCopyIsCollapsedButSavedResultStaysVisible() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Andorid")
|
||||
.put("body", "我发现当前这个仓库快照里没有 ios/ 目录,所以这份报告会明确分成两层。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p2")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Andorid")
|
||||
.put("body", "我准备新增一份 doc/iOS实时转写开发交接报告_20260419.md。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Andorid")
|
||||
.put("body", "报告已经落盘了。我再快速过一遍这份文档的结构和措辞。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(2, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
|
||||
assertEquals(2, items.get(0).processMessages.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(1).type);
|
||||
assertEquals("r1", items.get(1).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void threadExecutionConflictCopyExplainsPreferredGuiModeAsProjectScoped() throws Exception {
|
||||
JSONObject conflict = new JSONObject()
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.shadows.ShadowDialog;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
@@ -113,7 +114,7 @@ public class ProjectDetailActivityMasterAgentMenuTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void normalConversationMoreMenuShowsInfoAndRefresh() {
|
||||
public void normalConversationHeaderActionOpensConversationInfoDirectly() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss 移动控制台");
|
||||
@@ -122,15 +123,11 @@ public class ProjectDetailActivityMasterAgentMenuTest {
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showConversationMoreMenu");
|
||||
ReflectionHelpers.callInstanceMethod(activity, "openConversationInfo");
|
||||
|
||||
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
|
||||
assertTrue(latestDialog instanceof AlertDialog);
|
||||
AlertDialog actionDialog = (AlertDialog) latestDialog;
|
||||
ListView listView = actionDialog.getListView();
|
||||
|
||||
assertMenuItem(listView, 0, "会话信息");
|
||||
assertMenuItem(listView, 1, "刷新");
|
||||
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
|
||||
assertNotNull(nextIntent);
|
||||
assertEquals(ConversationInfoActivity.class.getName(), nextIntent.getComponent().getClassName());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.fail;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Looper;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
@@ -13,8 +21,12 @@ import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.RuntimeEnvironment;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.shadows.ShadowApplication;
|
||||
import org.robolectric.shadows.ShadowDialog;
|
||||
import org.robolectric.shadows.ShadowNotificationManager;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
@@ -43,7 +55,7 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(0, activity.reloadCount);
|
||||
assertEquals(1, activity.messageReloadCount);
|
||||
@@ -68,7 +80,7 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(0, activity.reloadCount);
|
||||
}
|
||||
@@ -92,7 +104,7 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-2"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(0, activity.reloadCount);
|
||||
}
|
||||
@@ -130,10 +142,10 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
)
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(1, activity.reloadCount);
|
||||
assertEquals(1, activity.messageReloadCount);
|
||||
assertEquals(0, activity.messageReloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -158,7 +170,7 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
)
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(1, activity.reloadCount);
|
||||
assertEquals(0, activity.messageReloadCount);
|
||||
@@ -197,12 +209,162 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
)
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(0, activity.reloadCount);
|
||||
assertEquals(1, activity.messageReloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dialogGuardInterventionRequiredShowsBlockedSafeActionDialog() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
|
||||
TestRealtimeProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.resume()
|
||||
.get();
|
||||
RecordingDialogGuardApiClient apiClient = new RecordingDialogGuardApiClient();
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
ReflectionHelpers.ClassParameter.from(
|
||||
BossRealtimeEvent.class,
|
||||
new BossRealtimeEvent(
|
||||
"desktop.dialog_guard.intervention_required",
|
||||
new JSONObject()
|
||||
.put("interventionId", "intervention-1")
|
||||
.put("dialogId", "dialog-1")
|
||||
.put("requestId", "request-1")
|
||||
.put("taskId", "task-1")
|
||||
.put("deviceId", "mac-studio")
|
||||
.put("projectId", "project-1")
|
||||
.put("appName", "微信")
|
||||
.put("platform", "macos")
|
||||
.put("risk", "blocked")
|
||||
.put("summary", "微信正在请求读取敏感通讯录权限")
|
||||
.put("recommendedAction", "handled_on_device")
|
||||
.put("availableActions", new JSONArray()
|
||||
.put("allow_once")
|
||||
.put("allow_for_device_dialog")
|
||||
.put("deny")
|
||||
.put("handled_on_device")
|
||||
.put("cancel_task"))
|
||||
)
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
Dialog latestDialog = ShadowDialog.getLatestDialog();
|
||||
assertTrue(latestDialog instanceof AlertDialog);
|
||||
AlertDialog dialog = (AlertDialog) latestDialog;
|
||||
assertTrue(dialog.isShowing());
|
||||
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "微信"));
|
||||
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "微信正在请求读取敏感通讯录权限"));
|
||||
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "我已在电脑上处理"));
|
||||
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "取消任务"));
|
||||
assertFalse(viewTreeContainsText(dialog.getWindow().getDecorView(), "允许本次"));
|
||||
assertFalse(viewTreeContainsText(dialog.getWindow().getDecorView(), "当前设备此弹窗允许"));
|
||||
|
||||
View handledButton = findClickableViewContainingText(dialog.getWindow().getDecorView(), "我已在电脑上处理");
|
||||
assertNotNull(handledButton);
|
||||
handledButton.performClick();
|
||||
waitFor(() -> apiClient.decisionCallCount == 1);
|
||||
|
||||
assertEquals("intervention-1", apiClient.lastInterventionId);
|
||||
assertEquals("handled_on_device", apiClient.lastDecision);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dialogGuardResolvedEventClosesMatchingDialog() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
|
||||
TestRealtimeProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.resume()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
ReflectionHelpers.ClassParameter.from(
|
||||
BossRealtimeEvent.class,
|
||||
new BossRealtimeEvent(
|
||||
"desktop.dialog_guard.intervention_required",
|
||||
new JSONObject()
|
||||
.put("interventionId", "intervention-2")
|
||||
.put("projectId", "project-1")
|
||||
.put("appName", "访达")
|
||||
.put("risk", "safe")
|
||||
.put("summary", "确认打开下载文件")
|
||||
.put("availableActions", new JSONArray().put("allow_once").put("deny"))
|
||||
)
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertTrue(dialog.isShowing());
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
ReflectionHelpers.ClassParameter.from(
|
||||
BossRealtimeEvent.class,
|
||||
new BossRealtimeEvent(
|
||||
"desktop.dialog_guard.intervention_resolved",
|
||||
new JSONObject()
|
||||
.put("interventionId", "intervention-2")
|
||||
.put("projectId", "project-1")
|
||||
)
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertFalse(dialog.isShowing());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openingMasterAgentConversationClearsPendingMasterAgentNotification() throws Exception {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
BossApplication application = (BossApplication) context.getApplicationContext();
|
||||
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
|
||||
ShadowNotificationManager notificationManager = Shadows.shadowOf(
|
||||
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||
);
|
||||
application.visibilityTracker().onAppBackgrounded();
|
||||
|
||||
JSONObject message = new JSONObject()
|
||||
.put("id", "master-msg-1")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent · gpt-5.4-mini")
|
||||
.put("body", "主 Agent 后台回复");
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("projectId", "master-agent")
|
||||
.put("projectMessagesPayload", new JSONObject().put(
|
||||
"project",
|
||||
new JSONObject().put("messages", new JSONArray().put(message))
|
||||
));
|
||||
assertTrue(application.notificationRouter().maybeNotifyForRealtimeEvent(
|
||||
new BossRealtimeEvent("project.messages.updated", payload)
|
||||
));
|
||||
assertEquals(1, notificationManager.size());
|
||||
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
Robolectric.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.resume()
|
||||
.get();
|
||||
|
||||
assertEquals(0, notificationManager.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void burstRealtimeEventsWhileReloadingCoalesceIntoSingleFollowUpReload() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
@@ -224,7 +386,7 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
drainRealtimeDebounce(activity);
|
||||
assertTrue(activity.awaitFirstLoadStarted());
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
@@ -243,7 +405,7 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-1"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(0, activity.loadCallCount);
|
||||
assertEquals(1, activity.messageLoadCallCount);
|
||||
@@ -277,7 +439,7 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
"handleRealtimeConnectionChanged",
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(1, activity.reloadCount);
|
||||
}
|
||||
@@ -317,6 +479,49 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
fail("condition not met before timeout");
|
||||
}
|
||||
|
||||
private static void drainRealtimeDebounce(TestRealtimeProjectDetailActivity activity) {
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(350, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof android.widget.TextView) {
|
||||
CharSequence text = ((android.widget.TextView) root).getText();
|
||||
if (expectedText.contentEquals(text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof android.view.ViewGroup)) {
|
||||
return false;
|
||||
}
|
||||
android.view.ViewGroup group = (android.view.ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static View findClickableViewContainingText(View root, String expectedText) {
|
||||
if (root == null) {
|
||||
return null;
|
||||
}
|
||||
if (viewTreeContainsText(root, expectedText) && root.isClickable()) {
|
||||
return root;
|
||||
}
|
||||
if (!(root instanceof android.view.ViewGroup)) {
|
||||
return null;
|
||||
}
|
||||
android.view.ViewGroup group = (android.view.ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
View match = findClickableViewContainingText(group.getChildAt(index), expectedText);
|
||||
if (match != null) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static class TestRealtimeProjectDetailActivity extends ProjectDetailActivity {
|
||||
int reloadCount;
|
||||
int messageReloadCount;
|
||||
@@ -397,4 +602,22 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingDialogGuardApiClient extends BossApiClient {
|
||||
int decisionCallCount;
|
||||
String lastInterventionId;
|
||||
String lastDecision;
|
||||
|
||||
RecordingDialogGuardApiClient() {
|
||||
super(RuntimeEnvironment.getApplication().getSharedPreferences("dialog_guard_test", Context.MODE_PRIVATE), "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse decideDialogGuardIntervention(String interventionId, String decision) throws org.json.JSONException {
|
||||
decisionCallCount += 1;
|
||||
lastInterventionId = interventionId;
|
||||
lastDecision = decision;
|
||||
return new ApiResponse(200, new JSONObject().put("ok", true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,21 @@ package com.hyzq.boss;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.graphics.drawable.GradientDrawable;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.ListView;
|
||||
@@ -26,6 +32,7 @@ import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.android.controller.ActivityController;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.shadows.ShadowDialog;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
@@ -34,11 +41,112 @@ import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TimeZone;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class ProjectDetailActivityUiTest {
|
||||
@Test
|
||||
public void typingAtInComposerShowsAgentMentionSuggestions() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
EditText input = activity.findViewById(R.id.project_chat_input);
|
||||
input.requestFocus();
|
||||
input.setText("@");
|
||||
input.setSelection(1);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
View panel = activity.findViewById(R.id.project_chat_mention_panel);
|
||||
assertEquals(View.VISIBLE, panel.getVisibility());
|
||||
assertTrue(viewTreeContainsText(panel, "主Agent"));
|
||||
assertTrue(viewTreeContainsText(panel, "审计Agent"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tappingMentionSuggestionInsertsAgentMentionAndClosesPanel() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
EditText input = activity.findViewById(R.id.project_chat_input);
|
||||
input.requestFocus();
|
||||
input.setText("@");
|
||||
input.setSelection(1);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
View panel = activity.findViewById(R.id.project_chat_mention_panel);
|
||||
View masterAgentRow = findClickableViewContainingText(panel, "主Agent");
|
||||
assertNotNull(masterAgentRow);
|
||||
|
||||
masterAgentRow.performClick();
|
||||
|
||||
assertEquals("@主Agent ", input.getText().toString());
|
||||
assertEquals(input.getText().length(), input.getSelectionStart());
|
||||
assertEquals(View.GONE, panel.getVisibility());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tappingAuditMentionSuggestionInsertsAuditAgentMention() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
EditText input = activity.findViewById(R.id.project_chat_input);
|
||||
input.requestFocus();
|
||||
input.setText("请看 @审");
|
||||
input.setSelection(input.getText().length());
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
View panel = activity.findViewById(R.id.project_chat_mention_panel);
|
||||
View auditAgentRow = findClickableViewContainingText(panel, "审计Agent");
|
||||
assertNotNull(auditAgentRow);
|
||||
|
||||
auditAgentRow.performClick();
|
||||
|
||||
assertEquals("请看 @审计Agent ", input.getText().toString());
|
||||
assertEquals(View.GONE, panel.getVisibility());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void formatMessageTimeConvertsUtcTimestampIntoLocalTimezoneClock() {
|
||||
TimeZone original = TimeZone.getDefault();
|
||||
try {
|
||||
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
String label = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"formatMessageTime",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "2026-04-20T09:01:00.000Z")
|
||||
);
|
||||
|
||||
assertEquals("17:01", label);
|
||||
} finally {
|
||||
TimeZone.setDefault(original);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void multiSelectModeUpdatesRealChatChrome() {
|
||||
Intent intent = new Intent()
|
||||
@@ -73,12 +181,14 @@ public class ProjectDetailActivityUiTest {
|
||||
LinearLayout multiSelectActions = activity.findViewById(R.id.project_chat_multi_select_actions);
|
||||
ImageButton backButton = activity.findViewById(R.id.screen_back_button);
|
||||
ImageButton refreshButton = activity.findViewById(R.id.screen_refresh_button);
|
||||
Button copyButton = activity.findViewById(R.id.project_chat_multi_copy);
|
||||
Button forwardButton = activity.findViewById(R.id.project_chat_multi_forward);
|
||||
|
||||
assertEquals(View.GONE, composerRow.getVisibility());
|
||||
assertEquals(View.VISIBLE, multiSelectActions.getVisibility());
|
||||
assertEquals("取消", String.valueOf(backButton.getContentDescription()));
|
||||
assertEquals(View.GONE, refreshButton.getVisibility());
|
||||
assertTrue(copyButton.isEnabled());
|
||||
assertEquals(false, forwardButton.isEnabled());
|
||||
|
||||
secondMessage.performClick();
|
||||
@@ -92,6 +202,101 @@ public class ProjectDetailActivityUiTest {
|
||||
assertEquals(View.GONE, refreshButton.getVisibility());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void systemBackInMultiSelectModeExitsSelectionInsteadOfClosingConversation() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
|
||||
ReflectionHelpers.setField(activity, "currentScreenTitle", "北区试产线回归");
|
||||
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "归档确认");
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"enterMultiSelectFromMessage",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "m1")
|
||||
);
|
||||
|
||||
assertEquals(View.GONE, activity.findViewById(R.id.project_chat_composer_row).getVisibility());
|
||||
assertEquals(View.VISIBLE, activity.findViewById(R.id.project_chat_multi_select_actions).getVisibility());
|
||||
|
||||
activity.getOnBackPressedDispatcher().onBackPressed();
|
||||
|
||||
assertEquals(0, activity.finishCallCount);
|
||||
assertEquals(View.VISIBLE, activity.findViewById(R.id.project_chat_composer_row).getVisibility());
|
||||
assertEquals(View.GONE, activity.findViewById(R.id.project_chat_multi_select_actions).getVisibility());
|
||||
assertEquals("返回", String.valueOf(((ImageButton) activity.findViewById(R.id.screen_back_button)).getContentDescription()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void multiSelectModeShowsCheckmarksBeforeMessagesAndCopiesTranscript() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("project", new JSONObject()
|
||||
.put("id", "project-1")
|
||||
.put("name", "北区试产线回归")
|
||||
.put("messages", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "msg-user")
|
||||
.put("sender", "user")
|
||||
.put("senderLabel", "Boss 超级管理员")
|
||||
.put("body", "请同步项目目标")
|
||||
.put("kind", "text")
|
||||
.put("sentAt", "2026-04-20T09:01:00+08:00"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "msg-master")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent · gpt-5.4-mini")
|
||||
.put("body", "我会先核对目标,再更新版本记录。")
|
||||
.put("kind", "text")
|
||||
.put("sentAt", "2026-04-20T09:02:00+08:00"))));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderProject",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
|
||||
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
|
||||
);
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"enterMultiSelectFromMessage",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "msg-user")
|
||||
);
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"toggleMultiSelectMessage",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "msg-master")
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "✓"));
|
||||
assertTrue(viewTreeContainsText(content, "你 · 09:01"));
|
||||
assertTrue(viewTreeContainsText(content, "主Agent · 09:02"));
|
||||
|
||||
Button copyButton = activity.findViewById(R.id.project_chat_multi_copy);
|
||||
copyButton.performClick();
|
||||
|
||||
android.content.ClipData clipData = activity
|
||||
.getSystemService(android.content.ClipboardManager.class)
|
||||
.getPrimaryClip();
|
||||
assertNotNull(clipData);
|
||||
String copied = String.valueOf(clipData.getItemAt(0).coerceToText(activity));
|
||||
assertTrue(copied.contains("09:01 你:请同步项目目标"));
|
||||
assertTrue(copied.contains("09:02 主Agent:我会先核对目标,再更新版本记录。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void composerFocus_scrollsChatToBottomToKeepLatestMessageVisible() {
|
||||
Intent intent = new Intent()
|
||||
@@ -148,6 +353,56 @@ public class ProjectDetailActivityUiTest {
|
||||
assertNotNull(childScrollCallback);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderProjectWithUnread_marksConversationReadOncePerVisibleSession() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
ActivityController<TestProjectDetailActivity> controller = Robolectric.buildActivity(TestProjectDetailActivity.class, intent);
|
||||
TestProjectDetailActivity activity = controller.setup().get();
|
||||
|
||||
RecordingConversationActionApiClient apiClient = new RecordingConversationActionApiClient();
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("project", new JSONObject()
|
||||
.put("id", "project-1")
|
||||
.put("name", "北区试产线回归")
|
||||
.put("unreadCount", 3)
|
||||
.put("messages", new JSONArray()));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderProject",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
|
||||
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
|
||||
);
|
||||
waitForUiCondition(activity, () -> apiClient.markConversationReadCount == 1);
|
||||
assertEquals("project-1", apiClient.lastMarkedProjectId);
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderProject",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
|
||||
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
|
||||
);
|
||||
Thread.sleep(80L);
|
||||
assertEquals(1, apiClient.markConversationReadCount);
|
||||
|
||||
controller.pause();
|
||||
controller.resume();
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderProject",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
|
||||
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
|
||||
);
|
||||
waitForUiCondition(activity, () -> apiClient.markConversationReadCount == 2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void composerRowLayoutChangeWithFocusedInput_scrollsChatToBottomAgain() {
|
||||
Intent intent = new Intent()
|
||||
@@ -235,6 +490,110 @@ public class ProjectDetailActivityUiTest {
|
||||
assertTrue(params.height >= BossUi.dp(activity, 46));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scrollBottomShortcutIsFloatingIconAboveComposerAndTriggersBottomScroll() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
int shortcutId = activity.getResources().getIdentifier(
|
||||
"project_chat_scroll_bottom",
|
||||
"id",
|
||||
activity.getPackageName()
|
||||
);
|
||||
assertTrue("project_chat_scroll_bottom id should exist", shortcutId != 0);
|
||||
View shortcutView = activity.findViewById(shortcutId);
|
||||
|
||||
assertNotNull(shortcutView);
|
||||
assertTrue(shortcutView instanceof ImageButton);
|
||||
assertEquals(View.GONE, shortcutView.getVisibility());
|
||||
assertTrue(shortcutView.getLayoutParams() instanceof FrameLayout.LayoutParams);
|
||||
FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) shortcutView.getLayoutParams();
|
||||
assertTrue((params.gravity & Gravity.BOTTOM) == Gravity.BOTTOM);
|
||||
assertTrue((params.gravity & Gravity.LEFT) == Gravity.LEFT || (params.gravity & Gravity.START) == Gravity.START);
|
||||
assertEquals(BossUi.dp(activity, 12), params.leftMargin);
|
||||
assertTrue(params.bottomMargin >= BossUi.dp(activity, 12));
|
||||
assertEquals(BossUi.dp(activity, 48), params.width);
|
||||
assertEquals(BossUi.dp(activity, 48), params.height);
|
||||
|
||||
int baselineScrollCount = activity.scrollChatToBottomCount;
|
||||
shortcutView.performClick();
|
||||
|
||||
assertTrue(activity.scrollChatToBottomCount > baselineScrollCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scrollBottomShortcutVisibilityLogicMatchesObservedSwipeDirection() {
|
||||
Boolean farFromBottom = ReflectionHelpers.callStaticMethod(
|
||||
ProjectDetailActivity.class,
|
||||
"shouldShowScrollBottomShortcut",
|
||||
ReflectionHelpers.ClassParameter.from(int.class, 140),
|
||||
ReflectionHelpers.ClassParameter.from(int.class, 96),
|
||||
ReflectionHelpers.ClassParameter.from(int.class, 460),
|
||||
ReflectionHelpers.ClassParameter.from(int.class, 400),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
Boolean oppositeDirection = ReflectionHelpers.callStaticMethod(
|
||||
ProjectDetailActivity.class,
|
||||
"shouldShowScrollBottomShortcut",
|
||||
ReflectionHelpers.ClassParameter.from(int.class, 140),
|
||||
ReflectionHelpers.ClassParameter.from(int.class, 96),
|
||||
ReflectionHelpers.ClassParameter.from(int.class, 320),
|
||||
ReflectionHelpers.ClassParameter.from(int.class, 400),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, true)
|
||||
);
|
||||
Boolean keepVisibleWhileStopped = ReflectionHelpers.callStaticMethod(
|
||||
ProjectDetailActivity.class,
|
||||
"shouldShowScrollBottomShortcut",
|
||||
ReflectionHelpers.ClassParameter.from(int.class, 140),
|
||||
ReflectionHelpers.ClassParameter.from(int.class, 96),
|
||||
ReflectionHelpers.ClassParameter.from(int.class, 400),
|
||||
ReflectionHelpers.ClassParameter.from(int.class, 400),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, true)
|
||||
);
|
||||
Boolean alreadyNearBottom = ReflectionHelpers.callStaticMethod(
|
||||
ProjectDetailActivity.class,
|
||||
"shouldShowScrollBottomShortcut",
|
||||
ReflectionHelpers.ClassParameter.from(int.class, 80),
|
||||
ReflectionHelpers.ClassParameter.from(int.class, 96),
|
||||
ReflectionHelpers.ClassParameter.from(int.class, 320),
|
||||
ReflectionHelpers.ClassParameter.from(int.class, 400),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, true)
|
||||
);
|
||||
|
||||
assertTrue(farFromBottom);
|
||||
assertFalse(oppositeDirection);
|
||||
assertTrue(keepVisibleWhileStopped);
|
||||
assertFalse(alreadyNearBottom);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void normalConversationHeaderActionOpensConversationInfoDirectlyWithoutDialog() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss 移动控制台");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
|
||||
ReflectionHelpers.setField(activity, "currentScreenTitle", "Boss 移动控制台");
|
||||
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "归档确认");
|
||||
ReflectionHelpers.callInstanceMethod(activity, "updateSelectionUi");
|
||||
|
||||
ImageButton headerAction = activity.findViewById(R.id.screen_header_action);
|
||||
ShadowDialog.reset();
|
||||
|
||||
headerAction.performClick();
|
||||
|
||||
assertNull(ShadowDialog.getLatestDialog());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void manualAnalysisAttachmentShowsActionChip() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
@@ -358,7 +717,7 @@ public class ProjectDetailActivityUiTest {
|
||||
|
||||
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
|
||||
prefs.edit()
|
||||
.putString("account", "17600003315")
|
||||
.putString("account", "krisolo")
|
||||
.putString("display_name", "OpenAI 平台账号")
|
||||
.apply();
|
||||
ReflectionHelpers.setField(activity, "apiClient", new BossApiClient(prefs, "https://boss.hyzq.net"));
|
||||
@@ -369,7 +728,7 @@ public class ProjectDetailActivityUiTest {
|
||||
.put("senderLabel", "Boss 超级管理员")
|
||||
.put("body", "请只回复一句:聊天链路自检正常。")
|
||||
.put("kind", "text")
|
||||
.put("sentAt", "2026-03-31T10:26:00.000Z");
|
||||
.put("sentAt", "2026-03-31T10:26:00+08:00");
|
||||
|
||||
View messageView = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
@@ -377,10 +736,174 @@ public class ProjectDetailActivityUiTest {
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, message)
|
||||
);
|
||||
|
||||
assertTrue(viewTreeContainsText(messageView, "10:26"));
|
||||
assertTrue(viewTreeContainsText(messageView, "你 · 10:26"));
|
||||
assertFalse(viewTreeContainsText(messageView, "Boss 超级管理员 · 10:26"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masterAgentMessageUsesStableSpeakerLabelAndLightBlueBubble() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
JSONObject message = new JSONObject()
|
||||
.put("id", "msg-master-1")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent · gpt-5.4-mini")
|
||||
.put("body", "我会先核对目标,再同步到顶部入口。")
|
||||
.put("kind", "text")
|
||||
.put("sentAt", "2026-04-20T09:16:00+08:00");
|
||||
|
||||
View messageView = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"buildMessageView",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, message)
|
||||
);
|
||||
|
||||
assertTrue(viewTreeContainsText(messageView, "主Agent · 09:16"));
|
||||
assertTrue(viewTreeHasGradientColor(messageView, 0xFFEAF5FF));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderThreadMessageUsesBoundCodexDeviceAvatar() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "thread-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss开发主线程");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("project", new JSONObject()
|
||||
.put("id", "thread-1")
|
||||
.put("name", "Boss开发主线程")
|
||||
.put("deviceIds", new JSONArray().put("mac-studio"))
|
||||
.put("messages", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "msg-device-1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程 · Mac Studio")
|
||||
.put("body", "已完成构建检查。")
|
||||
.put("kind", "text")
|
||||
.put("sentAt", "2026-05-09T09:10:00+08:00"))))
|
||||
.put("devices", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "mac-studio")
|
||||
.put("name", "Mac Studio")
|
||||
.put("avatar", "M")));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderProject",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
|
||||
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "M"));
|
||||
assertTrue(viewTreeContainsContentDescription(content, "来自 Mac Studio"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderGroupThreadMessageMatchesAvatarByCodexDeviceName() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "group-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "协作群");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("project", new JSONObject()
|
||||
.put("id", "group-1")
|
||||
.put("name", "协作群")
|
||||
.put("isGroup", true)
|
||||
.put("deviceIds", new JSONArray().put("mac-studio").put("windows-gpu"))
|
||||
.put("messages", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "msg-device-2")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "购物车修复 · Windows GPU")
|
||||
.put("body", "Windows 线程已回写结果。")
|
||||
.put("kind", "text")
|
||||
.put("sentAt", "2026-05-09T09:16:00+08:00"))))
|
||||
.put("devices", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "mac-studio")
|
||||
.put("name", "Mac Studio")
|
||||
.put("avatar", "M"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "windows-gpu")
|
||||
.put("name", "Windows GPU")
|
||||
.put("avatar", "W")));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderProject",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
|
||||
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "W"));
|
||||
assertTrue(viewTreeContainsContentDescription(content, "来自 Windows GPU"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void executionProgressMessageRendersAsStructuredCard() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "thread-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss开发主线程");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
JSONObject message = new JSONObject()
|
||||
.put("id", "progress-1")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent")
|
||||
.put("body", "执行进度")
|
||||
.put("kind", "execution_progress")
|
||||
.put("sentAt", "2026-05-08T10:16:00+08:00")
|
||||
.put("executionProgress", new JSONObject()
|
||||
.put("status", "completed")
|
||||
.put("steps", new JSONArray()
|
||||
.put(new JSONObject().put("text", "回读计划和 H5 商品支付链现状").put("status", "done"))
|
||||
.put(new JSONObject().put("text", "运行 targeted/full test、typecheck 和 diff 检查").put("status", "done")))
|
||||
.put("branch", new JSONObject()
|
||||
.put("additions", 181500)
|
||||
.put("deletions", 52)
|
||||
.put("githubCliStatus", "unavailable"))
|
||||
.put("artifacts", new JSONArray()
|
||||
.put(new JSONObject().put("label", "development_version_log_20260508.md").put("kind", "file"))
|
||||
.put(new JSONObject().put("label", "已生成图像 1").put("kind", "image")))
|
||||
.put("agents", new JSONArray()
|
||||
.put(new JSONObject().put("name", "Mendel").put("role", "explorer"))));
|
||||
|
||||
View messageView = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"buildMessageView",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, message)
|
||||
);
|
||||
|
||||
assertTrue(viewTreeContainsText(messageView, "进度"));
|
||||
assertTrue(viewTreeContainsText(messageView, "回读计划和 H5 商品支付链现状"));
|
||||
assertTrue(viewTreeContainsText(messageView, "+181,500"));
|
||||
assertTrue(viewTreeContainsText(messageView, "-52"));
|
||||
assertTrue(viewTreeContainsText(messageView, "GitHub CLI 不可用"));
|
||||
assertTrue(viewTreeContainsText(messageView, "development_version_log_20260508.md"));
|
||||
assertTrue(viewTreeContainsText(messageView, "Mendel(explorer)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void completedReplyResponseRendersImmediatelyWithoutReloadingProjectDetail() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
@@ -473,6 +996,170 @@ public class ProjectDetailActivityUiTest {
|
||||
assertEquals("更多", String.valueOf(headerAction.getContentDescription()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void completedBrowserControlResponseShowsControlSummaryInConversation() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
|
||||
ReflectionHelpers.setField(activity, "currentScreenTitle", "主 Agent");
|
||||
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话");
|
||||
|
||||
JSONObject initialPayload = new JSONObject()
|
||||
.put("project", new JSONObject()
|
||||
.put("id", "master-agent")
|
||||
.put("name", "主 Agent")
|
||||
.put("messages", new JSONArray()));
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderProject",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, initialPayload),
|
||||
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
|
||||
);
|
||||
|
||||
JSONObject userMessage = new JSONObject()
|
||||
.put("id", "msg-user-browser")
|
||||
.put("sender", "user")
|
||||
.put("senderLabel", "Boss 超级管理员")
|
||||
.put("body", "打开 https://example.com 看一下首页")
|
||||
.put("kind", "text")
|
||||
.put("sentAt", "2026-04-17T10:00:00.000Z");
|
||||
JSONObject replyMessage = new JSONObject()
|
||||
.put("id", "msg-master-browser")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent · gpt-5.4-mini")
|
||||
.put("body", "浏览器控制已完成:打开 https://example.com 看一下首页")
|
||||
.put("kind", "text")
|
||||
.put("sentAt", "2026-04-17T10:00:01.000Z");
|
||||
JSONObject sendResponse = new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("message", userMessage)
|
||||
.put("replyMessage", replyMessage)
|
||||
.put("masterReplyState", "completed")
|
||||
.put("replyPresenter", "master")
|
||||
.put("executionMode", "browser")
|
||||
.put("riskLevel", "medium")
|
||||
.put("requiresConfirmation", true)
|
||||
.put("targetUrl", "https://example.com")
|
||||
.put("task", JSONObject.NULL)
|
||||
.put("dispatchPlan", JSONObject.NULL)
|
||||
.put("collaborationGate", new JSONObject()
|
||||
.put("isGroup", false)
|
||||
.put("collaborationMode", "development")
|
||||
.put("approvalState", "not_required"));
|
||||
CompletedReplyApiClient fakeApiClient = new CompletedReplyApiClient(sendResponse);
|
||||
ReflectionHelpers.setField(activity, "apiClient", fakeApiClient);
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"sendProjectMessage",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "text"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "打开 https://example.com 看一下首页")
|
||||
);
|
||||
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
JSONObject controlSummary = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"buildControlSummaryMessageIfNeeded",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, sendResponse)
|
||||
);
|
||||
|
||||
assertNotNull(controlSummary);
|
||||
assertEquals("control_summary", controlSummary.optString("kind"));
|
||||
assertEquals("https://example.com", controlSummary.optString("controlTarget"));
|
||||
assertEquals("浏览器控制已完成:打开 https://example.com 看一下首页", controlSummary.optString("body"));
|
||||
assertEquals(0, fakeApiClient.projectDetailCallCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void completedDesktopControlResponseShowsControlSummaryInConversation() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
|
||||
ReflectionHelpers.setField(activity, "currentScreenTitle", "主 Agent");
|
||||
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话");
|
||||
|
||||
JSONObject initialPayload = new JSONObject()
|
||||
.put("project", new JSONObject()
|
||||
.put("id", "master-agent")
|
||||
.put("name", "主 Agent")
|
||||
.put("messages", new JSONArray()));
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderProject",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, initialPayload),
|
||||
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
|
||||
);
|
||||
|
||||
JSONObject userMessage = new JSONObject()
|
||||
.put("id", "msg-user-desktop")
|
||||
.put("sender", "user")
|
||||
.put("senderLabel", "Boss 超级管理员")
|
||||
.put("body", "打开微信并准备切到聊天窗口")
|
||||
.put("kind", "text")
|
||||
.put("sentAt", "2026-04-17T10:05:00.000Z");
|
||||
JSONObject replyMessage = new JSONObject()
|
||||
.put("id", "msg-master-desktop")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent · gpt-5.4-mini")
|
||||
.put("body", "桌面控制已完成:打开微信并准备切到聊天窗口")
|
||||
.put("kind", "text")
|
||||
.put("sentAt", "2026-04-17T10:05:01.000Z");
|
||||
JSONObject sendResponse = new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("message", userMessage)
|
||||
.put("replyMessage", replyMessage)
|
||||
.put("masterReplyState", "completed")
|
||||
.put("replyPresenter", "master")
|
||||
.put("executionMode", "desktop")
|
||||
.put("riskLevel", "medium")
|
||||
.put("requiresConfirmation", true)
|
||||
.put("targetApp", "微信")
|
||||
.put("task", JSONObject.NULL)
|
||||
.put("dispatchPlan", JSONObject.NULL)
|
||||
.put("collaborationGate", new JSONObject()
|
||||
.put("isGroup", false)
|
||||
.put("collaborationMode", "development")
|
||||
.put("approvalState", "not_required"));
|
||||
CompletedReplyApiClient fakeApiClient = new CompletedReplyApiClient(sendResponse);
|
||||
ReflectionHelpers.setField(activity, "apiClient", fakeApiClient);
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"sendProjectMessage",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "text"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "打开微信并准备切到聊天窗口")
|
||||
);
|
||||
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
JSONObject controlSummary = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"buildControlSummaryMessageIfNeeded",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, sendResponse)
|
||||
);
|
||||
|
||||
assertNotNull(controlSummary);
|
||||
assertEquals("control_summary", controlSummary.optString("kind"));
|
||||
assertEquals("微信", controlSummary.optString("controlTarget"));
|
||||
assertEquals("桌面控制已完成:打开微信并准备切到聊天窗口", controlSummary.optString("body"));
|
||||
assertEquals(0, fakeApiClient.projectDetailCallCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void normalConversationHeaderUsesWechatMoreMenuLabel() {
|
||||
Intent intent = new Intent()
|
||||
@@ -635,6 +1322,80 @@ public class ProjectDetailActivityUiTest {
|
||||
assertEquals(null, ReflectionHelpers.getField(activity, "masterAgentReplyBaselineMessageId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startReplyWaitTracksMasterRelayInThreadConversation() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "thread-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "AI 眼镜线程");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.setField(activity, "pendingReplyPresenter", "master");
|
||||
JSONObject sendResponse = new JSONObject()
|
||||
.put("message", new JSONObject().put("id", "msg-user-1"))
|
||||
.put("task", new JSONObject()
|
||||
.put("taskId", "task-1")
|
||||
.put("taskType", "conversation_reply")
|
||||
.put("status", "queued"));
|
||||
ProjectChatUiState.ReplyWaitSpec waitSpec =
|
||||
ProjectChatUiState.resolveReplyWaitAfterSend(sendResponse);
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"startReplyWait",
|
||||
ReflectionHelpers.ClassParameter.from(ProjectChatUiState.ReplyWaitSpec.class, waitSpec),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "消息已发送,主 Agent 正在转述")
|
||||
);
|
||||
|
||||
assertTrue(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyWaiting"));
|
||||
assertFalse(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyTimedOut"));
|
||||
assertEquals("msg-user-1", ReflectionHelpers.getField(activity, "masterAgentReplyBaselineMessageId"));
|
||||
assertEquals(1, activity.replyWaitPollCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderThreadProjectClearsMasterRelayWaitStateAfterNewReplyArrives() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "thread-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "AI 眼镜线程");
|
||||
TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
|
||||
ReflectionHelpers.setField(activity, "currentScreenTitle", "AI 眼镜线程");
|
||||
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话");
|
||||
ReflectionHelpers.setField(activity, "pendingReplyPresenter", "master");
|
||||
ReflectionHelpers.setField(activity, "masterAgentReplyWaiting", false);
|
||||
ReflectionHelpers.setField(activity, "masterAgentReplyTimedOut", true);
|
||||
ReflectionHelpers.setField(activity, "masterAgentReplyBaselineMessageId", "msg-user-1");
|
||||
|
||||
JSONObject project = new JSONObject()
|
||||
.put("project", new JSONObject()
|
||||
.put("id", "thread-1")
|
||||
.put("name", "AI 眼镜线程")
|
||||
.put("messages", new JSONArray()
|
||||
.put(new JSONObject().put("id", "msg-user-1").put("sender", "user"))
|
||||
.put(new JSONObject().put("id", "msg-master-1").put("sender", "master"))));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderProject",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, project),
|
||||
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
|
||||
);
|
||||
|
||||
assertFalse(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyWaiting"));
|
||||
assertFalse(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyTimedOut"));
|
||||
assertEquals(null, ReflectionHelpers.getField(activity, "masterAgentReplyBaselineMessageId"));
|
||||
assertEquals(null, ReflectionHelpers.getField(activity, "pendingReplyPresenter"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void outgoingAttachmentMetaPrefersTimeOnly() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
@@ -667,8 +1428,7 @@ public class ProjectDetailActivityUiTest {
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, true)
|
||||
);
|
||||
|
||||
assertTrue(viewTreeContainsText(attachmentView, "09:26"));
|
||||
assertFalse(viewTreeContainsText(attachmentView, "你 · 09:26"));
|
||||
assertTrue(viewTreeContainsText(attachmentView, "你 · 09:26"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -917,6 +1677,67 @@ public class ProjectDetailActivityUiTest {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsContentDescription(View root, String expectedText) {
|
||||
if (root == null) {
|
||||
return false;
|
||||
}
|
||||
CharSequence description = root.getContentDescription();
|
||||
if (description != null && expectedText.contentEquals(description)) {
|
||||
return true;
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return false;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeContainsContentDescription(group.getChildAt(index), expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean viewTreeHasBackgroundColor(View root, int expectedColor) {
|
||||
if (root.getBackground() instanceof ColorDrawable) {
|
||||
return ((ColorDrawable) root.getBackground()).getColor() == expectedColor;
|
||||
}
|
||||
if (root.getBackground() instanceof GradientDrawable) {
|
||||
ColorStateList color = ((GradientDrawable) root.getBackground()).getColor();
|
||||
if (color != null && color.getDefaultColor() == expectedColor) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return false;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeHasBackgroundColor(group.getChildAt(index), expectedColor)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean viewTreeHasGradientColor(View root, int expectedColor) {
|
||||
if (root.getBackground() instanceof GradientDrawable) {
|
||||
ColorStateList color = ((GradientDrawable) root.getBackground()).getColor();
|
||||
if (color != null && color.getDefaultColor() == expectedColor) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return false;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeHasGradientColor(group.getChildAt(index), expectedColor)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static View findClickableViewContainingText(View root, String expectedText) {
|
||||
if (root == null) {
|
||||
return null;
|
||||
@@ -954,6 +1775,7 @@ public class ProjectDetailActivityUiTest {
|
||||
String lastReplyWaitBaselineMessageId;
|
||||
boolean lastReplyWaitIncludeDispatchPlans;
|
||||
int scrollChatToBottomCount;
|
||||
int finishCallCount;
|
||||
|
||||
@Override
|
||||
boolean shouldLoadOnCreate() {
|
||||
@@ -971,6 +1793,12 @@ public class ProjectDetailActivityUiTest {
|
||||
void scrollChatToBottom() {
|
||||
scrollChatToBottomCount += 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void finish() {
|
||||
finishCallCount += 1;
|
||||
super.finish();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class CompletedReplyApiClient extends BossApiClient {
|
||||
@@ -1002,6 +1830,22 @@ public class ProjectDetailActivityUiTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConversationActionApiClient extends BossApiClient {
|
||||
int markConversationReadCount;
|
||||
String lastMarkedProjectId;
|
||||
|
||||
RecordingConversationActionApiClient() {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse markConversationRead(String projectId) throws org.json.JSONException {
|
||||
markConversationReadCount += 1;
|
||||
lastMarkedProjectId = projectId;
|
||||
return new ApiResponse(200, new JSONObject().put("ok", true));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InMemorySharedPreferences implements SharedPreferences {
|
||||
private final Map<String, Object> values = new HashMap<>();
|
||||
|
||||
|
||||
@@ -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), "已处理 update:3"));
|
||||
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().
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,13 +137,96 @@ public class WechatSurfaceMapperTest {
|
||||
assertEquals("已导入线程", row.lastMessagePreview);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_sanitizesLeakedPromptTitleToFolderFallback() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("conversationType", "single_device")
|
||||
.put("projectTitle", "你当前接手的项目根目录是:")
|
||||
.put("threadTitle", "你当前接手的项目根目录是:")
|
||||
.put("folderLabel", "boss")
|
||||
.put("latestReplyLabel", "17:35");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("boss", row.threadTitle);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_extractsWorkspaceFolderFromPromptLeakTitle() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("conversationType", "single_device")
|
||||
.put("projectTitle", "你现在接手的项目根目录是 /Users/kris/code/yuandi。")
|
||||
.put("threadTitle", "你现在接手的项目根目录是 /Users/kris/code/yuandi。")
|
||||
.put("latestReplyLabel", "17:36");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("yuandi", row.threadTitle);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_prefersStableMasterAgentProjectTitleOverOperationalThreadTitle() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("projectId", "master-agent")
|
||||
.put("projectTitle", "主 Agent")
|
||||
.put("threadTitle", "主 Agent 汇总")
|
||||
.put("lastMessagePreview", "同步已完成")
|
||||
.put("latestReplyLabel", "10:18");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("主 Agent", row.threadTitle);
|
||||
assertEquals("同步已完成", row.lastMessagePreview);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_prefersStableAuditProjectTitleOverOperationalThreadTitle() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("projectId", "audit-collab")
|
||||
.put("projectTitle", "硬件审计协作")
|
||||
.put("threadTitle", "审计对话")
|
||||
.put("lastMessagePreview", "审计结果已回写")
|
||||
.put("latestReplyLabel", "10:20");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("硬件审计协作", row.threadTitle);
|
||||
assertEquals("审计结果已回写", row.lastMessagePreview);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_hidesProcessLikePreviewFallback() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("projectTitle", "Boss")
|
||||
.put("threadTitle", "Boss开发主线程")
|
||||
.put("lastMessagePreview", "我继续往下收,这一轮先检查折叠链路,再确认未读逻辑,随后回你结果。")
|
||||
.put("latestReplyLabel", "10:20");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("", row.lastMessagePreview);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_keepsFinalSummaryPreviewVisible() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("projectTitle", "Boss")
|
||||
.put("threadTitle", "Boss开发主线程")
|
||||
.put("lastMessagePreview", "折叠修复已部署,未读数现在只按最终结果计数。")
|
||||
.put("latestReplyLabel", "10:22");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("折叠修复已部署,未读数现在只按最终结果计数。", row.lastMessagePreview);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toDeviceRow_mapsLegacyWechatThreeLineSummary() throws Exception {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("name", "Mac Studio")
|
||||
.withString("avatar", "M")
|
||||
.withString("status", "online")
|
||||
.withString("account", "17600003315")
|
||||
.withString("account", "krisolo")
|
||||
.withStringArray("projects", "北区试产线回归", "容灾切换验证")
|
||||
.withInt("quota5h", 8)
|
||||
.withInt("quota7d", 22);
|
||||
@@ -151,7 +234,7 @@ public class WechatSurfaceMapperTest {
|
||||
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
|
||||
|
||||
assertEquals("Mac Studio", row.title);
|
||||
assertEquals("账号: 17600003315 · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle);
|
||||
assertEquals("账号: krisolo · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle);
|
||||
assertEquals("额度: 5h 8% · 7d 22%", row.meta);
|
||||
assertEquals("M", row.avatarLabel);
|
||||
assertEquals("online", row.statusKey);
|
||||
@@ -162,12 +245,12 @@ public class WechatSurfaceMapperTest {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("name", "Mac Studio")
|
||||
.withString("status", "abnormal")
|
||||
.withString("account", "17600003315");
|
||||
.withString("account", "krisolo");
|
||||
|
||||
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
|
||||
|
||||
assertEquals("Mac Studio", row.title);
|
||||
assertEquals("账号: 17600003315", row.subtitle);
|
||||
assertEquals("账号: krisolo", row.subtitle);
|
||||
assertEquals("额度: 暂无 · 状态异常", row.meta);
|
||||
assertEquals("abnormal", row.statusKey);
|
||||
}
|
||||
@@ -177,7 +260,7 @@ public class WechatSurfaceMapperTest {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("name", "Mac Studio")
|
||||
.withString("status", "online")
|
||||
.withString("account", "17600003315")
|
||||
.withString("account", "krisolo")
|
||||
.withString("note", "书房主机")
|
||||
.withString("endpoint", "https://boss.hyzq.net/device/mac-studio")
|
||||
.withStringArray("projects", "master-agent", "android-app");
|
||||
@@ -185,14 +268,14 @@ public class WechatSurfaceMapperTest {
|
||||
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(item);
|
||||
|
||||
assertEquals("Mac Studio", summary.title);
|
||||
assertEquals("账号: 17600003315 · 项目: master-agent / android-app", summary.subtitle);
|
||||
assertEquals("账号: krisolo · 项目: master-agent / android-app", summary.subtitle);
|
||||
assertEquals("额度: 暂无 · 书房主机 · https://boss.hyzq.net/device/mac-studio · 项目 master-agent, android-app", summary.meta);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootMeMenuTitles_matchLegacyWechatMenuWithOpsEntry() throws Exception {
|
||||
assertArrayEquals(
|
||||
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
|
||||
new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
|
||||
WechatSurfaceMapper.rootMeMenuTitles()
|
||||
);
|
||||
}
|
||||
@@ -208,7 +291,7 @@ public class WechatSurfaceMapperTest {
|
||||
@Test
|
||||
public void mainPage_keepsOpsEntryInStableWechatMenuOrder() throws Exception {
|
||||
assertArrayEquals(
|
||||
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
|
||||
new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
|
||||
WechatSurfaceMapper.rootMeMenuTitles()
|
||||
);
|
||||
}
|
||||
@@ -292,7 +375,7 @@ public class WechatSurfaceMapperTest {
|
||||
JSONArray devices = new StubObjectArray(
|
||||
new StubJSONObject()
|
||||
.withString("id", "device-b")
|
||||
.withString("account", "17600003315"),
|
||||
.withString("account", "krisolo"),
|
||||
new StubJSONObject()
|
||||
.withString("id", "device-c")
|
||||
.withString("account", "other-account")
|
||||
@@ -311,7 +394,7 @@ public class WechatSurfaceMapperTest {
|
||||
null,
|
||||
"stale-device-id",
|
||||
"missing-bound-device",
|
||||
"17600003315",
|
||||
"krisolo",
|
||||
devices
|
||||
);
|
||||
|
||||
@@ -380,15 +463,20 @@ public class WechatSurfaceMapperTest {
|
||||
public void meMenuItems_useStableKeysInsteadOfDisplayTitlesForRouting() throws Exception {
|
||||
WechatSurfaceMapper.MeMenuItem[] items = WechatSurfaceMapper.rootMeMenuItems();
|
||||
|
||||
assertEquals(6, items.length);
|
||||
assertEquals(9, items.length);
|
||||
assertEquals("security", items[0].key);
|
||||
assertEquals("账号与安全", items[0].title);
|
||||
assertEquals("settings", items[1].key);
|
||||
assertEquals("ops", items[2].key);
|
||||
assertEquals("运维与修复", items[2].title);
|
||||
assertEquals("ai_accounts", items[3].key);
|
||||
assertEquals("skills", items[4].key);
|
||||
assertEquals("about", items[5].key);
|
||||
assertEquals("access", items[2].key);
|
||||
assertEquals("用户与权限", items[2].title);
|
||||
assertEquals("ops", items[3].key);
|
||||
assertEquals("运维与修复", items[3].title);
|
||||
assertEquals("ai_accounts", items[4].key);
|
||||
assertEquals("storage", items[5].key);
|
||||
assertEquals("附件与存储", items[5].title);
|
||||
assertEquals("telegram", items[6].key);
|
||||
assertEquals("skills", items[7].key);
|
||||
assertEquals("about", items[8].key);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -40,6 +40,17 @@ public class WechatSurfaceMapperTopActionTest {
|
||||
assertEquals("add_device", action.actionKey);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootTopAction_hidesAddDeviceForSubAccounts() {
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("devices", false, false, "member");
|
||||
|
||||
assertEquals("刷新", action.label);
|
||||
assertEquals("refresh", action.iconKey);
|
||||
assertFalse(action.primaryStyle);
|
||||
assertTrue(action.compactStyle);
|
||||
assertEquals("refresh", action.actionKey);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootTopAction_keepsRefreshOnMeTab() {
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("me", true);
|
||||
|
||||
12
apps/boss-admin-web/index.html
Normal file
12
apps/boss-admin-web/index.html
Normal 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
1762
apps/boss-admin-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
apps/boss-admin-web/package.json
Normal file
23
apps/boss-admin-web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
732
apps/boss-admin-web/src/App.vue
Normal file
732
apps/boss-admin-web/src/App.vue
Normal file
@@ -0,0 +1,732 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { message } from "ant-design-vue";
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
AuditOutlined,
|
||||
ClusterOutlined,
|
||||
DashboardOutlined,
|
||||
SafetyOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
ToolOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import {
|
||||
fetchBossAdminBackoffice,
|
||||
postAdminAccess,
|
||||
postRiskAction,
|
||||
postSkillLifecycleRequest,
|
||||
type BossAdminBackofficePayload,
|
||||
} from "./api/bossAdmin";
|
||||
|
||||
type AdminRecord = Record<string, unknown>;
|
||||
|
||||
const loading = ref(true);
|
||||
const mutating = ref(false);
|
||||
const error = ref("");
|
||||
const activeKey = ref("workbench");
|
||||
const payload = ref<BossAdminBackofficePayload | null>(null);
|
||||
|
||||
const companyForm = reactive({
|
||||
companyId: "",
|
||||
name: "",
|
||||
ownerAccount: "",
|
||||
successOwnerAccount: "",
|
||||
planTier: "enterprise",
|
||||
contractExpiresAt: "",
|
||||
note: "",
|
||||
});
|
||||
|
||||
const accountForm = reactive({
|
||||
account: "",
|
||||
displayName: "",
|
||||
role: "member",
|
||||
password: "",
|
||||
companyId: "",
|
||||
});
|
||||
|
||||
const grantForm = reactive({
|
||||
account: "",
|
||||
scope: "device",
|
||||
targetId: "",
|
||||
templateId: "developer",
|
||||
permissions: ["device.view"],
|
||||
expiresAt: "",
|
||||
note: "",
|
||||
});
|
||||
|
||||
const riskForm = reactive({
|
||||
ownerAccount: "",
|
||||
slaDueAt: "",
|
||||
note: "",
|
||||
});
|
||||
|
||||
const skillRequestForm = reactive({
|
||||
action: "update",
|
||||
deviceId: "",
|
||||
skillId: "",
|
||||
sourceUrl: "",
|
||||
trustedSourceId: "",
|
||||
targetVersion: "",
|
||||
rollbackToVersion: "",
|
||||
lockedVersion: "",
|
||||
checksum: "",
|
||||
note: "",
|
||||
});
|
||||
|
||||
const menuIconMap = {
|
||||
workbench: DashboardOutlined,
|
||||
tenant: ClusterOutlined,
|
||||
user: UserOutlined,
|
||||
role: SafetyOutlined,
|
||||
resource: AppstoreOutlined,
|
||||
skills: ToolOutlined,
|
||||
risk: AuditOutlined,
|
||||
audit: AuditOutlined,
|
||||
system: SettingOutlined,
|
||||
} as const;
|
||||
|
||||
const menuTree = computed(() => payload.value?.menuTree ?? []);
|
||||
const summary = computed(() => payload.value?.workbench.summary ?? {});
|
||||
const tenants = computed(() => payload.value?.tenants ?? []);
|
||||
const users = computed(() => payload.value?.users ?? []);
|
||||
const roles = computed(() => payload.value?.roles.builtInRoles ?? []);
|
||||
const templates = computed(() => payload.value?.roles.permissionTemplates ?? []);
|
||||
const devices = computed(() => payload.value?.resourceGroups.devices ?? []);
|
||||
const projects = computed(() => payload.value?.resourceGroups.projects ?? []);
|
||||
const skills = computed(() => payload.value?.resourceGroups.skills ?? []);
|
||||
const risks = computed(() => payload.value?.audit.risks ?? []);
|
||||
const auditLogs = computed(() => payload.value?.audit.permissionLogs ?? []);
|
||||
const grants = computed(() => payload.value?.resourceGroups.grants ?? { devices: [], projects: [], skills: [] });
|
||||
|
||||
const grantRows = computed(() => [
|
||||
...grants.value.devices.map((grant) => ({ ...grant, scopeLabel: "设备", targetLabel: text(grant.deviceId) })),
|
||||
...grants.value.projects.map((grant) => ({ ...grant, scopeLabel: "项目", targetLabel: text(grant.projectId) })),
|
||||
...grants.value.skills.map((grant) => ({ ...grant, scopeLabel: "Skill", targetLabel: text(grant.skillId) })),
|
||||
]);
|
||||
|
||||
const selectedTemplate = computed(() =>
|
||||
templates.value.find((item) => text(item.templateId) === grantForm.templateId),
|
||||
);
|
||||
|
||||
const selectedScopePermissions = computed(() => {
|
||||
if (grantForm.scope === "project") return selectedTemplate.value?.projectPermissions ?? ["project.view"];
|
||||
if (grantForm.scope === "skill") return selectedTemplate.value?.skillPermissions ?? ["skill.view"];
|
||||
return selectedTemplate.value?.devicePermissions ?? ["device.view"];
|
||||
});
|
||||
|
||||
const selectedScopePermissionPlaceholder = computed(() =>
|
||||
Array.isArray(selectedScopePermissions.value) ? selectedScopePermissions.value.join(" / ") : "device.view",
|
||||
);
|
||||
|
||||
async function loadBackoffice() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
payload.value = await fetchBossAdminBackoffice();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "后台数据加载失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectMenu(key: string) {
|
||||
activeKey.value = key;
|
||||
}
|
||||
|
||||
function text(value: unknown, fallback = "-") {
|
||||
if (value === null || value === undefined || value === "") return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function permissionText(value: unknown) {
|
||||
return Array.isArray(value) ? value.join(" / ") : text(value);
|
||||
}
|
||||
|
||||
function resetCompanyForm() {
|
||||
Object.assign(companyForm, {
|
||||
companyId: "",
|
||||
name: "",
|
||||
ownerAccount: "",
|
||||
successOwnerAccount: "",
|
||||
planTier: "enterprise",
|
||||
contractExpiresAt: "",
|
||||
note: "",
|
||||
});
|
||||
}
|
||||
|
||||
function resetAccountForm() {
|
||||
Object.assign(accountForm, {
|
||||
account: "",
|
||||
displayName: "",
|
||||
role: "member",
|
||||
password: "",
|
||||
companyId: "",
|
||||
});
|
||||
}
|
||||
|
||||
async function runMutation(label: string, task: () => Promise<unknown>) {
|
||||
mutating.value = true;
|
||||
const hide = message.loading(`${label}中...`, 0);
|
||||
try {
|
||||
await task();
|
||||
hide();
|
||||
message.success(`${label}完成`);
|
||||
await loadBackoffice();
|
||||
} catch (err) {
|
||||
hide();
|
||||
message.error(`${label}失败:${err instanceof Error ? err.message : "UNKNOWN_ERROR"}`);
|
||||
} finally {
|
||||
mutating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCompany() {
|
||||
await runMutation("新建租户", async () => {
|
||||
await postAdminAccess({
|
||||
action: "upsert_company",
|
||||
...companyForm,
|
||||
});
|
||||
resetCompanyForm();
|
||||
});
|
||||
}
|
||||
|
||||
async function setCompanyStatus(record: AdminRecord, status: "active" | "disabled") {
|
||||
await runMutation(status === "active" ? "启用租户" : "停用租户", () =>
|
||||
postAdminAccess({
|
||||
action: "set_company_status",
|
||||
companyId: text(record.companyId),
|
||||
status,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function saveAccount() {
|
||||
await runMutation("新建账号", async () => {
|
||||
await postAdminAccess({
|
||||
action: "upsert_account",
|
||||
...accountForm,
|
||||
});
|
||||
resetAccountForm();
|
||||
});
|
||||
}
|
||||
|
||||
async function setAccountStatus(record: AdminRecord, status: "active" | "disabled") {
|
||||
await runMutation(status === "active" ? "启用账号" : "停用账号", () =>
|
||||
postAdminAccess({
|
||||
action: "set_account_status",
|
||||
account: text(record.account),
|
||||
status,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function resetPassword(record: AdminRecord) {
|
||||
const password = window.prompt(`请输入 ${text(record.account)} 的新密码`);
|
||||
if (!password) return;
|
||||
await runMutation("重置密码", () =>
|
||||
postAdminAccess({
|
||||
action: "reset_account_password",
|
||||
account: text(record.account),
|
||||
password,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function reclaimAccount(record: AdminRecord) {
|
||||
if (!window.confirm(`确认离职回收 ${text(record.account)}?这会停用账号并清理授权。`)) return;
|
||||
await runMutation("离职回收", () =>
|
||||
postAdminAccess({
|
||||
action: "reclaim_account",
|
||||
account: text(record.account),
|
||||
reason: "enterprise-admin-web",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function submitGrant() {
|
||||
const permissions = grantForm.permissions.length > 0 ? grantForm.permissions : selectedScopePermissions.value;
|
||||
const base = {
|
||||
account: grantForm.account,
|
||||
permissions,
|
||||
expiresAt: grantForm.expiresAt,
|
||||
note: grantForm.note,
|
||||
};
|
||||
await runMutation("分配资源", async () => {
|
||||
if (grantForm.scope === "project") {
|
||||
await postAdminAccess({ action: "grant_project", ...base, projectId: grantForm.targetId });
|
||||
} else if (grantForm.scope === "skill") {
|
||||
await postAdminAccess({ action: "grant_skill", ...base, skillId: grantForm.targetId });
|
||||
} else {
|
||||
await postAdminAccess({ action: "grant_device", ...base, deviceId: grantForm.targetId });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function applyPermissionTemplate() {
|
||||
await runMutation("套用权限模板", () =>
|
||||
postAdminAccess({
|
||||
action: "apply_template",
|
||||
account: grantForm.account,
|
||||
templateId: grantForm.templateId,
|
||||
deviceIds: grantForm.scope === "device" ? [grantForm.targetId] : [],
|
||||
projectIds: grantForm.scope === "project" ? [grantForm.targetId] : [],
|
||||
skillIds: grantForm.scope === "skill" ? [grantForm.targetId] : [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function revokeGrant(record: AdminRecord) {
|
||||
if (!window.confirm(`确认撤销授权 ${text(record.grantId)}?`)) return;
|
||||
await runMutation("撤销授权", () =>
|
||||
postAdminAccess({
|
||||
action: "revoke_grant",
|
||||
grantId: text(record.grantId),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleRisk(record: AdminRecord, action: string) {
|
||||
await runMutation(
|
||||
action === "assign_owner"
|
||||
? "指派负责人"
|
||||
: action === "set_sla"
|
||||
? "设置 SLA"
|
||||
: action === "ack"
|
||||
? "确认风险"
|
||||
: action === "resolve"
|
||||
? "关闭风险"
|
||||
: "创建工单",
|
||||
() =>
|
||||
postRiskAction({
|
||||
riskId: text(record.riskId),
|
||||
action,
|
||||
ownerAccount: riskForm.ownerAccount,
|
||||
slaDueAt: riskForm.slaDueAt,
|
||||
note: riskForm.note,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function createSkillRequest() {
|
||||
await runMutation("创建 Skill 请求", () =>
|
||||
postSkillLifecycleRequest({
|
||||
...skillRequestForm,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(loadBackoffice);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider
|
||||
:theme="{
|
||||
token: {
|
||||
colorPrimary: '#10b981',
|
||||
borderRadius: 16,
|
||||
fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
|
||||
},
|
||||
}"
|
||||
>
|
||||
<div class="boss-admin-shell">
|
||||
<aside class="boss-admin-sidebar">
|
||||
<div class="boss-admin-brand">
|
||||
<div class="boss-admin-brand-mark">B</div>
|
||||
<div>
|
||||
<h1>Boss 企业后台</h1>
|
||||
<p>平台侧 To B 管理中心</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="boss-admin-menu" aria-label="企业后台菜单">
|
||||
<button
|
||||
v-for="item in menuTree"
|
||||
:key="item.key"
|
||||
class="boss-admin-menu-item"
|
||||
:class="{ active: activeKey === item.key }"
|
||||
type="button"
|
||||
@click="selectMenu(item.key)"
|
||||
>
|
||||
<component :is="menuIconMap[item.key as keyof typeof menuIconMap] ?? TeamOutlined" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main class="boss-admin-main">
|
||||
<header class="boss-admin-header">
|
||||
<div>
|
||||
<p class="boss-admin-eyebrow">YuDao / Vben 信息架构 · Boss 数据契约</p>
|
||||
<h2>{{ menuTree.find((item) => item.key === activeKey)?.label ?? "工作台" }}</h2>
|
||||
</div>
|
||||
<div class="boss-admin-header-actions">
|
||||
<a-tag color="green">highest_admin</a-tag>
|
||||
<a-button :loading="loading" @click="loadBackoffice">刷新</a-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<a-alert
|
||||
v-if="error"
|
||||
class="boss-admin-alert"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="`后台数据加载失败:${error}`"
|
||||
/>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<section v-if="activeKey === 'workbench'" class="boss-admin-section-grid">
|
||||
<a-card class="boss-admin-hero" :bordered="false">
|
||||
<p class="boss-admin-eyebrow">总览统计</p>
|
||||
<h3>公司、账号、电脑节点和风险一张图看清</h3>
|
||||
<div class="boss-admin-metrics">
|
||||
<a-statistic title="公司数" :value="summary.companies ?? 0" />
|
||||
<a-statistic title="账号数" :value="summary.accounts ?? 0" />
|
||||
<a-statistic title="在线设备" :value="summary.onlineDevices ?? 0" />
|
||||
<a-statistic title="开放风险" :value="summary.openRisks ?? 0" />
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card title="关键风险" :bordered="false">
|
||||
<a-table size="small" :pagination="false" :data-source="risks.slice(0, 5)" row-key="riskId">
|
||||
<a-table-column title="风险" data-index="title" />
|
||||
<a-table-column title="级别" data-index="severity" />
|
||||
<a-table-column title="对象" data-index="deviceId" />
|
||||
<a-table-column title="时间" data-index="lastSeenAt" />
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-card title="客户健康排行" :bordered="false">
|
||||
<a-table size="small" :pagination="false" :data-source="tenants.slice(0, 6)" row-key="companyId">
|
||||
<a-table-column title="公司" data-index="name" />
|
||||
<a-table-column title="账号" data-index="accountCount" />
|
||||
<a-table-column title="设备" data-index="deviceCount" />
|
||||
<a-table-column title="风险" data-index="openRiskCount" />
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-card title="节点健康" :bordered="false">
|
||||
<a-table size="small" :pagination="false" :data-source="devices.slice(0, 8)" row-key="id">
|
||||
<a-table-column title="设备" data-index="name" />
|
||||
<a-table-column title="状态" data-index="status" />
|
||||
<a-table-column title="CLI" data-index="codexCliOnline" />
|
||||
<a-table-column title="GUI" data-index="codexGuiOnline" />
|
||||
</a-table>
|
||||
</a-card>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeKey === 'tenant'" class="boss-admin-section-grid">
|
||||
<a-card title="新建租户" :bordered="false">
|
||||
<a-form layout="vertical" class="boss-admin-form">
|
||||
<a-form-item label="租户 ID">
|
||||
<a-input v-model:value="companyForm.companyId" placeholder="acme" />
|
||||
</a-form-item>
|
||||
<a-form-item label="公司名称">
|
||||
<a-input v-model:value="companyForm.name" placeholder="默认显示给平台运营人员" />
|
||||
</a-form-item>
|
||||
<a-form-item label="老板账号">
|
||||
<a-input v-model:value="companyForm.ownerAccount" placeholder="owner@example.com" />
|
||||
</a-form-item>
|
||||
<a-form-item label="客户成功">
|
||||
<a-input v-model:value="companyForm.successOwnerAccount" placeholder="cs@boss.com" />
|
||||
</a-form-item>
|
||||
<a-form-item label="套餐">
|
||||
<a-select v-model:value="companyForm.planTier">
|
||||
<a-select-option value="trial">trial</a-select-option>
|
||||
<a-select-option value="standard">standard</a-select-option>
|
||||
<a-select-option value="enterprise">enterprise</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="合同到期">
|
||||
<a-input v-model:value="companyForm.contractExpiresAt" placeholder="2027-05-01T00:00:00+08:00" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" block :loading="mutating" @click="saveCompany">新建租户</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card title="租户管理" :bordered="false">
|
||||
<a-table :data-source="tenants" row-key="companyId">
|
||||
<a-table-column title="公司" data-index="name" />
|
||||
<a-table-column title="套餐" data-index="planTier" />
|
||||
<a-table-column title="老板账号" data-index="ownerAccount" />
|
||||
<a-table-column title="账号数" data-index="accountCount" />
|
||||
<a-table-column title="开放风险" data-index="openRiskCount" />
|
||||
<a-table-column title="操作">
|
||||
<template #default="{ record }">
|
||||
<a-space>
|
||||
<a-button size="small" @click="setCompanyStatus(record, 'active')">启用租户</a-button>
|
||||
<a-button size="small" danger @click="setCompanyStatus(record, 'disabled')">停用租户</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeKey === 'user'" class="boss-admin-section-grid">
|
||||
<a-card title="新建账号" :bordered="false">
|
||||
<a-form layout="vertical" class="boss-admin-form">
|
||||
<a-form-item label="账号">
|
||||
<a-input v-model:value="accountForm.account" placeholder="member@example.com" />
|
||||
</a-form-item>
|
||||
<a-form-item label="昵称">
|
||||
<a-input v-model:value="accountForm.displayName" placeholder="成员姓名" />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色">
|
||||
<a-select v-model:value="accountForm.role">
|
||||
<a-select-option value="member">member</a-select-option>
|
||||
<a-select-option value="admin">admin</a-select-option>
|
||||
<a-select-option value="highest_admin">highest_admin</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="所属租户">
|
||||
<a-select v-model:value="accountForm.companyId" allow-clear>
|
||||
<a-select-option v-for="company in tenants" :key="text(company.companyId)" :value="text(company.companyId)">
|
||||
{{ text(company.name) }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="初始密码">
|
||||
<a-input-password v-model:value="accountForm.password" placeholder="留空时只更新资料" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" block :loading="mutating" @click="saveAccount">新建账号</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card title="账号管理" :bordered="false">
|
||||
<a-table :data-source="users" row-key="id">
|
||||
<a-table-column title="账号" data-index="account" />
|
||||
<a-table-column title="昵称" data-index="displayName" />
|
||||
<a-table-column title="角色" data-index="role" />
|
||||
<a-table-column title="公司" data-index="companyName" />
|
||||
<a-table-column title="状态" data-index="status" />
|
||||
<a-table-column title="操作">
|
||||
<template #default="{ record }">
|
||||
<a-space wrap>
|
||||
<a-button size="small" @click="setAccountStatus(record, 'active')">启用账号</a-button>
|
||||
<a-button size="small" @click="resetPassword(record)">重置密码</a-button>
|
||||
<a-button size="small" danger @click="setAccountStatus(record, 'disabled')">停用账号</a-button>
|
||||
<a-button size="small" danger @click="reclaimAccount(record)">离职回收</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeKey === 'role'" class="boss-admin-section-grid">
|
||||
<a-card title="角色权限" :bordered="false">
|
||||
<a-list :data-source="roles">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta :title="text(item.label)" :description="text(item.description)" />
|
||||
<a-tag>{{ item.role }}</a-tag>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
<a-card title="权限模板" :bordered="false">
|
||||
<a-list :data-source="templates">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<a-list-item-meta :title="text(item.name)" :description="text(item.description)" />
|
||||
<a-tag color="green">{{ item.templateId }}</a-tag>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-card>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeKey === 'resource'" class="boss-admin-section-grid">
|
||||
<div class="boss-admin-section-title">资源授权</div>
|
||||
<a-card title="分配资源" :bordered="false">
|
||||
<a-form layout="vertical" class="boss-admin-form">
|
||||
<a-form-item label="账号">
|
||||
<a-select v-model:value="grantForm.account" show-search>
|
||||
<a-select-option v-for="user in users" :key="text(user.account)" :value="text(user.account)">
|
||||
{{ text(user.displayName) }} · {{ text(user.account) }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="授权范围">
|
||||
<a-segmented v-model:value="grantForm.scope" :options="['device', 'project', 'skill']" />
|
||||
</a-form-item>
|
||||
<a-form-item label="目标">
|
||||
<a-select v-model:value="grantForm.targetId" show-search>
|
||||
<a-select-option
|
||||
v-for="device in devices"
|
||||
v-if="grantForm.scope === 'device'"
|
||||
:key="text(device.id)"
|
||||
:value="text(device.id)"
|
||||
>
|
||||
{{ text(device.name) }}
|
||||
</a-select-option>
|
||||
<a-select-option
|
||||
v-for="project in projects"
|
||||
v-if="grantForm.scope === 'project'"
|
||||
:key="text(project.id)"
|
||||
:value="text(project.id)"
|
||||
>
|
||||
{{ text(project.name) }}
|
||||
</a-select-option>
|
||||
<a-select-option
|
||||
v-for="skill in skills"
|
||||
v-if="grantForm.scope === 'skill'"
|
||||
:key="text(skill.skillId)"
|
||||
:value="text(skill.skillId)"
|
||||
>
|
||||
{{ text(skill.name) }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="权限模板">
|
||||
<a-select v-model:value="grantForm.templateId">
|
||||
<a-select-option v-for="template in templates" :key="text(template.templateId)" :value="text(template.templateId)">
|
||||
{{ text(template.name) }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="权限">
|
||||
<a-select v-model:value="grantForm.permissions" mode="tags" :placeholder="selectedScopePermissionPlaceholder" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-input v-model:value="grantForm.note" />
|
||||
</a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" :loading="mutating" @click="submitGrant">分配资源</a-button>
|
||||
<a-button :loading="mutating" @click="applyPermissionTemplate">套用权限模板</a-button>
|
||||
</a-space>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card title="授权清单" :bordered="false">
|
||||
<a-table :data-source="grantRows" row-key="grantId">
|
||||
<a-table-column title="范围" data-index="scopeLabel" />
|
||||
<a-table-column title="账号" data-index="account" />
|
||||
<a-table-column title="目标" data-index="targetLabel" />
|
||||
<a-table-column title="权限">
|
||||
<template #default="{ record }">
|
||||
{{ permissionText(record.permissions) }}
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作">
|
||||
<template #default="{ record }">
|
||||
<a-button size="small" danger @click="revokeGrant(record)">撤销授权</a-button>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeKey === 'skills'" class="boss-admin-section-grid">
|
||||
<a-card title="创建 Skill 请求" :bordered="false">
|
||||
<a-form layout="vertical" class="boss-admin-form">
|
||||
<a-form-item label="动作">
|
||||
<a-select v-model:value="skillRequestForm.action">
|
||||
<a-select-option value="install">install</a-select-option>
|
||||
<a-select-option value="update">update</a-select-option>
|
||||
<a-select-option value="uninstall">uninstall</a-select-option>
|
||||
<a-select-option value="rollback">rollback</a-select-option>
|
||||
<a-select-option value="version_lock">version_lock</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="设备">
|
||||
<a-select v-model:value="skillRequestForm.deviceId" show-search>
|
||||
<a-select-option v-for="device in devices" :key="text(device.id)" :value="text(device.id)">
|
||||
{{ text(device.name) }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="Skill">
|
||||
<a-select v-model:value="skillRequestForm.skillId" allow-clear show-search>
|
||||
<a-select-option v-for="skill in skills" :key="text(skill.skillId)" :value="text(skill.skillId)">
|
||||
{{ text(skill.name) }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="来源 URL">
|
||||
<a-input v-model:value="skillRequestForm.sourceUrl" placeholder="安装或更新远端来源" />
|
||||
</a-form-item>
|
||||
<a-form-item label="版本 / 回滚 / 锁定">
|
||||
<a-input-group compact>
|
||||
<a-input v-model:value="skillRequestForm.targetVersion" style="width: 33%" placeholder="targetVersion" />
|
||||
<a-input v-model:value="skillRequestForm.rollbackToVersion" style="width: 33%" placeholder="rollbackToVersion" />
|
||||
<a-input v-model:value="skillRequestForm.lockedVersion" style="width: 34%" placeholder="lockedVersion" />
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="校验和 / 备注">
|
||||
<a-input v-model:value="skillRequestForm.checksum" placeholder="sha256 checksum" />
|
||||
<a-input v-model:value="skillRequestForm.note" class="boss-admin-form-gap" placeholder="备注" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" block :loading="mutating" @click="createSkillRequest">创建 Skill 请求</a-button>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card title="Skill 中心" :bordered="false">
|
||||
<a-table :data-source="skills" row-key="skillId">
|
||||
<a-table-column title="Skill" data-index="name" />
|
||||
<a-table-column title="说明" data-index="description" />
|
||||
<a-table-column title="分类" data-index="category" />
|
||||
<a-table-column title="设备数" data-index="deviceCount" />
|
||||
<a-table-column title="更新时间" data-index="updatedAt" />
|
||||
</a-table>
|
||||
</a-card>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeKey === 'risk'" class="boss-admin-section">
|
||||
<a-card title="风险告警与处置" :bordered="false">
|
||||
<div class="boss-admin-action-strip">
|
||||
<a-input v-model:value="riskForm.ownerAccount" placeholder="负责人账号,用于指派负责人" />
|
||||
<a-input v-model:value="riskForm.slaDueAt" placeholder="SLA 时间,如 2026-05-02T18:00:00+08:00" />
|
||||
<a-input v-model:value="riskForm.note" placeholder="处理备注" />
|
||||
</div>
|
||||
<a-table :data-source="risks" row-key="riskId">
|
||||
<a-table-column title="风险" data-index="title" />
|
||||
<a-table-column title="级别" data-index="severity" />
|
||||
<a-table-column title="公司" data-index="companyId" />
|
||||
<a-table-column title="负责人" data-index="ownerAccount" />
|
||||
<a-table-column title="SLA" data-index="slaDueAt" />
|
||||
<a-table-column title="操作">
|
||||
<template #default="{ record }">
|
||||
<a-space wrap>
|
||||
<a-button size="small" @click="handleRisk(record, 'assign_owner')">指派负责人</a-button>
|
||||
<a-button size="small" @click="handleRisk(record, 'set_sla')">设置 SLA</a-button>
|
||||
<a-button size="small" @click="handleRisk(record, 'ack')">确认风险</a-button>
|
||||
<a-button size="small" danger @click="handleRisk(record, 'resolve')">关闭风险</a-button>
|
||||
<a-button size="small" @click="handleRisk(record, 'create_repair_ticket')">创建工单</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeKey === 'audit'" class="boss-admin-section">
|
||||
<a-card title="审计日志" :bordered="false">
|
||||
<a-table :data-source="auditLogs" row-key="auditId">
|
||||
<a-table-column title="操作人" data-index="actorAccount" />
|
||||
<a-table-column title="动作" data-index="action" />
|
||||
<a-table-column title="对象账号" data-index="targetAccount" />
|
||||
<a-table-column title="详情" data-index="detail" />
|
||||
<a-table-column title="时间" data-index="createdAt" />
|
||||
</a-table>
|
||||
</a-card>
|
||||
</section>
|
||||
|
||||
<section v-else class="boss-admin-section">
|
||||
<a-card title="系统设置" :bordered="false">
|
||||
<p>当前独立后台已接入 Boss Admin BFF,并已具备租户、账号、授权、风险和 Skill 的基础治理动作。</p>
|
||||
<a-descriptions bordered size="small" :column="1">
|
||||
<a-descriptions-item v-for="(value, key) in payload?.yudaoMapping ?? {}" :key="key" :label="key">
|
||||
{{ value }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</section>
|
||||
</a-spin>
|
||||
</main>
|
||||
</div>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
83
apps/boss-admin-web/src/api/bossAdmin.ts
Normal file
83
apps/boss-admin-web/src/api/bossAdmin.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export interface BossAdminMenuItem {
|
||||
key: string;
|
||||
label: string;
|
||||
children?: BossAdminMenuItem[];
|
||||
}
|
||||
|
||||
export interface BossAdminBackofficePayload {
|
||||
ok: boolean;
|
||||
menuTree: BossAdminMenuItem[];
|
||||
workbench: {
|
||||
summary: Record<string, number>;
|
||||
companies: Array<Record<string, unknown>>;
|
||||
devices: Array<Record<string, unknown>>;
|
||||
risks: Array<Record<string, unknown>>;
|
||||
notifications: Array<Record<string, unknown>>;
|
||||
grantsSummary: Record<string, number>;
|
||||
};
|
||||
tenants: Array<Record<string, unknown>>;
|
||||
users: Array<Record<string, unknown>>;
|
||||
roles: {
|
||||
builtInRoles: Array<Record<string, unknown>>;
|
||||
permissionTemplates: Array<Record<string, unknown>>;
|
||||
};
|
||||
resourceGroups: {
|
||||
devices: Array<Record<string, unknown>>;
|
||||
projects: Array<Record<string, unknown>>;
|
||||
skills: Array<Record<string, unknown>>;
|
||||
grants: Record<string, Array<Record<string, unknown>>>;
|
||||
};
|
||||
audit: {
|
||||
risks: Array<Record<string, unknown>>;
|
||||
notifications: Array<Record<string, unknown>>;
|
||||
riskTimeline: Array<Record<string, unknown>>;
|
||||
permissionLogs: Array<Record<string, unknown>>;
|
||||
};
|
||||
yudaoMapping: Record<string, string>;
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
credentials: "include",
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (response.status === 401) {
|
||||
window.location.href = "/auth/login";
|
||||
throw new Error("UNAUTHORIZED");
|
||||
}
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new Error(payload?.message ?? `HTTP_${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchBossAdminBackoffice(): Promise<BossAdminBackofficePayload> {
|
||||
return requestJson<BossAdminBackofficePayload>("/api/v1/admin/backoffice");
|
||||
}
|
||||
|
||||
export async function postAdminAccess(payload: Record<string, unknown>) {
|
||||
return requestJson<Record<string, unknown>>("/api/v1/admin/access", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function postRiskAction(payload: Record<string, unknown>) {
|
||||
return requestJson<Record<string, unknown>>("/api/v1/admin/risks/actions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function postSkillLifecycleRequest(payload: Record<string, unknown>) {
|
||||
return requestJson<Record<string, unknown>>("/api/v1/admin/skills/requests", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
7
apps/boss-admin-web/src/main.ts
Normal file
7
apps/boss-admin-web/src/main.ts
Normal 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");
|
||||
174
apps/boss-admin-web/src/styles.css
Normal file
174
apps/boss-admin-web/src/styles.css
Normal file
@@ -0,0 +1,174 @@
|
||||
:root {
|
||||
color: #102018;
|
||||
background: #eef4ef;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 1280px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(16, 185, 129, 0.16), transparent 30%),
|
||||
linear-gradient(135deg, #f7fbf8 0%, #eef4ef 55%, #e8f1ed 100%);
|
||||
}
|
||||
|
||||
.boss-admin-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.boss-admin-sidebar {
|
||||
padding: 28px 20px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border-right: 1px solid rgba(16, 32, 24, 0.08);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.boss-admin-brand {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.boss-admin-brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
color: white;
|
||||
font-weight: 800;
|
||||
background: #10b981;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 14px 28px rgba(16, 185, 129, 0.24);
|
||||
}
|
||||
|
||||
.boss-admin-brand h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.boss-admin-brand p,
|
||||
.boss-admin-eyebrow {
|
||||
margin: 0;
|
||||
color: #6b766f;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.boss-admin-menu {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.boss-admin-menu-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
color: #56615a;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.boss-admin-menu-item.active,
|
||||
.boss-admin-menu-item:hover {
|
||||
color: #0f7a55;
|
||||
background: #e7f8ef;
|
||||
}
|
||||
|
||||
.boss-admin-main {
|
||||
padding: 28px 34px 44px;
|
||||
}
|
||||
|
||||
.boss-admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.boss-admin-header h2 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.boss-admin-header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.boss-admin-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.boss-admin-section,
|
||||
.boss-admin-section-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.boss-admin-section-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.boss-admin-section-title {
|
||||
grid-column: 1 / -1;
|
||||
color: #25342b;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.boss-admin-hero {
|
||||
grid-column: 1 / -1;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(16, 185, 129, 0.16), rgba(255, 255, 255, 0.92)),
|
||||
white;
|
||||
}
|
||||
|
||||
.boss-admin-hero h3 {
|
||||
margin: 8px 0 20px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.boss-admin-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.boss-admin-metrics .ant-statistic {
|
||||
padding: 18px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
border: 1px solid rgba(16, 32, 24, 0.08);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.boss-admin-form {
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.boss-admin-form-gap {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.boss-admin-action-strip {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 320px 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
box-shadow: 0 18px 50px rgba(16, 32, 24, 0.07);
|
||||
}
|
||||
17
apps/boss-admin-web/tsconfig.json
Normal file
17
apps/boss-admin-web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
23
apps/boss-admin-web/vite.config.ts
Normal file
23
apps/boss-admin-web/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -10,6 +10,14 @@ boss.hyzq.net {
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
|
||||
admin.boss.hyzq.net {
|
||||
encode zstd gzip
|
||||
|
||||
redir / /admin 308
|
||||
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
|
||||
http://106.53.170.158 {
|
||||
encode zstd gzip
|
||||
|
||||
|
||||
29
deployment/launchd/com.hyzq.boss.codex-desktop-bridge.plist
Normal file
29
deployment/launchd/com.hyzq.boss.codex-desktop-bridge.plist
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.hyzq.boss.codex-desktop-bridge</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>BOSS_CODEX_DESKTOP_BRIDGE_HOST</key>
|
||||
<string>127.0.0.1</string>
|
||||
<key>BOSS_CODEX_DESKTOP_BRIDGE_PORT</key>
|
||||
<string>4318</string>
|
||||
</dict>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/zsh</string>
|
||||
<string>-lc</string>
|
||||
<string>cd /Users/kris/code/boss && 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>
|
||||
150
docs/architecture/admin_refine_backoffice_cn.md
Normal file
150
docs/architecture/admin_refine_backoffice_cn.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Boss To B 管理后台接入记录
|
||||
|
||||
更新时间:`2026-04-27`
|
||||
|
||||
## 目标
|
||||
|
||||
为 Boss To B 场景新增系统管理后台,供平台侧查看和管理不同客户公司的账号、设备、权限和风险状态,重点支持快速发现客户电脑 Codex 节点掉线、主 Agent 任务失败、线程上下文风险和运维故障。
|
||||
|
||||
## 技术选型
|
||||
|
||||
- 使用 `@refinedev/core` 作为管理后台资源抽象层。
|
||||
- 使用 `antd` 原生组件作为后台 UI 组件库。
|
||||
- 不使用 `@refinedev/antd`,原因是当前版本会传递引入 `@ant-design/pro-layout -> path-to-regexp@8.2.0` 高危 audit 链。
|
||||
- 不直接接入 `ant-design-pro` 工程,避免把 Umi/Max 路由和权限体系引入现有 `Next.js 16 + App Router` 主工程。
|
||||
|
||||
## 企业级后台独立化方向
|
||||
|
||||
2026-04-30 起,Boss 后台进入独立 PC 管理后台阶段。调研 `YunaiV/yudao-cloud` 后,当前策略是借鉴它的租户、用户、角色、菜单、日志和工作台信息架构,但不直接接入 YuDao 的 Java 微服务后端,避免把现有 `Next.js + 文件状态账本 + local-agent` 运行时拆碎。
|
||||
|
||||
第一批新增:
|
||||
|
||||
- `apps/boss-admin-web`:独立 Vue + Vite + Ant Design Vue 后台工程,面向平台侧运营和客户成功人员。
|
||||
- `/api/v1/admin/backoffice`:企业后台 BFF,把 Boss 当前账本聚合成 YuDao 风格的菜单、工作台、租户、账号、角色权限、资源授权、Skill 中心、风险和审计数据。
|
||||
- `/enterprise-admin`:Next 主站内的受保护入口,只允许 `highest_admin` 访问,并跳转到独立后台静态产物 `/admin-web/index.html`。
|
||||
- `admin:web:dev` / `admin:web:build` / `admin:web:publish`:根工程脚本入口。`admin:web:publish` 会把 Vue 构建产物写入 `public/admin-web`,随 Next standalone 的 `public` 一起发布。
|
||||
|
||||
边界:
|
||||
|
||||
- 现有 `/admin` 不删除,继续作为主站内 fallback。
|
||||
- 独立后台只消费 Admin BFF,不直接读取 `boss-state.json`。
|
||||
- 独立后台当前复用 Boss Cookie 登录态,后续再绑定 `admin.boss.hyzq.net` 的独立部署。
|
||||
- `/api/v1/admin/backoffice` 仍只允许 `highest_admin`,并过滤 `passwordHash`、`mfaSecret` 和 session token。
|
||||
|
||||
## 当前落地范围
|
||||
|
||||
- 新增 `/admin` 页面。
|
||||
- 新增 `/api/v1/admin/overview` 聚合接口。
|
||||
- 新增 `/api/v1/admin/backoffice` 独立企业后台聚合接口。
|
||||
- 新增 `/api/v1/admin/risks/actions` 风险处理动作接口。
|
||||
- 新增 `/api/v1/admin/notifications/dispatch` 风险通知派发接口。
|
||||
- 新增 `buildAdminOverview(state)` 纯函数,负责从当前文件状态聚合后台数据。
|
||||
- 新增显式 `adminCompanies` 租户账本,支持把账号和设备直接绑定到客户公司,不再只能依赖账号邮箱域名推断。
|
||||
|
||||
当前页面已在 `2026-04-30` 升级为 PC To B 总后台结构,不再是简单的 3 个表格页签。新结构包含 4 个一级区:
|
||||
|
||||
- `平台运营驾驶舱`:平台全局健康、待处理风险、客户健康、节点健康、最近事件。
|
||||
- `客户与账号`:客户公司、账号列表、设备归属和客户开通任务流。
|
||||
- `授权工作台`:复用既有账号 / 设备 / 项目 / Skill 授权能力,但放在更清晰的权限上下文里。
|
||||
- `风险与治理`:风险战情室、SLA、负责人、修复工单,以及 Skill 生命周期治理。
|
||||
|
||||
### 平台运营驾驶舱
|
||||
|
||||
- 展示今日待处理:客户公司、账号、在线设备、开放风险和风险通知。
|
||||
- 展示客户健康排行:按开放风险和设备在线情况优先排列。
|
||||
- 展示关键风险队列:只展示最值得处理的风险,完整队列进入风险战情室。
|
||||
- 展示节点健康:集中查看客户电脑、Codex GUI / CLI 和最近心跳。
|
||||
- 展示最近事件:风险通知和风险时间线,避免平台侧漏跟进。
|
||||
|
||||
### 客户与账号
|
||||
|
||||
- 展示客户公司列表、健康状态、账号数、在线设备、开放风险和客户成功负责人。
|
||||
- 展示客户开通任务流:创建客户公司、开通老板账号、绑定客户电脑、分配项目与 Skill 权限。
|
||||
- 展示账号列表:账号、角色、公司、状态和最近登录。
|
||||
- 展示客户设备:设备状态、GUI / CLI 在线状态、风险数和最近心跳。
|
||||
|
||||
### 授权工作台
|
||||
|
||||
- 继续复用 `/api/v1/admin/access`。
|
||||
- 支持创建 / 更新子账号、公司管理、批量导入、账号归属、设备归属、权限模板、设备 / 项目 / Skill 授权和离职回收。
|
||||
- 高危动作继续保留二次确认和审计记录。
|
||||
|
||||
### 风险与治理
|
||||
|
||||
- 风险战情室按严重程度、客户影响、负责人和 SLA 组织风险。
|
||||
- 风险处理不再使用浏览器 `window.prompt`,改成页面内处理面板。
|
||||
- 处理面板支持指派负责人、设置 SLA、确认、关闭和创建修复工单。
|
||||
- 对暂不支持动作的风险类型保持只读提示,不假装处置成功。
|
||||
- Skill 生命周期治理作为同一区域的第二页签,继续复用 `/api/v1/admin/skills/requests`。
|
||||
|
||||
## 旧版落地范围记录
|
||||
|
||||
以下是第一版落地内容,仍保留作为能力来源说明:
|
||||
|
||||
### 总览
|
||||
|
||||
- 总览统计:公司数、账号数、在线设备、开放风险。
|
||||
- 风险通知:展示由 SLA 扫描生成的超时通知,避免平台侧只看到风险列表、漏掉需要主动跟进的客户事项。
|
||||
- 风险时间线:展示风险通知生成、派发、确认、关闭、负责人和 SLA 调整等最近事件。
|
||||
- 关键风险:展示最高优先级风险。
|
||||
- 风险表:离线设备、未关闭运维故障、线程上下文告警、失败主 Agent 任务。
|
||||
- 风险动作:`ops_fault` 支持指派负责人、设置 SLA、确认、关闭和创建修复工单;`thread_context_alert` 支持指派负责人、设置 SLA、确认和关闭;暂不支持的风险类型会显式失败,不假成功。
|
||||
- 设备表:设备在线状态、CLI/GUI 连接状态、最近心跳和风险数量。
|
||||
- 公司表:优先使用显式 `adminCompanies`,账号和设备未绑定公司时才回退到账号域名或默认公司。
|
||||
- 公司表补齐 To B 运营字段:套餐等级、合同到期时间、客户负责人和客户成功负责人。
|
||||
- 账号表:展示账号、角色、公司、状态、创建/更新时间,不暴露 `passwordHash`。
|
||||
|
||||
### 账号与授权
|
||||
|
||||
- 复用 `/api/v1/admin/access`,支持创建 / 更新 `member` 或 `admin` 子账号。
|
||||
- 支持查看账号状态,并对非主账号执行启用 / 停用;停用账号会同步撤销该账号当前活跃会话。
|
||||
- 支持公司管理、账号归属、设备归属和公司列表。
|
||||
- 支持按公司批量导入成员账号,并支持先预览新增 / 更新 / 异常数量,预览不会写入状态账本。
|
||||
- 支持 CSV 文件导入账号清单,表头为 `account,displayName,role,password`。
|
||||
- 支持对子账号开启 / 关闭 MFA;后台 GET 不返回 `mfaSecret`,仅开启时在本次响应返回一次 `mfaSetupSecret` 供初始化。
|
||||
- 支持最高管理员重置子账号密码;重置后会撤销该账号所有活跃会话,响应不暴露 `passwordHash`。
|
||||
- 支持停用 / 启用客户公司;停用公司会同步禁用该租户下的普通子账号并撤销活跃会话,不会波及平台最高管理员。
|
||||
- 支持离职回收:停用账号、撤销活跃会话,并清理设备 / 项目 / Skill 授权。
|
||||
- 公司停用 / 授权撤销 / 密码重置 / 离职回收等高危动作均在 PC 后台做二次确认。
|
||||
- 支持套用内置权限模板。
|
||||
- 支持设备、项目、Skill 三类授权。
|
||||
- 支持撤销单条授权。
|
||||
- 支持查看最近权限审计记录。
|
||||
|
||||
### Skill 治理
|
||||
|
||||
- 复用 `/api/v1/admin/skills/requests`,支持创建 `install / update / uninstall / rollback / version_lock` 请求。
|
||||
- 设备端仍由 `local-agent` 按既有 lifecycle 链路认领和完成。
|
||||
- 管理后台只负责下发治理请求与查看请求状态,不绕过设备端 allowlist、checksum、备份和回滚约束。
|
||||
- 2026-04-30 起,PC 总后台的 Skill 治理入口改为 `Skill 中心`:先展示 Skill 目录、详情、授权对象和执行轨迹,再通过右侧安装向导创建生命周期请求。
|
||||
- `Skill 中心` 会优先使用 `/api/v1/admin/access` 返回的 `skillCatalog`,没有聚合目录时再由设备 Skill 清单前端兜底聚合,避免最高管理员必须记住每台电脑的原始 `skillId`。
|
||||
- 创建请求仍提交到 `/api/v1/admin/skills/requests`,只是把 `sourceUrl / trustedSourceId / checksum` 等字段放入向导步骤中,降低误操作和填表成本。
|
||||
|
||||
## 权限边界
|
||||
|
||||
- `/admin` 页面要求登录。
|
||||
- 非 `highest_admin` 只看到“仅最高管理员可用”提示。
|
||||
- `/api/v1/admin/overview` 未登录返回 `401`,非最高管理员返回 `403`。
|
||||
- `/api/v1/admin/risks/actions` 未登录返回 `401`,非最高管理员返回 `403`。
|
||||
- `/api/v1/admin/risks/scan` 未登录返回 `401`,非最高管理员返回 `403`。
|
||||
- `/api/v1/admin/notifications/dispatch` 未登录返回 `401`,非最高管理员返回 `403`。
|
||||
- 后台 mutation 路由和认证 mutation 路由会拒绝显式跨站浏览器请求;原生 APP 请求通过 `x-boss-native-app: 1` 豁免浏览器 CSRF 检查。
|
||||
- 后台 mutation 路由会把 `x-forwarded-for / x-real-ip / user-agent / x-request-id` 写入 `permissionAuditLogs`;高危动作会额外写入安全化 `beforeJson / afterJson` 快照,便于企业客户追责和回放。
|
||||
|
||||
## 数据来源
|
||||
|
||||
- `authAccounts`:账号与角色。
|
||||
- `adminCompanies`:客户公司 / 租户实体。
|
||||
- `devices`:电脑与 Codex CLI/GUI 能力状态。
|
||||
- `projects`:项目与设备关联。
|
||||
- `opsFaults`:未关闭运维故障。
|
||||
- `threadContextAlerts`:未解决线程上下文告警。
|
||||
- `masterAgentTasks`:失败任务。
|
||||
- `accountDeviceGrants`、`accountProjectGrants`、`accountSkillGrants`:授权汇总和过期授权统计。
|
||||
- `adminNotifications`:风险 SLA 超时通知账本,由 `/api/v1/admin/risks/scan` 幂等生成,并由 `/api/v1/admin/notifications/dispatch` 派发。
|
||||
- `adminRiskTimeline`:风险处理时间线,记录通知生成、派发和人工处置动作。
|
||||
|
||||
## 后续扩展
|
||||
|
||||
- 下一期应接入企业微信 / 飞书 / 短信等更多通知渠道;当前 `BOSS_ADMIN_NOTIFICATION_MODE=email` 可走服务器 sendmail,默认 `disabled` 只记录派发状态。
|
||||
- PostgreSQL 切换仍建议先用 `scripts/boss-state-store-maintenance.mjs` 做备份、dry-run 迁移和回滚演练,再设置 `BOSS_STATE_STORE=postgres`。
|
||||
@@ -19,10 +19,12 @@
|
||||
2. `docs/architecture/repo_map_cn.md`
|
||||
3. `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
4. `docs/architecture/api_and_service_inventory_cn.md`
|
||||
5. `docs/architecture/boss_server_connection_and_deploy_cn.md`
|
||||
6. `docs/architecture/wechat_project_conversation_mapping_cn.md`
|
||||
7. `docs/architecture/thread_context_budget_and_handoff_protocol_cn.md`
|
||||
8. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md`
|
||||
5. `docs/architecture/rbac_skill_regression_matrix_cn.md`
|
||||
6. `docs/architecture/boss_server_connection_and_deploy_cn.md`
|
||||
7. `docs/architecture/wechat_project_conversation_mapping_cn.md`
|
||||
8. `docs/architecture/thread_context_budget_and_handoff_protocol_cn.md`
|
||||
9. `docs/architecture/dependency_security_audit_cn.md`
|
||||
10. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md`
|
||||
|
||||
## 3. 当前有效实现边界
|
||||
|
||||
@@ -58,6 +60,7 @@
|
||||
- `android/app/src/main/java/com/hyzq/boss/AttachmentComposerState.java`:原生附件发送确认规则与待上传附件模型
|
||||
- `android/app/src/main/java/com/hyzq/boss/BossWindowInsets.java`:原生顶部安全区处理,负责把状态栏 / 刘海区让出来
|
||||
- `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`:原生设备详情与技能入口
|
||||
- `android/app/src/main/java/com/hyzq/boss/AccessManagementActivity.java`:原生最高管理员用户与权限管理页
|
||||
- `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`:原生 AI 账号管理页
|
||||
- `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`:原生运维 / 审计中心
|
||||
- `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`:原生微信式 surface contract
|
||||
@@ -97,6 +100,7 @@
|
||||
- `POST /api/auth/login` 正常,会写入 `boss_session`
|
||||
- `boss_session` 当前默认保持 30 天
|
||||
- `GET /api/auth/session` 正常
|
||||
- `GET/POST /api/v1/auth/sessions` 正常,已支持基础跨端会话治理和单会话撤销
|
||||
- `POST /api/auth/restore` 正常,原生 Android 客户端可用 `restore token` 自动恢复登录态
|
||||
- `GET /api/v1/app-logs` 正常,可按登录态分页读取 APP 日志
|
||||
- `POST /api/v1/projects/master-agent/messages` 正常,已验证通过 `local-agent -> codex exec -> complete` 返回真实主 Agent 回复
|
||||
@@ -137,6 +141,9 @@
|
||||
- 线程改名当前遵循微信最新逻辑:从聊天页右上角进入会话信息页,再进行改名
|
||||
- 当前已支持从单线程会话发起独立群聊:原会话保留,新群聊自动命名并可在群资料页改名
|
||||
- 当前群聊编排主链已经补到第一阶段:群聊消息先进入主 Agent,主 Agent 生成推荐下发方案,用户确认后再创建执行单;执行完成后线程原始结果会回群,主 Agent 再追加汇总
|
||||
- 当前 Boss APP 按“Codex 同一线程客户端”同步桌面记录:APP 直连线程和主 Agent 托管线程都会把用户原文镜像进目标 Codex rollout,供 Codex 桌面版打开/刷新该线程时看到同一段沟通记录;内部 prompt、调度字段和系统约束不得写入桌面可见记录
|
||||
- 当前桌面实时性新增轻量刷新桥:镜像成功后 `local-agent` 会优先调用本机常驻 `Codex Desktop Bridge` endpoint,再由 bridge 打开 `codex://threads/{threadId}` 目标线程深链并发送一次应用刷新快捷键,让 Codex 桌面版切到目标线程后重新感知线程更新;endpoint 不可用时会回退到原命令式刷新。这条桥只做打开/刷新提示,不承担消息输入,失败也不能阻断主链。默认配置会在短暂失败时重试 2 次、间隔 120ms,并保留 deep link 与尝试次数,方便排查桌面端是否收到刷新提示。bridge 还提供本机 SSE:`GET /api/v1/codex-desktop/events`,只广播安全元数据;`scripts/codex-desktop-event-consumer.mjs` 已作为 Desktop 插件/IPC 的消费样例
|
||||
- 当前还新增 `scripts/codex-desktop-integration-probe.mjs` 与 bridge `GET /api/v1/codex-desktop/capabilities`:用于自动探测当前 Codex Desktop 是否支持 `codex://threads/{threadId}` 这类稳定入口,并明确禁止把“修改 Codex.app 签名包体”作为支持能力
|
||||
- 当前设备导入主链已经补到第一阶段:设备 heartbeat 可上报真实候选线程,系统会生成导入草稿;用户勾选后可生成导入决议,并把选中的线程真正落成聊天窗口
|
||||
- 当前设备导入草稿不会再被旧 `projects` 字段绕过;只有 `apply` 之后,候选线程才会真正变成聊天窗口
|
||||
- 当前设备导入 `review` 已经会留下 `device_import_resolution` master task 轨迹,但决议内容仍是服务端 heuristic 版,尚未真正交给 `local-agent -> codex exec`
|
||||
@@ -150,17 +157,21 @@
|
||||
- 移动端 UI 已去掉假的状态栏与桌面预览壳;底部一级导航固定在视口底部,返回逻辑不会再把 APP 根页直接弹回桌面
|
||||
- `项目目标` 支持用户编辑、主 Agent 复核、完成项自动划线
|
||||
- `版本迭代记录` 只读,由主 Agent 汇总
|
||||
- `我的` 根页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于`
|
||||
- `我的` 根页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 附件与存储 / Telegram 接入 / 技能 / 关于`,其中 `用户与权限` 仅最高管理员可见
|
||||
- `我的 > 账号与安全` 已支持查看和撤销登录会话;最高管理员可管理全部活跃会话,子账号只能管理自己的会话
|
||||
- `我的 > 用户与权限` 与 Web `/me/access` 共用 `/api/v1/admin/access`,可创建子账号、分配设备 / 项目 / Skill 权限,并查看同名 Skill 跨设备聚合;PC `/admin` 已补公司停用、CSV/文本批量导入预览、重置密码、子账号 MFA、风险 SLA 通知派发、风险时间线和后台审计来源字段
|
||||
- 多用户 / RBAC / Skill / 主 Agent 权限和多设备控制的集中状态、回归矩阵与缺口清单见 `docs/architecture/rbac_skill_regression_matrix_cn.md`
|
||||
- `我的 > 主 Agent 提示词 / 记忆` 当前可编辑管理员全局主提示词、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 项目记忆
|
||||
- `我的 > AI 账号` 必须可查看和切换 `主 GPT / 备用 GPT / API 容灾`
|
||||
- `我的 > 技能` 必须按绑定设备展示 Skill,并支持一键复制调用语句
|
||||
- Skill 远程治理第一版已经接通最高管理员后端入口和设备端执行:`GET/POST /api/v1/admin/skills/requests` 可创建和查看 `install / update / uninstall / rollback / version_lock` 请求,local-agent 通过 `claim / complete` 认领执行并回写最新 Skill 清单。当前设备端已增加 source allowlist / trusted source、`checksum / expectedChecksum` sha256 校验、更新 / 卸载 / 回滚前备份和失败恢复;仍未做签名校验和依赖安装沙箱
|
||||
- `设备` 页当前只允许出现生产设备,旧演示脏数据不能回流到正式视图
|
||||
- 登录后必须形成最小会话,受保护页面和核心 `/api/v1/*` 接口不能再裸奔
|
||||
- 必须保留登录、注册、忘记密码和验证码入口
|
||||
|
||||
## 6. 当前技术路线
|
||||
|
||||
- Web:`Next.js 16.2.1 + React 19`
|
||||
- Web:`Next.js 16.2.4 + React 19`
|
||||
- 数据:当前是文件型持久化 `data/boss-state.json`
|
||||
- 状态写入:串行事务队列 + 原子写入 + `.bak` 备份恢复
|
||||
- device-agent:原生 Node HTTP 服务
|
||||
@@ -168,7 +179,7 @@
|
||||
- 邮件:`Postfix + Dovecot`
|
||||
- Android:`AppCompatActivity + 原生 XML 布局 + HttpURLConnection`
|
||||
- 原生登录恢复:`SharedPreferences + restore token`
|
||||
- 当前最新原生 APK:`2.5.4`(`versionCode=17`)
|
||||
- 当前最新原生 APK:`2.5.11`(`versionCode=24`)
|
||||
|
||||
当前不要误判成已经用了:
|
||||
|
||||
@@ -187,7 +198,7 @@ npm install
|
||||
npm run build
|
||||
npm run lint
|
||||
curl -sS http://127.0.0.1:3000/api/health
|
||||
curl -sS -H 'Content-Type: application/json' -d '{"account":"17600003315","password":"boss123456","method":"password"}' http://127.0.0.1:3000/api/auth/login
|
||||
curl -sS -H 'Content-Type: application/json' -d '{"account":"krisolo","password":"<admin-password>","method":"password"}' http://127.0.0.1:3000/api/auth/login
|
||||
curl -sS http://127.0.0.1:3000/api/auth/session
|
||||
curl -sS http://127.0.0.1:3000/api/v1/conversations
|
||||
curl -sS http://127.0.0.1:3000/api/v1/projects/master-agent
|
||||
@@ -211,20 +222,20 @@ npm run apk:debug
|
||||
|
||||
## 8. 当前已知未完成项
|
||||
|
||||
- 认证仍是 MVP 级别:虽然已有最小会话 Cookie,但还没有刷新令牌、跨端会话治理和 CSRF 防护
|
||||
- 认证仍是 MVP 级别但已收紧:已有最小会话 Cookie、restore token 轮换、浏览器 CSRF 基础防护、子账号 MFA、基础跨端会话治理和后台高危动作审计;临时免验证登录默认关闭,只能通过 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 显式开启
|
||||
- 当前已补“原生 restore token 自动恢复”,但这仍不是完整的多端会话系统
|
||||
- 当前默认最高管理员账号是 `17600003315`,默认密码 `boss123456`,并已绑定本机 Codex 节点
|
||||
- 当前默认最高管理员账号是 `krisolo`,默认密码由线上初始化配置管理,并已绑定本机 Codex 节点
|
||||
- 主 Agent 实时回复当前依赖被绑定设备的 `local-agent` 在线,并能在本机跑通 `codex exec`
|
||||
- API 容灾当前由用户在 APP 的 `我的 > AI 账号` 中自行配置 `OpenAI API` 账号
|
||||
- 服务器默认固定验证码仍是 `000000`
|
||||
- 服务器默认验证码模式仍是 fixed,但验证码登录也必须先申请验证码,不允许只靠固定码直接登录
|
||||
- 服务器邮件栈已部署完成,应用内也已经支持 email 模式,但默认开关还没切到 email
|
||||
- OTA 版本中心、检查更新、执行升级和 APK 包下载已接通,但当前仍是文件型状态驱动的 MVP
|
||||
- APP 实时日志同步、主 Agent 日志镜像、SSE 自动刷新和 Skill 同步页已经接通,但日志检索、告警和远程 Skill 管理仍未做
|
||||
- 设备导入主链当前已经具备后端闭环和 Web/Android 前台接线,后续重点改成继续细化导入筛选规则和主 Agent 理解策略,而不是再从 0 接页面
|
||||
- 数据库尚未替代文件存储
|
||||
- APP 实时日志同步、主 Agent 日志镜像、SSE 自动刷新和 Skill 同步页已经接通;日志检索已有基础分页,风险 SLA 通知账本已接入,外部通知渠道仍未做
|
||||
- 设备导入主链当前已经具备后端闭环和 Web/Android 前台接线;主 Agent 理解同步已经避免未接管状态下主动问线程,后续重点是继续细化导入筛选规则和用户主动同步体验
|
||||
- 数据库尚未替代文件存储;当前已补 `BOSS_STATE_STORE=postgres` 单行 JSONB 适配层、schema 和 `scripts/boss-state-store-maintenance.mjs` 备份 / 迁移 / 回滚工具,但生产仍默认文件状态
|
||||
- 域名入口的代理 / 分裂 DNS 结构仍未完全摸清
|
||||
- 当前只支持服务器文件存储和阿里 OSS,尚未接更多对象存储或更丰富的附件详情页
|
||||
- 认证没有真实 session 和令牌吊销
|
||||
- 认证已有真实 session、restore token 轮换、单会话撤销、CSRF 基础防护和 MFA 开关,但还没有企业 SSO / IdP
|
||||
|
||||
## 9. 继续开发时的工作原则
|
||||
|
||||
|
||||
@@ -16,7 +16,18 @@
|
||||
- 当前原生恢复态:`restore token + SharedPreferences`
|
||||
- 当前执行底座:`src/lib/execution/`,已包含 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现
|
||||
|
||||
### 1.2 boss-android-native
|
||||
### 1.2 boss-admin-web
|
||||
|
||||
- 形态:独立 PC 企业后台前端
|
||||
- 工程目录:`apps/boss-admin-web`
|
||||
- 技术栈:`Vue 3 + Vite + Ant Design Vue`
|
||||
- 本地开发脚本:`npm run admin:web:dev`
|
||||
- 构建脚本:`npm run admin:web:build`
|
||||
- 数据入口:`GET /api/v1/admin/backoffice`
|
||||
- 登录态:复用 `boss_session` HttpOnly Cookie
|
||||
- 当前定位:平台侧 To B 总后台,面向公司、账号、设备、项目、Skill、风险与审计治理;现有 `/admin` 继续作为主站内 fallback
|
||||
|
||||
### 1.3 boss-android-native
|
||||
|
||||
- 形态:原生 Android 客户端
|
||||
- 原生入口:`android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
@@ -39,8 +50,11 @@
|
||||
- `DeviceEnrollmentActivity`
|
||||
- `SkillInventoryActivity`
|
||||
- `SecurityActivity`
|
||||
- `AccessManagementActivity`
|
||||
- `SettingsActivity`
|
||||
- `StorageSettingsActivity`
|
||||
- `AiAccountsActivity`
|
||||
- `TelegramIntegrationActivity`
|
||||
- `OpenAiOnboardingActivity`
|
||||
- `OpsCenterActivity`
|
||||
- `AboutActivity`
|
||||
@@ -53,11 +67,13 @@
|
||||
- 单线程会话支持按微信最新逻辑改线程名
|
||||
- 当前已经支持从单线程会话发起独立群聊,群聊创建后作为新会话保留,原会话不升级
|
||||
- 当前单线程会话已经支持打开 `线程状态` 只读页,查看主 Agent 当前掌握的线程状态文档和最近进展事件
|
||||
- 当前已经支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`
|
||||
- 当前已经支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`,其中删除会调用服务端账本删除接口并刷新会话预览
|
||||
- 当前多选模式会切换成微信式 `取消 + 已选数量 + 底部转发` 状态
|
||||
- 当前统一使用 `ForwardTargetActivity` 选择目标会话,替换旧的备注转发主链
|
||||
- 当前已支持聊天附件主链:输入框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;图片 / 视频先确认,文件直接发送
|
||||
- 当前附件消息支持下载、原生打开、手动分析和自动分析状态展示
|
||||
- 当前线程聊天消息会按该线程绑定的 Codex 电脑显示来源头像:单线程会话使用项目绑定设备头像,多设备 / 群聊消息会优先根据发送人里的设备名匹配对应电脑头像;主 Agent 总入口自身仍保留主 Agent 对话样式
|
||||
- 当前已支持 `execution_progress` 执行进度卡:普通线程对话、主 Agent 托管线程和群聊目标线程执行时,会在对应聊天窗口显示“进度 / 分支详情 / 生成结果 / 后台智能体”结构化卡片;线程过程噪音仍走 `thread_process` 折叠
|
||||
- `线程详情 / 运维调试` 仍保留对应原生活动页,但已退出主聊天面
|
||||
- 当前已补上本地发送中气泡、发送按钮状态控制,以及“只有接近底部才自动滚到底”的消息流行为
|
||||
- 当前根页导航:
|
||||
@@ -74,17 +90,20 @@
|
||||
- 保留版本与 OTA 操作
|
||||
- 当前已补上 OTA 下载进度、失败重试、安装授权提示和返回关于页后的本地状态恢复
|
||||
- 当前 `我的` 根页:
|
||||
- 保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于`
|
||||
- 已按登录角色过滤入口:`member` 只显示 `账号与安全 / 设置 / 技能 / 关于`
|
||||
- `admin / highest_admin` 额外显示 `运维与修复 / AI 账号 / 附件与存储 / Telegram 接入`
|
||||
- `用户与权限` 仅 `highest_admin` 可见,用于创建子账号和分配设备 / 项目 / Skill 权限
|
||||
- `运维与修复` 直接进入 `OpsCenterActivity`
|
||||
- `技能` 入口会继续依赖服务端 Skill 授权过滤,不在客户端自行扩大可见范围
|
||||
- 当前 `OpenAiOnboardingActivity`:
|
||||
- 会先自动打开 `OpenAI Platform` 登录页
|
||||
- 支持继续打开 `API Keys` 页面
|
||||
- 回 APP 后可直接粘贴 key,并设为当前主控
|
||||
- 登录成功后会直接给出 `测试主 Agent 对话` 入口
|
||||
- 当前登录:临时免验证,点击登录直接创建最高管理员会话
|
||||
- 当前登录:默认要求账号密码或验证码校验;临时开发兜底只允许通过显式环境变量开启
|
||||
- 当前会话恢复:`SharedPreferences` 中保存 `boss_session / restore_token / account`
|
||||
|
||||
### 1.3 boss-local-agent
|
||||
### 1.4 boss-local-agent
|
||||
|
||||
- 形态:Node 原生 HTTP 服务
|
||||
- 本地端口:默认 `4317`
|
||||
@@ -94,21 +113,47 @@
|
||||
- 当前新增职责:递归扫描本机 `~/.codex/skills` 并同步到设备 Skill 接口
|
||||
- 当前完成回写:`conversation_reply / dispatch_execution` 会先标准化成统一远程执行结果,再调用 `/api/v1/master-agent/tasks/[taskId]/complete`
|
||||
- 当前 `dispatch_execution` 会按 `orchestrationBackendId` 分流:默认走 `codex exec resume`,显式选择 `omx-team` 且本机配置可用时改走 `OMX Team Runtime` JSON 协议
|
||||
- 当前 Codex 任务完成回写会附带 `executionProgress` 快照:包含 Git diff 简表、GitHub CLI 可用状态和从执行回复中提取的产物文件名,服务端更新同一张 `execution_progress` 卡片,不重复刷屏
|
||||
- 当前 `RemoteRuntimeAdapter` 还负责拦截固定模式的线程内部环境提示;命中后会直接改写成失败,避免把只读/cwd 这类脏文本写进聊天记录
|
||||
- 当前普通单线程 `conversation_reply` 在真正执行 `codex exec resume` 前,会先把 Boss 用户消息镜像进目标 Codex Desktop rollout;定位优先走 `state_5.sqlite`,不可用时回退扫描 `~/.codex/sessions`,并按 `sourceMessageId` 去重
|
||||
- 当前 Codex Desktop 同步新增常驻刷新桥:`scripts/codex-desktop-refresh-bridge-daemon.mjs` 通过 launchd 监听 `127.0.0.1:4318`,暴露 `POST /api/v1/codex-desktop/refresh`、`GET /api/v1/codex-desktop/events`、`GET /api/v1/codex-desktop/events/recent` 和 `GET /api/v1/codex-desktop/capabilities`;`local-agent` 会优先调用 refresh endpoint,失败时回退到 `scripts/codex-desktop-refresh-hint.mjs` 命令式刷新。SSE 事件只包含线程引用、消息 ID、状态、deep link 等安全元数据,不包含用户正文或内部 prompt;`scripts/codex-desktop-event-consumer.mjs` 可作为 Desktop 插件/IPC 接入前的订阅 smoke;`scripts/codex-desktop-integration-probe.mjs` 负责只读探测 Codex.app 能力
|
||||
- 当前 `local-agent` 还新增了两条统一电脑控制 runtime:
|
||||
- `local-agent/browser-control-task-runner.mjs`
|
||||
- `local-agent/computer-use-task-runner.mjs`
|
||||
- 当前 `browser_control / desktop_control` 任务已经可以被 `local-agent/server.mjs` 识别并分流;当本机配置了对应 runtime 命令时,会通过 JSON stdin/stdout 协议委托给外部进程执行,否则返回明确 runtime disabled 错误,不再回退占位成功结果
|
||||
- 当前 `browser_control / desktop_control` 的完成回写已贯通 `targetUrl / targetApp -> RemoteRuntimeAdapter -> /api/v1/master-agent/tasks/[taskId]/complete -> boss-state.json`,服务端写入 `control_summary` 消息时会保留 `controlTarget`,Android 会话页可直接渲染“目标:URL/应用名”
|
||||
- 相关配置项:
|
||||
- `browserControlEnabled / browserControlCommand / browserControlArgs / browserControlWorkdir / browserControlTimeoutMs`
|
||||
- `computerUseEnabled / computerUseCommand / computerUseArgs / computerUseWorkdir / computerUseTimeoutMs`
|
||||
- 当前仓库已自带最小 smoke runtime:
|
||||
- `scripts/browser-control-smoke.mjs`
|
||||
- `scripts/computer-use-smoke.mjs`
|
||||
- `scripts/browser-control-smoke.mjs` 当前已支持两段式最小真实动作:
|
||||
- 能从目标 URL 拉取 HTML 标题并回写到 `replyBody / executionSummary`
|
||||
- 在显式配置 opener 命令时可实际执行打开 URL
|
||||
- `scripts/computer-use-smoke.mjs` 当前已支持识别常见桌面应用名,macOS 下默认用 `osascript` 激活目标应用,并支持把用户请求中的引号文本输入到当前前台应用、按需回车发送;同时保留 `open -a` 兜底,并会落盘结构化 artifact,便于后续真实 Computer Use runtime 复用同一回写协议
|
||||
- `config.example.json / config.cloud.json` 现默认把这两条 smoke runtime 作为 browser/desktop 控制的推荐起步配置
|
||||
- `config.example.json / config.cloud.json` 现同时默认把 `browserAutomationConnected / computerUseConnected` 置为 `true`,让前台设备详情默认按“这台 Mac 已具备浏览器控制 / 桌面控制能力”展示
|
||||
- 这两条 smoke runtime 当前还会返回结构化字段:
|
||||
- browser:`targetUrl / artifacts`
|
||||
- desktop:`targetApp / typedText / artifacts`
|
||||
- 这样前台与后续真实 runtime 可以共用同一套结果形态,而不需要等接入 Playwright / Computer Use 后再改返回协议
|
||||
- heartbeat 的 `browserAutomation / computerUse` 能力上报会同时参考静态 connected 标记和 runtime 配置状态
|
||||
|
||||
### 1.4 Caddy
|
||||
### 1.5 Caddy
|
||||
|
||||
- 作用:反向代理和 HTTPS 自动续签
|
||||
- 服务器服务名:`caddy.service`
|
||||
- 配置文件:`deployment/Caddyfile`
|
||||
- 当前站点:`boss.hyzq.net` 服务客户 Web / App API;`admin.boss.hyzq.net` 服务平台总后台。独立后台第一批仍未替换线上 `/admin`,后续部署完成后再把该域名切到 `apps/boss-admin-web` 静态产物
|
||||
|
||||
### 1.5 boss-server-debug skill
|
||||
### 1.6 boss-server-debug skill
|
||||
|
||||
- 作用:跨 Codex 窗口稳定连接 `106.53.170.158`
|
||||
- 路径:`$HOME/.codex/skills/boss-server-debug/SKILL.md`
|
||||
- 密码来源:优先读取 macOS Keychain
|
||||
|
||||
### 1.6 Postfix + Dovecot
|
||||
### 1.7 Postfix + Dovecot
|
||||
|
||||
- 作用:服务器侧邮件发送 / 接收基础设施
|
||||
- SMTP 端口:`25 / 465 / 587`
|
||||
@@ -143,6 +188,7 @@
|
||||
- `GET /me/security`
|
||||
- `GET /me/about`
|
||||
- `GET /me/storage`
|
||||
- `GET /me/access`
|
||||
- `GET /me/ai-accounts`
|
||||
- `GET /me/ops`
|
||||
- `GET /me/ops/audit`
|
||||
@@ -163,9 +209,194 @@
|
||||
#### `GET /api/state`
|
||||
|
||||
- 用途:读取当前完整状态
|
||||
- 注意:这是内部 MVP 调试接口,会直接返回整个 `BossState`
|
||||
- 当前行为:最高管理员可读取完整状态;非最高管理员会返回已按当前账号授权裁剪后的状态快照,设备、项目、线程状态、进展事件、Skill、日志和任务都会尽量限制在可见范围内
|
||||
- 注意:这是内部 MVP 调试接口,仍不建议作为普通业务页面的主数据源;业务页面应优先使用具体 `/api/v1/*` 投影接口
|
||||
|
||||
### 3.1.1 执行底座抽象层
|
||||
### 3.1.1 多用户 RBAC 与 Skill 授权
|
||||
|
||||
- 权限模块:`src/lib/boss-permissions.ts`
|
||||
- 状态字段:
|
||||
- `accountDeviceGrants`
|
||||
- `accountProjectGrants`
|
||||
- `accountSkillGrants`
|
||||
- `skillCatalog`
|
||||
- `permissionAuditLogs`
|
||||
- 当前规则:
|
||||
- `highest_admin` 全局可见
|
||||
- 非管理员必须通过设备、项目或 Skill 授权获得可见性
|
||||
- `device.view` 只提供设备与关联项目只读可见性,不自动放大为聊天、接管、电脑控制或 Skill 使用权限
|
||||
- `thread.chat / master_agent.ask / master_agent.takeover / computer.control / skill.use` 需要显式授权
|
||||
- 当前已接入过滤的接口:
|
||||
- `GET/POST /api/v1/admin/access`(仅最高管理员)
|
||||
- `GET /api/v1/devices`
|
||||
- `GET /api/v1/conversations`
|
||||
- `GET /api/v1/conversations/home`
|
||||
- `GET /api/v1/conversation-folders/[folderKey]`
|
||||
- `GET /api/v1/projects/[projectId]`
|
||||
- `GET/POST /api/v1/projects/[projectId]/messages`
|
||||
- `GET /api/v1/devices/[deviceId]/skills`
|
||||
- `GET /api/state`
|
||||
- 当前主 Agent 行为:执行提示词使用授权快照生成,任务队列会记录 `authorizedDeviceIds / authorizedProjectIds / authorizedSkillIds / requiredPermissions`
|
||||
- 当前前台入口:Web `/me/access` 与原生 Android `AccessManagementActivity` 共用 `/api/v1/admin/access`,仅 `highest_admin` 可见;`admin/member` 不显示入口且直接请求会返回 `403`
|
||||
|
||||
#### `GET /api/v1/admin/access`
|
||||
|
||||
- 用途:最高管理员读取账号与授权管理台所需数据
|
||||
- 权限:仅 `highest_admin`
|
||||
- 返回:
|
||||
- 脱敏 `accounts`,不包含 `passwordHash`
|
||||
- `companies`:显式客户公司 / 租户列表
|
||||
- `devices / projects / skills`
|
||||
- 按同名 Skill 聚合的 `skillCatalog`
|
||||
- 内置 `permissionTemplates`
|
||||
- `grants.devices / grants.projects / grants.skills`
|
||||
- `auditLogs`
|
||||
|
||||
#### `POST /api/v1/admin/access`
|
||||
|
||||
- 用途:最高管理员执行最小授权管理动作
|
||||
- 权限:仅 `highest_admin`
|
||||
- 支持动作:
|
||||
- `upsert_company`:创建或更新客户公司 / 租户
|
||||
- `set_company_status`:启用或停用客户公司;停用时同步禁用该租户普通子账号并撤销活跃会话
|
||||
- `assign_account_company`:把账号绑定到指定客户公司
|
||||
- `assign_device_company`:把设备绑定到指定客户公司
|
||||
- `preview_bulk_import_accounts`:预览批量导入结果,返回新增 / 更新 / 异常数量,不写入状态
|
||||
- `bulk_import_accounts`:按公司批量导入 `member/admin` 子账号
|
||||
- `reset_account_password`:最高管理员重置子账号密码,重置后撤销该账号活跃会话且响应不返回 `passwordHash`
|
||||
- `reclaim_account`:离职回收,停用账号、撤销活跃会话并清理设备 / 项目 / Skill 授权
|
||||
- `upsert_account`:创建或更新子账号
|
||||
- `set_account_status`:启用或停用子账号;停用时撤销该账号当前活跃会话,且禁止停用最高管理员账号
|
||||
- `grant_device`:授予设备权限
|
||||
- `grant_project`:授予项目权限
|
||||
- `grant_skill`:授予 Skill 权限
|
||||
- `apply_template`:对指定账号和目标设备 / 项目 / Skill 批量套用内置权限模板
|
||||
- `revoke_grant`:撤销任意设备 / 项目 / Skill 授权
|
||||
- 当前行为:所有变更类动作都会写入 `permissionAuditLogs`,用于后续审计和主 Agent 接手时判断权限来源;后台 mutation 会记录 `ipAddress / userAgent / requestId`,高危动作可记录安全化 `beforeJson / afterJson`
|
||||
|
||||
#### `GET /api/v1/admin/overview`
|
||||
|
||||
- 用途:最高管理员读取 To B 管理后台总览数据
|
||||
- 权限:仅 `highest_admin`
|
||||
- 返回:
|
||||
- `summary`:公司、账号、设备、在线设备、开放风险、风险通知、严重风险数量
|
||||
- `companies[]`:优先使用显式客户公司 / 租户,其次按账号域名或默认公司聚合
|
||||
- `accounts[]`:脱敏账号列表,不包含 `passwordHash`
|
||||
- `devices[]`:设备在线状态、CLI/GUI 能力、项目数和风险数
|
||||
- `risks[]`:离线设备、运维故障、线程上下文风险和失败主 Agent 任务;运维故障和线程上下文风险会带出负责人和 SLA
|
||||
- `notifications[]`:开放中的风险 SLA 通知,当前由 `/api/v1/admin/risks/scan` 生成
|
||||
- `grantsSummary`:设备 / 项目 / Skill 授权数量与过期授权数量
|
||||
|
||||
#### `GET /api/v1/admin/backoffice`
|
||||
|
||||
- 用途:独立 PC 企业后台读取 YuDao/Vben 风格的总后台契约数据
|
||||
- 权限:仅 `highest_admin`
|
||||
- 返回:
|
||||
- `menuTree`:工作台、租户管理、账号管理、角色权限、资源授权、Skill 中心、风险告警、审计日志、系统设置
|
||||
- `workbench`:平台总览、客户健康、设备健康、风险、通知和授权摘要
|
||||
- `tenants[]`:客户公司 / 租户列表,来自 `adminCompanies` 与现有聚合
|
||||
- `users[]`:脱敏账号列表,不包含 `passwordHash / mfaSecret / authSessions`
|
||||
- `roles`:内置角色与 `BOSS_PERMISSION_TEMPLATES`
|
||||
- `resourceGroups`:设备、项目线程、Skill 聚合目录和授权记录
|
||||
- `audit`:风险、通知、风险时间线和 `permissionAuditLogs`
|
||||
- `yudaoMapping`:Boss 账本字段到后台概念的映射,用于后续数据库化或模块拆分
|
||||
- 当前定位:供 `apps/boss-admin-web` 消费;现有 `/admin` 仍继续使用 `/api/v1/admin/overview` 和 `/api/v1/admin/access`
|
||||
|
||||
#### `POST /api/v1/admin/risks/scan`
|
||||
|
||||
- 用途:扫描当前风险 SLA,幂等生成平台侧待跟进通知
|
||||
- 权限:仅 `highest_admin`
|
||||
- 当前行为:
|
||||
- 扫描未关闭的 `opsFaults` 和 `threadContextAlerts`
|
||||
- 当 `slaDueAt` 已早于当前时间时,写入 `adminNotifications[]`
|
||||
- 同一个 `riskId` 只生成一条 `risk_sla_overdue` 通知,重复扫描不会重复膨胀账本
|
||||
- 生成新通知时发布 `project.context_risk.updated`
|
||||
|
||||
#### `POST /api/v1/admin/risks/actions`
|
||||
|
||||
- 用途:最高管理员在管理后台处理风险
|
||||
- 权限:仅 `highest_admin`
|
||||
- 输入:
|
||||
- `riskId`:当前支持 `ops-fault:<faultId>` 和 `thread-alert:<alertId>`
|
||||
- `action`:`assign_owner | set_sla | ack | resolve | create_repair_ticket`
|
||||
- `ownerAccount`:`assign_owner` 必填
|
||||
- `slaDueAt`:`set_sla` 必填
|
||||
- `note`:可选处理备注
|
||||
- 当前行为:
|
||||
- `ops-fault` 支持指派负责人、设置 SLA、确认、关闭、创建或复用修复工单
|
||||
- `thread-alert` 支持指派负责人、设置 SLA、确认和关闭,关闭时写入 `resolvedAt`
|
||||
- 离线设备、失败主 Agent 任务等暂不支持直接动作,会返回 `RISK_ACTION_UNSUPPORTED`
|
||||
- 当前事件:成功动作会发布 `project.context_risk.updated`
|
||||
|
||||
#### `GET /api/v1/audits/permission-logs`
|
||||
|
||||
- 用途:查询 `permissionAuditLogs` 并返回第一版权限审计风险摘要
|
||||
- 权限:仅 `highest_admin`;普通 `admin/member` 直接返回 `403`
|
||||
- 查询参数:
|
||||
- `action`
|
||||
- `actorAccount`
|
||||
- `targetAccount`
|
||||
- `deviceId`
|
||||
- `projectId`
|
||||
- `skillId`
|
||||
- `cursor`
|
||||
- `limit`,默认 `50`,最大 `200`
|
||||
- 返回:
|
||||
- `logs[]`:按 `createdAt` 最新在前排序后的当前页审计日志
|
||||
- `nextCursor`:下一页游标;没有更多数据时为 `null`
|
||||
- `total`:匹配过滤条件的总数
|
||||
- `riskSummary`:基于现有 `permissionAuditLogs` 和仍存在授权记录生成的 deterministic 摘要
|
||||
- 当前风险规则:
|
||||
- `rapid_permission_grants`:同一 actor / target 在 10 分钟内出现 5 条及以上授权类日志
|
||||
- `skill_lifecycle_failed`:Skill lifecycle 完成日志中可识别失败,或后续写入 `skill.lifecycle.failed`
|
||||
- `expired_grant_present`:设备 / 项目 / Skill 授权记录已过期但仍留存在状态中
|
||||
- `admin_route_denied`:已有 `task.denied` 日志能识别非最高管理员访问 admin route 被拒
|
||||
- 当前限制:权限审计风险摘要仍是查询时实时计算;持久化通知账本只覆盖风险 SLA 超时场景。
|
||||
|
||||
#### `GET /api/v1/admin/skills/requests`
|
||||
|
||||
- 用途:最高管理员读取 Skill 远程治理请求队列
|
||||
- 权限:仅 `highest_admin`;普通 `admin/member` 直接返回 `403`
|
||||
- 返回:
|
||||
- `requests[]`:当前保存在 `boss-state.json` 的 Skill lifecycle 请求
|
||||
- 当前行为:按最新请求在前返回;设备端认领后状态会从 `pending` 变成 `running / completed / failed`
|
||||
|
||||
#### `POST /api/v1/admin/skills/requests`
|
||||
|
||||
- 用途:最高管理员创建 Skill 生命周期治理请求
|
||||
- 权限:仅 `highest_admin`
|
||||
- 支持动作:
|
||||
- `install`
|
||||
- `update`
|
||||
- `uninstall`
|
||||
- `rollback`
|
||||
- `version_lock`
|
||||
- 输入要求:
|
||||
- 必须提供 `deviceId`
|
||||
- 必须提供 `skillId` 或 `sourceUrl` 之一
|
||||
- 可选 `targetVersion / rollbackToVersion / lockedVersion / checksum / expectedChecksum / trustedSource / note`
|
||||
- 当前行为:请求以 `pending` 状态写入 `skillLifecycleRequests`,local-agent 会按设备 token 认领执行,并把 `completed / failed` 与结果摘要写回
|
||||
- 当前设备端安全策略:远程 `install` 或带 `sourceUrl` 的更新必须命中本机 `skillLifecycleAllowedSources` 或 `skillLifecycleTrustedSources`;allowlist 为空时只允许既有本地 Skill 的 `update / rollback / uninstall / version_lock`。如果请求带 `checksum / expectedChecksum`,local-agent 会对 `manifest.json` 或 `SKILL.md` 做 sha256 校验;校验失败会失败回写,并清理半安装目录或尽量从 `skillsDir/.boss-skill-backups` 恢复
|
||||
- 当前限制:第一版仅支持 Git 安装 / 更新、本地目录卸载、Git checkout 回滚和 `.boss-skill-locks.json` 版本锁;尚未做签名校验、依赖安装沙箱或 per-run Skill 执行审计
|
||||
|
||||
#### `POST /api/v1/devices/[deviceId]/skill-requests/claim`
|
||||
|
||||
- 用途:设备端领取下一条属于自己的 Skill 生命周期请求
|
||||
- 权限:设备 token 或具备 `device.manage` 的登录会话
|
||||
- 返回:
|
||||
- `request`:下一条请求;无待处理时为 `null`
|
||||
- 当前行为:只领取当前设备 `pending` 请求,领取后改为 `running`
|
||||
|
||||
#### `POST /api/v1/devices/[deviceId]/skill-requests/[requestId]/complete`
|
||||
|
||||
- 用途:设备端回写 Skill 生命周期请求执行结果
|
||||
- 权限:设备 token 或具备 `device.manage` 的登录会话
|
||||
- 输入:
|
||||
- `status`:`completed` 或 `failed`
|
||||
- `resultSummary` / `error`
|
||||
- 当前行为:写回 `completedAt / updatedAt / resultSummary / error`,并追加 `permissionAuditLogs`
|
||||
|
||||
### 3.1.2 执行底座抽象层
|
||||
|
||||
- 目录:`src/lib/execution/`
|
||||
- 当前默认实现:
|
||||
@@ -182,13 +413,14 @@
|
||||
- 当前状态:
|
||||
- 已在生产代码中被 `boss-master-agent.ts`、`local-agent/server.mjs` 和 `master-agent task complete route` 使用
|
||||
- 当前仍服务 Boss 自身执行链
|
||||
- 当前已补 `browser_control / desktop_control` 两个新的 execution tool,并已纳入统一权限与风险分级判断
|
||||
- 当前已最小接入 `ClawBackendAdapter`,但默认关闭,仅在显式配置且可用性探测通过时才参与执行
|
||||
- 如果历史 `backendOverride=claw-runtime` 当前不可用,运行时会自动回退到默认后端,并把原因回给前台
|
||||
- 当前仓库自带 `scripts/claw-runtime-smoke.mjs` 作为兼容 JSON 协议的 smoke runtime,可用于本地和服务器验证 `ClawBackendAdapter`
|
||||
- 当前已最小接入 `OmxTeamBackendAdapter`,但默认关闭;Web 群聊详情页和原生群资料页已经可以在 `Boss Native` 与 `OMX Team` 间切换编排后端,OMX 不可用时会自动回退到默认后端并返回明确原因
|
||||
- 当前仓库自带 `scripts/omx-team-smoke.mjs`,可用于本地和服务器验证 `OmxTeamBackendAdapter` 的 `dispatch_execution` JSON 协议
|
||||
|
||||
### 3.1.2 线程状态文档与进展事件
|
||||
### 3.1.3 线程状态文档与进展事件
|
||||
|
||||
- 状态字段:
|
||||
- `threadStatusDocuments`
|
||||
@@ -198,7 +430,8 @@
|
||||
- 让 Web / Android 前台能直接查看线程的当前目标、阶段、进度、架构、阻塞、建议下一步
|
||||
- 当前同步策略:
|
||||
- `heartbeat / thread reply` 平时优先写轻量进展事件
|
||||
- 首次理解、状态变薄、长时间未刷新或主 Agent 真正接手时,才补排隐藏全量理解任务
|
||||
- 只有单线程接管、全局接管或用户明确要求同步项目目标 / 版本记录时,才补排隐藏全量理解任务
|
||||
- 关闭接管会同步清理仍在 queued/running 的项目理解同步任务,避免取消接管后继续主动打扰线程
|
||||
|
||||
### 3.2 认证相关
|
||||
|
||||
@@ -208,13 +441,13 @@
|
||||
- 输入:
|
||||
- `account`
|
||||
- `purpose`: `login | register | forgot-password`
|
||||
- 当前行为:在邮件验证码正式切换前,固定验证码为 `000000`
|
||||
- 当前行为:在邮件验证码正式切换前,fixed 模式仍返回固定验证码,但所有验证码登录都必须先通过 `send-code` 生成有效记录
|
||||
- 当前说明:Web 侧已经支持 email 模式,email 模式下会通过本机 `sendmail` 调用 `Postfix` 发信;服务器默认仍保持 fixed
|
||||
- 当前保护:60 秒冷却,同一账号 15 分钟窗口内超过 5 次会被限流
|
||||
- 当前前置校验:
|
||||
- `purpose=login | forgot-password` 时要求账号已存在
|
||||
- `purpose=register` 时要求账号尚未注册
|
||||
- 当前 fixed 模式:登录可直接输入 `000000`,不再依赖先申请验证码;注册和重置密码仍走 `send-code` 申请链路
|
||||
- 当前 fixed 模式:登录、注册和重置密码都必须先走 `send-code` 申请链路,再消费账本里的有效验证码
|
||||
|
||||
#### `POST /api/auth/login`
|
||||
|
||||
@@ -224,16 +457,16 @@
|
||||
- `password`
|
||||
- `code`
|
||||
- 当前行为:
|
||||
- 当前已临时切到免验证模式,点击登录会直接创建 `17600003315` 的最高管理员会话
|
||||
- 默认不再允许临时免验证登录,只有显式配置 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 时才开启开发兜底
|
||||
- 原生 Android 端登录后会持久化 `boss_session + restore token`,用于 30 天登录保持和 OTA / 覆盖安装后的会话恢复
|
||||
- 当前阶段不会因为账号、密码或验证码为空而拒绝登录
|
||||
- 正常模式要求 `password` 或 `code` 校验通过
|
||||
- 校验通过后会写入 `boss_session` Cookie
|
||||
- 当请求头带 `x-boss-native-app: 1` 时,还会额外返回 `restoreToken`
|
||||
- 当前 `boss_session` 默认保持 30 天
|
||||
- 连续失败 5 次后会锁定 10 分钟
|
||||
- 当前密码存储:新注册 / 重置密码使用 `scrypt`;历史 `sha256` 会在下次密码登录时自动迁移
|
||||
- 当前默认管理员账号:`17600003315`
|
||||
- 当前默认测试密码:`boss123456`
|
||||
- 当前默认管理员账号:`krisolo`
|
||||
- 当前默认测试密码由线上初始化配置管理,文档不再明文记录
|
||||
|
||||
#### `GET /api/auth/session`
|
||||
|
||||
@@ -248,6 +481,26 @@
|
||||
- 当请求头带 `x-boss-native-app: 1` 时,还会返回:
|
||||
- `restoreToken`
|
||||
|
||||
#### `GET /api/v1/auth/sessions`
|
||||
|
||||
- 用途:查看可管理的登录会话
|
||||
- 当前行为:
|
||||
- `highest_admin` 可查看全部活跃会话
|
||||
- 其他账号只能查看自己的活跃会话
|
||||
- 返回内容只包含 `sessionId / account / role / displayName / loginMethod / createdAt / expiresAt / lastSeenAt / current`
|
||||
- 不返回 `sessionToken / restoreToken`
|
||||
- 前台入口:Web `/me/security` 与原生 Android `SecurityActivity`
|
||||
|
||||
#### `POST /api/v1/auth/sessions`
|
||||
|
||||
- 用途:撤销单个登录会话
|
||||
- 输入:
|
||||
- `action=revoke_session`
|
||||
- `sessionId`
|
||||
- 当前权限:
|
||||
- `highest_admin` 可撤销任意活跃会话
|
||||
- 其他账号只能撤销自己的会话
|
||||
|
||||
#### `POST /api/auth/restore`
|
||||
|
||||
- 用途:原生 APP 使用 `restore token` 恢复 `boss_session`
|
||||
@@ -385,6 +638,7 @@
|
||||
- 普通单线程项目当前会在写入用户消息后,继续创建 `taskType=conversation_reply` 的主 Agent 任务
|
||||
- 返回体会附带 `task.taskId / taskType / status`,给 Web 和原生 Android 保持等待真实回写使用
|
||||
- `projectId=master-agent` 且 `kind=text` 时,会先返回 `masterReplyState + task`,真实回复随后异步回写到账本
|
||||
- Telegram Gateway 当前也复用这条主 Agent 链路:Telegram 私聊文本会写入 `master-agent` 项目,快速回复直接返回,异步任务通过 `externalReplyTarget` 在完成后回推 Telegram
|
||||
- 当前主链路优先走 `Master Codex Node`:`task queue -> local-agent -> codex exec -> complete`
|
||||
- 如果当前主控是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 当前会优先尝试已配置的 `OpenAI API / 阿里百炼 Qwen` 账号,避免聊天直接只剩失败日志
|
||||
- 如本机节点未接通,可切到 `OpenAI API` 或 `阿里百炼 Qwen` 备用账号
|
||||
@@ -392,6 +646,16 @@
|
||||
- 群聊文本消息当前还会返回 `dispatchPlan / dispatchRecommendation`,用于展示主 Agent 推荐的线程下发方案
|
||||
- 如果群里已经有一条待确认推荐,接口会直接返回 `409`,要求先确认或拒绝当前推荐,避免审批消息叠加
|
||||
|
||||
#### `DELETE /api/v1/projects/[projectId]/messages`
|
||||
|
||||
- 用途:删除当前项目消息账本里的一条聊天消息
|
||||
- 输入:
|
||||
- `messageId`:优先从 query string 读取,也兼容 JSON body
|
||||
- 当前行为:
|
||||
- 删除成功后会刷新项目预览、更新时间和未读计数
|
||||
- 会发布 `project.messages.updated / conversation.updated`
|
||||
- Android 长按消息的“删除”菜单已接入该接口
|
||||
|
||||
#### `GET /api/v1/projects/[projectId]/agent-controls`
|
||||
|
||||
- 用途:读取当前对话级别的 `modelOverride / reasoningEffortOverride / backendOverride`
|
||||
@@ -660,6 +924,7 @@
|
||||
#### `GET /api/v1/storage/config`
|
||||
|
||||
- 用途:读取当前登录用户的附件存储配置
|
||||
- 当前入口:Web `我的 > 附件与存储` 与 Android `StorageSettingsActivity`
|
||||
- 返回:
|
||||
- `mode`: `server_file | oss`
|
||||
- `ossProvider`
|
||||
@@ -902,7 +1167,49 @@
|
||||
- `taskType=conversation_reply` 时,会把目标 Codex 线程的原始回复写回普通单线程会话
|
||||
- `taskType=dispatch_execution` 时,会把线程原始结果镜像回群聊,再追加一条主 Agent 汇总,并更新对应执行单状态
|
||||
- `failed` 时写入 relay 失败消息,并更新 AI 账号健康状态
|
||||
- 如果任务带有 `externalReplyTarget.provider=telegram`,完成后会尝试调用 Telegram Bot API 把 `replyBody` 回推到原始聊天
|
||||
- 对群聊分发推荐失败的情况,消息入口当前会额外写入一条 `system_notice`,把“没有真实线程”或“成员引用失效”明确回显给用户
|
||||
|
||||
#### `GET /api/v1/integrations/telegram`
|
||||
|
||||
- 用途:读取 Telegram Bot 接入配置
|
||||
- 当前保护:仅 `highest_admin` 可读
|
||||
- 返回:脱敏后的 `enabled / mode / botTokenConfigured / webhookSecretConfigured / allowFrom / groups / defaultProjectId / groupProjectRoutes`
|
||||
|
||||
#### `POST /api/v1/integrations/telegram`
|
||||
|
||||
- 用途:保存 Telegram Bot 接入配置,并可选执行 `getMe` 探测
|
||||
- 当前保护:仅 `highest_admin` 可写
|
||||
- 输入:
|
||||
- `enabled`
|
||||
- `mode`: `webhook | polling`
|
||||
- `botToken`
|
||||
- `dmPolicy`: `allowlist | open | disabled`
|
||||
- `allowFrom`: Telegram user id 字符串数组
|
||||
- `groupPolicy`: `allowlist | open | disabled`
|
||||
- `groups`: Telegram chat id 字符串数组
|
||||
- `requireMentionInGroups`
|
||||
- `defaultProjectId`
|
||||
- `groupProjectRoutes`: 群 / Topic 到 Boss 项目的路由表,单项格式为 `{ chatId, threadId?, projectId, label? }`
|
||||
- `webhookSecret`
|
||||
- `webhookUrl`
|
||||
- `testConnection`
|
||||
- 当前行为:
|
||||
- `mode=webhook` 且提供 `webhookUrl` 时,会自动调用 Telegram `setWebhook`
|
||||
- `mode=polling` 或关闭接入时,会自动调用 Telegram `deleteWebhook`
|
||||
- `testConnection=true` 时会额外调用 `getMe`,并把返回的 bot username 回写到配置视图
|
||||
|
||||
#### `POST /api/v1/integrations/telegram/webhook`
|
||||
|
||||
- 用途:Telegram Bot webhook 入口
|
||||
- 当前保护:优先校验 `x-telegram-bot-api-secret-token`,再执行 DM / group allowlist
|
||||
- 当前行为:
|
||||
- 私聊文本默认桥接到 `master-agent`
|
||||
- 群聊文本需要命中 `groups` 白名单;开启 `requireMentionInGroups` 时,必须 `@Bot` 或直接回复当前 Bot 上一条消息;进入主 Agent 前会自动清洗 bot mention
|
||||
- 如果配置了 `groupProjectRoutes`,会优先按 `chatId + threadId` 精确匹配,再按 `chatId` 匹配,把消息写入指定 Boss 项目;未命中时回到 `defaultProjectId`
|
||||
- 本地 fast path 回复会立即调用 Telegram `sendMessage`
|
||||
- 需要排队的主 Agent 任务会保存 `externalReplyTarget`,任务完成后从 `/api/v1/master-agent/tasks/[taskId]/complete` 自动回推 Telegram
|
||||
- 已处理的 `update_id` 会保留最近 256 条用于幂等去重
|
||||
- 当前保护:要求 `x-boss-device-token` 或匹配登录会话
|
||||
|
||||
#### `GET /api/v1/master-agent/prompt-policy`
|
||||
@@ -1080,6 +1387,7 @@
|
||||
- local-agent 会周期性请求 `POST /api/v1/master-agent/tasks/claim`
|
||||
- 认领到任务后会执行本机 `codex exec`
|
||||
- `conversation_reply` 当前会优先走 `codex exec resume <targetCodexThreadRef>`,把任务恢复到真实 Codex 线程;只有缺失真实线程引用时才退回 `--ephemeral`
|
||||
- 对已绑定 `targetCodexThreadRef` 的普通单线程 `conversation_reply`,local-agent 现在会在 `codex exec resume` 前先把 Boss 用户消息镜像写入目标 Codex Desktop 线程 rollout;镜像按 `sourceMessageId` 去重,不会因任务重试重复写入。rollout 定位优先走 `state_5.sqlite`,不可用时回退扫描 `~/.codex/sessions`;状态库可写且能命中 thread 时会同步刷新线程活跃时间
|
||||
- `dispatch_execution` 当前默认也走 `codex exec resume`,但当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议
|
||||
- `codex exec resume` 前当前还会做目标线程绑定预检;若目标线程缺失、已归档、cwd 不匹配或为只读会话,会直接失败并返回标准化错误,不继续把任务派进错误线程
|
||||
- 如果历史 `worker / explorer` 子线程需要转回可开发线程,除了数据库权限本身,还必须显式补发新的解锁指令覆盖其旧的“只读勘察 / 不改文件”上下文;否则前台看起来像可写,实际执行仍可能被旧上下文限制
|
||||
@@ -1094,15 +1402,32 @@
|
||||
|
||||
- `data/boss-state.json`
|
||||
|
||||
状态文件当前带有迁移前置元数据:
|
||||
|
||||
- `schemaVersion`:当前 BossState schema 版本
|
||||
- `migratedAt`:最近一次从旧 schema 迁移到当前 schema 的时间
|
||||
|
||||
读取状态时会先经过 `migrateBossState`,用于从无版本或旧版本 JSON 补齐当前结构,并规范化授权和 Skill 生命周期相关数组。这个机制只为后续正式 DB 迁移提供稳定 schema 边界,不表示数据库化已经完成。
|
||||
|
||||
关键对象:
|
||||
|
||||
- `schemaVersion`
|
||||
- `migratedAt`
|
||||
- `user`
|
||||
- `devices`
|
||||
- `projects`
|
||||
- `verificationCodes`
|
||||
- `verificationDispatches`
|
||||
- `adminCompanies`
|
||||
- `adminNotifications`
|
||||
- `adminRiskTimeline`
|
||||
- `authAccounts`
|
||||
- `authSessions`
|
||||
- `accountDeviceGrants`
|
||||
- `accountProjectGrants`
|
||||
- `accountSkillGrants`
|
||||
- `skillCatalog`
|
||||
- `skillLifecycleRequests`
|
||||
- `aiAccounts`
|
||||
- `aiAccountSwitchHistory`
|
||||
- `userAttachmentStorageConfigs`
|
||||
@@ -1130,8 +1455,8 @@
|
||||
|
||||
不要误以为已经存在:
|
||||
|
||||
- 正式数据库
|
||||
- 正式鉴权中间件
|
||||
- 已直接切换完成的正式数据库
|
||||
- 企业 SSO / IdP
|
||||
- 多家对象存储适配(当前只有服务器文件存储和阿里 OSS)
|
||||
- 完整的附件详情页与富预览器
|
||||
- 完整的多端用户会话系统与刷新令牌体系
|
||||
- 完整的多端会话风控平台(当前已有 restore token 轮换、CSRF 基础防护和 MFA 开关)
|
||||
|
||||
83
docs/architecture/codex_server_progress_card_cn.md
Normal file
83
docs/architecture/codex_server_progress_card_cn.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Codex Server 协议与 Boss 执行进度卡接入记录
|
||||
|
||||
更新时间:`2026-05-08`
|
||||
|
||||
## 1. Codex 最新开放协议结论
|
||||
|
||||
当前可作为 Boss 稳定集成入口的是 Codex CLI MCP server:
|
||||
|
||||
- 启动命令:`codex mcp-server`
|
||||
- Inspector 调试:`npx @modelcontextprotocol/inspector codex mcp-server`
|
||||
- 官方 MCP 工具:
|
||||
- `codex`:启动一个 Codex 会话,入参包含 `prompt / approval-policy / base-instructions / config / cwd / include-plan-tool / model / profile / sandbox`
|
||||
- `codex-reply`:继续一个 Codex 会话,入参包含 `prompt / threadId`,`conversationId` 只是兼容别名
|
||||
- 线程续写应使用 `tools/call` 返回里的 `structuredContent.threadId`
|
||||
- 现代 MCP 客户端主要读取 `structuredContent`;`content` 只作为旧客户端兼容输出
|
||||
|
||||
本机当前检测结果:
|
||||
|
||||
- 本机 `codex --version`:`codex-cli 0.114.0`
|
||||
- npm 最新稳定包:`@openai/codex 0.129.0`
|
||||
- npm alpha:`0.130.0-alpha.5`
|
||||
- 本机 `0.114.0` 已支持 `codex mcp-server --help`,但落后于当前 `0.129.0` 的 app-server / protocol 拆分、ThreadStore、MCP turn metadata、plugin sharing 等新能力
|
||||
|
||||
## 2. Boss 当前采用的接入策略
|
||||
|
||||
短期不直接依赖 Codex Desktop 私有 UI 结构,也不把 Codex CLI 原始 stderr/stdout 泄露给 APP。
|
||||
|
||||
当前实现采用 Boss 自有结构化消息:
|
||||
|
||||
- 新消息类型:`execution_progress`
|
||||
- 服务端字段:`Message.executionProgress`
|
||||
- 触发范围:
|
||||
- 普通单线程对话:用户在 Boss APP 指定线程里发消息
|
||||
- 主 Agent 托管线程:托管消息实际派到目标 Codex 线程时
|
||||
- 群聊确认下发:后续目标线程执行单会复用同一张卡
|
||||
- 生命周期:
|
||||
- 任务入队:创建进度卡
|
||||
- local-agent 认领:更新为 running
|
||||
- local-agent 完成:更新同一张卡为 completed / failed
|
||||
|
||||
APP 展示结构对齐截图:
|
||||
|
||||
- `进度`:步骤列表,显示已完成 / 进行中 / 待处理 / 失败
|
||||
- `分支详情`:变更行、Git 操作、GitHub CLI 可用状态
|
||||
- `生成结果`:从执行结果里提取文件、图片、APK、文档等产物名
|
||||
- `后台智能体`:预留 OMX / Hermes / explorer 等多智能体来源展示
|
||||
|
||||
## 3. 安全边界
|
||||
|
||||
进度卡只允许展示用户可见摘要:
|
||||
|
||||
- 不展示系统提示词
|
||||
- 不展示完整执行 prompt
|
||||
- 不展示设备 token、账号密钥、内部工作目录调度说明
|
||||
- 不展示 Codex CLI 启动 envelope、sandbox、approval、session id、MCP 启动日志
|
||||
- `RemoteRuntimeAdapter` 仍会先拦截只读环境提示和 Codex envelope 泄漏,再进入消息账本
|
||||
|
||||
## 4. 历史引用项目最新状态
|
||||
|
||||
本次按 GitHub 最新元数据核对过的项目:
|
||||
|
||||
| 项目 | 最新状态 | 对 Boss 的可借鉴点 |
|
||||
| --- | --- | --- |
|
||||
| `openai/codex` | `rust-v0.129.0`,2026-05-07 发布;main 在 2026-05-08 仍有提交 | 后续优先补 `codex mcp-server` 长驻适配器;参考 ThreadStore、turn metadata、app-server protocol v3 方向,不再只靠 `codex exec resume` |
|
||||
| `Yeachan-Heo/oh-my-codex` | `v0.16.2`,2026-05-08 发布 | `$ultragoal` 聚合目标、commit-shared wiki / compaction、state/session isolation、Codex native hook setup 值得同步到 Boss 的任务目标与进度卡 |
|
||||
| `ultraworkers/claw-code` | main 最新提交 2026-05-06;原 `instructkr/claw-code` 已指向该仓库;暂无 GitHub release | 继续保留抽象后端,不写死版本;重点观察 skills help routing、push_output_block、Rust harness 更新 |
|
||||
| `NousResearch/hermes-agent` | `v2026.5.7 / v0.13.0`,Tenacity Release | Durable Multi-Agent Kanban、heartbeat / reclaim / zombie detection、goal lock、checkpoints v2 可作为 Boss 主 Agent 长任务可靠性升级参考 |
|
||||
| `iflytek/skillhub` | `v0.2.6`,2026-04-29 发布;main 2026-05-08 仍更新 | Skill 订阅通知、OIDC 登录、S3 IAM、namespace CSV 批量成员导入,适合 Boss 企业 Skill 治理后台后续吸收 |
|
||||
| `openclaw/openclaw` | `v2026.5.7`,2026-05-07 发布;main 2026-05-08 仍更新 | Telegram allowlist、polling watchdog、deliverySucceeded、Codex approval 去重、provider/model callback 修复,可用于 Boss Telegram 网关和远程审批 |
|
||||
| `goldmar/openclaw-code-agent` | `v4.2.3`,2026-05-08 发布 | OpenClaw + Codex coding agent 的 session lifecycle、wake routing、worktree/PR policy,可作为 Boss “聊天控制桌面 Codex 开发”的旁路参考 |
|
||||
|
||||
## 5. 下一步建议
|
||||
|
||||
第一阶段已经落地:
|
||||
|
||||
- Boss 消息账本新增 `execution_progress`
|
||||
- Android 原生聊天页新增结构化进度卡
|
||||
- local-agent 完成回写会补 Git diff、GitHub CLI 状态和产物名
|
||||
|
||||
后续建议按两步继续:
|
||||
|
||||
1. 新增 `CodexMcpBackendAdapter`:让 `codex mcp-server` 成为 `ExecutionBackend` 的可选实现,先 feature flag 默认关闭,保留 `codex exec resume` 作为生产主链。
|
||||
2. 增加任务级 live progress API:`POST /api/v1/master-agent/tasks/[taskId]/progress`,让本地 agent 在执行中也能实时刷新进度卡,而不是只在 claim / complete 两个节点更新。
|
||||
@@ -1,6 +1,6 @@
|
||||
# Boss 当前运行与部署状态
|
||||
|
||||
更新时间:`2026-04-03`
|
||||
更新时间:`2026-04-27`
|
||||
|
||||
## 1. 本地状态
|
||||
|
||||
@@ -20,7 +20,12 @@
|
||||
- 登录接口:`POST http://127.0.0.1:3000/api/auth/login`
|
||||
- 登录态接口:`GET http://127.0.0.1:3000/api/auth/session`
|
||||
- 登录恢复接口:`POST http://127.0.0.1:3000/api/auth/restore`
|
||||
- 登录会话治理接口:`GET/POST http://127.0.0.1:3000/api/v1/auth/sessions`
|
||||
- 登出接口:`POST http://127.0.0.1:3000/api/auth/logout`
|
||||
- 管理后台总览接口:`GET http://127.0.0.1:3000/api/v1/admin/overview`
|
||||
- 独立企业后台 BFF:`GET http://127.0.0.1:3000/api/v1/admin/backoffice`
|
||||
- 管理后台授权接口:`GET/POST http://127.0.0.1:3000/api/v1/admin/access`
|
||||
- 管理后台风险 SLA 扫描接口:`POST http://127.0.0.1:3000/api/v1/admin/risks/scan`
|
||||
- OTA 包下载接口:`GET http://127.0.0.1:3000/api/v1/user/ota/package`
|
||||
- 本地 agent 健康检查:`http://127.0.0.1:4317/health`。当前这台开发机的 `launchd` 常驻已经恢复,`/health` 可在数十毫秒内返回,并且在手动 heartbeat 执行期间也不会再被 Codex 线程扫描卡死
|
||||
- 本地 Skill 扫描接口:`http://127.0.0.1:4317/api/v1/skills`
|
||||
@@ -28,7 +33,9 @@
|
||||
- `launchd` 已安装:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist`
|
||||
- 当前执行底座抽象层已落地在 `src/lib/execution/`,并已补齐 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现
|
||||
- 当前生产主链仍然沿用 `local-agent -> codex exec resume -> /api/v1/master-agent/tasks/[taskId]/complete`,执行底座重构以“先抽象、不改行为”为准
|
||||
- 当前 Codex server 调研结论已记录在 `docs/architecture/codex_server_progress_card_cn.md`:官方稳定入口是 `codex mcp-server` 的 MCP 协议,本机 `codex-cli 0.114.0` 已支持该命令但落后于 npm 最新 `0.129.0`;Boss 当前先保留 `codex exec resume` 主链,并新增 `execution_progress` 结构化进度卡作为 APP 可见执行态
|
||||
- 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有显式配置 `BOSS_CLAW_*` 且可用性探测通过时,`master-agent` 当前对话中才会出现并允许选择 `claw-runtime`
|
||||
- 当前已新增最小 `Telegram Gateway`:Boss 当前可直接暴露 Telegram webhook,把 Telegram 私聊或受控群聊文本桥接进 `master-agent` 或按群 / Topic 路由到指定 Boss 项目,并在主 Agent 异步任务完成后自动回推 Telegram;配置入口已接到 Web `/me/telegram` 和原生 Android `我的 > Telegram 接入`
|
||||
- 如果历史上已经保存过 `backendOverride=claw-runtime`,但当前 `Claw Runtime` 不可用,运行时会自动回退到默认后端,并在 Web/Android 前台给出明确原因
|
||||
- 当前仓库已自带 `scripts/claw-runtime-smoke.mjs` 作为本地 smoke runtime;在没有真实 `claw-code` 可执行文件时,可先用 `BOSS_CLAW_COMMAND=node` 与 `BOSS_CLAW_ARGS=scripts/claw-runtime-smoke.mjs` 验证整条链
|
||||
- 当前 `oh-my-codex` 已以最小 `OmxTeamBackendAdapter` 形式接入执行底座,但默认关闭;当前已经接到 Web 群聊详情页 / 原生群资料页的编排后端选择卡,可在 `Boss Native` 与 `OMX Team` 间切换,OMX 不可用时会自动回退到默认后端并明确提示原因
|
||||
@@ -40,6 +47,10 @@
|
||||
- 当前 `conversation_reply / dispatch_execution` 的线程执行结果会先经过 `RemoteRuntimeAdapter` 标准化;如果线程返回的是固定模式的内部环境提示(如“当前会话环境只读 / cwd …”),会直接转成失败,不再把原文写回会话消息
|
||||
- 当前设备模型已支持同一台 Mac / Windows 同时接入 Codex `GUI + CLI` 双能力;Web / Android 设备详情页都会展示两种能力状态,并允许切换默认执行模式
|
||||
- 当前同项目 `GUI / CLI` 并行写入风险已接入项目/文件夹级冲突控制:默认阻断,用户只能对当前异常项目/文件夹选择 `禁止 / 允许本次 / 永久放行`
|
||||
- 当前已补上“Boss 统一电脑控制中枢”第二批本地 runtime:主 Agent 已能把聊天请求识别为 `discussion_only / project_development / browser_control / desktop_control`;`browser_control / desktop_control` 已能作为正式 `MasterAgentTask` 入队,并返回 `executionMode / riskLevel / requiresConfirmation` 元数据给前台;本机 `local-agent` 现已把 `browser-control-task-runner.mjs / computer-use-task-runner.mjs` 升级成外部 runtime 桥,并默认带上 `scripts/browser-control-smoke.mjs / scripts/computer-use-smoke.mjs` 作为 smoke 执行器,后续只需要替换配置就能接真实 browser automation 与 computer use runtime
|
||||
- 当前这两条控制链的 `control_summary` 已能回写结构化目标信息:browser 会保留 `targetUrl`,desktop 会保留 `targetApp`,Android 聊天窗口会在控制结果卡片里直接显示执行目标
|
||||
- 当前 `scripts/browser-control-smoke.mjs` 已提升到“最小真实浏览器探测”:如果目标 URL 可访问,会抓取页面 `<title>` 并回写结果;`scripts/computer-use-smoke.mjs` 也已升级为 macOS 默认 `osascript` 激活应用、引号文本输入、按需回车发送、`open -a` 兜底和 artifact 回写,因此 Boss App 里的 browser/desktop 控制消息都已开始返回真实执行结果而不是固定 smoke 文案
|
||||
- 当前本机 `local-agent` 默认 heartbeat 已把 `browserAutomation / computerUse` 两项能力视为“已接通起步版 runtime”,因此 Boss 前台设备能力会直接显示这两条链路在线;如果后续需要临时关闭,可在 `local-agent/config.cloud.json` 里单独下掉对应 connected 标记或 runtime 命令
|
||||
|
||||
本地已知运行方式:
|
||||
|
||||
@@ -90,19 +101,31 @@ cd /Users/kris/code/boss
|
||||
- `npm start`、服务器 `systemd` 与远端 `npm run build` 当前都显式设置了 `BOSS_RUNTIME_ROOT`,避免 `process.cwd()` 在 standalone / 服务器构建阶段误扫描整个仓库
|
||||
- `next.config.ts` 当前已把 `deployment / docs / design / local-agent / prompts / scripts / android` 等目录排除出 standalone tracing,服务器端构建不会再把非运行时资产卷进 `.next/standalone`
|
||||
- `data/boss-state.json` 的写入已经改成串行事务队列、原子替换和 `.bak` 备份恢复,`heartbeat` 与 APP 日志并发写入已复核通过
|
||||
- `BossState` 当前新增 `schemaVersion / migratedAt` 元数据和 `migrateBossState` 迁移入口;读取旧的无版本状态时会补齐当前 schema,并规范化 `accountDeviceGrants / accountProjectGrants / accountSkillGrants / skillLifecycleRequests / permissionAuditLogs`
|
||||
- 这只是正式数据库迁移前置层,当前生产读写仍然是 `data/boss-state.json`,尚未完成 PostgreSQL / Redis / 其他 DB 落地
|
||||
- 当前登录成功后会写入 `boss_session` Cookie;`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 路由都要求有效会话
|
||||
- 当前 `boss_session` 默认保持 30 天,`Set-Cookie` 已验证为 `Max-Age=2592000`
|
||||
- 原生 Android 客户端当前会把登录返回的 `boss_session / restore token / account` 落到 `SharedPreferences`,并在 APP 启动时通过 `/api/auth/restore` 自动补回会话;已本地验证“登录 -> 取 restore token -> restore 接口恢复”链路
|
||||
- 当前多用户 / RBAC 第一阶段已落地:状态文件新增 `accountDeviceGrants / accountProjectGrants / accountSkillGrants / skillCatalog / skillLifecycleRequests / permissionAuditLogs`,非最高管理员访问 `devices / conversations / projects / messages / device skills / state` 时都会先走 `src/lib/boss-permissions.ts` 和 session-aware projections 过滤
|
||||
- 当前最高管理员授权管理接口已落地:`GET/POST /api/v1/admin/access` 可以查看脱敏账号、公司、设备、项目、Skill、授权、权限模板和审计日志,并支持公司管理、公司启用/停用、账号/设备归属、批量导入预览、批量导入子账号、重置子账号密码、离职回收、创建/更新子账号、启用/停用子账号、授予设备/项目/Skill 权限、套用权限模板、撤销授权;停用公司会禁用该租户普通子账号并撤销会话,停用 / 回收 / 重置账号也会撤销该账号当前活跃会话,普通账号访问返回 `403`
|
||||
- 当前 To B 管理后台第一版可操作面已经落地:Web `/admin` 仅 `highest_admin` 可进,包含 `总览 / 账号与授权 / Skill 治理` 三个页签;总览使用 `/api/v1/admin/overview`,账号与授权复用 `/api/v1/admin/access`,Skill 治理复用 `/api/v1/admin/skills/requests`;公司聚合优先使用显式 `adminCompanies`,未绑定时才回退账号域名。
|
||||
- 当前企业级后台独立化第一批已开始落地:新增 `apps/boss-admin-web` 作为 Vue + Vite + Ant Design Vue 独立 PC 后台骨架,新增 `/api/v1/admin/backoffice` 作为 YuDao/Vben 风格 BFF;现有 `/admin` 暂保留为主站 fallback,`admin.boss.hyzq.net` 后续再切到独立后台静态产物。
|
||||
- 当前后台风险处理接口已落地:`POST /api/v1/admin/risks/actions` 仅 `highest_admin` 可用,支持对 `ops_fault` 指派负责人、设置 SLA、确认、关闭、创建或复用修复工单,对 `thread_context_alert` 指派负责人、设置 SLA、确认和关闭;`POST /api/v1/admin/risks/scan` 会扫描超时 SLA 并幂等写入 `adminNotifications`,管理后台总览会展示开放风险通知;不支持的风险类型会明确返回 `RISK_ACTION_UNSUPPORTED`。
|
||||
- 当前权限审计查询第一版已落地:`GET /api/v1/audits/permission-logs` 仅 `highest_admin` 可读,支持按 `action / actorAccount / targetAccount / deviceId / projectId / skillId / cursor / limit` 查询 `permissionAuditLogs`,并实时返回短时间大量授权、Skill lifecycle 失败、过期授权仍存在、admin route 拒绝访问等 deterministic 风险摘要;后台 mutation 审计已支持 `ipAddress / userAgent / requestId / beforeJson / afterJson`,其中重置密码会记录安全化前后快照;Web `/me/ops/audit` 会向最高管理员展示最近权限审计和风险摘要
|
||||
- 当前 Skill 远程治理第一版可执行链路已落地:`GET/POST /api/v1/admin/skills/requests` 仅允许 `highest_admin` 创建和查看 `install / update / uninstall / rollback / version_lock` 请求;设备端通过 `/api/v1/devices/[deviceId]/skill-requests/claim` 和 `/complete` 认领回写,local-agent 默认每 5 秒执行本机 Skill 安装 / 更新 / 卸载 / 回滚 / 版本锁,并同步最新 Skill 清单。远程安装或带 `sourceUrl` 的更新必须命中本机 `skillLifecycleAllowedSources` 或 `skillLifecycleTrustedSources`;配置为空时不允许远程新来源安装,但保留既有本地 Skill 的更新 / 回滚 / 卸载 / 版本锁。携带 `checksum / expectedChecksum` 的请求会校验 `manifest.json` 或 `SKILL.md` 的 sha256,更新 / 卸载 / 回滚前会写入 `skillsDir/.boss-skill-backups` 并在失败时尽量恢复
|
||||
- 当前授权管理前台已接入:Web `/me/access` 与原生 Android `我的 > 用户与权限` 仅最高管理员可见,可创建子账号、授权设备/项目/Skill、套用 `只读观察员 / 项目开发者 / 设备操作者` 模板、查看同名 Skill 跨设备聚合并撤销单条授权
|
||||
- 当前权限继承规则:显式 `device.view` 可带来绑定该设备项目的只读可见性,但不会自动获得 `thread.chat / master_agent.ask / master_agent.takeover / computer.control / skill.use`;这些动作必须来自项目或 Skill 显式授权
|
||||
- 当前主 Agent 执行链已经使用授权快照:`boss-master-agent.ts` 会先按请求账号裁出可见设备、项目、线程状态、进展事件和 Skill,再生成执行提示词;排入 `MasterAgentTask` 时会记录本次授权范围,供后续审计和执行器收敛
|
||||
- 登录成功后的客户端跳转当前已做稳态兜底:会先确认 `/api/auth/session` 已可读,再 `replace` 到 `/conversations`,并补一次 `window.location.replace` 防止真机 WebView 偶发卡在登录提示页
|
||||
- `POST /api/auth/send-code` 当前已增加 60 秒冷却和 15 分钟窗口限流
|
||||
- `POST /api/auth/send-code` 当前还会先按用途校验账号状态:登录 / 忘记密码必须是已存在账号,注册必须是未注册账号
|
||||
- 当前账号连续登录失败 5 次后会锁定 10 分钟
|
||||
- 当前登录页已临时切到免验证模式;点击“登录”会直接创建最高管理员会话,不再校验账号密码或验证码
|
||||
- 当前登录页默认要求账号密码或验证码校验;临时开发兜底只有显式设置 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 时才会开启
|
||||
- 新注册和重置密码当前已切到 `scrypt` 哈希;历史 `sha256` 密码会在下一次密码登录时自动迁移
|
||||
- `launchd` 会保持 `com.hyzq.boss.local-agent` 常驻,所以本地 agent 被手动结束后会自动重启
|
||||
- `launchd` 默认加载 `local-agent/config.cloud.json`,控制面指向 `https://boss.hyzq.net`
|
||||
- `local-agent/config.example.json` 仍保留给本地 `127.0.0.1:3000` 回环开发
|
||||
- 本地 `launchd` 当前已把 `mac-studio` 作为 `17600003315` 的绑定 Codex 节点上报
|
||||
- 本地 `launchd` 当前已把 `mac-studio` 作为 `krisolo` 的绑定 Codex 节点上报
|
||||
- 本地 agent 当前会递归扫描 `~/.codex/skills`,并把本机 Skill 同步到云端设备维度
|
||||
- 根布局当前会挂载 APP 日志桥,路由切换、运行时错误、消息发送和 OTA 操作会通过 `/api/v1/app-logs` 实时同步到服务器;日志绑定已改成按当前登录会话解析设备
|
||||
- 根布局当前还会挂载原生运行时桥:维护 APP 内导航历史、拦截 Android 返回键、防止根页直接退回桌面,并在 OTA / 同签名覆盖安装后自动尝试恢复登录态
|
||||
@@ -123,6 +146,9 @@ cd /Users/kris/code/boss
|
||||
- 当前原生聊天页已把待审批推荐前移到主消息流:`ProjectDetailActivity` 会直接显示 `确认下发 / 拒绝` 操作,且刷新后仍能恢复最近一条待确认推荐
|
||||
- 当前 `approval_required` 群聊在已经存在一条 `pending_user_confirmation` 推荐时,会拒绝继续创建新的推荐并返回 `409`,前台会提示用户先确认或拒绝当前推荐
|
||||
- 当前普通单线程聊天也已补上真实执行链:`POST /api/v1/projects/[projectId]/messages` 不再只写用户消息,而是会追加 `conversation_reply` 任务;绑定设备上的 `local-agent` 认领后会继续恢复到真实 Codex 线程,再把线程原始回复回写到该聊天窗口
|
||||
- 当前 Boss APP 到 Codex 桌面版的记录同步以数据层镜像为主:普通单线程消息和托管模式消息都会把 APP 用户原文作为干净 `user_message` 写入目标 Codex 线程 rollout,并同步刷新 Codex thread 的 `updated_at / updated_at_ms`;托管链路不会把主 Agent 内部调度 prompt、系统提示词或权限字段镜像成桌面可见聊天记录
|
||||
- 当前 `local-agent` 已补 `Codex Desktop Refresh Bridge`:rollout 镜像完成后会优先 POST 到本机常驻 `http://127.0.0.1:4318/api/v1/codex-desktop/refresh`,由 `scripts/codex-desktop-refresh-bridge-daemon.mjs` 给 Codex 桌面版发安全刷新提示;daemon 不可用时回退到 `scripts/codex-desktop-refresh-hint.mjs` 命令式刷新。默认 `deeplink-reload` 模式会打开 `codex://threads/{threadId}` 目标线程深链,并在短延迟后发送一次应用刷新快捷键;它仍不模拟聊天输入、不点击、不发送。刷新桥默认会对短暂失败重试 2 次、每次间隔 120ms,并把 deep link 与尝试次数作为结果返回;失败只记 `local_agent.codex_desktop_refresh_failed`,不会回滚已经写入的线程消息。当前 bridge 还暴露 `GET /api/v1/codex-desktop/events` SSE 和 `GET /api/v1/codex-desktop/events/recent`,每次刷新 hint 都会广播不含消息正文、不含内部 prompt 的 `codex_desktop_refresh` 事件;`scripts/codex-desktop-event-consumer.mjs` 是后续 Codex Desktop 插件/IPC 的订阅样例,可用 `BOSS_CODEX_DESKTOP_EVENTS_ONCE=true` 做一次性 smoke
|
||||
- 当前 bridge 还暴露 `GET /api/v1/codex-desktop/capabilities`,内部复用 `scripts/codex-desktop-integration-probe.mjs` 探测当前 Codex Desktop:读取 `Info.plist`、确认 `codex` URL scheme、扫描 `app.asar` 中是否存在 `codex://threads/`,并明确返回 `packagePatch.supported=false`,避免后续误走修改签名 app 包体的路线
|
||||
- 当前 Web 群聊详情页也已补上待确认推荐的刷新恢复:服务端会在页面渲染时读取最近一条 `pending_user_confirmation` 的 dispatch plan,聊天输入区会继续显示“等待你确认主 Agent 推荐”,不再因刷新丢失确认入口
|
||||
- 当前 `AI 账号` 页面已分成三条显式接入链:`登录 OpenAI 平台账号(API Key)`、`接入阿里百炼备用账号` 和 `绑定 Master Codex Node`;OpenAI API 登录成功后会立即切成当前主控,阿里百炼账号会作为备用链路保存
|
||||
- 当前 `登录 OpenAI 平台账号` 已升级成浏览器辅助登录流:原生 Android 会先进入 `OpenAiOnboardingActivity`,自动打开 `OpenAI Platform` 登录页;用户登录后可直接跳到 `API Keys` 页面,回 APP 粘贴 key 完成接入
|
||||
@@ -138,8 +164,8 @@ cd /Users/kris/code/boss
|
||||
- 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口
|
||||
- Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链
|
||||
- 当前设备导入前台的状态表达已经统一为:`等待候选线程 / 等待勾选 / 建议生成中 / 建议已生成 / 已导入`,并会回显最终导入的线程名
|
||||
- 当前已导入设备也支持自动同步项目理解:绑定设备 heartbeat 发现活跃线程有新活动、或线程刚回写了新的执行结果时,系统会直接为这台设备上已导入的线程排隐藏的 `conversation_reply` 主 Agent 任务,回写最新的项目目标、当前进度、技术架构和下一步建议
|
||||
- 当前自动同步链路已经拆成两层:heartbeat / thread reply 默认只追加轻量 `threadProgressEvent`;只有在线程首次理解、文档信息过薄、距离上次全量刷新太久或主 Agent 真的要接手时,才补排隐藏的全量理解任务并更新 `ThreadStatusDocument`
|
||||
- 当前已导入设备的项目理解同步已经收窄到“显式接管 / 用户主动要求同步”边界:绑定设备 heartbeat 或线程回写默认只追加轻量 `threadProgressEvent`,不会在未接管状态下主动向 Codex 线程发起隐藏对话
|
||||
- 当前全量理解链路只在单线程接管有效、全局接管有效,或用户明确要求“同步/核对项目目标和版本记录”时排 `conversation_reply` 任务;关闭接管会同步清理仍在 queued/running 的项目理解同步任务,避免取消接管后继续主动打扰线程
|
||||
- 当前群资料页已补上“修复群成员”入口:当群里存在失效线程引用、`master-agent` 这类不可下发成员,或真实线程成员少于 2 个时,前台会明确提示并允许重新选择真实线程成员
|
||||
- 当前原生聊天页也已前移“修复群成员”入口:脏群会在消息流上方直接显示 `去修复` 按钮,并跳转到群资料页完成成员替换
|
||||
- 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `deviceImportDraft`,不再绕过勾选/审核阶段直接自动导入聊天窗口
|
||||
@@ -148,7 +174,8 @@ cd /Users/kris/code/boss
|
||||
- 当前设备导入 `review` 已补 owner/admin 鉴权,并已切成真实异步审核:`review` 会先排队 `device_import_resolution` master task,前台进入“主 Agent 审核中”并自动刷新;导入草稿在 `apply` 后再次 heartbeat 也不会从 `applied` 回退成 `resolved`
|
||||
- 原生会话页当前的刷新失败策略已改成按当前 tab 独立判错:`会话` 不会再因为 `设备 / OTA / 设置` 的旁路请求失败而整体提示“刷新失败”
|
||||
- 会话页、设备页、技能页和项目详情页当前都通过 `/api/v1/events` 的 SSE 自动刷新
|
||||
- 我的页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于` 六个一级入口;`AI 账号` 支持查看 `主 GPT / 备用 GPT / API 容灾`,并明确主链路优先走已经在绑定电脑上登录 `ChatGPT Plus / Codex` 的 `Master Codex Node`
|
||||
- 我的页当前保留角色感知入口:`member` 只显示 `账号与安全 / 设置 / 技能 / 关于`,其中 Skill 列表继续由服务端按授权过滤;`admin / highest_admin` 额外显示 `运维与修复 / AI 账号 / 附件与存储 / Telegram 接入`;`用户与权限` 只给 `highest_admin`
|
||||
- `AI 账号` 支持查看 `主 GPT / 备用 GPT / API 容灾`,并明确主链路优先走已经在绑定电脑上登录 `ChatGPT Plus / Codex` 的 `Master Codex Node`
|
||||
- `AI 账号` 页当前已补上显式 `登录指引`:手机端不会直接弹出 ChatGPT OAuth;`主 GPT` 需要先在绑定电脑上的 Codex / ChatGPT Plus 会话里登录,再回手机端点“测试连接 / 校验连接”
|
||||
- `AI 账号` 页当前已升级成双入口:首页会显式展示 `登录 OpenAI 平台账号` 和 `绑定电脑上的 Codex 节点`
|
||||
- `登录 OpenAI 平台账号` 当前通过填写 `OpenAI API Key` 完成;校验成功后会立即设为当前主控
|
||||
@@ -157,6 +184,8 @@ cd /Users/kris/code/boss
|
||||
- 因此 `POST /api/v1/accounts/onboard/openai-api` 在公网环境下已经能返回明确中文网络错误,但在服务器出网恢复前,还不能完成真实 OpenAI 平台账号探针与调用
|
||||
- `POST /api/v1/accounts/[accountId]/validate` 当前对 `master_codex_node` 不再只看 `nodeId`,还会同时校验绑定设备是否在线;设备离线时返回 `degraded` 和清晰的人类可读提示
|
||||
- 主 Agent 当前真实对话链路已验证通过:`Boss Web -> /api/v1/projects/master-agent/messages -> master-agent task queue -> local-agent -> codex exec -> /complete -> 项目消息账本`
|
||||
- Telegram 当前真实对话链路已接通:`Telegram Bot webhook -> /api/v1/integrations/telegram/webhook -> master-agent -> /api/v1/master-agent/tasks/[taskId]/complete -> Telegram Bot sendMessage`
|
||||
- Telegram 配置保存当前也会自动做 webhook 同步:webhook 模式自动 `setWebhook`,polling/关闭时自动 `deleteWebhook`
|
||||
- 主 Agent 单聊当前已改成“快速入队 + 异步回流”:`POST /api/v1/projects/master-agent/messages` 会先返回 `masterReplyState + task`,真实回复随后再回写消息账本
|
||||
- 当前对话级 `agentControls` 已经生效:`master-agent` 会话支持 `modelOverride / reasoningEffortOverride`,并会优先作用到实际 OpenAI 回复和 Master Codex Node 执行 prompt
|
||||
- 当前对话级 `agentControls` 也已支持 `backendOverride`:`master-agent` 会话可在 `Claw Runtime` 可用时显式选择 `claw-runtime`,由 `ExecutionBackendSelector` 在当前对话里优先尝试对应后端;不可用时保存接口会直接拒绝,并返回人类可读原因
|
||||
@@ -170,19 +199,19 @@ cd /Users/kris/code/boss
|
||||
- `npm run aab:release` 当前会先准备本机 release keystore,再构建 signed release AAB 并发布到 `public/downloads/boss-android-latest.aab`
|
||||
- AAB 发布脚本当前还会额外保留带版本号的归档包:`public/downloads/boss-android-v{versionName}-{flavor}.aab`
|
||||
- AAB 归档元数据会写入 `public/downloads/boss-android-latest-aab.json`
|
||||
- 当前默认管理员账号:`17600003315`
|
||||
- 当前默认测试密码:`boss123456`
|
||||
- 登录页当前是临时免验证入口;Web 登录页和原生 Android 登录页都会直接创建会话
|
||||
- 当前默认管理员账号:`krisolo`
|
||||
- 当前默认测试密码由线上初始化配置管理,文档不再明文记录
|
||||
- Web 登录页和原生 Android 登录页默认都必须通过账号密码或验证码校验后才会创建会话
|
||||
- 当前已生成 Android debug APK:`android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
- 当前已生成 Android signed release APK:`android/app/build/outputs/apk/release/app-release.apk`
|
||||
- 当前 release 构建还会额外生成带版本号的 APK:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
|
||||
- 当前最新 release 构建版本:`2.5.11`(`versionCode=24`)
|
||||
- 当前 release keystore 位于本机 `android/keystores/boss-release.keystore`,签名参数位于 `android/signing/release-signing.properties`
|
||||
- 真机开发约束:除非用户明确要求切换设备,后续 Android 开发、ADB 安装、交互回归与问题复现统一只使用 `PLB110`;如果 `PLB110` 当前不在线,应先恢复这台设备连接,不自动切到其他手机
|
||||
- 真机开发约束:用户已明确切换到当前连接的 OPPO `PHZ110`(ADB serial `U84XJRIB7D65ZH45`);除非用户再次要求切换设备,后续 Android 开发、ADB 安装、交互回归与问题复现统一使用这台 OPPO,不再回退到原 `PLB110`
|
||||
- Android 真机无线调试当前可恢复使用,但系统层面没有“永久保持无线调试开启”的官方稳定开关;重启、切网、ADB server 重启或重新切换 USB 调试后,都可能自动失效
|
||||
- 如果要尽量稳定,当前推荐做法是:同一局域网下先走 USB 启用,再执行 `adb tcpip 5555` 与 `adb connect <phone-ip>:5555`;同时固定同一 SSID、避免切热点/VPN、开启“保持唤醒”,并保留 USB 作为长时间调试兜底
|
||||
- `2.0.1` 已在本机连接的华为真机上复核通过,修复了 `Theme.SplashScreen` 导致的 `AppCompatActivity` 启动闪退
|
||||
- `2.1.0` 已把 Web 一级页和主要二级页全部补成原生活动页:`MainActivity / ProjectDetailActivity / ProjectGoalsActivity / ProjectVersionsActivity / ProjectForwardActivity / ThreadDetailActivity / DeviceDetailActivity / DeviceEnrollmentActivity / SkillInventoryActivity / SecurityActivity / SettingsActivity / AiAccountsActivity / OpsCenterActivity / AboutActivity`
|
||||
- `2.1.0` 已把 Web 一级页和主要二级页全部补成原生活动页:`MainActivity / ProjectDetailActivity / ProjectGoalsActivity / ProjectVersionsActivity / ProjectForwardActivity / ThreadDetailActivity / DeviceDetailActivity / DeviceEnrollmentActivity / SkillInventoryActivity / SecurityActivity / AccessManagementActivity / SettingsActivity / AiAccountsActivity / OpsCenterActivity / AboutActivity`
|
||||
- `2.1.0` 已完成签名包覆盖安装到本机连接的华为真机,并确认 `com.hyzq.boss` 可以成功拉起进程
|
||||
- `2.1.1` 已补上原生 OTA 下载安装引导、`REQUEST_INSTALL_PACKAGES` 权限声明,以及根页默认入口/返回逻辑收口
|
||||
- `2.2.0` 已把原生 UI 回退到微信式交互:会话首页改为简单聊天列表,项目详情页改为聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口,设备页和我的页根面改为简单列表
|
||||
@@ -201,16 +230,24 @@ cd /Users/kris/code/boss
|
||||
- `2.5.5` 已给 `approval_required` 群聊补齐“确认 / 拒绝”两条审批动作;拒绝后会把群审批状态写成 `rejected`,并追加系统提示,不再继续下发到线程
|
||||
- `2.5.11` 对应这一轮的主链收口:Android 会话首页改为直接读取 `/api/v1/conversations`,会把这台 Mac 上已导入的 Codex 线程对话直接平铺出来;`master-agent` 对“操作真实线程”的请求会先生成推荐下发方案,确认后再把任务派到真实线程执行;线程无绑定或设备离线时,确认接口会给清晰失败原因,避免假成功状态
|
||||
- 当前附件分析任务已带受控 `task token` 下载链接和文本摘录:本地开发环境会跟随请求 origin 生成链接,生产环境默认走 `https://boss.hyzq.net`
|
||||
- `2.5.11` 当前补齐了消息删除闭环:`DELETE /api/v1/projects/[projectId]/messages?messageId=...` 会删除账本消息、刷新会话预览并推送实时事件;Android 长按消息的“删除”已接入该接口
|
||||
- `2.5.11` 当前补齐了原生 `我的 > 附件与存储` 入口:Android 可直接查看当前存储方式,切换服务器文件存储 / 阿里 OSS,并支持保存或测试并保存
|
||||
- `2.5.11` 当前后台通知已扩展到所有会话里的主 Agent 回复:只要 APP 不在前台,线程会话内的主 Agent 接管回复也会触发 Android 系统通知
|
||||
- `2.5.x` 当前已补上会话首页独立建群入口:可以不从单线程聊天内部出发,直接在会话首页右上角 `+` 建立新群聊;同时已把多个原生自定义 top bar 页面统一纳入状态栏安全区处理
|
||||
- 当前 `local-agent` 已能回写带 `dispatchExecutionId / targetProjectId / targetThreadId / rawThreadReply` 的任务完成载荷,群聊分发执行结果不再只停留在主 Agent 队列
|
||||
- 当前 `local-agent` 对 `conversation_reply` 任务会优先使用 `codex exec resume <targetCodexThreadRef>`,只有缺失真实线程引用时才退回 `--ephemeral`
|
||||
- 当前已绑定真实 `codexThreadRef` 的普通单线程聊天,会在 `local-agent` 执行 `codex exec resume` 前,先把 Boss 用户消息镜像写入对应 Codex Desktop rollout;这样 APP 发起的消息也能进入桌面版同一线程历史,并按 `sourceMessageId` 去重。rollout 定位优先使用 `state_5.sqlite`,状态库不可用或索引缺失时回退扫描 `~/.codex/sessions`;写入后会尽量刷新 `threads.updated_at / updated_at_ms / has_user_event`,再通过 `codex://threads/{threadId}` 深链提示桌面版打开目标线程
|
||||
- 当前 `local-agent` 对 `dispatch_execution` 任务会按 `orchestrationBackendId` 分流:默认走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行并回写 `rawThreadReply / replyBody`
|
||||
- 当前 `local-agent` 会在 Codex 任务完成时回传 `executionProgress`:服务端把同一任务的进度卡从 queued / running 更新到 completed / failed,Android 原生聊天页会显示“进度 / 分支详情 / 生成结果 / 后台智能体”,其中 Git diff、GitHub CLI 可用性和产物名由本地 agent 补齐
|
||||
- 当前 `local-agent` 对 `browser_control / desktop_control` 已从占位骨架升级成外部 runtime 桥:当本机配置了 `browserControlEnabled + browserControlCommand` 或 `computerUseEnabled + computerUseCommand` 时,会把标准化 JSON 请求透传给外部进程,并解析单行 JSON 结果;未启用时会 fail closed,返回明确的 runtime disabled 错误,不再假装执行成功
|
||||
- 当前历史脏群如果不再包含真实线程成员,群聊消息不会再表现成“无响应”;服务端会在群内追加明确 `system_notice`,提示先重新添加线程成员
|
||||
- 当前设备导入决议已经升级成真正通过 `local-agent -> codex exec -> /complete` 回写的主 Agent 决议链;Web 和 Android 前台都会在 `pending_resolution` 阶段显示审核任务状态,并在任务完成后自动刷新出正式导入建议
|
||||
- 当前 `local-agent` 已改成先启动本地 `4317` 健康监听,再异步跑首次 heartbeat 和 task poll,避免控制面短时阻塞时本地健康探针不可用
|
||||
- 当前 heartbeat 上报 `browserAutomation / computerUse` 能力时,不再只看静态 `browserAutomationConnected / computerUseConnected` 布尔值;如果本机已经配置可执行的 browser/computer runtime,也会自动把对应能力标记成 connected
|
||||
- Codex 项目/线程扫描当前已搬到 worker 线程执行,避免 `.codex/logs_1.sqlite` 和 `state_5.sqlite` 的同步扫描阻塞主线程健康接口
|
||||
- 当前 `local-agent` 的任务完成回写已通过 `RemoteRuntimeAdapter` 标准化,`conversation_reply / dispatch_execution` 的完成载荷会先做统一归一化,再进入主 Agent 完成路由
|
||||
- 原生 Android 当前对 `master-agent` 聊天不再依赖长时间同步等待;发送后会先显示“主 Agent 思考中”,右上角改成微信式 `...` 菜单,菜单项包含 `模型 / 推理强度 / 会话信息 / 刷新`
|
||||
- 原生 Android 当前已新增 `TelegramIntegrationActivity`:可从 `我的 > Telegram 接入` 查看当前 Bot 状态、配置 Bot Token / Webhook Secret / Webhook URL、私聊白名单、群聊白名单、群聊触发策略和群 / Topic 到 Boss 项目的路由;群聊可配置为只接受 `@Bot` 或直接回复当前 Bot 的消息,并可直接测试连接或保存配置
|
||||
|
||||
## 2. 服务器状态
|
||||
|
||||
@@ -237,7 +274,8 @@ cd /Users/kris/code/boss
|
||||
- `boss-web` 当前通过 `npm start` 启动
|
||||
- 实际监听端口为 `3000`
|
||||
- `boss-web.service` 显式设置了 `BOSS_STATE_FILE=/opt/boss/data/boss-state.json`
|
||||
- `Caddy` 反代 `127.0.0.1:3000`
|
||||
- `Caddy` 反代 `127.0.0.1:3000`;`boss.hyzq.net` 服务客户 Web / App API,`admin.boss.hyzq.net` 作为平台总后台独立 PC 入口并把根路径跳转到 `/admin`
|
||||
- 服务器上存在 `gptpluscontrol-boss-caddy-reconcile.timer`,会周期性用 `/home/ubuntu/build/gptpluscontrol/deploy/server/caddy.boss_hyzq_net.gptpluscontrol.conf` 重写 `/etc/caddy/Caddyfile` 和 `/opt/boss/deployment/Caddyfile`;以后改 Caddy 入口必须同步更新这份 canonical,否则会重新生成重复站点块并导致 Caddy reload 失败
|
||||
- `Postfix` 监听 `25 / 465 / 587`
|
||||
- `Dovecot` 监听 `993`
|
||||
- 当前部署脚本在远端重启服务后会自动执行一遍本机 health check
|
||||
@@ -257,10 +295,12 @@ cd /Users/kris/code/boss
|
||||
- 服务器本机 `dig +short boss.hyzq.net` 返回 `106.53.170.158`
|
||||
- 服务器本机访问 `http://boss.hyzq.net` 会被 `308` 跳转到 `https://boss.hyzq.net`
|
||||
- 服务器本机执行 `curl --resolve boss.hyzq.net:443:127.0.0.1 https://boss.hyzq.net -I` 返回 `307` 并跳转到 `/auth/login`
|
||||
- 当前 `admin.boss.hyzq.net` 用于平台总后台,应用根路由会在该 host 下把已登录用户送到 `/admin`,未登录用户送到 `/auth/login`
|
||||
|
||||
同时也确认了这些事实:
|
||||
|
||||
- 当前本机网络 `dig +short boss.hyzq.net` 仍返回 `198.18.1.188`
|
||||
- 当前本机网络 `dig +short admin.boss.hyzq.net` 暂无 A 记录;需要在 DNSPod 增加 `admin -> 106.53.170.158`
|
||||
- 当前本机网络 `curl -I http://boss.hyzq.net` 返回 `308`
|
||||
- 当前本机网络 `curl -I https://boss.hyzq.net` 返回 `HTTP/2 307`,并跳转到 `/auth/login`
|
||||
- 当前本机网络 `curl https://boss.hyzq.net/api/health` 返回 `{"ok":true,"service":"boss-web",...}`
|
||||
@@ -283,13 +323,13 @@ cd /Users/kris/code/boss
|
||||
|
||||
## 4. 当前未完成或仅为 MVP 的部分
|
||||
|
||||
- 当前服务器默认仍是 `fixed`,验证码为 `000000`
|
||||
- 当前服务器默认仍是 `fixed`,但验证码登录必须先通过 `send-code` 生成账本记录;不能只靠固定码直接登录
|
||||
- 当前虽然已经补齐 OTA 版本中心、检查更新、执行升级和 APK 包下载链路,但仍是文件型状态驱动的 MVP,不是原生增量更新基础设施
|
||||
- 当前“OTA / 重装后不掉登录”覆盖原生 Android 客户端的 `SharedPreferences` 恢复与同签名覆盖安装;如果用户先卸载 APP 再全新安装,仍可能丢失本地原生存储
|
||||
- 数据存储仍是文件型,而不是数据库
|
||||
- 数据存储默认仍是文件型,但已经有 PostgreSQL store adapter、schema 和维护脚本;生产切换前需先执行备份、dry-run 迁移和回滚演练
|
||||
- 设备发现、项目扫描和额度采集仍是静态配置驱动的 MVP
|
||||
- APP 实时日志当前已能同步到主 Agent 会话,但还没有单独的日志检索、分页和告警升级规则
|
||||
- Skill 清单当前按设备同步和展示已经可用,但还没有“安装 / 卸载 Skill”这种远程管理能力
|
||||
- Skill 清单当前按设备同步和展示已经可用;远程治理目前只有最高管理员创建 lifecycle 请求和 list 状态,尚未真正下发到设备端执行安装 / 更新 / 卸载 / 回滚
|
||||
- 服务器侧主 Agent 实时回复依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;如果设备离线,只能保留任务或走 API 容灾账号
|
||||
- 设备导入主链的后端状态机已经跑通,并且已经分成两条:
|
||||
- 新接入设备继续走 `import draft -> 勾选 -> review -> apply`
|
||||
@@ -304,7 +344,9 @@ cd /Users/kris/code/boss
|
||||
- 原生 Android 的二级深层页虽然仍保留 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等能力,但它们已经退出主 UI 正面;后续如再加入口,需继续遵守“一级微信式,复杂能力下沉”的规则
|
||||
- Android 本地 Gradle 验证当前必须串行执行;如果并发跑 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug`,会导致中间产物互踩并出现假失败
|
||||
- 聊天附件当前已经支持真实上传、消息落账本、受保护下载和原生打开;默认后端为服务器文件存储,可按用户切到阿里 OSS 私有桶
|
||||
- 认证虽然已有最小会话 Cookie,但还没有刷新令牌、跨端会话治理、CSRF 防护和更细的风控策略
|
||||
- 企业认证默认值已收紧:`POST /api/auth/login` 默认不再允许临时免验证登录,只有显式设置 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 才会开启开发兜底。
|
||||
- 状态存储现在通过 `src/lib/boss-state-store.ts` 抽象,默认继续使用 `data/boss-state.json`;设置 `BOSS_STATE_STORE=postgres` 时必须同时配置 `BOSS_DATABASE_URL`,schema 见 `scripts/postgres-state-schema.sql`。
|
||||
- 认证已补 CSRF 基础防护、restore token 轮换、账号锁定和子账号 MFA 开关;后续仍可继续补更完整的企业 IdP / SSO
|
||||
- 邮件对外正式投递仍缺少 DNS / 信誉相关的最终收口,例如 SPF、DKIM、DMARC、MX 与退信策略
|
||||
- 外部真实邮箱的 end-to-end 收件链路还没有在生产账号上完成最终验收
|
||||
|
||||
@@ -314,11 +356,13 @@ cd /Users/kris/code/boss
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:3000/api/health
|
||||
curl -sS -H 'Content-Type: application/json' -d '{"account":"17600003315","password":"boss123456","method":"password"}' http://127.0.0.1:3000/api/auth/login
|
||||
curl -sS -H 'Content-Type: application/json' -d '{"account":"krisolo","password":"<admin-password>","method":"password"}' http://127.0.0.1:3000/api/auth/login
|
||||
curl -sS http://127.0.0.1:3000/api/auth/session
|
||||
curl -sS http://127.0.0.1:3000/api/v1/conversations
|
||||
curl -sS http://127.0.0.1:3000/api/v1/projects/master-agent
|
||||
curl -sS http://127.0.0.1:3000/api/v1/devices/mac-studio/skills
|
||||
node scripts/boss-state-store-maintenance.mjs backup-file --dry-run
|
||||
node scripts/boss-state-store-maintenance.mjs migrate-file-to-postgres --dry-run
|
||||
curl -I http://127.0.0.1:3000/api/v1/user/ota/package
|
||||
curl -sS http://127.0.0.1:4317/health
|
||||
curl -sS http://127.0.0.1:4317/api/v1/skills
|
||||
|
||||
88
docs/architecture/dependency_security_audit_cn.md
Normal file
88
docs/architecture/dependency_security_audit_cn.md
Normal 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。
|
||||
197
docs/architecture/rbac_skill_regression_matrix_cn.md
Normal file
197
docs/architecture/rbac_skill_regression_matrix_cn.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# RBAC / Skill / 主 Agent 权限与回归矩阵
|
||||
|
||||
更新时间:`2026-04-27`
|
||||
|
||||
这份文档只梳理当前已经落地或明确未生产化的多用户、RBAC、Skill、主 Agent 权限和多设备控制链路。当前运行真相仍以 `current_runtime_and_deploy_status_cn.md` 和 `api_and_service_inventory_cn.md` 为准。
|
||||
|
||||
## 1. 当前开发状态
|
||||
|
||||
### 1.1 已落地
|
||||
|
||||
- 多用户 / RBAC 第一阶段已经落地到文件状态:`accountDeviceGrants / accountProjectGrants / accountSkillGrants / skillCatalog / permissionAuditLogs` 已进入 `BossState`。
|
||||
- 最高管理员授权台已经可用:`GET/POST /api/v1/admin/access` 仅 `highest_admin` 可访问,支持创建 / 更新子账号、授予设备 / 项目 / Skill 权限、套用模板和撤销授权。
|
||||
- Web `/me/access` 与 Android `AccessManagementActivity` 已接入授权管理;`member` 不显示入口,直接请求也应返回 `403`。
|
||||
- 会话、设备、项目详情、消息读写、设备 Skill、`/api/state` 已按当前登录账号裁剪;最高管理员保持全局可见。
|
||||
- 主 Agent prompt 与任务队列已接入授权快照:生成提示词时只带当前账号可见设备、项目、线程状态文档、进展事件和 Skill;`MasterAgentTask` 会记录 `authorizedDeviceIds / authorizedProjectIds / authorizedSkillIds / requiredPermissions`。
|
||||
- 本地 `local-agent` 已能扫描 `~/.codex/skills` 并同步到云端设备 Skill 接口;Web / Android Skill 页按授权后的设备和 Skill 展示。
|
||||
- 普通线程单聊、群聊下发、设备导入审核、browser/desktop 控制都已经进入 `master-agent task queue -> local-agent -> complete` 主链。
|
||||
- browser/desktop 控制已从占位结果升级为外部 runtime 桥:未配置时 fail closed,配置 smoke runtime 时可回写结构化 `control_summary`。
|
||||
|
||||
### 1.2 部分落地但仍属 MVP
|
||||
|
||||
- 登录会话已有 `boss_session`、原生 `restore token` 和单会话撤销,但还没有独立刷新令牌、完整吊销审计、CSRF 防护和更细风控策略。
|
||||
- `permissionAuditLogs` 已有第一版最高管理员查询入口和 deterministic 风险摘要;仍不是后台持久告警、归档和独立审计存储系统。
|
||||
- Skill 当前已支持“扫描、展示、授权、复制调用语句、进入主 Agent 授权上下文”,并新增最高管理员发起的远程安装 / 更新 / 卸载 / 回滚 / 版本锁请求;local-agent 会认领并执行本机 Skill 文件或 Git 操作,但还不是带签名校验、依赖沙箱和执行审计的完整 Skill 平台。
|
||||
- 主 Agent 可以携带授权快照并派任务,但审批流仍是局部场景:群聊 `approval_required` 已有确认 / 拒绝;高风险 Skill、远程安装、跨设备接管还没有统一审批引擎。
|
||||
- 多设备控制当前以设备在线状态、设备绑定、项目线程绑定和 runtime 配置为准,尚未形成租约、抢占、并发冲突仲裁的完整生产级控制面。
|
||||
|
||||
## 2. 权限模型
|
||||
|
||||
### 2.1 角色
|
||||
|
||||
| 角色 | 当前含义 | 当前边界 |
|
||||
| --- | --- | --- |
|
||||
| `highest_admin` | 最高管理员,默认账号 `krisolo` | 全局可见;可管理账号、授权、AI 账号、Telegram、运维入口和所有活跃会话 |
|
||||
| `admin` | 管理员 / 可信协作者 | 可见更多“我的”入口,但当前不是全局授权管理员;不能访问 `/api/v1/admin/access` |
|
||||
| `member` | 子账号 / 普通成员 | 只看被授权设备、项目和 Skill;`我的` 入口限制为个人安全、设置、技能、关于 |
|
||||
|
||||
### 2.2 权限点
|
||||
|
||||
| 权限 | 作用对象 | 当前作用 |
|
||||
| --- | --- | --- |
|
||||
| `device.view` | 设备 | 查看设备;可带来该设备关联项目的只读可见性 |
|
||||
| `device.manage` | 设备 | 预留给设备管理动作,当前不是主要前台能力 |
|
||||
| `project.view` | 项目 / 线程 / 群聊 | 查看项目详情、会话列表、线程状态和项目投影 |
|
||||
| `thread.chat` | 项目 / 线程 / 群聊 | 向普通项目或线程发送消息并创建 `conversation_reply` |
|
||||
| `master_agent.ask` | 项目 / 主 Agent | 向主 Agent 提问或让主 Agent 生成推荐 / 任务 |
|
||||
| `master_agent.takeover` | 项目 / 线程 | 允许主 Agent 接管或代表用户推动线程执行 |
|
||||
| `computer.control` | 设备 / 项目 | 允许 browser/desktop 控制类任务进入执行链 |
|
||||
| `skill.view` | Skill | 查看已授权 Skill |
|
||||
| `skill.use` | Skill | 把 Skill 作为可用能力放入授权上下文或调用语句 |
|
||||
| `skill.manage` | Skill | 预留给细粒度 Skill 管理;当前远程安装 / 更新仍由 `highest_admin` 入口硬限制 |
|
||||
| `account.manage` | 账号 | 预留;当前账号授权管理仍以 `highest_admin` 角色硬限制为准 |
|
||||
| `audit.view` | 审计 | 预留;当前权限审计查询入口仍以 `highest_admin` 硬限制为准 |
|
||||
|
||||
### 2.3 继承与显式授权
|
||||
|
||||
- `highest_admin` 绕过设备、项目和 Skill 授权检查。
|
||||
- 非最高管理员如果拥有某台设备或被授予 `device.view`,可以只读看到该设备关联的项目。
|
||||
- `device.view` 不会自动放大为 `thread.chat / master_agent.ask / master_agent.takeover / computer.control / skill.use`。
|
||||
- 聊天、主 Agent 接管、电脑控制和 Skill 使用必须来自项目授权、设备授权或 Skill 授权中的显式权限。
|
||||
- Skill 授权可带 `deviceId / projectId` scope;同名 Skill 会聚合进 `skillCatalog`,但实际可见 / 可用仍要按设备和项目 scope 判断。
|
||||
- 过期授权通过 `expiresAt` 失效;当前应在回归中覆盖过期授权不再生效。
|
||||
|
||||
### 2.4 内置模板
|
||||
|
||||
| 模板 | 设备权限 | 项目权限 | Skill 权限 | 适用场景 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 只读观察员 | `device.view` | `project.view` | `skill.view` | 只看设备、项目和 Skill,不允许聊天或执行 |
|
||||
| 项目开发者 | `device.view` | `project.view / thread.chat / master_agent.ask` | `skill.view / skill.use` | 参与项目开发,可问主 Agent 和调用已分配 Skill |
|
||||
| 设备操作者 | `device.view / computer.control` | `project.view / thread.chat / master_agent.ask / master_agent.takeover / computer.control` | `skill.view / skill.use` | 可信协作者,可触发接管和电脑控制 |
|
||||
|
||||
## 3. 控制链路权限边界
|
||||
|
||||
### 3.1 主 Agent 单聊
|
||||
|
||||
- 入口:`POST /api/v1/projects/master-agent/messages`。
|
||||
- 权限:当前需要 `master_agent.ask`;最高管理员全局通过。
|
||||
- 执行链:写入消息后创建 `MasterAgentTask`,优先走 `Master Codex Node`,设备离线或立即失败时可退到已配置 API / 阿里备用链。
|
||||
- 授权快照:任务保存 `authorizedDeviceIds / authorizedProjectIds / authorizedSkillIds / requiredPermissions`,用于执行器和后续审计判断。
|
||||
|
||||
### 3.2 普通线程单聊
|
||||
|
||||
- 入口:`POST /api/v1/projects/[projectId]/messages`。
|
||||
- 权限:普通项目需要 `thread.chat`;只读 `project.view` 不能发送消息。
|
||||
- 执行链:创建 `conversation_reply`,由绑定设备 `local-agent` 用 `codex exec resume <targetCodexThreadRef>` 回到真实 Codex 线程。
|
||||
- 重要边界:如果线程缺失、设备离线、cwd 不匹配或历史只读线程上下文未解除,应失败并给出明确原因,不应假成功。
|
||||
|
||||
### 3.3 群聊下发
|
||||
|
||||
- 入口:群聊消息进入主 Agent 推荐;用户确认后调用 dispatch plan confirm。
|
||||
- 权限:需要项目聊天 / 主 Agent 推荐能力;最终下发目标必须是真实可执行线程成员。
|
||||
- 当前审批:`approval_required` 群聊支持确认 / 拒绝,一次只保留一个待确认推荐,避免叠加。
|
||||
- 回写:`dispatch_execution` 完成后线程原始结果回群,再追加主 Agent 汇总;重复完成应幂等。
|
||||
|
||||
### 3.4 Skill
|
||||
|
||||
- 采集:`local-agent` 扫描本机 `~/.codex/skills`,上报 `/api/v1/devices/[deviceId]/skills`。
|
||||
- 展示:`GET /api/v1/devices/[deviceId]/skills` 和 Web / Android Skill 页按账号授权过滤。
|
||||
- 使用:`skill.use` 决定 Skill 是否能进入当前账号的主 Agent 授权上下文或被用户复制调用。
|
||||
- 已落地第一版:远程安装、远程更新、远程卸载、Git checkout 回滚和版本锁请求会被 local-agent 认领执行并同步最新清单。
|
||||
- 未生产化:远程 Skill 执行、签名校验、沙箱隔离、撤销后本地禁用、来源信任和依赖安装策略尚未完成。
|
||||
|
||||
### 3.5 多设备与电脑控制
|
||||
|
||||
- 设备能力来自 heartbeat:Codex GUI / CLI、browserAutomation、computerUse 等。
|
||||
- browser/desktop 控制任务要求 `computer.control`,并通过 `MasterAgentTask` 进入 `local-agent` 外部 runtime 桥。
|
||||
- smoke runtime 当前能做最小真实动作和 artifact 回写,但还不是完整 Playwright / Computer Use 生产运行时。
|
||||
- 同项目 GUI / CLI 并行写入已有冲突控制:默认阻断,用户可对异常项目选择本次 / 永久放行。
|
||||
|
||||
## 4. 回归矩阵
|
||||
|
||||
### 4.1 Web / API
|
||||
|
||||
| 场景 | 要测什么 | 命令 |
|
||||
| --- | --- | --- |
|
||||
| 基础构建 | Next.js 构建和 lint 无退化 | `npm run build && npm run lint` |
|
||||
| 健康检查 | Web 服务可启动、健康探针正常 | `npm start` 后执行 `curl -sS http://127.0.0.1:3000/api/health` |
|
||||
| 登录态 | 登录、session、restore、logout 链路 | `curl -sS -H 'Content-Type: application/json' -d '{"account":"krisolo","password":"<admin-password>","method":"password"}' http://127.0.0.1:3000/api/auth/login` |
|
||||
| 会话治理 | 最高管理员可看全部会话,子账号只能看自己,会话 token 不泄露 | `curl -sS http://127.0.0.1:3000/api/v1/auth/sessions` |
|
||||
| 授权台保护 | 未登录返回 `401`,非最高管理员返回 `403`,最高管理员可读脱敏数据 | `curl -i http://127.0.0.1:3000/api/v1/admin/access` |
|
||||
| 授权动作 | `upsert_account / grant_device / grant_project / grant_skill / apply_template / revoke_grant` 都写入 `permissionAuditLogs` | 用最高管理员 Cookie 对 `/api/v1/admin/access` 发 JSON POST |
|
||||
| 权限审计查询 | 最高管理员可按 action / actor / target / device / project / skill / cursor / limit 查询,普通账号 `403` | `npx tsx --test tests/audit-permission-logs-route.test.ts` |
|
||||
| 权限审计风险 | 能识别短时间大量授权、Skill lifecycle 失败、过期授权仍存在、admin route 拒绝访问 | `npx tsx --test tests/audit-permission-logs-route.test.ts` |
|
||||
| 账号裁剪 | 子账号只看到被授权设备 / 项目 / Skill | 用子账号 Cookie 分别请求 `/api/v1/devices`、`/api/v1/conversations`、`/api/v1/devices/mac-studio/skills` |
|
||||
| 项目写权限 | 只有 `thread.chat` 可发普通线程消息,只读账号应 `403` | `curl -i -H 'Content-Type: application/json' -d '{"kind":"text","body":"权限回归"}' http://127.0.0.1:3000/api/v1/projects/<projectId>/messages` |
|
||||
| 主 Agent 权限 | `master_agent.ask` 才能进入主 Agent 任务链,任务包含授权快照 | `curl -sS -H 'Content-Type: application/json' -d '{"kind":"text","body":"列出我可见的设备和 Skill"}' http://127.0.0.1:3000/api/v1/projects/master-agent/messages` |
|
||||
| Skill 过滤 | Skill 列表按设备和账号授权过滤 | `curl -sS http://127.0.0.1:3000/api/v1/devices/mac-studio/skills` |
|
||||
| Skill 治理请求 | 最高管理员可创建请求,设备可认领和回写 | `npx tsx --test tests/skill-lifecycle-route.test.ts` |
|
||||
| browser/desktop fail closed | 未配置 runtime 时返回明确 disabled,不写假成功 | 关闭 `browserControl* / computerUse*` 后发控制类主 Agent 消息 |
|
||||
| 群聊审批 | `approval_required` 只能有一条待确认推荐,确认 / 拒绝状态正确 | 用群聊项目消息接口触发 dispatch plan,再测 confirm / reject |
|
||||
|
||||
### 4.2 local-agent / 本机设备
|
||||
|
||||
| 场景 | 要测什么 | 命令 |
|
||||
| --- | --- | --- |
|
||||
| 健康探针 | `launchd` 常驻不被首次 heartbeat 阻塞 | `curl -sS http://127.0.0.1:4317/health` |
|
||||
| Skill 扫描 | 本机 Skill 能递归扫描并返回 | `curl -sS http://127.0.0.1:4317/api/v1/skills` |
|
||||
| Skill lifecycle | local-agent 能认领请求、执行版本锁/卸载等安全本机操作并同步清单 | `node --test tests/local-agent-skill-lifecycle-runner.test.mjs` |
|
||||
| 心跳上报 | 设备、能力、Skill、thread-context 能上报 | `curl -sS -X POST http://127.0.0.1:4317/api/v1/heartbeat` |
|
||||
| 任务认领 | `conversation_reply / dispatch_execution / browser_control / desktop_control` 能被认领或明确失败 | 触发对应 Web/API 消息后观察项目消息账本和 local-agent 日志 |
|
||||
| Codex 线程恢复 | 有 `targetCodexThreadRef` 时走 `codex exec resume`,缺失时才退 `--ephemeral` | 发送普通线程消息并检查回写内容 |
|
||||
| rollout 镜像 | Boss 用户消息先镜像进 Codex Desktop 同线程,重试不重复写入 | 对已绑定真实线程的项目发送消息后检查 Desktop 线程历史 |
|
||||
|
||||
### 4.3 Android
|
||||
|
||||
| 场景 | 要测什么 | 命令 |
|
||||
| --- | --- | --- |
|
||||
| 构建 | Debug APK 可构建并发布到 downloads | `npm run apk:debug` |
|
||||
| Release | signed release APK 可构建 | `npm run apk:release` |
|
||||
| 单元测试 | Android 本地测试串行通过,避免 Gradle 中间产物互踩 | `cd android && ./gradlew testDebugUnitTest` |
|
||||
| 真机安装 | OPPO `PHZ110` 安装并可启动 | `adb -s U84XJRIB7D65ZH45 install -r android/app/build/outputs/apk/debug/app-debug.apk` |
|
||||
| 角色入口 | `member / admin / highest_admin` 的“我的”入口差异正确 | 真机登录不同账号,检查 `用户与权限 / 技能 / 运维 / AI 账号 / Telegram` 可见性 |
|
||||
| 权限管理页 | 最高管理员可创建子账号、套模板、撤销授权 | 真机打开 `我的 > 用户与权限` |
|
||||
| Skill 页 | 子账号只看到授权 Skill,可复制调用语句 | 真机打开 `我的 > 技能` |
|
||||
| 聊天等待态 | 主 Agent、普通线程、群聊下发都显示等待直到真实回写或超时 | 真机分别发送三类消息 |
|
||||
| 控制结果卡 | browser/desktop 控制结果以目标 URL / 应用名卡片展示 | 真机向主 Agent 发送控制类请求 |
|
||||
|
||||
### 4.4 真机 / 多设备控制
|
||||
|
||||
| 场景 | 要测什么 | 命令 |
|
||||
| --- | --- | --- |
|
||||
| ADB 目标 | 默认只使用 OPPO `PHZ110`,不自动回退旧设备 | `adb devices` |
|
||||
| 无线调试 | 同一局域网 + USB 初次启用后可切无线 | `adb -s U84XJRIB7D65ZH45 tcpip 5555 && adb connect <phone-ip>:5555` |
|
||||
| 设备在线 | Boss 设备页显示 `mac-studio` 在线及 browser/computer 能力 | `curl -sS http://127.0.0.1:4317/health && curl -sS -X POST http://127.0.0.1:4317/api/v1/heartbeat` |
|
||||
| GUI / CLI 冲突 | 同项目双执行模式默认阻断,允许本次 / 永久放行生效 | 在设备详情或项目冲突提示中切换策略后再次派发 |
|
||||
| 设备离线 | 主 Agent / 项目确认接口返回明确离线原因,不假成功 | 停掉 local-agent 后发送线程或控制任务 |
|
||||
|
||||
### 4.5 服务器部署
|
||||
|
||||
| 场景 | 要测什么 | 命令 |
|
||||
| --- | --- | --- |
|
||||
| SSH 健康 | 固定服务器 `106.53.170.158` 可连 | `"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" health` |
|
||||
| Web 服务 | `boss-web.service` 运行且本机 API 正常 | `"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "systemctl status boss-web --no-pager && curl -sS http://127.0.0.1:3000/api/health"` |
|
||||
| Caddy / HTTPS | Caddy 运行,域名跳转正常 | `"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "systemctl status caddy --no-pager && curl -I --resolve boss.hyzq.net:443:127.0.0.1 https://boss.hyzq.net"` |
|
||||
| 外网域名 | 当前网络能访问公网 HTTPS API | `curl -I https://boss.hyzq.net && curl -sS https://boss.hyzq.net/api/health` |
|
||||
| 状态文件 | 部署不覆盖服务器 `data/boss-state.json` | 部署前后在服务器检查 `/opt/boss/data/boss-state.json` mtime 和关键账号数据 |
|
||||
| 邮件端口 | Postfix / Dovecot 在线 | `"$HOME/.codex/skills/boss-server-debug/scripts/server_ssh.sh" exec "systemctl status postfix --no-pager && systemctl status dovecot --no-pager"` |
|
||||
|
||||
## 5. 剩余缺口
|
||||
|
||||
### 5.1 需要生产化的权限能力
|
||||
|
||||
- Skill 远程安装 / 更新 / 卸载:第一版设备端安装器和版本锁已落地;仍需要签名校验、来源信任、安装审计增强和失败自动回滚。
|
||||
- Skill 远程执行:需要明确输入输出协议、沙箱边界、资源限制、敏感权限提示和 per-run 审计。
|
||||
- 统一审批流:高风险 Skill、电脑控制、跨设备接管、生产部署、账号和存储配置变更应进入同一审批模型,而不是分散在群聊确认里。
|
||||
- 多级组织:当前只有角色 + 单账号授权;还没有组织、团队、项目组、继承授权、批量授权、离职回收和委派管理员。
|
||||
- 审计告警:授权日志已有账本,但缺少审计检索、异常检测、告警通道、不可篡改归档和审计报表。
|
||||
- 风险分级执行:`requiresConfirmation / riskLevel` 已开始进入任务元数据,但还没有统一策略中心约束哪些风险必须二次确认。
|
||||
|
||||
### 5.2 需要生产化的运行能力
|
||||
|
||||
- 数据库:当前仍是 `data/boss-state.json` 文件存储;RBAC、审计、会话和任务队列生产化前需要迁移到数据库并补索引和事务边界。
|
||||
- 会话安全:需要独立刷新令牌、完整吊销审计、CSRF 防护、设备绑定策略、登录风险检测和异常会话告警。
|
||||
- 多设备控制租约:需要 device lease、抢占、超时释放、只读观察、独占输入和并发写入审计。
|
||||
- 真实 Browser / Computer Use runtime:当前 smoke runtime 只能作为过渡层;生产需要接入真实浏览器自动化和桌面控制执行器。
|
||||
- 服务器出网:公网服务器当前对 `api.openai.com` 直接出网仍未完全打通,OpenAI API 线上探针和调用依赖网络恢复。
|
||||
- 生产邮件验收:Postfix / Dovecot 已部署,但 SPF、DKIM、DMARC、MX、退信策略和真实外部收件链路仍需最终验收。
|
||||
@@ -0,0 +1,75 @@
|
||||
# Telegram Gateway Integration Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 为 Boss 增加可用的 Telegram 对话入口,让 Telegram 用户能安全地与主 Agent 对话,并在主 Agent 异步完成任务后把结果回推回 Telegram。
|
||||
|
||||
**Architecture:** 在 `src/lib` 新增一个轻量 Telegram gateway,负责 update 归一化、访问控制、消息分流和 Telegram Bot API 调用;Next.js 暴露 webhook 与管理员配置接口,仍然复用现有 `boss-master-agent` 与 `boss-data` 主链,不复制对话业务。异步回复依赖现有 `/api/v1/master-agent/tasks/[taskId]/complete` 完成回调,在任务落盘后立即尝试发回 Telegram。
|
||||
|
||||
**Tech Stack:** Next.js App Router、TypeScript、文件型状态 `data/boss-state.json`、原生 `fetch`、Node test runner + `tsx --test`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 定义 Telegram 状态与配置模型
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/telegram-gateway.ts`
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- Test: `tests/telegram-gateway.test.ts`
|
||||
|
||||
- [ ] **Step 1: 写 Telegram 配置/状态的失败测试**
|
||||
- [ ] **Step 2: 运行测试确认因接口缺失而失败**
|
||||
- [ ] **Step 3: 在 `boss-data.ts` 增加 Telegram 配置与任务外部回推字段**
|
||||
- [ ] **Step 4: 在 `telegram-gateway.ts` 增加归一化、mask、session key、chunk 等纯函数**
|
||||
- [ ] **Step 5: 重新运行测试确认通过**
|
||||
|
||||
### Task 2: 打通 webhook 与主 Agent 桥接
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/api/v1/integrations/telegram/webhook/route.ts`
|
||||
- Modify: `src/lib/telegram-gateway.ts`
|
||||
- Modify: `src/lib/boss-master-agent.ts`
|
||||
- Test: `tests/telegram-gateway.test.ts`
|
||||
|
||||
- [ ] **Step 1: 写 webhook 接收、secret 校验、allowlist、主 Agent 快速回复 的失败测试**
|
||||
- [ ] **Step 2: 跑测试确认 RED**
|
||||
- [ ] **Step 3: 实现 webhook handler,把 Telegram 文本桥接到 `master-agent`**
|
||||
- [ ] **Step 4: 对快速回复直接回 Telegram;对排队任务保存外部回推目标**
|
||||
- [ ] **Step 5: 跑测试确认 GREEN**
|
||||
|
||||
### Task 3: 打通任务完成后的 Telegram 异步回推
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- Modify: `src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts`
|
||||
- Modify: `src/lib/telegram-gateway.ts`
|
||||
- Test: `tests/telegram-gateway.test.ts`
|
||||
|
||||
- [ ] **Step 1: 写任务完成后自动回推 Telegram 的失败测试**
|
||||
- [ ] **Step 2: 跑测试确认 RED**
|
||||
- [ ] **Step 3: 在任务模型中加入 `externalReplyTarget` 并在 complete route 中触发 Telegram 发信**
|
||||
- [ ] **Step 4: 补充发送成功/失败去重保护**
|
||||
- [ ] **Step 5: 跑测试确认 GREEN**
|
||||
|
||||
### Task 4: 增加管理员配置接口
|
||||
|
||||
**Files:**
|
||||
- Create: `src/app/api/v1/integrations/telegram/route.ts`
|
||||
- Modify: `src/lib/telegram-gateway.ts`
|
||||
- Test: `tests/telegram-integration-route.test.ts`
|
||||
|
||||
- [ ] **Step 1: 写 GET/POST 配置接口失败测试**
|
||||
- [ ] **Step 2: 跑测试确认 RED**
|
||||
- [ ] **Step 3: 实现管理员鉴权、配置读取、保存、token 掩码与 getMe 探测**
|
||||
- [ ] **Step 4: 跑测试确认 GREEN**
|
||||
|
||||
### Task 5: 文档与回归验证
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
|
||||
- [ ] **Step 1: 更新 Boss 当前能力文档,写清 Telegram 接入方式、能力边界与部署方式**
|
||||
- [ ] **Step 2: 运行 `tsx --test`、`npm run lint`、`npm run build`**
|
||||
- [ ] **Step 3: 记录未完成项与后续扩展点(群聊策略、pairing、Feishu/Telegram 复用层)**
|
||||
274
docs/superpowers/plans/2026-04-21-codex-desktop-thread-sync.md
Normal file
274
docs/superpowers/plans/2026-04-21-codex-desktop-thread-sync.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# Codex Desktop Thread Sync Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 让 Boss App 发往单线程 Codex 会话的用户消息,在继续现有 `conversation_reply -> codex exec resume` 主链前,同步镜像进本机 Codex Desktop 的同一个线程历史。
|
||||
|
||||
**Architecture:** 服务端继续以 Boss 项目账本为主真相,但给普通单线程 `conversation_reply` 任务补齐 `sourceMessage*` 元数据和显式镜像开关。`local-agent` 在执行 `codex exec resume` 前,按 `targetCodexThreadRef` 解析目标 rollout 文件,先做一次本地 user_message append + 去重,再继续原有执行链。rollout 定位优先使用 `state_5.sqlite`,若本机 Codex CLI/Desktop 版本导致状态库不可用,则回退扫描 `~/.codex/sessions`;rollout 写入后尽量刷新 `threads.updated_at / updated_at_ms / has_user_event`,但不依赖 GUI 自动化。
|
||||
|
||||
**Tech Stack:** Next.js App Router, TypeScript, Node.js, sqlite, local-agent Node runtime, `tsx --test`, Node test runner
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 给 conversation task 补齐 Desktop 镜像元数据
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- Modify: `src/lib/boss-master-agent.ts`
|
||||
- Modify: `src/app/api/v1/projects/[projectId]/messages/route.ts`
|
||||
- Test: `tests/single-thread-message-execution.test.ts`
|
||||
|
||||
- [x] **Step 1: 写失败测试,要求普通单线程消息返回的任务带 sourceMessage 元数据**
|
||||
|
||||
```ts
|
||||
assert.equal(task?.sourceMessageId, message.id);
|
||||
assert.equal(task?.sourceMessageBody, "请同步一下当前阻塞情况");
|
||||
assert.equal(task?.sourceMessageSentAt, message.sentAt);
|
||||
assert.equal(task?.mirrorBossUserMessageToCodexDesktop, true);
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run: `npx tsx --test tests/single-thread-message-execution.test.ts`
|
||||
Expected: FAIL,提示 `sourceMessageBody/sourceMessageSentAt/mirrorBossUserMessageToCodexDesktop` 不存在或断言失败。
|
||||
|
||||
- [x] **Step 3: 写最小实现**
|
||||
|
||||
在 `MasterAgentTask` 和状态序列化/反序列化里补字段:
|
||||
|
||||
```ts
|
||||
sourceMessageId?: string;
|
||||
sourceMessageBody?: string;
|
||||
sourceMessageSentAt?: string;
|
||||
mirrorBossUserMessageToCodexDesktop?: boolean;
|
||||
```
|
||||
|
||||
在 `queueThreadConversationReplyTask` 中透传:
|
||||
|
||||
```ts
|
||||
sourceMessageId: params.sourceMessageId,
|
||||
sourceMessageBody: params.sourceMessageBody,
|
||||
sourceMessageSentAt: params.sourceMessageSentAt,
|
||||
mirrorBossUserMessageToCodexDesktop:
|
||||
params.relayViaMasterAgent ? undefined : true,
|
||||
```
|
||||
|
||||
在消息 route 调用时补:
|
||||
|
||||
```ts
|
||||
const queuedTask = await queueThreadConversationReplyTask({
|
||||
projectId,
|
||||
requestMessageId: message.id,
|
||||
requestText: message.body,
|
||||
requestedBy: session.displayName || session.account,
|
||||
requestedByAccount: session.account,
|
||||
sourceMessageId: message.id,
|
||||
sourceMessageBody: message.body,
|
||||
sourceMessageSentAt: message.sentAt,
|
||||
});
|
||||
```
|
||||
|
||||
- [x] **Step 4: 运行测试确认通过**
|
||||
|
||||
Run: `npx tsx --test tests/single-thread-message-execution.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
### Task 2: 新增 rollout writer,并确保重复任务不重复写 Desktop 线程
|
||||
|
||||
**Files:**
|
||||
- Create: `local-agent/codex-thread-rollout-writer.mjs`
|
||||
- Test: `tests/local-agent-codex-rollout-writer.test.mjs`
|
||||
|
||||
- [x] **Step 1: 写失败测试,约束 writer 会写入 user_message 且按 sourceMessageId 去重**
|
||||
|
||||
```js
|
||||
test("appendBossUserMessageToCodexThreadRollout writes one user_message event and dedupes by source message id", async () => {
|
||||
const first = await appendBossUserMessageToCodexThreadRollout({ ... });
|
||||
const second = await appendBossUserMessageToCodexThreadRollout({ ... });
|
||||
assert.equal(first.status, "written");
|
||||
assert.equal(second.status, "duplicate");
|
||||
});
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run: `node --test tests/local-agent-codex-rollout-writer.test.mjs`
|
||||
Expected: FAIL,提示模块不存在或导出函数不存在。
|
||||
|
||||
- [x] **Step 3: 写最小实现**
|
||||
|
||||
实现 writer 逻辑:
|
||||
|
||||
```js
|
||||
export async function appendBossUserMessageToCodexThreadRollout(params) {
|
||||
const rolloutPath = await resolveThreadRolloutPath(params);
|
||||
const duplicate = await hasBossSourceMessageInRolloutTail(rolloutPath, params.sourceMessageId);
|
||||
if (duplicate) return { status: "duplicate", rolloutPath };
|
||||
const responseItem = JSON.stringify({
|
||||
timestamp: params.sentAt,
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: params.message }],
|
||||
},
|
||||
});
|
||||
const event = JSON.stringify({
|
||||
timestamp: params.sentAt,
|
||||
type: "event_msg",
|
||||
payload: {
|
||||
type: "user_message",
|
||||
message: params.message,
|
||||
images: [],
|
||||
local_images: [],
|
||||
text_elements: [],
|
||||
metadata: {
|
||||
bossSourceMessageId: params.sourceMessageId,
|
||||
bossMirroredFrom: "boss-app",
|
||||
},
|
||||
},
|
||||
});
|
||||
await appendFile(rolloutPath, `${responseItem}\n${event}\n`, "utf8");
|
||||
return { status: "written", rolloutPath };
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 4: 运行测试确认通过**
|
||||
|
||||
Run: `node --test tests/local-agent-codex-rollout-writer.test.mjs`
|
||||
Expected: PASS
|
||||
|
||||
### Task 3: 在 local-agent 执行 resume 前追加 Desktop 线程镜像
|
||||
|
||||
**Files:**
|
||||
- Modify: `local-agent/codex-task-runner.mjs`
|
||||
- Modify: `tests/local-agent-codex-task-runner.test.mjs`
|
||||
|
||||
- [x] **Step 1: 写失败测试,要求 prepare 阶段保留镜像计划,且 relay task 不启用**
|
||||
|
||||
```js
|
||||
assert.deepEqual(result.execution.desktopMirror, {
|
||||
enabled: true,
|
||||
sourceMessageId: "msg-1",
|
||||
sourceMessageBody: "请继续推进",
|
||||
});
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run: `node --test tests/local-agent-codex-task-runner.test.mjs`
|
||||
Expected: FAIL,提示 `desktopMirror` 不存在。
|
||||
|
||||
- [x] **Step 3: 写最小实现**
|
||||
|
||||
在 `buildCodexTaskExecution` 返回值中增加:
|
||||
|
||||
```js
|
||||
desktopMirror: shouldMirrorBossUserMessageToDesktop(task)
|
||||
? {
|
||||
enabled: true,
|
||||
sourceMessageId: task.sourceMessageId,
|
||||
sourceMessageBody: task.sourceMessageBody,
|
||||
sourceMessageSentAt: task.sourceMessageSentAt,
|
||||
targetThreadRef,
|
||||
}
|
||||
: { enabled: false }
|
||||
```
|
||||
|
||||
其中 `shouldMirrorBossUserMessageToDesktop(task)` 需要保证:
|
||||
|
||||
- `task.taskType === "conversation_reply"`
|
||||
- `task.mirrorBossUserMessageToCodexDesktop === true`
|
||||
- `task.relayViaMasterAgent !== true`
|
||||
- `targetThreadRef/sourceMessageId/sourceMessageBody` 全部存在
|
||||
|
||||
- [x] **Step 4: 运行测试确认通过**
|
||||
|
||||
Run: `node --test tests/local-agent-codex-task-runner.test.mjs`
|
||||
Expected: PASS
|
||||
|
||||
### Task 4: 在实际任务执行前调用 rollout writer,并保持现有 resume/complete 主链不回归
|
||||
|
||||
**Files:**
|
||||
- Modify: `local-agent/server.mjs`
|
||||
- Modify: `tests/local-agent-codex-task-runner.test.mjs`
|
||||
- Modify: `tests/single-thread-message-execution.test.ts`
|
||||
|
||||
- [x] **Step 1: 写失败测试,要求 server 在 spawn codex 前先执行 rollout 镜像**
|
||||
|
||||
```js
|
||||
assert.equal(writerCalls.length, 1);
|
||||
assert.equal(writerCalls[0].sourceMessageId, "msg-1");
|
||||
assert.equal(writerCalls[0].message, "请继续推进");
|
||||
```
|
||||
|
||||
- [x] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run: `node --test tests/local-agent-codex-task-runner.test.mjs`
|
||||
Expected: FAIL,提示 writer 未被调用。
|
||||
|
||||
- [x] **Step 3: 写最小实现**
|
||||
|
||||
在 `runMasterAgentTask` 的 `spawn("codex", ...)` 前增加:
|
||||
|
||||
```js
|
||||
if (codexExecution.desktopMirror?.enabled) {
|
||||
await appendBossUserMessageToCodexThreadRollout({
|
||||
stateDbPath: config.codexStateDbPath,
|
||||
targetThreadRef: codexExecution.desktopMirror.targetThreadRef,
|
||||
sourceMessageId: codexExecution.desktopMirror.sourceMessageId,
|
||||
message: codexExecution.desktopMirror.sourceMessageBody,
|
||||
sentAt: codexExecution.desktopMirror.sourceMessageSentAt ?? new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
如果镜像失败:
|
||||
|
||||
- 不吞掉错误
|
||||
- 直接按任务失败返回,让链路保持 fail-closed
|
||||
|
||||
- [x] **Step 4: 运行定向测试确认通过**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --test tests/local-agent-codex-rollout-writer.test.mjs
|
||||
node --test tests/local-agent-codex-task-runner.test.mjs
|
||||
npx tsx --test tests/single-thread-message-execution.test.ts
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
### Task 5: 回归验证与文档同步
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`(如需补一句运行时说明)
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
|
||||
|
||||
- [x] **Step 1: 跑仓库要求的基线验证**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [x] **Step 2: 补文档**
|
||||
|
||||
在运行时/服务清单文档补一句:
|
||||
|
||||
- Boss 普通线程单聊现在会在 local-agent 执行 `codex exec resume` 前,把 Boss 用户消息镜像进目标 Codex Desktop 线程 rollout
|
||||
- 该能力仅针对已绑定 `codexThreadRef` 的单线程会话
|
||||
|
||||
- [x] **Step 3: 完成最终自检**
|
||||
|
||||
检查:
|
||||
|
||||
- 没有把主 Agent 会话或 takeover relay 错写进 Desktop 子线程
|
||||
- 没有重复写 rollout
|
||||
- 现有 heartbeat 读取 recent desktop replies 仍可工作
|
||||
176
docs/superpowers/plans/2026-04-22-boss-computer-control-hub.md
Normal file
176
docs/superpowers/plans/2026-04-22-boss-computer-control-hub.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Boss 统一电脑控制中枢 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 让 Boss 聊天成为统一电脑控制入口。用户在主 Agent 或线程聊天里提出需求后,系统能在“普通讨论 / Codex 开发 / 浏览器自动化 / 桌面控制”之间自动选路,并通过本机 `local-agent` 执行。
|
||||
|
||||
**Architecture:** 继续复用 Boss 现有消息账本、`MasterAgentTask` 队列、执行底座和 `local-agent`。本次新增 `browser_control / desktop_control` 两类正式任务与 runtime,并让主 Agent 先做执行意图判断,再把请求路由到 Codex 线程、browser automation、computer use 或直接回复。
|
||||
|
||||
**Tech Stack:** Next.js App Router, TypeScript, Node.js local-agent, existing execution abstraction, `tsx --test`, Node test runner
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 扩展执行类型与任务模型
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/execution/types.ts`
|
||||
- Modify: `src/lib/execution/tool-registry.ts`
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- Test: `tests/computer-control-task-model.test.ts`
|
||||
|
||||
- [ ] **Step 1: 先写失败测试,要求任务模型支持 browser_control / desktop_control**
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx tsx --test tests/computer-control-task-model.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现最小模型扩展**
|
||||
|
||||
补齐:
|
||||
|
||||
- `ExecutionRequestKind`
|
||||
- `ExecutionToolName`
|
||||
- `MasterAgentTaskType`
|
||||
- `MasterAgentTask` 的 `intentCategory / runtimeKind / riskLevel / confirmationPolicy` 等字段
|
||||
|
||||
- [ ] **Step 4: 再跑测试确认通过**
|
||||
|
||||
### Task 2: 给主 Agent 增加控制意图分类
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/boss-master-agent.ts`
|
||||
- Modify: `src/app/api/v1/projects/[projectId]/messages/route.ts`
|
||||
- Test: `tests/master-agent-control-intent-routing.test.ts`
|
||||
|
||||
- [ ] **Step 1: 先写失败测试,覆盖讨论 / 开发 / 浏览器 / 桌面四类消息**
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx tsx --test tests/master-agent-control-intent-routing.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现最小路由逻辑**
|
||||
|
||||
要求:
|
||||
|
||||
- 讨论类继续直接回复
|
||||
- 开发类仍优先走现有 `conversation_reply`
|
||||
- 浏览器类排 `browser_control`
|
||||
- 桌面类排 `desktop_control`
|
||||
|
||||
- [ ] **Step 4: 再跑测试确认通过**
|
||||
|
||||
### Task 3: 给 local-agent 增加 browser/computer use runtime 分流骨架
|
||||
|
||||
**Files:**
|
||||
- Create: `local-agent/browser-control-task-runner.mjs`
|
||||
- Create: `local-agent/computer-use-task-runner.mjs`
|
||||
- Modify: `local-agent/server.mjs`
|
||||
- Test: `tests/local-agent-browser-control-runner.test.mjs`
|
||||
- Test: `tests/local-agent-computer-use-runner.test.mjs`
|
||||
|
||||
- [ ] **Step 1: 先写失败测试,要求 local-agent 能识别新任务类型并分流**
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --test tests/local-agent-browser-control-runner.test.mjs
|
||||
node --test tests/local-agent-computer-use-runner.test.mjs
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现最小分流骨架**
|
||||
|
||||
第一版先做:
|
||||
|
||||
- browser_control: 返回标准化占位结果或接入现有 browser runtime
|
||||
- desktop_control: 返回标准化占位结果或接入 computer-use runtime
|
||||
|
||||
要求:
|
||||
|
||||
- 不能影响现有 `conversation_reply / dispatch_execution`
|
||||
|
||||
- [ ] **Step 4: 再跑测试确认通过**
|
||||
|
||||
### Task 4: 增加风险分级与确认策略骨架
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/execution/permission-policy.ts`
|
||||
- Modify: `src/app/api/v1/projects/[projectId]/messages/route.ts`
|
||||
- Test: `tests/computer-control-permission-policy.test.ts`
|
||||
|
||||
- [ ] **Step 1: 写失败测试,验证 low/medium/high 三档行为**
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx tsx --test tests/computer-control-permission-policy.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 实现最小确认策略**
|
||||
|
||||
要求:
|
||||
|
||||
- `low` 默认可执行
|
||||
- `medium` 标记需轻确认
|
||||
- `high` 标记需强确认
|
||||
|
||||
- [ ] **Step 4: 再跑测试确认通过**
|
||||
|
||||
### Task 5: 前台返回执行模式元数据并做回归
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/api/v1/projects/[projectId]/messages/route.ts`
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- Test: `tests/project-message-execution-mode.test.ts`
|
||||
|
||||
- [ ] **Step 1: 写失败测试,要求消息返回 executionMode/riskLevel**
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
- [ ] **Step 3: 实现最小返回结构**
|
||||
|
||||
- [ ] **Step 4: 再跑测试确认通过**
|
||||
|
||||
### Task 6: 基线验证与文档同步
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
|
||||
|
||||
- [ ] **Step 1: 同步运行时文档**
|
||||
|
||||
- [ ] **Step 2: 跑仓库基线验证**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 跑新增与相关回归测试**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx tsx --test tests/computer-control-task-model.test.ts
|
||||
npx tsx --test tests/master-agent-control-intent-routing.test.ts
|
||||
npx tsx --test tests/computer-control-permission-policy.test.ts
|
||||
npx tsx --test tests/project-message-execution-mode.test.ts
|
||||
node --test tests/local-agent-browser-control-runner.test.mjs
|
||||
node --test tests/local-agent-computer-use-runner.test.mjs
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 如涉及 Android 前台行为,再补真机复核**
|
||||
168
docs/superpowers/plans/2026-04-27-boss-enterprise-hardening.md
Normal file
168
docs/superpowers/plans/2026-04-27-boss-enterprise-hardening.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Boss Enterprise Hardening Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把 Boss 从 MVP 管理后台推进到 To B 可交付的第一批企业化硬化版本。
|
||||
|
||||
**Architecture:** 保留当前 `BossState` 文件账本作为兼容层,同时新增可切换的状态存储适配层、企业认证默认值、租户隔离守卫、风险 SLA 扫描和增强审计字段。所有高危行为先用测试锁住,再通过 `/admin` 和 `/api/v1/*` 渐进暴露。
|
||||
|
||||
**Tech Stack:** Next.js App Router、TypeScript、Node `node:test`、现有文件型状态、可选 PostgreSQL JSONB 单行快照、Ant Design 管理后台。
|
||||
|
||||
---
|
||||
|
||||
## 执行状态
|
||||
|
||||
- 2026-04-27:已补齐认证安全默认值、状态存储适配层、租户强隔离、风险 SLA 通知账本、后台高危操作、增强审计字段和文档更新。
|
||||
- 待最终收口:全量 `tests/*.test.ts`、`npm run lint`、`npm run build`、`npm audit` 和服务器 smoke。
|
||||
|
||||
### Task 1: 认证安全默认值
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/api/auth/login/route.ts`
|
||||
- Modify: `src/app/auth/login/page.tsx`
|
||||
- Modify: `src/components/app-ui.tsx`
|
||||
- Test: `tests/auth-login-hardening-route.test.ts`
|
||||
|
||||
- [x] **Step 1: Write failing tests**
|
||||
|
||||
覆盖默认关闭免验证登录、显式 `BOSS_AUTH_AUTO_LOGIN=1` 才允许免验证、账号密码登录仍可用。
|
||||
|
||||
- [ ] **Step 2: Implement minimal auth hardening**
|
||||
|
||||
把 `shouldAllowTemporaryAutoLogin()` 改成只接受 `1 / true / yes`;登录页文案从“临时免验证”改成企业登录;固定验证码提示只在 fixed delivery 模式下展示。
|
||||
|
||||
- [ ] **Step 3: Verify**
|
||||
|
||||
Run: `npx tsx --test tests/auth-login-hardening-route.test.ts tests/auth-session-governance.test.ts`
|
||||
|
||||
### Task 2: 状态存储适配层
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/boss-state-store.ts`
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- Create: `scripts/postgres-state-schema.sql`
|
||||
- Test: `tests/boss-state-store.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
覆盖默认 `file` 模式、`BOSS_STATE_STORE=postgres` 但无 `BOSS_DATABASE_URL` 时 fail closed、PostgreSQL SQL schema 包含 `boss_state_snapshots` 和 `jsonb`。
|
||||
|
||||
- [ ] **Step 2: Implement adapter**
|
||||
|
||||
新增 `createBossStateStore()`,默认文件读写;PostgreSQL 模式先通过动态 `pg` 依赖实现单行 JSONB 快照,未安装或未配置时给出明确错误。
|
||||
|
||||
- [ ] **Step 3: Wire boss-data**
|
||||
|
||||
让 `readState/writeState/loadPersistedStateRaw` 通过 store 读写,保持 `mutateState` 事务队列不变。
|
||||
|
||||
### Task 3: 租户强隔离
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/boss-permissions.ts`
|
||||
- Modify: `src/lib/boss-admin-overview.ts`
|
||||
- Test: `tests/rbac-tenant-isolation.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
同公司账号能访问授权设备 / 项目;不同公司账号即使误授 grant 也不能访问;未绑定公司历史数据继续按 owner/grant 兼容。
|
||||
|
||||
- [ ] **Step 2: Implement tenant guard**
|
||||
|
||||
在非 `highest_admin` 路径中加入 `companyId` 比对。设备优先读 `device.companyId`,账号读 `authAccount.companyId`,项目通过绑定设备推导公司集合。
|
||||
|
||||
### Task 4: 风险 SLA 和通知账本
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- Create: `src/lib/boss-risk-notifications.ts`
|
||||
- Create: `src/app/api/v1/admin/risks/scan/route.ts`
|
||||
- Modify: `src/components/admin/boss-admin-app.tsx`
|
||||
- Test: `tests/admin-risk-sla-notifications-route.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
设置过期 SLA 后扫描会生成通知;重复扫描不重复生成同一风险通知;通知会进入管理后台总览。
|
||||
|
||||
- [x] **Step 2: Implement notification model**
|
||||
|
||||
新增 `adminNotifications`,字段包含 `notificationId / kind / severity / companyId / riskId / title / body / status / createdAt / acknowledgedAt`。
|
||||
|
||||
- [x] **Step 3: Implement scanner**
|
||||
|
||||
- [x] **Step 4: Implement dispatch and timeline**
|
||||
|
||||
新增 `/api/v1/admin/notifications/dispatch`,支持 sendmail 邮件通道或 disabled 模式状态落账;新增 `adminRiskTimeline` 记录通知生成、派发和人工处置。
|
||||
|
||||
扫描 `opsFaults` 和 `threadContextAlerts`,对 `slaDueAt < now` 且未关闭的风险生成 `risk_sla_overdue` 通知。
|
||||
|
||||
### Task 5: 后台高危操作补齐
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- Modify: `src/app/api/v1/admin/access/route.ts`
|
||||
- Modify: `src/components/admin/admin-access-panel.tsx`
|
||||
- Test: `tests/admin-access-enterprise-ops-route.test.ts`
|
||||
|
||||
- [x] **Step 1: Write failing tests**
|
||||
|
||||
最高管理员可停用公司、重置子账号密码、批量导入预检;停用公司会禁用该公司普通子账号并撤销会话;最高管理员账号不可被公司停用波及。
|
||||
|
||||
- [x] **Step 2: Implement actions**
|
||||
|
||||
新增 `set_company_status / reset_account_password / preview_bulk_import_accounts` 三个 action,并接到 PC 管理后台。
|
||||
|
||||
- [x] **Step 3: Implement enterprise UX polish**
|
||||
|
||||
补齐公司套餐、合同到期、客户成功、CSV 导入、危险操作确认和子账号 MFA 开关。
|
||||
|
||||
### Task 6: 增强审计字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- Modify: admin action routes under `src/app/api/v1/admin/*`
|
||||
- Test: `tests/admin-audit-compliance.test.ts`
|
||||
|
||||
- [x] **Step 1: Write failing tests**
|
||||
|
||||
高危动作写入 `ipAddress / userAgent / beforeJson / afterJson / requestId`;API 响应不泄露 `passwordHash / apiKey / sessionToken / restoreToken`。
|
||||
|
||||
- [x] **Step 2: Implement audit metadata**
|
||||
|
||||
扩展 `PermissionAuditLog`,新增 `buildRequestAuditMeta(request)`,所有 admin mutation route 传入审计上下文。
|
||||
|
||||
- [x] **Step 3: Implement auth hardening**
|
||||
|
||||
补齐浏览器 CSRF 基础防护、restore token 轮换和子账号 MFA 校验。
|
||||
|
||||
### Task 7: Docs, regression, deploy
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- Modify: `docs/architecture/api_and_service_inventory_cn.md`
|
||||
- Modify: `docs/architecture/admin_refine_backoffice_cn.md`
|
||||
|
||||
- [x] **Step 1: Update docs**
|
||||
|
||||
记录企业登录默认值、PostgreSQL 切换方式、租户隔离规则、风险 SLA 扫描和后台高危操作。
|
||||
|
||||
- [ ] **Step 2: Full verification**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx tsx --test tests/*.test.ts
|
||||
npm run lint
|
||||
npm run build
|
||||
npm audit
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Deploy and smoke**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
./scripts/deploy-server.sh
|
||||
curl -fsS https://boss.hyzq.net/api/health
|
||||
```
|
||||
|
||||
Post-deploy verify `/admin`、`/api/v1/admin/access`、`/api/v1/admin/overview`、`/api/v1/admin/risks/scan`。
|
||||
158
docs/superpowers/plans/2026-04-30-admin-backoffice-redesign.md
Normal file
158
docs/superpowers/plans/2026-04-30-admin-backoffice-redesign.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Boss Admin Backoffice Redesign Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rebuild `/admin` into a PC To B operations backoffice with a dashboard, customer workspace, permission workspace, and risk/governance command center.
|
||||
|
||||
**Architecture:** Keep the existing Next.js App Router route and existing admin APIs. Refactor the current client shell into focused React components under `src/components/admin/`, reusing `/api/v1/admin/overview`, `/api/v1/admin/access`, `/api/v1/admin/risks/actions`, and `/api/v1/admin/skills/requests`.
|
||||
|
||||
**Tech Stack:** Next.js 16 App Router, React 19, Ant Design, `@refinedev/core`, TypeScript source tests with `node:test`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Source Tests For New Admin Structure
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/admin-refine-page.test.ts`
|
||||
|
||||
- [ ] **Step 1: Update the admin structure assertions**
|
||||
|
||||
Replace the old page shell expectations with assertions for these strings:
|
||||
|
||||
```ts
|
||||
for (const title of ["平台运营驾驶舱", "客户与账号", "授权工作台", "风险与治理"]) {
|
||||
assert.match(source, new RegExp(title));
|
||||
}
|
||||
for (const title of ["今日待处理", "客户健康排行", "关键风险队列", "节点健康"]) {
|
||||
assert.match(source, new RegExp(title));
|
||||
}
|
||||
assert.doesNotMatch(source, /window\.prompt/);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx tsx --test tests/admin-refine-page.test.ts
|
||||
```
|
||||
|
||||
Expected: fail until the component is refactored.
|
||||
|
||||
### Task 2: Admin Shell And Navigation
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/admin/boss-admin-app.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace top tabs with PC backoffice navigation**
|
||||
|
||||
Implement a left-side navigation with four keys: `dashboard`, `customers`, `permissions`, `governance`.
|
||||
|
||||
- [ ] **Step 2: Keep Refine data provider mounted**
|
||||
|
||||
Keep:
|
||||
|
||||
```tsx
|
||||
<Refine dataProvider={createBossAdminDataProvider(initialOverview ?? undefined)} resources={resources}>
|
||||
```
|
||||
|
||||
Expected: existing data provider tests continue to pass.
|
||||
|
||||
### Task 3: Dashboard
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/admin/boss-admin-app.tsx`
|
||||
|
||||
- [ ] **Step 1: Build metric cards**
|
||||
|
||||
Use existing `summary`, `companies`, `devices`, `risks`, `notifications`.
|
||||
|
||||
- [ ] **Step 2: Build key queues**
|
||||
|
||||
Dashboard must include:
|
||||
|
||||
```tsx
|
||||
"今日待处理"
|
||||
"客户健康排行"
|
||||
"关键风险队列"
|
||||
"节点健康"
|
||||
"最近事件"
|
||||
```
|
||||
|
||||
Expected: no big full-width table as the first visual object.
|
||||
|
||||
### Task 4: Customer And Permission Workspaces
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/admin/boss-admin-app.tsx`
|
||||
- Reuse: `src/components/admin/admin-access-panel.tsx`
|
||||
|
||||
- [ ] **Step 1: Add customer overview section**
|
||||
|
||||
Show company table, account table, and customer onboarding hints.
|
||||
|
||||
- [ ] **Step 2: Mount `AdminAccessPanel` under 授权工作台**
|
||||
|
||||
Keep the existing working mutation path, but wrap it in clearer page copy and narrower visual hierarchy.
|
||||
|
||||
### Task 5: Risk Command Center
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/admin/boss-admin-app.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace prompt-based actions**
|
||||
|
||||
Remove `window.prompt` for assigning owner and SLA. Use controlled inline inputs and buttons.
|
||||
|
||||
- [ ] **Step 2: Keep existing risk actions**
|
||||
|
||||
Continue posting:
|
||||
|
||||
```ts
|
||||
{ riskId, action: "assign_owner", ownerAccount }
|
||||
{ riskId, action: "set_sla", slaDueAt }
|
||||
{ riskId, action: "ack" }
|
||||
{ riskId, action: "resolve" }
|
||||
{ riskId, action: "create_repair_ticket" }
|
||||
```
|
||||
|
||||
### Task 6: Skill Governance
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/admin/boss-admin-app.tsx`
|
||||
- Reuse: `src/components/admin/admin-skill-lifecycle-panel.tsx`
|
||||
|
||||
- [ ] **Step 1: Move Skill lifecycle panel under 风险与治理**
|
||||
|
||||
Use a nested Ant Design `Tabs` with `风险战情室` and `Skill 生命周期`.
|
||||
|
||||
### Task 7: Verification
|
||||
|
||||
**Files:**
|
||||
- Test: `tests/admin-refine-page.test.ts`
|
||||
- Test: `tests/admin-overview-route.test.ts`
|
||||
- Test: `tests/admin-risk-actions-route.test.ts`
|
||||
- Test: `tests/admin-skill-lifecycle-panel-source.test.ts`
|
||||
|
||||
- [ ] **Step 1: Run focused admin tests**
|
||||
|
||||
```bash
|
||||
npx tsx --test tests/admin-refine-page.test.ts tests/admin-overview-route.test.ts tests/admin-risk-actions-route.test.ts tests/admin-skill-lifecycle-panel-source.test.ts
|
||||
```
|
||||
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 2: Run lint/build**
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: both pass.
|
||||
|
||||
## Self-review
|
||||
|
||||
- Spec coverage: covers dashboard, customer workspace, permission workspace, risk command center, Skill governance, testing.
|
||||
- Placeholder scan: no TBD/TODO language.
|
||||
- Type consistency: component names and existing endpoints match current code.
|
||||
24
docs/superpowers/plans/2026-04-30-yudao-independent-admin.md
Normal file
24
docs/superpowers/plans/2026-04-30-yudao-independent-admin.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# YuDao 风格企业后台独立化实施计划
|
||||
|
||||
日期:2026-04-30
|
||||
|
||||
## 执行批次
|
||||
|
||||
第一批只做独立后台骨架和 BFF 契约,确保可以继续迭代而不影响现有 `/admin`。
|
||||
|
||||
## 步骤
|
||||
|
||||
1. 新增 `/api/v1/admin/backoffice` 的测试,覆盖 `highest_admin` 鉴权、YuDao 风格菜单、租户/账号/角色/资源/风险/审计数据,以及敏感字段不泄露。
|
||||
2. 新增 `apps/boss-admin-web` 源码测试,覆盖 Vue/Vite/Ant Design Vue 工程骨架、API 地址、登录态携带、核心页面文案和根工程隔离。
|
||||
3. 实现 Admin BFF,把 `buildAdminOverview(state)`、`BOSS_PERMISSION_TEMPLATES`、设备、项目、Skill、审计记录聚合为独立后台契约。
|
||||
4. 搭建独立 Vue 后台工程,提供工作台、租户、账号、角色权限、资源授权、Skill 中心、风险告警和审计日志页面骨架。
|
||||
5. 修改根工程 `tsconfig.json`、`eslint.config.mjs`,避免尚未安装 Vue 依赖时影响 Next 主站构建。
|
||||
6. 更新架构文档,说明独立后台、现有 `/admin` fallback、BFF 契约和后续部署方向。
|
||||
7. 运行专项测试、lint 和 build;如失败,修到通过再交付。
|
||||
|
||||
## 成功标准
|
||||
|
||||
- `/api/v1/admin/backoffice` 可被测试调用并返回稳定结构。
|
||||
- `apps/boss-admin-web` 具备可独立安装运行的 Vue/Vite 工程文件。
|
||||
- 根工程 lint/build 不受新独立前端影响。
|
||||
- 文档能说明为什么不整套引入 YuDao 后端,以及后续如何独立部署。
|
||||
405
docs/superpowers/plans/2026-05-09-desktop-dialog-guard.md
Normal file
405
docs/superpowers/plans/2026-05-09-desktop-dialog-guard.md
Normal file
@@ -0,0 +1,405 @@
|
||||
# Desktop Dialog Guard Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build a cross-platform dialog guard foundation so Boss desktop control can classify macOS and Windows popups, auto-handle safe prompts, and pause for user confirmation on risky prompts.
|
||||
|
||||
**Architecture:** Add a local-agent dialog policy engine with platform-neutral snapshots and signatures, expose `needs_user_action` from the computer-use runner, and wire the smoke runtime through the guard before executing desktop actions. macOS and Windows share policy/consent semantics while platform adapters provide snapshots through the same JSON shape.
|
||||
|
||||
**Tech Stack:** Node.js ESM, `node:test`, local-agent runtime scripts, Boss `desktop_control` payloads.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Policy Engine
|
||||
|
||||
**Files:**
|
||||
- Create: `local-agent/desktop-dialog-guard.mjs`
|
||||
- Test: `tests/local-agent-desktop-dialog-guard.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```js
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildDialogInterventionResult,
|
||||
createDialogSignature,
|
||||
evaluateDialogSnapshot,
|
||||
normalizeDialogSnapshot,
|
||||
} from "../local-agent/desktop-dialog-guard.mjs";
|
||||
|
||||
test("dialog guard auto-handles safe welcome prompts on macOS and Windows", () => {
|
||||
for (const platform of ["darwin", "win32"]) {
|
||||
const decision = evaluateDialogSnapshot({
|
||||
platform,
|
||||
appName: platform === "darwin" ? "Google Chrome" : "Microsoft Edge",
|
||||
title: "Welcome",
|
||||
text: "Welcome. Not now",
|
||||
buttons: ["Get started", "Not now"],
|
||||
});
|
||||
|
||||
assert.equal(decision.disposition, "auto_action");
|
||||
assert.equal(decision.action, "click_button");
|
||||
assert.equal(decision.button, "Not now");
|
||||
assert.equal(decision.risk, "safe");
|
||||
}
|
||||
});
|
||||
|
||||
test("dialog guard pauses for sensitive permission prompts", () => {
|
||||
const decision = evaluateDialogSnapshot({
|
||||
platform: "darwin",
|
||||
appName: "System Settings",
|
||||
title: "Screen Recording",
|
||||
text: "BossComputerUseHelper would like to record this computer's screen",
|
||||
buttons: ["Allow", "Don't Allow"],
|
||||
});
|
||||
|
||||
assert.equal(decision.disposition, "needs_user_action");
|
||||
assert.equal(decision.risk, "blocked");
|
||||
assert.equal(decision.kind, "permission_required");
|
||||
});
|
||||
|
||||
test("dialog guard generates stable signatures from normalized content", () => {
|
||||
const a = createDialogSignature({
|
||||
platform: "darwin",
|
||||
deviceId: "macbook-air",
|
||||
appBundleId: "com.google.Chrome",
|
||||
title: " Welcome ",
|
||||
text: "Not now",
|
||||
buttons: ["Not now", "OK"],
|
||||
});
|
||||
const b = createDialogSignature({
|
||||
platform: "darwin",
|
||||
deviceId: "macbook-air",
|
||||
appBundleId: "com.google.Chrome",
|
||||
title: "Welcome",
|
||||
text: " Not now ",
|
||||
buttons: ["Not now", "OK"],
|
||||
});
|
||||
|
||||
assert.equal(a.id, b.id);
|
||||
assert.equal(a.scopeKey, "darwin:macbook-air:com.google.Chrome");
|
||||
});
|
||||
|
||||
test("dialog guard emits app-safe intervention payload", () => {
|
||||
const snapshot = normalizeDialogSnapshot({
|
||||
platform: "win32",
|
||||
deviceId: "win-node",
|
||||
appName: "Installer",
|
||||
title: "User Account Control",
|
||||
text: "Do you want to allow this app to make changes to your device?",
|
||||
buttons: ["Yes", "No"],
|
||||
});
|
||||
const decision = evaluateDialogSnapshot(snapshot);
|
||||
const result = buildDialogInterventionResult({
|
||||
requestId: "desktop-task-1",
|
||||
snapshot,
|
||||
decision,
|
||||
});
|
||||
|
||||
assert.equal(result.status, "needs_user_action");
|
||||
assert.equal(result.kind, "dialog_intervention_required");
|
||||
assert.equal(result.risk, "blocked");
|
||||
assert.deepEqual(result.availableActions, ["handled_on_device", "cancel_task"]);
|
||||
assert.match(result.summary, /Installer/);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test tests/local-agent-desktop-dialog-guard.test.mjs`
|
||||
|
||||
Expected: FAIL because `local-agent/desktop-dialog-guard.mjs` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement minimal policy engine**
|
||||
|
||||
Create `local-agent/desktop-dialog-guard.mjs` with:
|
||||
|
||||
```js
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const SAFE_DISMISS_BUTTONS = ["稍后", "跳过", "以后再说", "Not now", "Skip", "Later", "Cancel"];
|
||||
const BLOCKED_TEXT_PATTERNS = [
|
||||
/screen recording/i,
|
||||
/accessibility/i,
|
||||
/input monitoring/i,
|
||||
/full disk access/i,
|
||||
/keychain/i,
|
||||
/administrator/i,
|
||||
/apple id/i,
|
||||
/user account control/i,
|
||||
/make changes to your device/i,
|
||||
/屏幕录制/,
|
||||
/辅助功能/,
|
||||
/输入监控/,
|
||||
/完整磁盘访问/,
|
||||
/钥匙串/,
|
||||
/管理员密码/,
|
||||
];
|
||||
|
||||
export function normalizeDialogText(value) {
|
||||
return String(value || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function normalizeDialogSnapshot(input = {}) {
|
||||
const buttons = Array.isArray(input.buttons)
|
||||
? input.buttons.map(normalizeDialogText).filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
platform: normalizeDialogText(input.platform || process.platform || "unknown"),
|
||||
deviceId: normalizeDialogText(input.deviceId || "unknown-device"),
|
||||
appName: normalizeDialogText(input.appName || input.app || "Unknown App"),
|
||||
appBundleId: normalizeDialogText(input.appBundleId || input.appId || input.appName || input.app || "unknown-app"),
|
||||
title: normalizeDialogText(input.title),
|
||||
text: normalizeDialogText(input.text),
|
||||
buttons,
|
||||
};
|
||||
}
|
||||
|
||||
function hash(value) {
|
||||
return createHash("sha256").update(value).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
export function createDialogSignature(snapshotInput = {}) {
|
||||
const snapshot = normalizeDialogSnapshot(snapshotInput);
|
||||
const titleHash = hash(snapshot.title.toLowerCase());
|
||||
const textHash = hash(snapshot.text.toLowerCase());
|
||||
const buttonHash = hash(snapshot.buttons.join("|").toLowerCase());
|
||||
return {
|
||||
id: hash([snapshot.platform, snapshot.deviceId, snapshot.appBundleId, titleHash, textHash, buttonHash].join("|")),
|
||||
scopeKey: [snapshot.platform, snapshot.deviceId, snapshot.appBundleId].join(":"),
|
||||
platform: snapshot.platform,
|
||||
deviceId: snapshot.deviceId,
|
||||
appBundleId: snapshot.appBundleId,
|
||||
titleHash,
|
||||
textHash,
|
||||
buttonHash,
|
||||
};
|
||||
}
|
||||
|
||||
function findSafeDismissButton(buttons) {
|
||||
return buttons.find((button) =>
|
||||
SAFE_DISMISS_BUTTONS.some((candidate) => candidate.toLowerCase() === button.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
function isBlockedPrompt(snapshot) {
|
||||
const combined = `${snapshot.title} ${snapshot.text}`;
|
||||
return BLOCKED_TEXT_PATTERNS.some((pattern) => pattern.test(combined));
|
||||
}
|
||||
|
||||
export function evaluateDialogSnapshot(snapshotInput = {}) {
|
||||
const snapshot = normalizeDialogSnapshot(snapshotInput);
|
||||
const signature = createDialogSignature(snapshot);
|
||||
if (isBlockedPrompt(snapshot)) {
|
||||
return {
|
||||
disposition: "needs_user_action",
|
||||
kind: "permission_required",
|
||||
risk: "blocked",
|
||||
action: "pause_for_user",
|
||||
signature,
|
||||
};
|
||||
}
|
||||
|
||||
const safeButton = findSafeDismissButton(snapshot.buttons);
|
||||
if (safeButton) {
|
||||
return {
|
||||
disposition: "auto_action",
|
||||
kind: "safe_dismiss",
|
||||
risk: "safe",
|
||||
action: "click_button",
|
||||
button: safeButton,
|
||||
signature,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
disposition: "needs_user_action",
|
||||
kind: "unknown_dialog",
|
||||
risk: "medium",
|
||||
action: "pause_for_user",
|
||||
signature,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDialogInterventionResult({ requestId, snapshot: snapshotInput, decision }) {
|
||||
const snapshot = normalizeDialogSnapshot(snapshotInput);
|
||||
const signature = decision?.signature || createDialogSignature(snapshot);
|
||||
const blocked = decision?.risk === "blocked";
|
||||
return {
|
||||
status: "needs_user_action",
|
||||
requestId: requestId || undefined,
|
||||
kind: "dialog_intervention_required",
|
||||
dialogId: signature.id,
|
||||
risk: decision?.risk || "medium",
|
||||
summary: `${snapshot.appName} 弹窗需要确认:${snapshot.title || snapshot.text || "未知弹窗"}`,
|
||||
recommendedAction: blocked ? "handle_on_device" : "review",
|
||||
availableActions: blocked
|
||||
? ["handled_on_device", "cancel_task"]
|
||||
: ["allow_once", "allow_for_device_dialog", "deny"],
|
||||
platform: snapshot.platform,
|
||||
appName: snapshot.appName,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test tests/local-agent-desktop-dialog-guard.test.mjs`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 2: Runner Result Support
|
||||
|
||||
**Files:**
|
||||
- Modify: `local-agent/computer-use-task-runner.mjs`
|
||||
- Test: `tests/local-agent-computer-use-runner.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Write failing parser test**
|
||||
|
||||
Append this test:
|
||||
|
||||
```js
|
||||
test("computer use runner parses dialog intervention runtime payload", () => {
|
||||
const result = parseComputerUseTaskResult(
|
||||
JSON.stringify({
|
||||
status: "needs_user_action",
|
||||
requestId: "desktop-task-dialog",
|
||||
kind: "dialog_intervention_required",
|
||||
dialogId: "dialog-1",
|
||||
risk: "medium",
|
||||
summary: "QQ 弹窗需要确认",
|
||||
recommendedAction: "review",
|
||||
availableActions: ["allow_once", "deny"],
|
||||
platform: "darwin",
|
||||
appName: "QQ",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.status, "needs_user_action");
|
||||
assert.equal(result.requestId, "desktop-task-dialog");
|
||||
assert.equal(result.kind, "dialog_intervention_required");
|
||||
assert.equal(result.dialogId, "dialog-1");
|
||||
assert.equal(result.risk, "medium");
|
||||
assert.equal(result.summary, "QQ 弹窗需要确认");
|
||||
assert.deepEqual(result.availableActions, ["allow_once", "deny"]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test tests/local-agent-computer-use-runner.test.mjs`
|
||||
|
||||
Expected: FAIL because `needs_user_action` is not parsed.
|
||||
|
||||
- [ ] **Step 3: Implement parser branch**
|
||||
|
||||
Update `parseComputerUseTaskResult` so `status === "needs_user_action"` returns a structured object containing requestId, kind, dialogId, risk, summary, recommendedAction, availableActions, platform and appName.
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `node --test tests/local-agent-computer-use-runner.test.mjs`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 3: Runtime Guard Integration
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/computer-use-smoke.mjs`
|
||||
- Test: `tests/browser-desktop-smoke-runtime-scripts.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Write failing runtime tests**
|
||||
|
||||
Add tests that run `scripts/computer-use-smoke.mjs` with `BOSS_DIALOG_GUARD_ENABLED=true` and `BOSS_DIALOG_GUARD_SNAPSHOT_JSON`.
|
||||
|
||||
The safe test should assert the runtime completes and includes a `dialogGuard` artifact entry. The blocked test should assert the runtime returns `needs_user_action` before executing the desktop action.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `node --test tests/browser-desktop-smoke-runtime-scripts.test.mjs`
|
||||
|
||||
Expected: FAIL because the smoke runtime ignores dialog guard env vars.
|
||||
|
||||
- [ ] **Step 3: Implement runtime preflight**
|
||||
|
||||
Import the dialog guard module, parse `BOSS_DIALOG_GUARD_ENABLED`, parse `BOSS_DIALOG_GUARD_SNAPSHOT_JSON`, evaluate it before desktop automation, return `needs_user_action` for pause decisions, and include auto-action audit info in artifacts for safe decisions.
|
||||
|
||||
- [ ] **Step 4: Run tests to verify pass**
|
||||
|
||||
Run: `node --test tests/browser-desktop-smoke-runtime-scripts.test.mjs`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 4: Config Defaults
|
||||
|
||||
**Files:**
|
||||
- Modify: `local-agent/config.example.json`
|
||||
- Modify: `local-agent/config.cloud.json`
|
||||
- Test: `tests/browser-desktop-runtime-config-defaults.test.mjs`
|
||||
|
||||
- [ ] **Step 1: Write failing config test**
|
||||
|
||||
Extend the default config test to assert `dialogGuardEnabled`, `dialogGuardPlatformAdapters`, and `dialogGuardConsentRequired` are present.
|
||||
|
||||
- [ ] **Step 2: Run test to verify failure**
|
||||
|
||||
Run: `node --test tests/browser-desktop-runtime-config-defaults.test.mjs`
|
||||
|
||||
Expected: FAIL because the config keys are absent.
|
||||
|
||||
- [ ] **Step 3: Add defaults**
|
||||
|
||||
Add:
|
||||
|
||||
```json
|
||||
"dialogGuardEnabled": true,
|
||||
"dialogGuardConsentRequired": true,
|
||||
"dialogGuardPlatformAdapters": ["darwin", "win32"]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify pass**
|
||||
|
||||
Run: `node --test tests/browser-desktop-runtime-config-defaults.test.mjs`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 5: Focused Verification
|
||||
|
||||
**Files:**
|
||||
- No production files.
|
||||
|
||||
- [ ] **Step 1: Run focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node --test tests/local-agent-desktop-dialog-guard.test.mjs \
|
||||
tests/local-agent-computer-use-runner.test.mjs \
|
||||
tests/browser-desktop-smoke-runtime-scripts.test.mjs \
|
||||
tests/browser-desktop-runtime-config-defaults.test.mjs
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 2: Run project checks**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
Expected: both PASS.
|
||||
|
||||
## Self-review
|
||||
|
||||
Spec coverage:
|
||||
|
||||
1. macOS and Windows are represented by platform-neutral snapshots and adapter-ready config keys.
|
||||
2. Safe auto handling, sensitive pause, signatures, APP-friendly intervention payloads and audit artifacts are covered.
|
||||
3. Full native AX/UIA helpers are intentionally deferred behind the adapter interface because this batch establishes the runtime contract first.
|
||||
|
||||
Placeholder scan: no unresolved placeholders.
|
||||
|
||||
Type consistency: `needs_user_action`, `dialogId`, `risk`, `summary`, `availableActions`, `platform`, and `appName` are consistent across plan tasks.
|
||||
@@ -0,0 +1,75 @@
|
||||
# Ruflo Governance And Dialog Guard Completion Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Finish the next Boss control-plane layer: Desktop Dialog Guard user confirmation/audit, plus Ruflo-inspired task ownership, capability grouping, and device trust foundations.
|
||||
|
||||
**Architecture:** Keep Ruflo as a reference only, not a runtime dependency. Add small Boss-native modules and route contracts that fit the current file-backed state store, Android realtime channel, local-agent desktop runtime, and existing RBAC/audit patterns.
|
||||
|
||||
**Tech Stack:** Next.js route handlers, TypeScript state helpers, Node test runner/tsx tests, Android Java/Robolectric, Boss SSE events, local-agent runtime payloads.
|
||||
|
||||
---
|
||||
|
||||
## Task A: Dialog Guard Backend Completion
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/boss-data.ts`
|
||||
- Modify: `src/app/api/v1/master-agent/tasks/[taskId]/complete/route.ts`
|
||||
- Create: `src/app/api/v1/dialog-guard/interventions/[interventionId]/decision/route.ts`
|
||||
- Test: `tests/dialog-guard-interventions-route.test.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
- [ ] Add a failing route test proving `status: "needs_user_action"` with `kind: "dialog_intervention_required"` creates a pending intervention and publishes `desktop.dialog_guard.intervention_required`.
|
||||
- [ ] Add a failing route test proving a user decision updates the intervention, writes a permission audit log, and publishes `desktop.dialog_guard.intervention_resolved`.
|
||||
- [ ] Add `DialogGuardIntervention` state shape and migration default.
|
||||
- [ ] Extend the master-agent completion route to preserve `needs_user_action` instead of normalizing it to completed.
|
||||
- [ ] Implement decision route with allowed decisions: `allow_once`, `allow_for_device_dialog`, `deny`, `handled_on_device`, `cancel_task`.
|
||||
- [ ] Run the focused backend tests.
|
||||
|
||||
## Task B: Android Dialog Guard Confirmation UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossApiClient.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossNotificationRouter.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Modify: `android/app/src/main/java/com/hyzq/boss/BossUi.java`
|
||||
- Test: `android/app/src/test/java/com/hyzq/boss/DialogGuardInterventionUiTest.java`
|
||||
|
||||
**Steps:**
|
||||
|
||||
- [ ] Add failing Robolectric test for handling `desktop.dialog_guard.intervention_required`.
|
||||
- [ ] Render a compact confirmation card/dialog using the current Boss/微信-style visual system.
|
||||
- [ ] For `blocked` risk, show only `我已在电脑上处理` and `取消任务`.
|
||||
- [ ] For medium/safe risk, show `允许本次`, `当前设备此弹窗允许`, `拒绝` based on `availableActions`.
|
||||
- [ ] Call `POST /api/v1/dialog-guard/interventions/{interventionId}/decision`.
|
||||
- [ ] Remove/refresh the card on `desktop.dialog_guard.intervention_resolved`.
|
||||
- [ ] Run the focused Android test.
|
||||
|
||||
## Task C: Ruflo-Inspired Governance Foundation
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/boss-work-claims.ts`
|
||||
- Create: `src/lib/boss-capability-groups.ts`
|
||||
- Create: `src/lib/boss-device-trust.ts`
|
||||
- Test: `tests/ruflo-governance-foundation.test.ts`
|
||||
|
||||
**Steps:**
|
||||
|
||||
- [ ] Add failing tests for claim, handoff, stale claim detection, and stealable work.
|
||||
- [ ] Add failing tests for grouped capabilities: computer control, Codex development, browser automation, skill operations, admin ops.
|
||||
- [ ] Add failing tests for device trust tiers and budget/hop limit checks.
|
||||
- [ ] Implement pure Boss-native modules without importing Ruflo.
|
||||
- [ ] Keep the API persistence-ready but not UI-bound.
|
||||
- [ ] Run the focused governance test.
|
||||
|
||||
## Integration Verification
|
||||
|
||||
- [ ] Run focused backend tests.
|
||||
- [ ] Run focused Android test if local Gradle supports it.
|
||||
- [ ] Run `npm run lint`.
|
||||
- [ ] Run `npm run build`.
|
||||
|
||||
## Notes
|
||||
|
||||
Ruflo is used as architecture reference only. The Boss implementation must stay deterministic, auditable, RBAC-aware, and safe for multi-tenant enterprise deployment.
|
||||
@@ -0,0 +1,181 @@
|
||||
# Codex Desktop 同线程消息镜像设计
|
||||
|
||||
目标:当用户在 Boss App 里对一个已绑定 `codexThreadRef` 的单线程会话发消息时,这条用户消息不仅进入 Boss 自己的项目账本和 `conversation_reply` 执行队列,也要被镜像进本机 Codex Desktop 的同一个线程历史里。这样用户稍后回到 Codex Desktop,看见的是同一个线程下连续的聊天记录,而不是 Boss 与 Desktop 两套割裂历史。
|
||||
|
||||
## 背景与现状
|
||||
|
||||
当前 Boss 的普通线程单聊主链是:
|
||||
|
||||
- Web / Android 调 `POST /api/v1/projects/[projectId]/messages`
|
||||
- 服务端写入 Boss 项目消息账本
|
||||
- 服务端排一个 `conversation_reply` 任务
|
||||
- 本机 `local-agent` 认领任务后调用 `codex exec resume <targetCodexThreadRef>`
|
||||
- Codex 线程完成后,再把线程回复回写到 Boss 项目账本
|
||||
|
||||
这条链现在已经能做到“Desktop 回复被 Boss 看见”,因为 heartbeat 扫描 `~/.codex/sessions/.../rollout-*.jsonl` 时,会把最近桌面 assistant 回复镜像回 Boss。缺口在反方向:
|
||||
|
||||
- Boss App 发起的用户消息只存在于 Boss 项目账本
|
||||
- `codex exec resume` 虽然会把 prompt 交给目标线程继续执行,但 Boss 发起的这条消息并不会先出现在 Desktop 线程历史
|
||||
- 结果就是用户在 APP 和 Desktop 里看到的“同一个线程”并不是同一份完整聊天记录
|
||||
|
||||
## 方案对比
|
||||
|
||||
### 方案 1:直接操控 Codex Desktop GUI 输入并发送
|
||||
|
||||
优点:
|
||||
|
||||
- 理论上最贴近“像用户在桌面端亲自发了一条消息”
|
||||
|
||||
缺点:
|
||||
|
||||
- 依赖窗口前台、焦点、输入法、系统权限
|
||||
- 极易被 Codex Desktop UI 更新打断
|
||||
- 无法稳定支持后台运行和多线程并发
|
||||
|
||||
不推荐作为主方案。
|
||||
|
||||
### 方案 2:直接把 Boss 用户消息写入对应 Codex rollout JSONL,再继续现有 `codex exec resume`
|
||||
|
||||
优点:
|
||||
|
||||
- 与当前 Desktop/CLI 共用的真实线程存储一致
|
||||
- 不需要操控 GUI
|
||||
- 可以保持现有 `local-agent -> codex exec resume` 主链不变
|
||||
- 能与现有 heartbeat 读取 rollout 的能力形成闭环
|
||||
|
||||
缺点:
|
||||
|
||||
- 需要谨慎贴合 Codex rollout 事件格式
|
||||
- 需要处理重复写入和 Desktop 刷新感知
|
||||
|
||||
这是本次推荐方案。
|
||||
|
||||
### 方案 3:单独给 Desktop 再建一条镜像线程
|
||||
|
||||
优点:
|
||||
|
||||
- 对现有线程文件侵入最小
|
||||
|
||||
缺点:
|
||||
|
||||
- 用户要的是“同一个线程”,不是“另一个镜像线程”
|
||||
- 历史会继续分叉
|
||||
|
||||
不满足目标。
|
||||
|
||||
## 本次设计
|
||||
|
||||
### 1. 保持 Boss 账本为移动端/UI 主真相
|
||||
|
||||
Boss 的项目消息账本、会话排序、未读数、主 Agent 协同逻辑继续基于现有 `boss-state.json`。这次不把 Boss UI 改成直接读取 `~/.codex`。
|
||||
|
||||
### 2. 对单线程 `conversation_reply` 任务增加“写入 Desktop 线程历史”的镜像步骤
|
||||
|
||||
当任务满足以下条件时,在 `local-agent` 侧做一次 rollout 镜像:
|
||||
|
||||
- `task.taskType === "conversation_reply"`
|
||||
- 存在 `targetCodexThreadRef`
|
||||
- 存在用户原始消息文本
|
||||
- 不属于 `relayViaMasterAgent === true` 的接管中转任务
|
||||
|
||||
镜像行为:
|
||||
|
||||
- 优先通过 `state_5.sqlite` 的 `threads.rollout_path` 定位目标 rollout 文件
|
||||
- 如果本机 Codex 因版本/迁移差异无法稳定解析 `state_5.sqlite`,则回退扫描 `~/.codex/sessions/**/rollout-*-<threadId>.jsonl`
|
||||
- 向该 rollout 文件追加一组 Codex 用户消息记录:`response_item / message(role=user)` 和 `event_msg / user_message`
|
||||
- 事件内容使用 Boss 原始用户消息文本,而不是执行 prompt
|
||||
- 事件时间优先使用 Boss 消息的 `sentAt`
|
||||
- 事件写入成功后,再继续现有 `codex exec resume`
|
||||
|
||||
### 3. 任务负载补齐“Boss 原始消息”字段
|
||||
|
||||
现在任务里只有:
|
||||
|
||||
- `requestMessageId`
|
||||
- `requestText`
|
||||
- `executionPrompt`
|
||||
|
||||
这还不够稳,因为后续去重和 Desktop 镜像需要区分:
|
||||
|
||||
- 哪条 Boss 用户消息已经镜像过
|
||||
- 这次镜像的真实显示文本是什么
|
||||
- 这条消息的原始时间戳是什么
|
||||
|
||||
因此为 `MasterAgentTask` 增加:
|
||||
|
||||
- `sourceMessageId?: string`
|
||||
- `sourceMessageBody?: string`
|
||||
- `sourceMessageSentAt?: string`
|
||||
- `mirrorBossUserMessageToCodexDesktop?: boolean`
|
||||
|
||||
对普通线程单聊:
|
||||
|
||||
- `sourceMessageId = message.id`
|
||||
- `sourceMessageBody = message.body`
|
||||
- `sourceMessageSentAt = message.sentAt`
|
||||
- `mirrorBossUserMessageToCodexDesktop = true`
|
||||
|
||||
对主 Agent 直聊、`@主Agent`、托管中转等不应写进子线程 Desktop 历史的场景,不开启这个标记。
|
||||
|
||||
### 4. 去重策略
|
||||
|
||||
同一条 Boss 消息可能因为:
|
||||
|
||||
- 任务重试
|
||||
- local-agent 重启
|
||||
- claim / complete 重放
|
||||
|
||||
而被多次处理。为避免在 Desktop 线程里重复写入同一条用户消息,本次采用“rollout 末尾去重”:
|
||||
|
||||
- 生成稳定镜像 key:`boss-user:<threadRef>:<sourceMessageId>`
|
||||
- 写入的 `event_msg` 中带上 `payload.metadata.bossSourceMessageId`
|
||||
- 写入前读取 rollout 尾部固定窗口,检查最近是否已经存在同一 `bossSourceMessageId`
|
||||
- 若存在,则跳过写入,仅继续 `codex exec resume`
|
||||
|
||||
这样不需要引入新的状态库,也能与 Codex 原始线程文件保持局部自洽。
|
||||
|
||||
### 5. 刷新感知
|
||||
|
||||
第一版只写 rollout 还不够稳,因为 Desktop 的线程列表排序和“最近活跃”判断通常还依赖 `threads.updated_at / updated_at_ms / has_user_event`。因此本次实现改为:
|
||||
|
||||
- rollout append 成功后,若 `state_5.sqlite` 可写且能命中该 thread,则同步刷新:
|
||||
- `updated_at`
|
||||
- `updated_at_ms`
|
||||
- `has_user_event = 1`
|
||||
- 如果当前机器上的 Codex 状态库不可用、字段不兼容或压根没有这条 thread 记录,则只保留 rollout 写入,不把整条消息链路判成失败
|
||||
|
||||
这样做的取舍是:
|
||||
|
||||
- 先保证 Boss -> Codex Desktop 同线程历史不丢
|
||||
- 再尽可能提升 Desktop 侧的列表刷新和最近活跃感知
|
||||
- 不引入 GUI 自动化,不依赖桌面窗口前台
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- 新增 `local-agent/codex-thread-rollout-writer.mjs`
|
||||
- 修改 `local-agent/codex-task-runner.mjs`
|
||||
- 修改 `local-agent/server.mjs`
|
||||
- 修改 `src/lib/boss-data.ts`
|
||||
- 修改 `src/app/api/v1/projects/[projectId]/messages/route.ts`
|
||||
- 修改 `src/lib/boss-master-agent.ts`(如果当前普通线程任务创建逻辑在这里有共用 helper,也一起补齐)
|
||||
- 新增测试 `tests/local-agent-codex-rollout-writer.test.mjs`
|
||||
- 修改测试 `tests/local-agent-codex-task-runner.test.mjs`
|
||||
- 修改测试 `tests/single-thread-message-execution.test.ts`
|
||||
|
||||
## 边界
|
||||
|
||||
- 本次只处理“Boss App -> 已绑定 Codex Desktop 同线程”的用户消息镜像
|
||||
- 不处理群聊镜像到 Desktop
|
||||
- 不处理主 Agent 自己的回复写入 Desktop 子线程
|
||||
- 不做 Codex Desktop GUI 自动输入
|
||||
- 不把 Boss 会话列表直接改成读取 Desktop 原始线程文件
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 普通单线程会话发消息后,生成的 `conversation_reply` 任务带有完整 `sourceMessage*` 字段
|
||||
- local-agent 在执行 `codex exec resume` 前,能把这条 Boss 用户消息写进目标 rollout
|
||||
- 同一 `sourceMessageId` 重试时不会重复写入 rollout
|
||||
- 若状态库可用,镜像后会同步刷新 thread 的活跃时间和 `has_user_event`
|
||||
- 若状态库不可用或这台机器上的线程索引不完整,仍可通过 `sessions` 回退找到 rollout 并完成消息镜像
|
||||
- 现有普通线程回复链不回归,Boss 仍能收到 Codex 线程回复
|
||||
- 若目标线程缺失、只读或 cwd 不合法,仍保持现有 fail-closed 行为
|
||||
@@ -0,0 +1,399 @@
|
||||
# Boss 聊天统一电脑控制中枢设计
|
||||
|
||||
目标:让用户在 Boss App 里,无论是和 `主 Agent` 对话,还是和某个具体线程对话,都可以稳定驱动这台 Mac/Windows 设备完成三类事情:
|
||||
|
||||
- 项目开发与代码执行
|
||||
- 浏览器/桌面 GUI 操作
|
||||
- 普通产品讨论、调研和任务协同
|
||||
|
||||
并且这三类能力不再割裂成几条旁路,而是统一挂在 Boss 现有的聊天、执行底座和设备心跳体系下面。
|
||||
|
||||
## 背景与现状
|
||||
|
||||
当前 Boss 已经具备几条关键基础链路:
|
||||
|
||||
- `master-agent` 单聊可以通过 `local-agent -> codex exec` 真实产出回复
|
||||
- 普通单线程聊天已经可以排 `conversation_reply` 任务,并恢复到真实 Codex 线程执行
|
||||
- 群聊已有 `group_dispatch_plan -> dispatch_execution` 的编排链
|
||||
- 设备模型已经支持同一台机器的 `GUI + CLI` 双能力声明与默认执行模式切换
|
||||
- 本机 `local-agent` 已能做 Codex 线程发现、task claim、task complete、Desktop rollout 镜像
|
||||
|
||||
但目前缺的不是“再加一个按钮”,而是统一控制中枢:
|
||||
|
||||
- `conversation_reply` 只适合“把消息转给 Codex 线程继续聊”
|
||||
- `dispatch_execution` 主要面向群聊下发和线程编排
|
||||
- 还没有正式的“桌面控制 / 浏览器控制”任务类型
|
||||
- 主 Agent 也没有显式的能力路由模型,无法稳定判断当前消息应该走:
|
||||
- Codex 开发
|
||||
- Browser automation
|
||||
- Computer Use
|
||||
- 单纯讨论/总结
|
||||
|
||||
这会导致现在的体验像“能做一些事”,但还不是“可以靠 Boss 聊天控制电脑做事”。
|
||||
|
||||
## 目标边界
|
||||
|
||||
### 本次要达到的能力
|
||||
|
||||
1. 主 Agent 能把用户消息识别成四类执行意图:
|
||||
- `project_development`
|
||||
- `thread_collaboration`
|
||||
- `browser_control`
|
||||
- `desktop_control`
|
||||
|
||||
2. Boss 执行底座能显式表达这四类请求,并把它们路由到正确 runtime。
|
||||
|
||||
3. `local-agent` 增加两条新 runtime:
|
||||
- `browser-automation-runtime`
|
||||
- `computer-use-runtime`
|
||||
|
||||
4. Web / Android 前台至少能拿到任务执行方式和当前状态,知道这条消息是:
|
||||
- 交给 Codex 线程
|
||||
- 交给浏览器自动化
|
||||
- 交给桌面控制
|
||||
- 仅由主 Agent 直接回复
|
||||
|
||||
5. 对高风险桌面动作建立最小确认机制,避免“发一句话就直接在电脑上乱点/乱删”。
|
||||
|
||||
### 本次不做的事情
|
||||
|
||||
- 不做完整远控桌面产品
|
||||
- 不做视频流式屏幕回传
|
||||
- 不做跨设备键鼠镜像
|
||||
- 不把 Codex Desktop 自己纳入 Computer Use 自动点击目标
|
||||
- 不依赖 GUI 自动化去操控 Codex 自己的窗口
|
||||
|
||||
## 方案原则
|
||||
|
||||
### 原则 1:复用 Boss 现有执行底座,不另起一套“远控系统”
|
||||
|
||||
如果我们再单独造一层 `remote-control service`,会把:
|
||||
|
||||
- 会话账本
|
||||
- 任务队列
|
||||
- 权限与确认
|
||||
- 前台状态展示
|
||||
- 设备能力发现
|
||||
|
||||
全部再复制一遍。成本高,而且会和当前 Boss 的“聊天即控制入口”相冲突。
|
||||
|
||||
所以本次明确采用:
|
||||
|
||||
- 用户入口仍然是 Boss 聊天
|
||||
- 任务记录仍然是 `MasterAgentTask`
|
||||
- 路由仍然收敛进 `src/lib/execution`
|
||||
- 执行仍然由绑定设备上的 `local-agent` 落地
|
||||
|
||||
### 原则 2:先把“控制判断”标准化,再扩 runtime
|
||||
|
||||
现在最大的问题不是工具不够,而是没有统一的“这条消息该怎么执行”判断结果。
|
||||
|
||||
因此要先补一层执行意图:
|
||||
|
||||
- `discussion_only`
|
||||
- `thread_reply`
|
||||
- `browser_control`
|
||||
- `desktop_control`
|
||||
- `development_execution`
|
||||
|
||||
然后让不同后端只关心自己该执行哪一种。
|
||||
|
||||
### 原则 3:危险动作永远要显式分级
|
||||
|
||||
Boss 最终要能“做任何事”,但不能把“任何事”理解成“任何时候都自动执行”。
|
||||
|
||||
所以本次引入最小风险分级:
|
||||
|
||||
- `low`
|
||||
- 打开页面
|
||||
- 搜索信息
|
||||
- 读取项目文件
|
||||
- 运行只读检查
|
||||
- `medium`
|
||||
- 登录态网页操作
|
||||
- 浏览器表单提交
|
||||
- 桌面应用点击导航
|
||||
- 修改非代码业务内容
|
||||
- `high`
|
||||
- 删除/覆盖文件
|
||||
- 系统设置改动
|
||||
- 批量提交/发布
|
||||
- 不可逆外部操作
|
||||
|
||||
策略:
|
||||
|
||||
- `low`:默认直接执行
|
||||
- `medium`:默认轻确认,可在项目/会话级放行
|
||||
- `high`:必须明确确认
|
||||
|
||||
## 控制中枢设计
|
||||
|
||||
## 1. 新的执行意图模型
|
||||
|
||||
在当前 `ExecutionRequestKind` 基础上新增:
|
||||
|
||||
- `browser_control`
|
||||
- `desktop_control`
|
||||
|
||||
并补充一个统一意图字段,供主 Agent 和前台共用:
|
||||
|
||||
- `intentCategory`
|
||||
- `discussion_only`
|
||||
- `project_development`
|
||||
- `thread_collaboration`
|
||||
- `browser_control`
|
||||
- `desktop_control`
|
||||
|
||||
其中:
|
||||
|
||||
- `project_development` 继续优先走现有 Codex 线程 / CLI 执行链
|
||||
- `thread_collaboration` 继续走 `conversation_reply`
|
||||
- `browser_control` 新增浏览器自动化 runtime
|
||||
- `desktop_control` 新增 Computer Use runtime
|
||||
|
||||
## 2. 新的 runtime 层
|
||||
|
||||
### 2.1 browser-automation-runtime
|
||||
|
||||
用途:
|
||||
|
||||
- 打开网页
|
||||
- 登录指定后台
|
||||
- 提交表单
|
||||
- 抓取页面信息
|
||||
- 复现 Web bug
|
||||
|
||||
第一版实现直接复用现有 Playwright 能力,不重新造驱动协议。
|
||||
|
||||
建议协议:
|
||||
|
||||
- 输入:
|
||||
- `taskId`
|
||||
- `projectId`
|
||||
- `requestText`
|
||||
- `executionPrompt`
|
||||
- `targetUrl?`
|
||||
- `riskLevel`
|
||||
- 输出:
|
||||
- `status`
|
||||
- `replyBody`
|
||||
- `structuredResult?`
|
||||
- `artifacts?`
|
||||
|
||||
落地约束:
|
||||
|
||||
- `local-agent/browser-control-task-runner.mjs` 先收口成外部 runtime 桥,不把 Playwright 逻辑硬编码进 `server.mjs`
|
||||
- 通过 `browserControlEnabled / browserControlCommand / browserControlArgs / browserControlWorkdir / browserControlTimeoutMs` 配置启用
|
||||
- runtime 进程只需要遵守单行 JSON stdout 协议,后续可以平滑替换成真实 Playwright/OpenClaw/browser adapter
|
||||
|
||||
### 2.2 computer-use-runtime
|
||||
|
||||
用途:
|
||||
|
||||
- 打开本机应用
|
||||
- 在桌面 GUI 上点击、输入、切换
|
||||
- 配合浏览器外的桌面软件完成操作
|
||||
|
||||
第一版实现直接对接 Codex App 现有的 Computer Use 能力约束:
|
||||
|
||||
- 只能操作普通桌面应用
|
||||
- 需要系统 Screen Recording + Accessibility
|
||||
- 不把终端/Codex 自己当作自动点击目标
|
||||
|
||||
这意味着:
|
||||
|
||||
- 项目开发仍然优先走 Codex CLI/线程
|
||||
- Computer Use 负责 GUI 世界
|
||||
- 两者由主 Agent 在同一条聊天链里自动选择
|
||||
|
||||
落地约束:
|
||||
|
||||
- `local-agent/computer-use-task-runner.mjs` 同样先做成外部 runtime 桥
|
||||
- 通过 `computerUseEnabled / computerUseCommand / computerUseArgs / computerUseWorkdir / computerUseTimeoutMs` 配置启用
|
||||
- 先统一 Boss 与 runtime 的协议,再按设备情况接 Codex App Computer Use、OpenClaw 或其他 GUI runtime
|
||||
|
||||
## 3. MasterAgentTask 扩展
|
||||
|
||||
当前 `MasterAgentTaskType` 只有:
|
||||
|
||||
- `conversation_reply`
|
||||
- `attachment_analysis`
|
||||
- `group_dispatch_plan`
|
||||
- `dispatch_execution`
|
||||
- `device_import_resolution`
|
||||
|
||||
本次新增:
|
||||
|
||||
- `browser_control`
|
||||
- `desktop_control`
|
||||
|
||||
新增字段:
|
||||
|
||||
- `intentCategory?`
|
||||
- `runtimeKind?`
|
||||
- `riskLevel?`
|
||||
- `confirmationPolicy?`
|
||||
- `requiresUserConfirmation?`
|
||||
- `confirmationScopeKey?`
|
||||
|
||||
目的:
|
||||
|
||||
- 前台能展示“当前这条消息要走哪条执行链”
|
||||
- 服务端能统一处理确认/拦截
|
||||
- `local-agent` 能按 runtimeKind 正确分流
|
||||
|
||||
## 4. 主 Agent 路由逻辑
|
||||
|
||||
主 Agent 不再简单分成“自己答”或“排 conversation_reply”,而是多一步意图判断。
|
||||
|
||||
推荐判断顺序:
|
||||
|
||||
1. 如果是明显的项目讨论、总结、目标/版本记录、普通问答
|
||||
- `discussion_only`
|
||||
|
||||
2. 如果是“继续开发 / 改代码 / 跑测试 / 看项目状态 / 接手某线程”
|
||||
- `project_development` 或 `thread_collaboration`
|
||||
|
||||
3. 如果是“打开网站 / 点网页 / 查后台 / 提交表单 / 看页面”
|
||||
- `browser_control`
|
||||
|
||||
4. 如果是“打开电脑软件 / 操作桌面 / 系统 GUI / 非浏览器界面”
|
||||
- `desktop_control`
|
||||
|
||||
路由结果:
|
||||
|
||||
- `discussion_only`
|
||||
- 主 Agent 直接回复
|
||||
- `thread_collaboration`
|
||||
- 继续 `conversation_reply`
|
||||
- `project_development`
|
||||
- 优先真实 Codex 线程 / CLI
|
||||
- `browser_control`
|
||||
- 排 `browser_control` 任务
|
||||
- `desktop_control`
|
||||
- 排 `desktop_control` 任务
|
||||
|
||||
## 5. 设备能力模型
|
||||
|
||||
当前设备只有:
|
||||
|
||||
- `gui`
|
||||
- `cli`
|
||||
|
||||
这对“统一控制电脑”不够精确,所以建议在设备 heartbeat 能力里细化为:
|
||||
|
||||
- `cli`
|
||||
- `gui`
|
||||
- `browserAutomation`
|
||||
- `computerUse`
|
||||
|
||||
其中:
|
||||
|
||||
- `browserAutomation` 可由本机 Playwright/runtime 探测
|
||||
- `computerUse` 由本机配置和权限状态探测
|
||||
|
||||
这样前台与主 Agent 都能知道:
|
||||
|
||||
- 当前机器只能写代码
|
||||
- 还是也能控浏览器
|
||||
- 还是能做完整桌面 GUI 操作
|
||||
|
||||
## 6. 前台产品表现
|
||||
|
||||
### 会话页 / 聊天页
|
||||
|
||||
每条“触发执行”的用户消息,服务端返回时增加:
|
||||
|
||||
- `executionMode`
|
||||
- `discussion`
|
||||
- `thread`
|
||||
- `development`
|
||||
- `browser`
|
||||
- `desktop`
|
||||
- `riskLevel`
|
||||
- `requiresConfirmation`
|
||||
|
||||
前台展示原则:
|
||||
|
||||
- 不做厚重控制台 UI
|
||||
- 保持当前微信式聊天界面
|
||||
- 只在消息下方补一条轻状态:
|
||||
- `已交给主 Agent`
|
||||
- `正在调用浏览器自动化`
|
||||
- `正在调用桌面控制`
|
||||
- `等待你确认后执行`
|
||||
|
||||
### 会话信息 / 设备详情
|
||||
|
||||
补一个轻量能力区:
|
||||
|
||||
- `默认开发模式:CLI / GUI`
|
||||
- `浏览器自动化:可用 / 不可用`
|
||||
- `桌面控制:可用 / 不可用`
|
||||
|
||||
不把这些塞回聊天主界面。
|
||||
|
||||
## 7. 风险确认设计
|
||||
|
||||
### 会话级别
|
||||
|
||||
如果当前会话在某个项目下已经对中风险动作做过一次确认,则可以对这个项目保留:
|
||||
|
||||
- `禁止`
|
||||
- `允许本次`
|
||||
- `当前项目永久放行`
|
||||
|
||||
这和现有 GUI/CLI 并行冲突的项目级策略一致,避免用户多学一套规则。
|
||||
|
||||
### 任务级别
|
||||
|
||||
当主 Agent 判断为高风险时:
|
||||
|
||||
- 不直接执行
|
||||
- 先在聊天里给出极简确认卡
|
||||
- 用户点确认后再排任务
|
||||
|
||||
## 8. 本次实施顺序
|
||||
|
||||
### 第一批
|
||||
|
||||
- 写设计与计划文档
|
||||
- 扩展任务类型、执行请求类型、设备能力类型
|
||||
- 接入 `browser_control / desktop_control` 两类任务基础骨架
|
||||
- `local-agent` 增加 runtime 分流占位
|
||||
- 前台返回 `executionMode/riskLevel` 元数据
|
||||
|
||||
### 第二批
|
||||
|
||||
- 接入 browser automation 真执行
|
||||
- 接入 computer use 真执行
|
||||
- 完成确认链
|
||||
|
||||
### 第三批
|
||||
|
||||
- Android/Web 前台补状态展示
|
||||
- 真机回归
|
||||
- 文档回写
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- 修改 `src/lib/execution/types.ts`
|
||||
- 修改 `src/lib/execution/tool-registry.ts`
|
||||
- 修改 `src/lib/execution/permission-policy.ts`
|
||||
- 修改 `src/lib/boss-data.ts`
|
||||
- 修改 `src/lib/boss-master-agent.ts`
|
||||
- 修改 `src/app/api/v1/projects/[projectId]/messages/route.ts`
|
||||
- 修改 `local-agent/codex-task-runner.mjs`
|
||||
- 修改 `local-agent/server.mjs`
|
||||
- 新增 `local-agent/browser-control-task-runner.mjs`
|
||||
- 新增 `local-agent/computer-use-task-runner.mjs`
|
||||
- 新增对应 tests
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 主 Agent 能把聊天输入稳定区分成讨论、开发、浏览器控制、桌面控制四类
|
||||
- `browser_control / desktop_control` 能以正式任务进入 Boss 队列
|
||||
- `local-agent` 能识别并分流这两类任务
|
||||
- 前台能看到当前消息是走哪条执行链
|
||||
- 中高风险动作不会静默直接执行
|
||||
- 现有 `conversation_reply / dispatch_execution` 主链不回归
|
||||
151
docs/superpowers/specs/2026-04-30-admin-backoffice-redesign.md
Normal file
151
docs/superpowers/specs/2026-04-30-admin-backoffice-redesign.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# Boss To B 总后台重构设计
|
||||
|
||||
日期:2026-04-30
|
||||
|
||||
## 背景
|
||||
|
||||
当前 `/admin` 已经具备最高管理员访问控制、总览聚合、账号授权、风险处理和 Skill 生命周期治理能力,但页面仍像“几个数据表拼在一起”。对于 To B 交付场景,平台侧需要的是一套能服务客户成功、运维值守和权限开通的 PC 总后台,而不是一个调试看板。
|
||||
|
||||
本次重构不新增大业务边界,优先重组现有 `/api/v1/admin/overview`、`/api/v1/admin/access`、`/api/v1/admin/risks/actions`、`/api/v1/admin/skills/requests` 数据和动作,把后台做成可用、可读、可处置的运营控制台。
|
||||
|
||||
## 目标
|
||||
|
||||
1. 最高管理员进入后台后,能在 10 秒内看出哪些客户、设备、主 Agent 任务或线程风险需要处理。
|
||||
2. 客户开通从“多个分散表单”收口成可理解的工作台:公司、老板账号、子账号、设备、项目、Skill 授权有清晰入口和状态。
|
||||
3. 风险处理从“表格按钮”升级为战情室:按严重程度、客户影响、负责人、SLA 和下一步动作组织。
|
||||
4. Skill 治理保留安全约束,但展示成可追踪的生命周期队列。
|
||||
5. UI 风格从移动端微信效率风改为 PC To B 管理后台:高密度、强层级、清晰状态、少装饰。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不引入新的 Umi / Ant Design Pro 工程。
|
||||
- 不切换 PostgreSQL 或重写状态存储。
|
||||
- 不改 Android APP 端交互。
|
||||
- 不绕过 local-agent 的 Skill allowlist、checksum、备份和回滚约束。
|
||||
- 不把客户侧 Web 控制台和平台总后台混成一个产品。
|
||||
|
||||
## 信息架构
|
||||
|
||||
后台改为 4 个一级区:
|
||||
|
||||
1. `驾驶舱`:平台全局健康、关键风险、客户影响、在线设备、主 Agent 失败、待处理通知。
|
||||
2. `客户与账号`:公司列表、客户详情、账号开通、角色状态、登录与安全概览。
|
||||
3. `授权工作台`:设备、项目、Skill 授权,权限模板,过期授权,离职回收和审计。
|
||||
4. `风险与治理`:风险战情室、SLA、负责人、修复工单、风险时间线、Skill 生命周期请求。
|
||||
|
||||
现有 `账号与授权` 和 `Skill 治理` 不是删除,而是拆到更合理的上下文里:账号归客户,授权归权限,风险和 Skill 请求归治理。
|
||||
|
||||
## 页面设计
|
||||
|
||||
### 驾驶舱
|
||||
|
||||
顶部保留平台身份和刷新动作,但标题从“Boss 管理后台”升级为“平台运营驾驶舱”。主区域按优先级展示:
|
||||
|
||||
- `今日待处理`:关键风险数、超 SLA 通知、离线设备、主 Agent 失败。
|
||||
- `客户健康排行`:按开放风险、在线设备比例、合同/套餐状态排序。
|
||||
- `关键风险队列`:只展示最值得处理的风险,提供负责人、SLA、确认、关闭、工单动作。
|
||||
- `设备与节点健康`:GUI/CLI、Browser、Computer Use 能力状态集中展示。
|
||||
- `最近事件`:风险时间线和权限审计摘要。
|
||||
|
||||
驾驶舱默认不展示大分页表,避免用户一打开就被表格淹没。
|
||||
|
||||
### 客户与账号
|
||||
|
||||
采用左侧客户列表 + 右侧详情的结构:
|
||||
|
||||
- 客户列表显示公司名、套餐、账号数、设备数、开放风险、客户成功负责人。
|
||||
- 右侧详情显示老板账号、子账号、绑定设备、项目数量和最近风险。
|
||||
- 新建客户流程拆成三步:创建公司、创建老板账号、绑定设备/项目。
|
||||
- 子账号管理支持启用/停用、重置密码、MFA 状态和登录会话摘要。
|
||||
|
||||
这部分复用现有 `/api/v1/admin/access`,但前台从表单堆叠改成任务流。
|
||||
|
||||
### 授权工作台
|
||||
|
||||
授权页面按“给谁授权”而不是“授权类型”组织:
|
||||
|
||||
- 先选择账号或客户。
|
||||
- 再选择设备、项目、Skill。
|
||||
- 最后套用权限模板或手动勾选权限。
|
||||
|
||||
页面底部保留最近授权审计和过期授权提醒。高危动作继续二次确认。
|
||||
|
||||
### 风险与治理
|
||||
|
||||
风险页面采用“战情室”结构:
|
||||
|
||||
- 左侧风险队列:按 `critical / warning / info`、客户、负责人、SLA 筛选。
|
||||
- 中间风险详情:影响对象、错误摘要、最近时间线、建议动作。
|
||||
- 右侧处理面板:指派、设置 SLA、确认、关闭、创建工单。
|
||||
|
||||
Skill 生命周期治理放在同一区域的第二页签,展示为请求队列:
|
||||
|
||||
- 待认领、执行中、成功、失败分栏。
|
||||
- 每条请求展示设备、Skill、动作、来源、checksum、结果摘要。
|
||||
- 创建请求表单保留,但根据动作动态收敛字段。
|
||||
|
||||
## 组件边界
|
||||
|
||||
建议拆出以下组件,降低当前 `boss-admin-app.tsx` 的复杂度:
|
||||
|
||||
- `AdminShell`:PC 后台壳、顶部栏、一级导航。
|
||||
- `AdminDashboard`:驾驶舱。
|
||||
- `AdminCustomerWorkspace`:客户与账号工作台。
|
||||
- `AdminPermissionWorkspace`:授权工作台。
|
||||
- `AdminRiskCommandCenter`:风险战情室。
|
||||
- `AdminSkillGovernance`:Skill 生命周期治理,可复用并改造当前组件。
|
||||
- `AdminStatusBadge`、`AdminMetricCard`、`AdminActionRail`:统一状态、指标和动作区。
|
||||
|
||||
数据请求先继续使用现有 fetch,不强制引入新的客户端状态库。
|
||||
|
||||
## 数据和接口
|
||||
|
||||
第一批不要求新增后端字段,但前台应完整使用现有字段:
|
||||
|
||||
- `summary`
|
||||
- `companies`
|
||||
- `accounts`
|
||||
- `devices`
|
||||
- `risks`
|
||||
- `notifications`
|
||||
- `riskTimeline`
|
||||
- `grantsSummary`
|
||||
|
||||
如果发现页面需要客户健康分数,可先在前端由 `openRiskCount / onlineDeviceCount / deviceCount / status` 计算,不改状态 schema。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 后台总览读取失败时展示一张明确的恢复卡,提供重试按钮。
|
||||
- 风险动作失败时保留原行状态,不做乐观关闭。
|
||||
- 指派负责人和 SLA 不再使用 `window.prompt`,改成右侧处理面板或弹窗表单。
|
||||
- 空状态要表达下一步,例如“暂无风险,可以查看设备在线情况”,不要只写“暂无数据”。
|
||||
|
||||
## 测试策略
|
||||
|
||||
- 保留并更新 `tests/admin-refine-page.test.ts`,验证新的一级区和关键文案。
|
||||
- 增加组件 source 测试,确认不再使用 `window.prompt` 做风险指派和 SLA。
|
||||
- 复跑 `tests/admin-overview-route.test.ts`、`tests/admin-risk-actions-route.test.ts`、`tests/admin-skill-lifecycle-panel-source.test.ts`。
|
||||
- 最后跑 `npm run lint` 和相关 Node 测试。
|
||||
|
||||
## 分批落地
|
||||
|
||||
第一批直接做到可用:
|
||||
|
||||
1. 重构 `BossAdminApp` 外壳和一级导航。
|
||||
2. 做新版驾驶舱。
|
||||
3. 做风险战情室,替换 `window.prompt`。
|
||||
4. 账号授权和 Skill 治理先迁入新结构,并压缩视觉层级。
|
||||
|
||||
第二批再增强:
|
||||
|
||||
1. 客户详情抽屉。
|
||||
2. 新建客户三步流程。
|
||||
3. 风险筛选和搜索。
|
||||
4. 客户健康分数和趋势。
|
||||
|
||||
## 自检
|
||||
|
||||
- 无 TBD / TODO。
|
||||
- 范围聚焦 `/admin` PC 总后台,不触碰 APP。
|
||||
- 没有要求新增大后端能力,优先复用现有接口。
|
||||
- 关键交互从数据表改成工作台与战情室,解决“后台管理不太好”的主要问题。
|
||||
@@ -0,0 +1,75 @@
|
||||
# YuDao 风格企业后台独立化设计
|
||||
|
||||
日期:2026-04-30
|
||||
|
||||
## 背景
|
||||
|
||||
Boss 需要从“客户也能用的 Web 页面”升级为平台侧 To B 总后台。这个后台用于平台运营人员管理公司、老板账号、子账号、电脑节点、Skill 授权、风险告警和审计记录。现有 `/admin` 已能展示核心数据,但仍运行在 Next 主站内,信息架构不够像成熟企业后台,后续不适合承载更复杂的租户、权限和治理能力。
|
||||
|
||||
调研 `YunaiV/yudao-cloud` 后,结论是:不直接引入它的 Spring Cloud 微服务后端;借鉴它的租户、用户、角色、菜单、日志、工作台和独立前端思路。前端形态参考 YuDao 的 Vben/Vue 管理后台,数据仍由 Boss 现有状态账本和 Admin BFF 提供。
|
||||
|
||||
## 目标
|
||||
|
||||
第一批目标是完成企业后台独立化的可运行骨架:
|
||||
|
||||
- 新增独立 PC 后台工程 `apps/boss-admin-web`,使用 Vue + Vite + Ant Design Vue。
|
||||
- 新增 `/api/v1/admin/backoffice` 聚合接口,输出 YuDao 风格的菜单、工作台、租户、账号、角色权限、资源授权、风险和审计数据。
|
||||
- 保留现有 `/admin`,作为 Boss 主站内 fallback,不和独立后台互相替代。
|
||||
- 后台权限继续只允许 `highest_admin` 访问,不暴露密码哈希、MFA 密钥和会话令牌。
|
||||
- 新后台先复用 Boss Cookie 登录态,后续再接独立域名 `admin.boss.hyzq.net`。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不引入 YuDao Java 后端、MySQL 表结构或微服务网关。
|
||||
- 不在第一批替换所有现有后台 mutation 页面。
|
||||
- 不重新设计 Android APP。
|
||||
- 不改变当前 Boss 文件存储运行时。
|
||||
|
||||
## 架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Boss Admin Web\nVue + Ant Design Vue"] --> B["/api/v1/admin/backoffice\nAdmin BFF"]
|
||||
B --> C["boss-state.json\n当前状态账本"]
|
||||
B --> D["buildAdminOverview\n现有后台聚合"]
|
||||
B --> E["BOSS_PERMISSION_TEMPLATES\n权限模板"]
|
||||
F["现有 /admin\nNext fallback"] --> G["/api/v1/admin/overview"]
|
||||
```
|
||||
|
||||
`apps/boss-admin-web` 是独立前端工程。它只消费 BFF,不直接读取本地文件,也不复制业务规则。`/api/v1/admin/backoffice` 是企业后台的新契约层,负责把 Boss 当前状态翻译为更稳定的后台管理模型。
|
||||
|
||||
## 数据模型
|
||||
|
||||
第一批 BFF 返回:
|
||||
|
||||
- `menuTree`:工作台、租户管理、账号管理、角色权限、资源授权、Skill 中心、风险告警、审计日志、系统设置。
|
||||
- `workbench`:总览指标、客户健康、设备健康、风险摘要。
|
||||
- `tenants`:公司列表、套餐、负责人、账号数、设备数、风险数。
|
||||
- `users`:账号、昵称、角色、状态、公司、最近登录。
|
||||
- `roles`:内置角色和权限模板。
|
||||
- `resourceGroups`:设备、项目线程和 Skill 目录。
|
||||
- `audit`:风险、风险时间线和权限审计。
|
||||
- `yudaoMapping`:Boss 账本字段到后台概念的映射,便于后续迁移数据库或接 YuDao 风格模块。
|
||||
|
||||
## UI 方向
|
||||
|
||||
第一批 UI 只做高保真骨架,不新增业务动作:
|
||||
|
||||
- 左侧固定菜单,右侧工作区。
|
||||
- 顶部展示当前账号、后台说明和刷新入口。
|
||||
- 工作台使用指标卡、风险横幅、客户健康和节点表。
|
||||
- 租户、账号、角色、资源、风险、审计分别使用独立区块或表格。
|
||||
- Skill 中心聚合展示 Skill 目录、来源、设备数和治理状态,后续再接完整安装向导。
|
||||
|
||||
## 权限与安全
|
||||
|
||||
- 未登录返回 `401`。
|
||||
- 非 `highest_admin` 返回 `403`。
|
||||
- BFF 只返回安全账号字段,不返回 `passwordHash`、`mfaSecret`、`authSessions` 或任何 session token。
|
||||
- 所有返回头使用 `private, no-store`,避免后台数据被缓存。
|
||||
|
||||
## 验证
|
||||
|
||||
- 新增 BFF 路由测试,验证鉴权、菜单结构、数据聚合和敏感字段过滤。
|
||||
- 新增独立前端源代码测试,验证工程骨架、API 契约、核心页面模块和根工程隔离。
|
||||
- 跑 `npm run lint` 和 `npm run build`,确认不会破坏现有 Next 主站。
|
||||
@@ -14,6 +14,8 @@ const eslintConfig = defineConfig([
|
||||
"main-*.js",
|
||||
"android/.gradle/**",
|
||||
"android/**/build/**",
|
||||
"apps/boss-admin-web/**",
|
||||
"public/admin-web/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
208
local-agent/browser-control-task-runner.mjs
Normal file
208
local-agent/browser-control-task-runner.mjs
Normal file
@@ -0,0 +1,208 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
|
||||
function parseBoolean(value) {
|
||||
return String(value || "").trim().toLowerCase() === "true";
|
||||
}
|
||||
|
||||
function parseArgs(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseTimeoutMs(value) {
|
||||
const parsed = Number.parseInt(String(value || ""), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 45000;
|
||||
}
|
||||
|
||||
function pickConfigValue(config, key, fallback) {
|
||||
if (config && config[key] !== undefined && config[key] !== null && `${config[key]}`.trim() !== "") {
|
||||
return config[key];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function resolveCommandArgs(command, args, cwd) {
|
||||
const runtimeName = path.basename(command || "").toLowerCase();
|
||||
const scriptRuntimes = new Set([
|
||||
"node",
|
||||
"node.exe",
|
||||
"tsx",
|
||||
"tsx.cmd",
|
||||
"bun",
|
||||
"bun.exe",
|
||||
"deno",
|
||||
"deno.exe",
|
||||
]);
|
||||
if (!scriptRuntimes.has(runtimeName) || args.length === 0) {
|
||||
return args;
|
||||
}
|
||||
const [first, ...rest] = args;
|
||||
if (!first || first.startsWith("-")) {
|
||||
return args;
|
||||
}
|
||||
return [path.isAbsolute(first) ? first : path.resolve(cwd || process.cwd(), first), ...rest];
|
||||
}
|
||||
|
||||
function parseJsonLine(rawOutput) {
|
||||
const lines = String(rawOutput || "")
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
return JSON.parse(lines.at(-1) || "");
|
||||
}
|
||||
|
||||
export function getBrowserControlTaskRunnerConfig(env = process.env, config = {}) {
|
||||
const enabled = parseBoolean(pickConfigValue(config, "browserControlEnabled", env.BOSS_BROWSER_CONTROL_ENABLED));
|
||||
const command = String(pickConfigValue(config, "browserControlCommand", env.BOSS_BROWSER_CONTROL_COMMAND) || "").trim() || undefined;
|
||||
const args = Array.isArray(config?.browserControlArgs)
|
||||
? config.browserControlArgs.map((item) => String(item)).filter(Boolean)
|
||||
: parseArgs(pickConfigValue(config, "browserControlArgs", env.BOSS_BROWSER_CONTROL_ARGS));
|
||||
const cwd = String(pickConfigValue(config, "browserControlWorkdir", env.BOSS_BROWSER_CONTROL_WORKDIR) || "").trim() || undefined;
|
||||
const timeoutMs = parseTimeoutMs(pickConfigValue(config, "browserControlTimeoutMs", env.BOSS_BROWSER_CONTROL_TIMEOUT_MS));
|
||||
return {
|
||||
enabled,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
timeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
export function canHandleBrowserControlTask(task) {
|
||||
return String(task?.taskType || "").trim() === "browser_control";
|
||||
}
|
||||
|
||||
export function buildBrowserControlTaskExecution(config, task) {
|
||||
if (!config?.enabled) {
|
||||
throw new Error("BROWSER_CONTROL_RUNTIME_DISABLED");
|
||||
}
|
||||
if (!config?.command) {
|
||||
throw new Error("BROWSER_CONTROL_COMMAND_REQUIRED");
|
||||
}
|
||||
|
||||
const cwd = config.cwd || process.cwd();
|
||||
return {
|
||||
command: config.command,
|
||||
args: resolveCommandArgs(config.command, config.args || [], cwd),
|
||||
cwd,
|
||||
timeoutMs: config.timeoutMs || 45000,
|
||||
stdinPayload: {
|
||||
requestKind: "browser_control",
|
||||
requestId: String(task?.taskId || "").trim(),
|
||||
objective: String(task?.requestText || task?.executionPrompt || "").trim(),
|
||||
context: {
|
||||
projectId: String(task?.projectId || "").trim() || undefined,
|
||||
threadId: String(task?.threadId || task?.targetThreadId || "").trim() || undefined,
|
||||
requestedBy: String(task?.requestedByAccount || task?.requestedBy || "").trim() || undefined,
|
||||
requestedAt: String(task?.requestedAt || "").trim() || undefined,
|
||||
confirmationScopeKey: String(task?.confirmationScopeKey || "").trim() || undefined,
|
||||
riskLevel: String(task?.riskLevel || "").trim() || undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseBrowserControlTaskResult(rawOutput) {
|
||||
const parsed = parseJsonLine(rawOutput);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("INVALID_BROWSER_CONTROL_RUNTIME_PAYLOAD");
|
||||
}
|
||||
|
||||
if (parsed.status === "failed") {
|
||||
return {
|
||||
status: "failed",
|
||||
requestId: typeof parsed.requestId === "string" ? parsed.requestId.trim() || undefined : undefined,
|
||||
errorMessage:
|
||||
typeof parsed.error === "string" && parsed.error.trim()
|
||||
? parsed.error.trim()
|
||||
: "BROWSER_CONTROL_FAILED",
|
||||
};
|
||||
}
|
||||
|
||||
const replyBody =
|
||||
typeof parsed.replyBody === "string" && parsed.replyBody.trim()
|
||||
? parsed.replyBody.trim()
|
||||
: typeof parsed.summary === "string" && parsed.summary.trim()
|
||||
? parsed.summary.trim()
|
||||
: "";
|
||||
if (!replyBody) {
|
||||
throw new Error("INVALID_BROWSER_CONTROL_RUNTIME_PAYLOAD");
|
||||
}
|
||||
|
||||
return {
|
||||
status: "completed",
|
||||
requestId: typeof parsed.requestId === "string" ? parsed.requestId.trim() || undefined : undefined,
|
||||
replyBody,
|
||||
targetUrl:
|
||||
typeof parsed.targetUrl === "string" && parsed.targetUrl.trim()
|
||||
? parsed.targetUrl.trim()
|
||||
: undefined,
|
||||
executionSummary:
|
||||
typeof parsed.executionSummary === "string" && parsed.executionSummary.trim()
|
||||
? parsed.executionSummary.trim()
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeBrowserControlTask(task, config = {}) {
|
||||
const runnerConfig = getBrowserControlTaskRunnerConfig(process.env, config);
|
||||
if (!runnerConfig.enabled) {
|
||||
return {
|
||||
status: "failed",
|
||||
errorMessage: "BROWSER_CONTROL_RUNTIME_DISABLED",
|
||||
};
|
||||
}
|
||||
|
||||
const execution = buildBrowserControlTaskExecution(runnerConfig, task);
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(execution.command, execution.args, {
|
||||
cwd: execution.cwd,
|
||||
env: process.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGKILL");
|
||||
}, execution.timeoutMs);
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (timedOut) {
|
||||
reject(new Error("BROWSER_CONTROL_TIMEOUT"));
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `browser control exit code ${code}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(parseBrowserControlTaskResult(stdout));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
child.stdin.write(JSON.stringify(execution.stdinPayload));
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
335
local-agent/codex-desktop-refresh-bridge.mjs
Normal file
335
local-agent/codex-desktop-refresh-bridge.mjs
Normal file
@@ -0,0 +1,335 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
|
||||
function parseBoolean(value) {
|
||||
return String(value || "").trim().toLowerCase() === "true";
|
||||
}
|
||||
|
||||
function parseArgs(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseTimeoutMs(value) {
|
||||
const parsed = Number.parseInt(String(value || ""), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 3000;
|
||||
}
|
||||
|
||||
function parseNonNegativeInteger(value, fallback) {
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function trimToDefined(value) {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function pickConfigValue(config, key, fallback) {
|
||||
if (config && config[key] !== undefined && config[key] !== null && `${config[key]}`.trim() !== "") {
|
||||
return config[key];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function resolveCommandArgs(command, args, cwd) {
|
||||
const runtimeName = path.basename(command || "").toLowerCase();
|
||||
const scriptRuntimes = new Set([
|
||||
"node",
|
||||
"node.exe",
|
||||
"tsx",
|
||||
"tsx.cmd",
|
||||
"bun",
|
||||
"bun.exe",
|
||||
"deno",
|
||||
"deno.exe",
|
||||
]);
|
||||
if (!scriptRuntimes.has(runtimeName) || args.length === 0) {
|
||||
return args;
|
||||
}
|
||||
const [first, ...rest] = args;
|
||||
if (!first || first.startsWith("-")) {
|
||||
return args;
|
||||
}
|
||||
return [path.isAbsolute(first) ? first : path.resolve(cwd || process.cwd(), first), ...rest];
|
||||
}
|
||||
|
||||
function parseJsonLine(rawOutput) {
|
||||
const lines = String(rawOutput || "")
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
return JSON.parse(lines.at(-1) || "");
|
||||
}
|
||||
|
||||
export function getCodexDesktopRefreshBridgeConfig(env = process.env, config = {}) {
|
||||
const enabled = parseBoolean(
|
||||
pickConfigValue(config, "codexDesktopRefreshEnabled", env.BOSS_CODEX_DESKTOP_REFRESH_ENABLED),
|
||||
);
|
||||
const command =
|
||||
trimToDefined(
|
||||
pickConfigValue(config, "codexDesktopRefreshCommand", env.BOSS_CODEX_DESKTOP_REFRESH_COMMAND),
|
||||
) || undefined;
|
||||
const endpoint =
|
||||
trimToDefined(
|
||||
pickConfigValue(config, "codexDesktopRefreshEndpoint", env.BOSS_CODEX_DESKTOP_REFRESH_ENDPOINT),
|
||||
) || undefined;
|
||||
const args = Array.isArray(config?.codexDesktopRefreshArgs)
|
||||
? config.codexDesktopRefreshArgs.map((item) => String(item)).filter(Boolean)
|
||||
: parseArgs(pickConfigValue(config, "codexDesktopRefreshArgs", env.BOSS_CODEX_DESKTOP_REFRESH_ARGS));
|
||||
const cwd =
|
||||
trimToDefined(
|
||||
pickConfigValue(config, "codexDesktopRefreshWorkdir", env.BOSS_CODEX_DESKTOP_REFRESH_WORKDIR),
|
||||
) || undefined;
|
||||
const timeoutMs = parseTimeoutMs(
|
||||
pickConfigValue(config, "codexDesktopRefreshTimeoutMs", env.BOSS_CODEX_DESKTOP_REFRESH_TIMEOUT_MS),
|
||||
);
|
||||
const appName =
|
||||
trimToDefined(pickConfigValue(config, "codexDesktopRefreshAppName", env.BOSS_CODEX_DESKTOP_APP_NAME)) ||
|
||||
"Codex";
|
||||
const refreshMode =
|
||||
trimToDefined(
|
||||
pickConfigValue(config, "codexDesktopRefreshMode", env.BOSS_CODEX_DESKTOP_REFRESH_MODE),
|
||||
) || "deeplink-reload";
|
||||
const retryCount = parseNonNegativeInteger(
|
||||
pickConfigValue(config, "codexDesktopRefreshRetryCount", env.BOSS_CODEX_DESKTOP_REFRESH_RETRY_COUNT),
|
||||
2,
|
||||
);
|
||||
const retryDelayMs = parseNonNegativeInteger(
|
||||
pickConfigValue(config, "codexDesktopRefreshRetryDelayMs", env.BOSS_CODEX_DESKTOP_REFRESH_RETRY_DELAY_MS),
|
||||
120,
|
||||
);
|
||||
return {
|
||||
enabled,
|
||||
endpoint,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
timeoutMs,
|
||||
appName,
|
||||
refreshMode,
|
||||
retryCount,
|
||||
retryDelayMs,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCodexDesktopRefreshPayload(config, mirrorHint) {
|
||||
if (!config?.enabled) {
|
||||
throw new Error("CODEX_DESKTOP_REFRESH_DISABLED");
|
||||
}
|
||||
const targetThreadRef = trimToDefined(mirrorHint?.targetThreadRef);
|
||||
const sourceMessageId = trimToDefined(mirrorHint?.sourceMessageId);
|
||||
if (!targetThreadRef || !sourceMessageId) {
|
||||
throw new Error("CODEX_DESKTOP_REFRESH_HINT_REQUIRED");
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "codex_desktop_refresh_hint",
|
||||
targetThreadRef,
|
||||
sourceMessageId,
|
||||
rolloutPath: trimToDefined(mirrorHint?.rolloutPath),
|
||||
threadTouchStatus: trimToDefined(mirrorHint?.threadTouchStatus),
|
||||
appName: trimToDefined(config.appName) || "Codex",
|
||||
refreshMode: trimToDefined(config.refreshMode) || "deeplink-reload",
|
||||
requestedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCodexDesktopRefreshExecution(config, mirrorHint) {
|
||||
if (!config?.enabled) {
|
||||
throw new Error("CODEX_DESKTOP_REFRESH_DISABLED");
|
||||
}
|
||||
if (!config?.command) {
|
||||
throw new Error("CODEX_DESKTOP_REFRESH_COMMAND_REQUIRED");
|
||||
}
|
||||
|
||||
const cwd = config.cwd || process.cwd();
|
||||
return {
|
||||
command: config.command,
|
||||
args: resolveCommandArgs(config.command, config.args || [], cwd),
|
||||
cwd,
|
||||
timeoutMs: config.timeoutMs || 3000,
|
||||
stdinPayload: buildCodexDesktopRefreshPayload(config, mirrorHint),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCodexDesktopRefreshResult(rawOutput) {
|
||||
const parsed = parseJsonLine(rawOutput);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("INVALID_CODEX_DESKTOP_REFRESH_PAYLOAD");
|
||||
}
|
||||
const attemptCount = parseNonNegativeInteger(parsed.attemptCount, undefined);
|
||||
const baseResult = {
|
||||
targetThreadRef: trimToDefined(parsed.targetThreadRef),
|
||||
appName: trimToDefined(parsed.appName),
|
||||
deepLink: trimToDefined(parsed.deepLink),
|
||||
attemptCount,
|
||||
};
|
||||
if (parsed.status === "failed") {
|
||||
return {
|
||||
status: "failed",
|
||||
...baseResult,
|
||||
detail: trimToDefined(parsed.error) || "CODEX_DESKTOP_REFRESH_FAILED",
|
||||
};
|
||||
}
|
||||
if (parsed.status !== "completed" && parsed.status !== "skipped") {
|
||||
throw new Error("INVALID_CODEX_DESKTOP_REFRESH_PAYLOAD");
|
||||
}
|
||||
return {
|
||||
status: parsed.status,
|
||||
...baseResult,
|
||||
detail: trimToDefined(parsed.detail),
|
||||
};
|
||||
}
|
||||
|
||||
function compactUndefinedFields(result) {
|
||||
return Object.fromEntries(Object.entries(result).filter(([, value]) => value !== undefined));
|
||||
}
|
||||
|
||||
function attachBridgeAttemptCount(result, attemptIndex) {
|
||||
if (result.attemptCount !== undefined || attemptIndex > 1) {
|
||||
return compactUndefinedFields({
|
||||
...result,
|
||||
attemptCount: result.attemptCount ?? attemptIndex,
|
||||
});
|
||||
}
|
||||
return compactUndefinedFields(result);
|
||||
}
|
||||
|
||||
function runCodexDesktopRefreshExecution(execution) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(execution.command, execution.args, {
|
||||
cwd: execution.cwd,
|
||||
env: process.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGKILL");
|
||||
}, execution.timeoutMs);
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (timedOut) {
|
||||
reject(new Error("CODEX_DESKTOP_REFRESH_TIMEOUT"));
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `codex desktop refresh exit code ${code}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(parseCodexDesktopRefreshResult(stdout));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
child.stdin.write(JSON.stringify(execution.stdinPayload));
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function runCodexDesktopRefreshEndpoint(config, payload) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, config.timeoutMs || 3000);
|
||||
try {
|
||||
const response = await fetch(config.endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const body = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(body.trim() || `codex desktop refresh endpoint status ${response.status}`);
|
||||
}
|
||||
return parseCodexDesktopRefreshResult(body);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeWithRetries(operation, runnerConfig) {
|
||||
const maxAttempts = Math.max(1, parseNonNegativeInteger(runnerConfig.retryCount, 2) + 1);
|
||||
const retryDelayMs = parseNonNegativeInteger(runnerConfig.retryDelayMs, 120);
|
||||
let lastError;
|
||||
let lastFailedResult;
|
||||
|
||||
for (let attemptIndex = 1; attemptIndex <= maxAttempts; attemptIndex += 1) {
|
||||
try {
|
||||
const result = attachBridgeAttemptCount(await operation(), attemptIndex);
|
||||
if (result.status !== "failed") {
|
||||
return result;
|
||||
}
|
||||
lastFailedResult = result;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
if (attemptIndex < maxAttempts) {
|
||||
await sleep(retryDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
if (lastFailedResult) {
|
||||
return attachBridgeAttemptCount(lastFailedResult, maxAttempts);
|
||||
}
|
||||
|
||||
const message = lastError instanceof Error ? lastError.message : String(lastError || "CODEX_DESKTOP_REFRESH_FAILED");
|
||||
throw new Error(`${message}; attempts=${maxAttempts}`);
|
||||
}
|
||||
|
||||
export async function executeCodexDesktopRefreshBridge(mirrorHint, config = {}) {
|
||||
const runnerConfig =
|
||||
config && Object.prototype.hasOwnProperty.call(config, "enabled")
|
||||
? config
|
||||
: getCodexDesktopRefreshBridgeConfig(process.env, config);
|
||||
if (!runnerConfig.enabled) {
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "disabled",
|
||||
};
|
||||
}
|
||||
|
||||
if (runnerConfig.endpoint) {
|
||||
const endpointPayload = buildCodexDesktopRefreshPayload(runnerConfig, mirrorHint);
|
||||
try {
|
||||
return await executeWithRetries(() => runCodexDesktopRefreshEndpoint(runnerConfig, endpointPayload), runnerConfig);
|
||||
} catch (error) {
|
||||
if (!runnerConfig.command) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const execution = buildCodexDesktopRefreshExecution(runnerConfig, mirrorHint);
|
||||
return executeWithRetries(() => runCodexDesktopRefreshExecution(execution), runnerConfig);
|
||||
}
|
||||
@@ -1,26 +1,130 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import os from "node:os";
|
||||
import { basename, resolve } from "node:path";
|
||||
import { basename, dirname, resolve } from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { open, readFile, readdir } from "node:fs/promises";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
|
||||
|
||||
const MAX_ROLLOUT_TAIL_BYTES = 768 * 1024;
|
||||
const MAX_RECENT_ASSISTANT_MESSAGES = 6;
|
||||
const ASSISTANT_DUPLICATE_TURN_WINDOW_MS = 2_000;
|
||||
const LEAKED_TITLE_PREFIXES = [
|
||||
"你当前接手的项目根目录是",
|
||||
"你现在接手的项目根目录是",
|
||||
"你现在以目标线程身份直接回复用户",
|
||||
"你正在向主 Agent 同步当前项目状态",
|
||||
"只回复对用户真正有用的内容",
|
||||
"只输出 JSON",
|
||||
];
|
||||
const LEAKED_TITLE_CONTAINS = [
|
||||
"不要发送内部字段",
|
||||
"不要自称主 Agent",
|
||||
"不要解释系统如何分发",
|
||||
"不要输出 JSON",
|
||||
"项目名称:",
|
||||
"线程名称:",
|
||||
"文件夹:",
|
||||
"同步原因:",
|
||||
"当前消息:",
|
||||
"用户当前消息:",
|
||||
];
|
||||
|
||||
function toIsoFromUnixSeconds(value) {
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
return new Date(value * 1000).toISOString();
|
||||
}
|
||||
|
||||
function sanitizeDisplayName(raw, fallback) {
|
||||
function normalizeDisplayName(raw) {
|
||||
const source = typeof raw === "string" ? raw : "";
|
||||
const firstLine = source
|
||||
.replace(/\u0000/g, "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
if (!firstLine) return fallback;
|
||||
const compact = firstLine.replace(/\s+/g, " ").trim();
|
||||
if (!compact) return fallback;
|
||||
return compact.length > 48 ? `${compact.slice(0, 45)}...` : compact;
|
||||
if (!firstLine) return "";
|
||||
return firstLine.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function trimWorkspacePrefix(value) {
|
||||
const normalized = normalizeDisplayName(value).replaceAll("\\", "/");
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
return normalized
|
||||
.replace(/^\/Users\/[^/]+\/code\//i, "")
|
||||
.replace(/^\/home\/[^/]+\/code\//i, "")
|
||||
.replace(/^[A-Za-z]:\/Users\/[^/]+\/code\//i, "");
|
||||
}
|
||||
|
||||
function stripTrailingDisplayNameNoise(value) {
|
||||
return value.replace(/['"}\]]{2,}$/g, "").trimEnd();
|
||||
}
|
||||
|
||||
function looksLikeLeakedDisplayName(value) {
|
||||
const normalized = normalizeDisplayName(value);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
LEAKED_TITLE_PREFIXES.some((marker) => normalized.startsWith(marker)) ||
|
||||
LEAKED_TITLE_CONTAINS.some((marker) => normalized.includes(marker))
|
||||
);
|
||||
}
|
||||
|
||||
function extractWorkspaceProjectName(value) {
|
||||
const normalized = normalizeDisplayName(value).replaceAll("\\", "/");
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
const patterns = [
|
||||
/\/Users\/[^/]+\/code\/([^/\s"'`,。;!?]+)/i,
|
||||
/\/home\/[^/]+\/code\/([^/\s"'`,。;!?]+)/i,
|
||||
/[A-Za-z]:\/Users\/[^/]+\/code\/([^/\s"'`,。;!?]+)/i,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = normalized.match(pattern);
|
||||
if (match?.[1]) {
|
||||
return match[1].split("/")[0]?.trim() ?? "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function pickDisplayNameFallback(candidates) {
|
||||
for (const candidate of candidates) {
|
||||
const extracted = extractWorkspaceProjectName(candidate);
|
||||
if (extracted && !looksLikeLeakedDisplayName(extracted)) {
|
||||
return extracted;
|
||||
}
|
||||
const normalized = stripTrailingDisplayNameNoise(trimWorkspacePrefix(candidate));
|
||||
if (normalized && !looksLikeLeakedDisplayName(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function sanitizeDisplayName(raw, fallback, options = {}) {
|
||||
const compact = stripTrailingDisplayNameNoise(trimWorkspacePrefix(raw));
|
||||
if (compact && !looksLikeLeakedDisplayName(raw) && !looksLikeLeakedDisplayName(compact)) {
|
||||
return compact.length > 48 ? `${compact.slice(0, 45)}...` : compact;
|
||||
}
|
||||
|
||||
const extractedProject = extractWorkspaceProjectName(raw);
|
||||
if (extractedProject && !looksLikeLeakedDisplayName(extractedProject)) {
|
||||
return extractedProject;
|
||||
}
|
||||
|
||||
const safeFallback = pickDisplayNameFallback([
|
||||
options.folderName,
|
||||
options.folderPath,
|
||||
fallback,
|
||||
]);
|
||||
if (safeFallback) {
|
||||
return safeFallback;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function fallbackDisplayName(thread, folderName) {
|
||||
@@ -119,7 +223,7 @@ function loadThreadsFromStateDb(stateDbPath) {
|
||||
try {
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT id, cwd, updated_at, archived, title, sandbox_policy, agent_nickname, agent_role FROM threads WHERE archived = 0 ORDER BY updated_at DESC",
|
||||
"SELECT id, cwd, updated_at, archived, title, sandbox_policy, agent_nickname, agent_role, rollout_path, source FROM threads WHERE archived = 0 ORDER BY updated_at DESC",
|
||||
)
|
||||
.all()
|
||||
.map((row) => ({
|
||||
@@ -131,6 +235,8 @@ function loadThreadsFromStateDb(stateDbPath) {
|
||||
sandboxPolicy: typeof row.sandbox_policy === "string" ? row.sandbox_policy : "",
|
||||
agentNickname: typeof row.agent_nickname === "string" ? row.agent_nickname : "",
|
||||
agentRole: typeof row.agent_role === "string" ? row.agent_role : "",
|
||||
rolloutPath: typeof row.rollout_path === "string" ? row.rollout_path : "",
|
||||
source: typeof row.source === "string" ? row.source : "",
|
||||
}));
|
||||
} finally {
|
||||
db.close();
|
||||
@@ -154,16 +260,45 @@ function parseSessionMeta(line) {
|
||||
title: "",
|
||||
agentNickname: typeof parsed.payload.agent_nickname === "string" ? parsed.payload.agent_nickname : "",
|
||||
agentRole: typeof parsed.payload.agent_role === "string" ? parsed.payload.agent_role : "",
|
||||
rolloutPath: "",
|
||||
source: "sessions",
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadThreadsFromSessions(sessionsDir) {
|
||||
function parseRolloutFilenameTimestampSeconds(fileName) {
|
||||
const match = fileName.match(
|
||||
/^rollout-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-/,
|
||||
);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const [, datePart, hour, minute, second] = match;
|
||||
const parsed = new Date(`${datePart}T${hour}:${minute}:${second}`);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return Math.floor(parsed.getTime() / 1000);
|
||||
}
|
||||
|
||||
function shouldReadSessionFile(fileName, cutoffSeconds) {
|
||||
if (!Number.isFinite(cutoffSeconds)) {
|
||||
return true;
|
||||
}
|
||||
const filenameSeconds = parseRolloutFilenameTimestampSeconds(fileName);
|
||||
if (filenameSeconds === null) {
|
||||
return true;
|
||||
}
|
||||
return filenameSeconds >= cutoffSeconds;
|
||||
}
|
||||
|
||||
async function loadThreadsFromSessions(sessionsDir, options = {}) {
|
||||
if (!sessionsDir) return [];
|
||||
const pending = [resolve(sessionsDir)];
|
||||
const threads = [];
|
||||
const cutoffSeconds = Number(options.cutoffSeconds);
|
||||
while (pending.length > 0) {
|
||||
const dir = pending.pop();
|
||||
if (!dir) continue;
|
||||
@@ -180,11 +315,12 @@ async function loadThreadsFromSessions(sessionsDir) {
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
|
||||
if (!shouldReadSessionFile(entry.name, cutoffSeconds)) continue;
|
||||
try {
|
||||
const raw = await readFile(fullPath, "utf8");
|
||||
const firstLine = raw.split(/\r?\n/, 1)[0];
|
||||
const parsed = parseSessionMeta(firstLine);
|
||||
if (parsed) threads.push(parsed);
|
||||
if (parsed) threads.push({ ...parsed, rolloutPath: fullPath });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
@@ -193,10 +329,165 @@ async function loadThreadsFromSessions(sessionsDir) {
|
||||
return threads;
|
||||
}
|
||||
|
||||
function normalizeEventTimestamp(value) {
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
function buildAssistantMessageId(threadId, sentAt, body) {
|
||||
const digest = createHash("sha1").update(body).digest("hex").slice(0, 12);
|
||||
return `codex-thread:${threadId}:${sentAt}:${digest}`;
|
||||
}
|
||||
|
||||
function assistantMessageTimeValue(value) {
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function assistantPhasePriority(value) {
|
||||
const phase = normalizeAssistantMessagePhase(value);
|
||||
if (!phase) return 0;
|
||||
if (phase === "final_answer" || phase === "final" || phase === "answer") return 3;
|
||||
if (phase === "commentary" || phase === "process" || phase === "thinking") return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function normalizeAssistantMessagePhase(value) {
|
||||
const phase = trimToDefined(value);
|
||||
return phase || undefined;
|
||||
}
|
||||
|
||||
async function readRolloutTail(rolloutPath) {
|
||||
if (!rolloutPath) return "";
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(resolve(rolloutPath), "r");
|
||||
const stats = await handle.stat();
|
||||
const start = Math.max(0, stats.size - MAX_ROLLOUT_TAIL_BYTES);
|
||||
const length = Math.max(0, stats.size - start);
|
||||
if (length === 0) {
|
||||
return "";
|
||||
}
|
||||
const buffer = Buffer.alloc(length);
|
||||
await handle.read(buffer, 0, length, start);
|
||||
let text = buffer.toString("utf8");
|
||||
if (start > 0) {
|
||||
const firstNewline = text.indexOf("\n");
|
||||
text = firstNewline >= 0 ? text.slice(firstNewline + 1) : "";
|
||||
}
|
||||
return text;
|
||||
} catch {
|
||||
return "";
|
||||
} finally {
|
||||
await handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function parseRecentAssistantMessage(line, threadId) {
|
||||
if (!line.trim()) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
let body = "";
|
||||
let phase;
|
||||
if (parsed?.type === "event_msg" && parsed?.payload?.type === "agent_message") {
|
||||
body = typeof parsed.payload.message === "string" ? parsed.payload.message.trim() : "";
|
||||
phase = normalizeAssistantMessagePhase(parsed.payload.phase);
|
||||
} else if (
|
||||
parsed?.type === "response_item" &&
|
||||
parsed?.payload?.type === "message" &&
|
||||
parsed?.payload?.role === "assistant"
|
||||
) {
|
||||
const content = Array.isArray(parsed.payload.content) ? parsed.payload.content : [];
|
||||
body = content
|
||||
.map((item) => (typeof item?.text === "string" ? item.text.trim() : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
phase = normalizeAssistantMessagePhase(parsed.payload.phase);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
const sentAt = normalizeEventTimestamp(parsed.timestamp ?? parsed.payload.timestamp);
|
||||
if (!body || !sentAt) {
|
||||
return null;
|
||||
}
|
||||
const message = {
|
||||
messageId: buildAssistantMessageId(threadId, sentAt, body),
|
||||
body,
|
||||
sentAt,
|
||||
};
|
||||
return phase ? { ...message, phase } : message;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeRecentAssistantMessage(existing, incoming) {
|
||||
if (!existing) return incoming;
|
||||
const existingPriority = assistantPhasePriority(existing.phase);
|
||||
const incomingPriority = assistantPhasePriority(incoming.phase);
|
||||
if (!incoming.phase || existingPriority >= incomingPriority) return existing;
|
||||
return {
|
||||
...existing,
|
||||
phase: incoming.phase,
|
||||
};
|
||||
}
|
||||
|
||||
function isDuplicateAssistantTurn(existing, incoming) {
|
||||
if (existing.body !== incoming.body) return false;
|
||||
const existingTime = assistantMessageTimeValue(existing.sentAt);
|
||||
const incomingTime = assistantMessageTimeValue(incoming.sentAt);
|
||||
if (!existingTime || !incomingTime) return false;
|
||||
return Math.abs(existingTime - incomingTime) <= ASSISTANT_DUPLICATE_TURN_WINDOW_MS;
|
||||
}
|
||||
|
||||
function isGuiCodexThreadSource(value) {
|
||||
const source = trimToDefined(value);
|
||||
if (!source) return false;
|
||||
if (source === "cli" || source === "exec" || source === "sessions") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function loadRecentAssistantMessages(rolloutPath, threadId) {
|
||||
const tail = await readRolloutTail(rolloutPath);
|
||||
if (!tail) return [];
|
||||
const messagesById = new Map();
|
||||
for (const line of tail.split(/\r?\n/)) {
|
||||
const message = parseRecentAssistantMessage(line, threadId);
|
||||
if (!message) continue;
|
||||
const duplicateEntry = [...messagesById.entries()].find(([, existing]) =>
|
||||
isDuplicateAssistantTurn(existing, message),
|
||||
);
|
||||
if (duplicateEntry) {
|
||||
const [duplicateId, existing] = duplicateEntry;
|
||||
messagesById.set(duplicateId, mergeRecentAssistantMessage(existing, message));
|
||||
continue;
|
||||
}
|
||||
messagesById.set(message.messageId, mergeRecentAssistantMessage(messagesById.get(message.messageId), message));
|
||||
}
|
||||
return [...messagesById.values()]
|
||||
.sort((left, right) => left.sentAt.localeCompare(right.sentAt))
|
||||
.slice(-MAX_RECENT_ASSISTANT_MESSAGES);
|
||||
}
|
||||
|
||||
function requireText(filePath) {
|
||||
return readFileSync(resolve(filePath), "utf8");
|
||||
}
|
||||
|
||||
function resolveDefaultSessionsDir(options = {}) {
|
||||
if (options.sessionsDir) {
|
||||
return options.sessionsDir;
|
||||
}
|
||||
if (options.stateDbPath) {
|
||||
return resolve(dirname(resolve(options.stateDbPath)), "sessions");
|
||||
}
|
||||
return resolve(os.homedir(), ".codex/sessions");
|
||||
}
|
||||
|
||||
export async function discoverCodexProjectCandidates(options = {}) {
|
||||
const now = options.now instanceof Date ? options.now : new Date();
|
||||
const lookbackHours = Number.isFinite(options.lookbackHours) ? Number(options.lookbackHours) : 24;
|
||||
@@ -209,17 +500,17 @@ export async function discoverCodexProjectCandidates(options = {}) {
|
||||
options.logsDbPath ?? resolve(os.homedir(), ".codex/logs_1.sqlite"),
|
||||
);
|
||||
|
||||
let threads = loadThreadsFromStateDb(
|
||||
const stateDbThreads = loadThreadsFromStateDb(
|
||||
options.stateDbPath ?? resolve(os.homedir(), ".codex/state_5.sqlite"),
|
||||
);
|
||||
if (threads.length === 0) {
|
||||
threads = await loadThreadsFromSessions(
|
||||
options.sessionsDir ?? resolve(os.homedir(), ".codex/sessions"),
|
||||
);
|
||||
}
|
||||
const sessionThreads = await loadThreadsFromSessions(resolveDefaultSessionsDir(options), {
|
||||
cutoffSeconds,
|
||||
});
|
||||
const threads = [...stateDbThreads, ...sessionThreads];
|
||||
|
||||
const seenThreadIds = new Set();
|
||||
const groupedCandidates = new Map();
|
||||
let guiConnected = false;
|
||||
for (const thread of threads) {
|
||||
if (!thread?.id || seenThreadIds.has(thread.id)) continue;
|
||||
if (isReadOnlySandboxPolicy(thread.sandboxPolicy)) {
|
||||
@@ -231,6 +522,7 @@ export async function discoverCodexProjectCandidates(options = {}) {
|
||||
}
|
||||
|
||||
seenThreadIds.add(thread.id);
|
||||
guiConnected = guiConnected || isGuiCodexThreadSource(thread.source);
|
||||
const hintedPath = workspaceHints.get(thread.id);
|
||||
const folderPath = resolve(hintedPath || thread.cwd || "");
|
||||
const folderName = basename(folderPath);
|
||||
@@ -239,8 +531,16 @@ export async function discoverCodexProjectCandidates(options = {}) {
|
||||
const sessionName = sessionNames.get(thread.id)?.threadName;
|
||||
const displayName = sanitizeDisplayName(
|
||||
sessionName,
|
||||
sanitizeDisplayName(thread.title, fallbackDisplayName(thread, folderName)),
|
||||
sanitizeDisplayName(thread.title, fallbackDisplayName(thread, folderName), {
|
||||
folderName,
|
||||
folderPath,
|
||||
}),
|
||||
{
|
||||
folderName,
|
||||
folderPath,
|
||||
},
|
||||
);
|
||||
const recentAssistantMessages = await loadRecentAssistantMessages(thread.rolloutPath, thread.id);
|
||||
|
||||
const candidate = {
|
||||
folderName,
|
||||
@@ -251,6 +551,7 @@ export async function discoverCodexProjectCandidates(options = {}) {
|
||||
codexThreadRef: thread.id,
|
||||
lastActiveAt: toIsoFromUnixSeconds(latestActivitySeconds) ?? now.toISOString(),
|
||||
suggestedImport: true,
|
||||
...(recentAssistantMessages.length > 0 ? { recentAssistantMessages } : {}),
|
||||
};
|
||||
const folderKey = folderPath || folderName;
|
||||
const bucket = groupedCandidates.get(folderKey) ?? [];
|
||||
@@ -277,6 +578,7 @@ export async function discoverCodexProjectCandidates(options = {}) {
|
||||
return {
|
||||
projects,
|
||||
projectCandidates: candidates,
|
||||
guiConnected,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import os from "node:os";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { constants } from "node:fs";
|
||||
import { access, stat } from "node:fs/promises";
|
||||
import { access, readFile, readdir, stat } from "node:fs/promises";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { resolve } from "node:path";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
function trimToDefined(value) {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
@@ -45,6 +45,14 @@ function defaultCodexPath(relativePath) {
|
||||
return resolve(os.homedir(), ".codex", relativePath);
|
||||
}
|
||||
|
||||
function defaultSessionsDirForStateDb(stateDbPath) {
|
||||
const resolvedStateDbPath = trimToDefined(stateDbPath);
|
||||
if (resolvedStateDbPath) {
|
||||
return resolve(dirname(resolve(resolvedStateDbPath)), "sessions");
|
||||
}
|
||||
return defaultCodexPath("sessions");
|
||||
}
|
||||
|
||||
function loadThreadWorkspaceHints(globalStatePath) {
|
||||
try {
|
||||
const raw = readFileSync(resolve(globalStatePath), "utf8");
|
||||
@@ -72,6 +80,29 @@ function shouldPreflightResumeTask(task) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildDesktopMirrorPlan(task, targetThreadRef) {
|
||||
if (task?.taskType !== "conversation_reply") {
|
||||
return { enabled: false };
|
||||
}
|
||||
if (task?.mirrorBossUserMessageToCodexDesktop !== true) {
|
||||
return { enabled: false };
|
||||
}
|
||||
|
||||
const sourceMessageId = trimToDefined(task?.sourceMessageId || task?.requestMessageId);
|
||||
const sourceMessageBody = trimToDefined(task?.sourceMessageBody || task?.requestText);
|
||||
if (!targetThreadRef || !sourceMessageId || !sourceMessageBody) {
|
||||
return { enabled: false };
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
targetThreadRef,
|
||||
sourceMessageId,
|
||||
sourceMessageBody,
|
||||
sourceMessageSentAt: trimToDefined(task?.sourceMessageSentAt),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStructuredTaskBindingError(code, message, details) {
|
||||
return {
|
||||
code,
|
||||
@@ -80,7 +111,71 @@ function buildStructuredTaskBindingError(code, message, details) {
|
||||
};
|
||||
}
|
||||
|
||||
function inspectCodexThreadBinding(config, targetThreadRef, targetFolderRef) {
|
||||
function parseSessionMetaLine(line) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed?.type !== "session_meta" || !parsed?.payload?.id || !parsed?.payload?.cwd) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: String(parsed.payload.id),
|
||||
cwd: String(parsed.payload.cwd),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function findSessionThreadBinding(config, targetThreadRef) {
|
||||
const root = trimToDefined(
|
||||
config?.codexSessionsDir || defaultSessionsDirForStateDb(config?.codexStateDbPath),
|
||||
);
|
||||
if (!root) {
|
||||
return {
|
||||
status: "missing",
|
||||
};
|
||||
}
|
||||
|
||||
const stack = [resolve(root)];
|
||||
const suffix = `-${targetThreadRef}.jsonl`;
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current) continue;
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const entryPath = resolve(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(entryPath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith(suffix)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const raw = await readFile(entryPath, "utf8");
|
||||
const meta = parseSessionMetaLine(raw.split(/\r?\n/, 1)[0] ?? "");
|
||||
if (meta?.id === targetThreadRef) {
|
||||
return {
|
||||
status: "ok",
|
||||
threadCwd: trimToDefined(meta.cwd) || "",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: "missing",
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectCodexThreadBinding(config, targetThreadRef, targetFolderRef) {
|
||||
const stateDbPath = trimToDefined(config?.codexStateDbPath || defaultCodexPath("state_5.sqlite"));
|
||||
if (!stateDbPath) {
|
||||
return {
|
||||
@@ -94,7 +189,10 @@ function inspectCodexThreadBinding(config, targetThreadRef, targetFolderRef) {
|
||||
const row = db
|
||||
.prepare("SELECT id, cwd, archived, sandbox_policy FROM threads WHERE id = ? LIMIT 1")
|
||||
.get(targetThreadRef);
|
||||
if (!row || row.archived) {
|
||||
if (!row) {
|
||||
return await findSessionThreadBinding(config, targetThreadRef);
|
||||
}
|
||||
if (row.archived) {
|
||||
return {
|
||||
status: "missing",
|
||||
};
|
||||
@@ -127,9 +225,7 @@ function inspectCodexThreadBinding(config, targetThreadRef, targetFolderRef) {
|
||||
db.close();
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
status: "unavailable",
|
||||
};
|
||||
return await findSessionThreadBinding(config, targetThreadRef);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +255,7 @@ export async function prepareCodexTaskExecution(config, task, outputFile) {
|
||||
}
|
||||
|
||||
const resumeTarget = resolveResumeTarget(config, task);
|
||||
const bindingInspection = inspectCodexThreadBinding(config, targetThreadRef, resumeTarget.cwd);
|
||||
const bindingInspection = await inspectCodexThreadBinding(config, targetThreadRef, resumeTarget.cwd);
|
||||
if (bindingInspection.status === "missing") {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -255,6 +351,7 @@ export function buildCodexTaskExecution(config, task, outputFile) {
|
||||
mode: "resume",
|
||||
cwd,
|
||||
args,
|
||||
desktopMirror: buildDesktopMirrorPlan(task, targetThreadRef),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -277,5 +374,6 @@ export function buildCodexTaskExecution(config, task, outputFile) {
|
||||
mode: "ephemeral",
|
||||
cwd: config.masterAgentWorkdir || process.cwd(),
|
||||
args,
|
||||
desktopMirror: { enabled: false },
|
||||
};
|
||||
}
|
||||
|
||||
279
local-agent/codex-thread-rollout-writer.mjs
Normal file
279
local-agent/codex-thread-rollout-writer.mjs
Normal file
@@ -0,0 +1,279 @@
|
||||
import os from "node:os";
|
||||
import { appendFile, open, readdir } from "node:fs/promises";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const MAX_ROLLOUT_TAIL_BYTES = 256 * 1024;
|
||||
|
||||
function trimToDefined(value) {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function defaultCodexPath(relativePath) {
|
||||
return resolve(os.homedir(), ".codex", relativePath);
|
||||
}
|
||||
|
||||
function resolveThreadRolloutPath({ stateDbPath, targetThreadRef }) {
|
||||
const resolvedStateDbPath = trimToDefined(stateDbPath || defaultCodexPath("state_5.sqlite"));
|
||||
if (!resolvedStateDbPath) {
|
||||
throw new Error("CODEX_STATE_DB_MISSING");
|
||||
}
|
||||
|
||||
const db = new DatabaseSync(resolvedStateDbPath, { readonly: true });
|
||||
try {
|
||||
const row = db
|
||||
.prepare("SELECT rollout_path, archived FROM threads WHERE id = ? LIMIT 1")
|
||||
.get(targetThreadRef);
|
||||
if (!row) {
|
||||
throw new Error("CODEX_THREAD_NOT_FOUND");
|
||||
}
|
||||
if (row.archived) {
|
||||
throw new Error("CODEX_THREAD_ARCHIVED");
|
||||
}
|
||||
const rolloutPath = trimToDefined(row.rollout_path);
|
||||
if (!rolloutPath) {
|
||||
throw new Error("CODEX_ROLLOUT_PATH_MISSING");
|
||||
}
|
||||
return resolve(rolloutPath);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
function defaultCodexSessionsDir() {
|
||||
return defaultCodexPath("sessions");
|
||||
}
|
||||
|
||||
async function findRolloutPathInSessionsDir({ sessionsDir, targetThreadRef }) {
|
||||
const root = trimToDefined(sessionsDir || defaultCodexSessionsDir());
|
||||
if (!root) {
|
||||
throw new Error("CODEX_SESSIONS_DIR_MISSING");
|
||||
}
|
||||
|
||||
const stack = [resolve(root)];
|
||||
const suffix = `-${targetThreadRef}.jsonl`;
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await readdir(current, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const entryPath = resolve(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(entryPath);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name.endsWith(suffix)) {
|
||||
return entryPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("CODEX_ROLLOUT_PATH_FALLBACK_NOT_FOUND");
|
||||
}
|
||||
|
||||
async function resolveThreadRolloutPathWithFallback({ stateDbPath, sessionsDir, targetThreadRef }) {
|
||||
try {
|
||||
return resolveThreadRolloutPath({ stateDbPath, targetThreadRef });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message === "CODEX_THREAD_ARCHIVED") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return findRolloutPathInSessionsDir({
|
||||
sessionsDir,
|
||||
targetThreadRef,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveThreadTouchTimestamps(sentAt) {
|
||||
const parsed = Date.parse(sentAt);
|
||||
const updatedAtMs = Number.isFinite(parsed) ? parsed : Date.now();
|
||||
return {
|
||||
updatedAtMs,
|
||||
updatedAt: Math.floor(updatedAtMs / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
function touchThreadActivity({ stateDbPath, targetThreadRef, sentAt }) {
|
||||
const resolvedStateDbPath = trimToDefined(stateDbPath || defaultCodexPath("state_5.sqlite"));
|
||||
if (!resolvedStateDbPath) {
|
||||
throw new Error("CODEX_STATE_DB_MISSING");
|
||||
}
|
||||
|
||||
const { updatedAt, updatedAtMs } = resolveThreadTouchTimestamps(sentAt);
|
||||
const db = new DatabaseSync(resolvedStateDbPath);
|
||||
try {
|
||||
try {
|
||||
const result = db.prepare(
|
||||
`
|
||||
UPDATE threads
|
||||
SET updated_at = ?,
|
||||
updated_at_ms = ?,
|
||||
has_user_event = 1
|
||||
WHERE id = ?
|
||||
`,
|
||||
).run(updatedAt, updatedAtMs, targetThreadRef);
|
||||
if (Number(result?.changes ?? 0) <= 0) {
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "thread-not-found",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes("updated_at_ms")) {
|
||||
throw error;
|
||||
}
|
||||
const result = db.prepare(
|
||||
`
|
||||
UPDATE threads
|
||||
SET updated_at = ?,
|
||||
has_user_event = 1
|
||||
WHERE id = ?
|
||||
`,
|
||||
).run(updatedAt, targetThreadRef);
|
||||
if (Number(result?.changes ?? 0) <= 0) {
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "thread-not-found",
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: "updated",
|
||||
updatedAt,
|
||||
updatedAtMs,
|
||||
};
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function readRolloutTail(rolloutPath) {
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(rolloutPath, "r");
|
||||
const stats = await handle.stat();
|
||||
const start = Math.max(0, stats.size - MAX_ROLLOUT_TAIL_BYTES);
|
||||
const length = Math.max(0, stats.size - start);
|
||||
if (length === 0) {
|
||||
return "";
|
||||
}
|
||||
const buffer = Buffer.alloc(length);
|
||||
await handle.read(buffer, 0, length, start);
|
||||
let text = buffer.toString("utf8");
|
||||
if (start > 0) {
|
||||
const firstNewline = text.indexOf("\n");
|
||||
text = firstNewline >= 0 ? text.slice(firstNewline + 1) : "";
|
||||
}
|
||||
return text;
|
||||
} finally {
|
||||
await handle?.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function rolloutAlreadyHasSourceMessage(rolloutPath, sourceMessageId) {
|
||||
const tail = await readRolloutTail(rolloutPath);
|
||||
if (!tail.trim()) {
|
||||
return false;
|
||||
}
|
||||
for (const line of tail.split(/\r?\n/)) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed?.payload?.metadata?.bossSourceMessageId === sourceMessageId) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function appendBossUserMessageToCodexThreadRollout(params) {
|
||||
const targetThreadRef = trimToDefined(params?.targetThreadRef);
|
||||
const sourceMessageId = trimToDefined(params?.sourceMessageId);
|
||||
const message = trimToDefined(params?.message);
|
||||
const sentAt = trimToDefined(params?.sentAt) ?? new Date().toISOString();
|
||||
|
||||
if (!targetThreadRef) {
|
||||
throw new Error("CODEX_THREAD_REF_REQUIRED");
|
||||
}
|
||||
if (!sourceMessageId) {
|
||||
throw new Error("CODEX_SOURCE_MESSAGE_ID_REQUIRED");
|
||||
}
|
||||
if (!message) {
|
||||
throw new Error("CODEX_SOURCE_MESSAGE_BODY_REQUIRED");
|
||||
}
|
||||
|
||||
const rolloutPath = await resolveThreadRolloutPathWithFallback({
|
||||
stateDbPath: params?.stateDbPath,
|
||||
sessionsDir: params?.sessionsDir,
|
||||
targetThreadRef,
|
||||
});
|
||||
if (await rolloutAlreadyHasSourceMessage(rolloutPath, sourceMessageId)) {
|
||||
return {
|
||||
status: "duplicate",
|
||||
rolloutPath,
|
||||
};
|
||||
}
|
||||
|
||||
const responseItem = {
|
||||
timestamp: sentAt,
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: message,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const event = {
|
||||
timestamp: sentAt,
|
||||
type: "event_msg",
|
||||
payload: {
|
||||
type: "user_message",
|
||||
message,
|
||||
images: [],
|
||||
local_images: [],
|
||||
text_elements: [],
|
||||
metadata: {
|
||||
bossSourceMessageId: sourceMessageId,
|
||||
bossMirroredFrom: "boss-app",
|
||||
},
|
||||
},
|
||||
};
|
||||
await appendFile(rolloutPath, `${JSON.stringify(responseItem)}\n${JSON.stringify(event)}\n`, "utf8");
|
||||
let threadTouch = { status: "skipped" };
|
||||
try {
|
||||
threadTouch = touchThreadActivity({
|
||||
stateDbPath: params?.stateDbPath,
|
||||
targetThreadRef,
|
||||
sentAt,
|
||||
});
|
||||
} catch {
|
||||
threadTouch = { status: "skipped" };
|
||||
}
|
||||
return {
|
||||
status: "written",
|
||||
rolloutPath,
|
||||
threadTouch,
|
||||
};
|
||||
}
|
||||
275
local-agent/computer-use-task-runner.mjs
Normal file
275
local-agent/computer-use-task-runner.mjs
Normal file
@@ -0,0 +1,275 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
|
||||
function parseBoolean(value) {
|
||||
return String(value || "").trim().toLowerCase() === "true";
|
||||
}
|
||||
|
||||
function parseArgs(value) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseCsv(value) {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseTimeoutMs(value) {
|
||||
const parsed = Number.parseInt(String(value || ""), 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 45000;
|
||||
}
|
||||
|
||||
function pickConfigValue(config, key, fallback) {
|
||||
if (config && config[key] !== undefined && config[key] !== null && `${config[key]}`.trim() !== "") {
|
||||
return config[key];
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function resolveCommandArgs(command, args, cwd) {
|
||||
const runtimeName = path.basename(command || "").toLowerCase();
|
||||
const scriptRuntimes = new Set([
|
||||
"node",
|
||||
"node.exe",
|
||||
"tsx",
|
||||
"tsx.cmd",
|
||||
"bun",
|
||||
"bun.exe",
|
||||
"deno",
|
||||
"deno.exe",
|
||||
]);
|
||||
if (!scriptRuntimes.has(runtimeName) || args.length === 0) {
|
||||
return args;
|
||||
}
|
||||
const [first, ...rest] = args;
|
||||
if (!first || first.startsWith("-")) {
|
||||
return args;
|
||||
}
|
||||
return [path.isAbsolute(first) ? first : path.resolve(cwd || process.cwd(), first), ...rest];
|
||||
}
|
||||
|
||||
function parseJsonLine(rawOutput) {
|
||||
const lines = String(rawOutput || "")
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
return JSON.parse(lines.at(-1) || "");
|
||||
}
|
||||
|
||||
export function getComputerUseTaskRunnerConfig(env = process.env, config = {}) {
|
||||
const enabled = parseBoolean(pickConfigValue(config, "computerUseEnabled", env.BOSS_COMPUTER_USE_ENABLED));
|
||||
const command = String(pickConfigValue(config, "computerUseCommand", env.BOSS_COMPUTER_USE_COMMAND) || "").trim() || undefined;
|
||||
const args = Array.isArray(config?.computerUseArgs)
|
||||
? config.computerUseArgs.map((item) => String(item)).filter(Boolean)
|
||||
: parseArgs(pickConfigValue(config, "computerUseArgs", env.BOSS_COMPUTER_USE_ARGS));
|
||||
const cwd = String(pickConfigValue(config, "computerUseWorkdir", env.BOSS_COMPUTER_USE_WORKDIR) || "").trim() || undefined;
|
||||
const timeoutMs = parseTimeoutMs(pickConfigValue(config, "computerUseTimeoutMs", env.BOSS_COMPUTER_USE_TIMEOUT_MS));
|
||||
const dialogGuardEnabled = parseBoolean(pickConfigValue(config, "dialogGuardEnabled", env.BOSS_DIALOG_GUARD_ENABLED));
|
||||
const dialogGuardConsentRequired = parseBoolean(
|
||||
pickConfigValue(config, "dialogGuardConsentRequired", env.BOSS_DIALOG_GUARD_CONSENT_REQUIRED),
|
||||
);
|
||||
const dialogGuardPlatformAdapters = Array.isArray(config?.dialogGuardPlatformAdapters)
|
||||
? config.dialogGuardPlatformAdapters.map((item) => String(item).trim()).filter(Boolean)
|
||||
: parseCsv(pickConfigValue(config, "dialogGuardPlatformAdapters", env.BOSS_DIALOG_GUARD_PLATFORM_ADAPTERS));
|
||||
const dialogGuardMacActionCommand = String(
|
||||
pickConfigValue(config, "dialogGuardMacActionCommand", env.BOSS_MAC_DIALOG_GUARD_ACTION_COMMAND) || "",
|
||||
).trim();
|
||||
const dialogGuardMacActionArgs = Array.isArray(config?.dialogGuardMacActionArgs)
|
||||
? config.dialogGuardMacActionArgs.map((item) => String(item)).filter(Boolean)
|
||||
: parseArgs(pickConfigValue(config, "dialogGuardMacActionArgs", env.BOSS_MAC_DIALOG_GUARD_ACTION_ARGS));
|
||||
const dialogGuardWindowsActionCommand = String(
|
||||
pickConfigValue(config, "dialogGuardWindowsActionCommand", env.BOSS_WINDOWS_DIALOG_GUARD_ACTION_COMMAND) || "",
|
||||
).trim();
|
||||
const dialogGuardWindowsActionArgs = Array.isArray(config?.dialogGuardWindowsActionArgs)
|
||||
? config.dialogGuardWindowsActionArgs.map((item) => String(item)).filter(Boolean)
|
||||
: parseArgs(pickConfigValue(config, "dialogGuardWindowsActionArgs", env.BOSS_WINDOWS_DIALOG_GUARD_ACTION_ARGS));
|
||||
return {
|
||||
enabled,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
timeoutMs,
|
||||
dialogGuardEnabled,
|
||||
dialogGuardConsentRequired,
|
||||
dialogGuardPlatformAdapters,
|
||||
dialogGuardMacActionCommand,
|
||||
dialogGuardMacActionArgs,
|
||||
dialogGuardWindowsActionCommand,
|
||||
dialogGuardWindowsActionArgs,
|
||||
};
|
||||
}
|
||||
|
||||
export function canHandleComputerUseTask(task) {
|
||||
return String(task?.taskType || "").trim() === "desktop_control";
|
||||
}
|
||||
|
||||
export function buildComputerUseTaskExecution(config, task) {
|
||||
if (!config?.enabled) {
|
||||
throw new Error("COMPUTER_USE_RUNTIME_DISABLED");
|
||||
}
|
||||
if (!config?.command) {
|
||||
throw new Error("COMPUTER_USE_COMMAND_REQUIRED");
|
||||
}
|
||||
|
||||
const cwd = config.cwd || process.cwd();
|
||||
return {
|
||||
command: config.command,
|
||||
args: resolveCommandArgs(config.command, config.args || [], cwd),
|
||||
cwd,
|
||||
timeoutMs: config.timeoutMs || 45000,
|
||||
env: {
|
||||
BOSS_DIALOG_GUARD_ENABLED: config.dialogGuardEnabled ? "true" : "false",
|
||||
BOSS_DIALOG_GUARD_CONSENT_REQUIRED: config.dialogGuardConsentRequired ? "true" : "false",
|
||||
BOSS_DIALOG_GUARD_PLATFORM_ADAPTERS: Array.isArray(config.dialogGuardPlatformAdapters)
|
||||
? config.dialogGuardPlatformAdapters.join(",")
|
||||
: "",
|
||||
BOSS_MAC_DIALOG_GUARD_ACTION_COMMAND: config.dialogGuardMacActionCommand || "",
|
||||
BOSS_MAC_DIALOG_GUARD_ACTION_ARGS_JSON: JSON.stringify(config.dialogGuardMacActionArgs || []),
|
||||
BOSS_WINDOWS_DIALOG_GUARD_ACTION_COMMAND: config.dialogGuardWindowsActionCommand || "",
|
||||
BOSS_WINDOWS_DIALOG_GUARD_ACTION_ARGS_JSON: JSON.stringify(config.dialogGuardWindowsActionArgs || []),
|
||||
},
|
||||
stdinPayload: {
|
||||
requestKind: "desktop_control",
|
||||
requestId: String(task?.taskId || "").trim(),
|
||||
objective: String(task?.requestText || task?.executionPrompt || "").trim(),
|
||||
context: {
|
||||
projectId: String(task?.projectId || "").trim() || undefined,
|
||||
threadId: String(task?.threadId || task?.targetThreadId || "").trim() || undefined,
|
||||
requestedBy: String(task?.requestedByAccount || task?.requestedBy || "").trim() || undefined,
|
||||
requestedAt: String(task?.requestedAt || "").trim() || undefined,
|
||||
confirmationScopeKey: String(task?.confirmationScopeKey || "").trim() || undefined,
|
||||
riskLevel: String(task?.riskLevel || "").trim() || undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseComputerUseTaskResult(rawOutput) {
|
||||
const parsed = parseJsonLine(rawOutput);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("INVALID_COMPUTER_USE_RUNTIME_PAYLOAD");
|
||||
}
|
||||
|
||||
if (parsed.status === "failed") {
|
||||
return {
|
||||
status: "failed",
|
||||
requestId: typeof parsed.requestId === "string" ? parsed.requestId.trim() || undefined : undefined,
|
||||
errorMessage:
|
||||
typeof parsed.error === "string" && parsed.error.trim()
|
||||
? parsed.error.trim()
|
||||
: "COMPUTER_USE_FAILED",
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.status === "needs_user_action") {
|
||||
return {
|
||||
status: "needs_user_action",
|
||||
requestId: typeof parsed.requestId === "string" ? parsed.requestId.trim() || undefined : undefined,
|
||||
kind: typeof parsed.kind === "string" && parsed.kind.trim() ? parsed.kind.trim() : "user_action_required",
|
||||
dialogId: typeof parsed.dialogId === "string" && parsed.dialogId.trim() ? parsed.dialogId.trim() : undefined,
|
||||
risk: typeof parsed.risk === "string" && parsed.risk.trim() ? parsed.risk.trim() : "medium",
|
||||
summary: typeof parsed.summary === "string" && parsed.summary.trim() ? parsed.summary.trim() : "",
|
||||
recommendedAction:
|
||||
typeof parsed.recommendedAction === "string" && parsed.recommendedAction.trim()
|
||||
? parsed.recommendedAction.trim()
|
||||
: undefined,
|
||||
availableActions: Array.isArray(parsed.availableActions)
|
||||
? parsed.availableActions.map((item) => String(item).trim()).filter(Boolean)
|
||||
: [],
|
||||
platform: typeof parsed.platform === "string" && parsed.platform.trim() ? parsed.platform.trim() : undefined,
|
||||
appName: typeof parsed.appName === "string" && parsed.appName.trim() ? parsed.appName.trim() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const replyBody =
|
||||
typeof parsed.replyBody === "string" && parsed.replyBody.trim()
|
||||
? parsed.replyBody.trim()
|
||||
: typeof parsed.summary === "string" && parsed.summary.trim()
|
||||
? parsed.summary.trim()
|
||||
: "";
|
||||
if (!replyBody) {
|
||||
throw new Error("INVALID_COMPUTER_USE_RUNTIME_PAYLOAD");
|
||||
}
|
||||
|
||||
return {
|
||||
status: "completed",
|
||||
requestId: typeof parsed.requestId === "string" ? parsed.requestId.trim() || undefined : undefined,
|
||||
replyBody,
|
||||
targetApp:
|
||||
typeof parsed.targetApp === "string" && parsed.targetApp.trim()
|
||||
? parsed.targetApp.trim()
|
||||
: undefined,
|
||||
executionSummary:
|
||||
typeof parsed.executionSummary === "string" && parsed.executionSummary.trim()
|
||||
? parsed.executionSummary.trim()
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeComputerUseTask(task, config = {}) {
|
||||
const runnerConfig = getComputerUseTaskRunnerConfig(process.env, config);
|
||||
if (!runnerConfig.enabled) {
|
||||
return {
|
||||
status: "failed",
|
||||
errorMessage: "COMPUTER_USE_RUNTIME_DISABLED",
|
||||
};
|
||||
}
|
||||
|
||||
const execution = buildComputerUseTaskExecution(runnerConfig, task);
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(execution.command, execution.args, {
|
||||
cwd: execution.cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...(execution.env || {}),
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGKILL");
|
||||
}, execution.timeoutMs);
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (timedOut) {
|
||||
reject(new Error("COMPUTER_USE_TIMEOUT"));
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr.trim() || `computer use exit code ${code}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(parseComputerUseTaskResult(stdout));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
child.stdin.write(JSON.stringify(execution.stdinPayload));
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
@@ -2,13 +2,56 @@
|
||||
"bindHost": "127.0.0.1",
|
||||
"port": 4317,
|
||||
"heartbeatIntervalMs": 15000,
|
||||
"masterAgentPollIntervalMs": 3000,
|
||||
"masterAgentPollIntervalMs": 1000,
|
||||
"skillLifecycleEnabled": true,
|
||||
"skillLifecyclePollIntervalMs": 5000,
|
||||
"skillLifecycleTimeoutMs": 120000,
|
||||
"skillLifecycleAllowedSources": [],
|
||||
"skillLifecycleTrustedSources": {},
|
||||
"controlPlaneUrl": "https://boss.hyzq.net",
|
||||
"skillsDir": "/Users/kris/.codex/skills",
|
||||
"masterAgentEnabled": true,
|
||||
"masterAgentWorkdir": "/Users/kris/code/boss",
|
||||
"masterAgentSandbox": "workspace-write",
|
||||
"masterAgentModel": "gpt-5.4",
|
||||
"browserAutomationConnected": true,
|
||||
"computerUseConnected": true,
|
||||
"browserControlEnabled": true,
|
||||
"browserControlCommand": "node",
|
||||
"browserControlArgs": [
|
||||
"scripts/browser-control-smoke.mjs"
|
||||
],
|
||||
"browserControlWorkdir": "/Users/kris/code/boss",
|
||||
"browserControlTimeoutMs": 45000,
|
||||
"computerUseEnabled": true,
|
||||
"computerUseCommand": "node",
|
||||
"computerUseArgs": [
|
||||
"scripts/computer-use-smoke.mjs"
|
||||
],
|
||||
"computerUseWorkdir": "/Users/kris/code/boss",
|
||||
"computerUseTimeoutMs": 45000,
|
||||
"dialogGuardEnabled": true,
|
||||
"dialogGuardConsentRequired": true,
|
||||
"dialogGuardPlatformAdapters": [
|
||||
"darwin",
|
||||
"win32"
|
||||
],
|
||||
"dialogGuardMacActionCommand": "",
|
||||
"dialogGuardMacActionArgs": [],
|
||||
"dialogGuardWindowsActionCommand": "",
|
||||
"dialogGuardWindowsActionArgs": [],
|
||||
"codexDesktopRefreshEnabled": true,
|
||||
"codexDesktopRefreshCommand": "node",
|
||||
"codexDesktopRefreshEndpoint": "http://127.0.0.1:4318/api/v1/codex-desktop/refresh",
|
||||
"codexDesktopRefreshArgs": [
|
||||
"scripts/codex-desktop-refresh-hint.mjs"
|
||||
],
|
||||
"codexDesktopRefreshWorkdir": "/Users/kris/code/boss",
|
||||
"codexDesktopRefreshTimeoutMs": 3000,
|
||||
"codexDesktopRefreshAppName": "Codex",
|
||||
"codexDesktopRefreshMode": "deeplink-reload",
|
||||
"codexDesktopRefreshRetryCount": 2,
|
||||
"codexDesktopRefreshRetryDelayMs": 120,
|
||||
"omxEnabled": false,
|
||||
"omxCommand": "",
|
||||
"omxArgs": [],
|
||||
@@ -20,7 +63,7 @@
|
||||
"token": "boss-mac-studio-token",
|
||||
"name": "Mac Studio",
|
||||
"avatar": "M",
|
||||
"account": "17600003315",
|
||||
"account": "krisolo",
|
||||
"status": "online",
|
||||
"quota5h": 68,
|
||||
"quota7d": 81,
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
"bindHost": "127.0.0.1",
|
||||
"port": 4317,
|
||||
"heartbeatIntervalMs": 15000,
|
||||
"masterAgentPollIntervalMs": 3000,
|
||||
"masterAgentPollIntervalMs": 1000,
|
||||
"skillLifecycleEnabled": true,
|
||||
"skillLifecyclePollIntervalMs": 5000,
|
||||
"skillLifecycleTimeoutMs": 120000,
|
||||
"skillLifecycleAllowedSources": [],
|
||||
"skillLifecycleTrustedSources": {},
|
||||
"controlPlaneUrl": "http://127.0.0.1:3000",
|
||||
"skillsDir": "/Users/kris/.codex/skills",
|
||||
"masterAgentEnabled": true,
|
||||
@@ -11,6 +16,44 @@
|
||||
"masterAgentModel": "gpt-5.4",
|
||||
"preferredExecutionMode": "cli",
|
||||
"guiConnected": false,
|
||||
"browserAutomationConnected": true,
|
||||
"computerUseConnected": true,
|
||||
"browserControlEnabled": true,
|
||||
"browserControlCommand": "node",
|
||||
"browserControlArgs": [
|
||||
"scripts/browser-control-smoke.mjs"
|
||||
],
|
||||
"browserControlWorkdir": "/Users/kris/code/boss",
|
||||
"browserControlTimeoutMs": 45000,
|
||||
"computerUseEnabled": true,
|
||||
"computerUseCommand": "node",
|
||||
"computerUseArgs": [
|
||||
"scripts/computer-use-smoke.mjs"
|
||||
],
|
||||
"computerUseWorkdir": "/Users/kris/code/boss",
|
||||
"computerUseTimeoutMs": 45000,
|
||||
"dialogGuardEnabled": true,
|
||||
"dialogGuardConsentRequired": true,
|
||||
"dialogGuardPlatformAdapters": [
|
||||
"darwin",
|
||||
"win32"
|
||||
],
|
||||
"dialogGuardMacActionCommand": "",
|
||||
"dialogGuardMacActionArgs": [],
|
||||
"dialogGuardWindowsActionCommand": "",
|
||||
"dialogGuardWindowsActionArgs": [],
|
||||
"codexDesktopRefreshEnabled": true,
|
||||
"codexDesktopRefreshCommand": "node",
|
||||
"codexDesktopRefreshEndpoint": "http://127.0.0.1:4318/api/v1/codex-desktop/refresh",
|
||||
"codexDesktopRefreshArgs": [
|
||||
"scripts/codex-desktop-refresh-hint.mjs"
|
||||
],
|
||||
"codexDesktopRefreshWorkdir": "/Users/kris/code/boss",
|
||||
"codexDesktopRefreshTimeoutMs": 3000,
|
||||
"codexDesktopRefreshAppName": "Codex",
|
||||
"codexDesktopRefreshMode": "deeplink-reload",
|
||||
"codexDesktopRefreshRetryCount": 2,
|
||||
"codexDesktopRefreshRetryDelayMs": 120,
|
||||
"omxEnabled": false,
|
||||
"omxCommand": "",
|
||||
"omxArgs": [],
|
||||
@@ -22,7 +65,7 @@
|
||||
"token": "boss-mac-studio-token",
|
||||
"name": "Mac Studio",
|
||||
"avatar": "M",
|
||||
"account": "17600003315",
|
||||
"account": "krisolo",
|
||||
"status": "online",
|
||||
"quota5h": 68,
|
||||
"quota7d": 81,
|
||||
|
||||
202
local-agent/desktop-dialog-guard.mjs
Normal file
202
local-agent/desktop-dialog-guard.mjs
Normal file
@@ -0,0 +1,202 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const SAFE_DISMISS_BUTTONS = [
|
||||
"稍后",
|
||||
"跳过",
|
||||
"以后再说",
|
||||
"不,谢谢",
|
||||
"Not now",
|
||||
"Skip",
|
||||
"Later",
|
||||
"Cancel",
|
||||
"Maybe later",
|
||||
"No thanks",
|
||||
];
|
||||
|
||||
const BLOCKED_TEXT_PATTERNS = [
|
||||
/screen recording/i,
|
||||
/accessibility/i,
|
||||
/input monitoring/i,
|
||||
/full disk access/i,
|
||||
/keychain/i,
|
||||
/administrator/i,
|
||||
/apple id/i,
|
||||
/user account control/i,
|
||||
/make changes to your device/i,
|
||||
/屏幕录制/,
|
||||
/辅助功能/,
|
||||
/输入监控/,
|
||||
/完整磁盘访问/,
|
||||
/钥匙串/,
|
||||
/管理员密码/,
|
||||
/用户帐户控制/,
|
||||
/用户账户控制/,
|
||||
];
|
||||
|
||||
export function normalizeDialogText(value) {
|
||||
return String(value || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function normalizeDialogSnapshot(input = {}) {
|
||||
const buttons = Array.isArray(input.buttons)
|
||||
? input.buttons.map(normalizeDialogText).filter(Boolean)
|
||||
: [];
|
||||
const appName = normalizeDialogText(input.appName || input.app || "Unknown App");
|
||||
return {
|
||||
platform: normalizeDialogText(input.platform || process.platform || "unknown"),
|
||||
deviceId: normalizeDialogText(input.deviceId || "unknown-device"),
|
||||
appName,
|
||||
appBundleId: normalizeDialogText(input.appBundleId || input.appId || appName || "unknown-app"),
|
||||
title: normalizeDialogText(input.title),
|
||||
text: normalizeDialogText(input.text),
|
||||
buttons,
|
||||
raw: input.raw,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSnapshotJson(raw, sourceName) {
|
||||
const value = String(raw || "").trim();
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error(`INVALID_DIALOG_GUARD_SNAPSHOT:${sourceName}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function readDialogSnapshotFromEnv(env = process.env, platform = process.platform) {
|
||||
const normalizedPlatform = normalizeDialogText(platform || process.platform || "unknown");
|
||||
const platformSnapshotKey =
|
||||
normalizedPlatform === "darwin"
|
||||
? "BOSS_MAC_DIALOG_GUARD_SNAPSHOT_JSON"
|
||||
: normalizedPlatform === "win32"
|
||||
? "BOSS_WINDOWS_DIALOG_GUARD_SNAPSHOT_JSON"
|
||||
: "";
|
||||
const parsed =
|
||||
parseSnapshotJson(env.BOSS_DIALOG_GUARD_SNAPSHOT_JSON, "BOSS_DIALOG_GUARD_SNAPSHOT_JSON") ||
|
||||
(platformSnapshotKey ? parseSnapshotJson(env[platformSnapshotKey], platformSnapshotKey) : undefined);
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeDialogSnapshot({
|
||||
...parsed,
|
||||
platform: parsed.platform || normalizedPlatform,
|
||||
});
|
||||
}
|
||||
|
||||
function hash(value) {
|
||||
return createHash("sha256").update(String(value || "")).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
export function createDialogSignature(snapshotInput = {}) {
|
||||
const snapshot = normalizeDialogSnapshot(snapshotInput);
|
||||
const titleHash = hash(snapshot.title.toLowerCase());
|
||||
const textHash = hash(snapshot.text.toLowerCase());
|
||||
const buttonHash = hash(snapshot.buttons.join("|").toLowerCase());
|
||||
return {
|
||||
id: hash([
|
||||
snapshot.platform,
|
||||
snapshot.deviceId,
|
||||
snapshot.appBundleId,
|
||||
titleHash,
|
||||
textHash,
|
||||
buttonHash,
|
||||
].join("|")),
|
||||
scopeKey: [snapshot.platform, snapshot.deviceId, snapshot.appBundleId].join(":"),
|
||||
platform: snapshot.platform,
|
||||
deviceId: snapshot.deviceId,
|
||||
appBundleId: snapshot.appBundleId,
|
||||
titleHash,
|
||||
textHash,
|
||||
buttonHash,
|
||||
};
|
||||
}
|
||||
|
||||
function textMatchesAny(text, patterns) {
|
||||
return patterns.some((pattern) => pattern.test(text));
|
||||
}
|
||||
|
||||
function findSafeDismissButton(buttons) {
|
||||
return buttons.find((button) =>
|
||||
SAFE_DISMISS_BUTTONS.some((candidate) => candidate.toLowerCase() === button.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
function isBlockedPrompt(snapshot) {
|
||||
const combined = `${snapshot.title} ${snapshot.text}`;
|
||||
return textMatchesAny(combined, BLOCKED_TEXT_PATTERNS);
|
||||
}
|
||||
|
||||
export function evaluateDialogSnapshot(snapshotInput = {}) {
|
||||
const snapshot = normalizeDialogSnapshot(snapshotInput);
|
||||
const signature = createDialogSignature(snapshot);
|
||||
if (isBlockedPrompt(snapshot)) {
|
||||
return {
|
||||
disposition: "needs_user_action",
|
||||
kind: "permission_required",
|
||||
risk: "high",
|
||||
action: "pause_for_user",
|
||||
signature,
|
||||
};
|
||||
}
|
||||
|
||||
const safeButton = findSafeDismissButton(snapshot.buttons);
|
||||
if (safeButton) {
|
||||
return {
|
||||
disposition: "auto_action",
|
||||
kind: "safe_dismiss",
|
||||
risk: "low",
|
||||
action: "click_button",
|
||||
button: safeButton,
|
||||
signature,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
disposition: "needs_user_action",
|
||||
kind: "unknown_dialog",
|
||||
risk: "medium",
|
||||
action: "pause_for_user",
|
||||
signature,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDialogAuditEntry({ requestId, snapshot: snapshotInput, decision, handledAt = new Date().toISOString() }) {
|
||||
const snapshot = normalizeDialogSnapshot(snapshotInput);
|
||||
const signature = decision?.signature || createDialogSignature(snapshot);
|
||||
return {
|
||||
kind: "desktop_dialog_guard",
|
||||
requestId: requestId || undefined,
|
||||
handledAt,
|
||||
platform: snapshot.platform,
|
||||
appName: snapshot.appName,
|
||||
dialogId: signature.id,
|
||||
risk: decision?.risk || "medium",
|
||||
disposition: decision?.disposition || "unknown",
|
||||
action: decision?.action || "pause_for_user",
|
||||
button: decision?.button || undefined,
|
||||
policyKind: decision?.kind || "unknown_dialog",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDialogInterventionResult({ requestId, snapshot: snapshotInput, decision }) {
|
||||
const snapshot = normalizeDialogSnapshot(snapshotInput);
|
||||
const signature = decision?.signature || createDialogSignature(snapshot);
|
||||
const blocked = decision?.risk === "high";
|
||||
return {
|
||||
status: "needs_user_action",
|
||||
requestId: requestId || undefined,
|
||||
kind: "dialog_intervention_required",
|
||||
dialogId: signature.id,
|
||||
risk: decision?.risk || "medium",
|
||||
summary: `${snapshot.appName} 弹窗需要确认:${snapshot.title || snapshot.text || "未知弹窗"}`,
|
||||
recommendedAction: blocked ? "handled_on_device" : "allow_once",
|
||||
availableActions: blocked
|
||||
? ["handled_on_device", "cancel_task"]
|
||||
: ["allow_once", "allow_for_device_dialog", "deny"],
|
||||
platform: snapshot.platform,
|
||||
appName: snapshot.appName,
|
||||
};
|
||||
}
|
||||
71
local-agent/master-task-output-sanitizer.mjs
Normal file
71
local-agent/master-task-output-sanitizer.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
export const MASTER_CODEX_NODE_OUTPUT_LEAKED = "MASTER_CODEX_NODE_OUTPUT_LEAKED";
|
||||
|
||||
const EXECUTION_PROMPT_SECTION_LABELS = [
|
||||
"管理员全局主提示词:",
|
||||
"用户私有主提示词:",
|
||||
"当前对话附加提示词:",
|
||||
"当前消息:",
|
||||
"项目记忆:",
|
||||
"用户通用记忆:",
|
||||
];
|
||||
|
||||
function trimToDefined(value) {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function looksLikeCodexCliEnvelopeLeak(value) {
|
||||
const text = trimToDefined(value);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
const hasCodexHeader = /OpenAI Codex v[\d.]+/i.test(text);
|
||||
const hasExecutionMetadata =
|
||||
/^workdir:\s+/m.test(text) &&
|
||||
/^model:\s+/m.test(text) &&
|
||||
/^provider:\s+/m.test(text);
|
||||
const hasRuntimePolicy = /^approval:\s+/m.test(text) || /^sandbox:\s+/m.test(text);
|
||||
const hasSessionOrMcp = /^session id:\s+/m.test(text) || /^mcp:\s+/m.test(text);
|
||||
return hasCodexHeader && hasExecutionMetadata && hasRuntimePolicy && hasSessionOrMcp;
|
||||
}
|
||||
|
||||
export function looksLikeExecutionPromptLeak(value) {
|
||||
const text = trimToDefined(value);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sectionHitCount = EXECUTION_PROMPT_SECTION_LABELS.filter((label) => text.includes(label)).length;
|
||||
if (sectionHitCount >= 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
text.includes("管理员全局主提示词") &&
|
||||
text.includes("系统级最高约束") &&
|
||||
text.includes("不可被用户私有提示词")
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldBlockSensitiveMasterAgentOutput(value) {
|
||||
return looksLikeCodexCliEnvelopeLeak(value) || looksLikeExecutionPromptLeak(value);
|
||||
}
|
||||
|
||||
export function sanitizeSensitiveTaskFailureDetailForTransport(value) {
|
||||
const text = trimToDefined(value);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
return shouldBlockSensitiveMasterAgentOutput(text) ? MASTER_CODEX_NODE_OUTPUT_LEAKED : text;
|
||||
}
|
||||
|
||||
export function sanitizeSensitiveTaskFailureDetailForLog(value) {
|
||||
const text = trimToDefined(value);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
if (!shouldBlockSensitiveMasterAgentOutput(text)) {
|
||||
return text;
|
||||
}
|
||||
return "已拦截内部执行日志,原始内容不再展示。";
|
||||
}
|
||||
68
local-agent/master-task-timeout.mjs
Normal file
68
local-agent/master-task-timeout.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
const DEFAULT_PROJECT_UNDERSTANDING_TIMEOUT_MS = 8 * 60 * 1000;
|
||||
const DEFAULT_MASTER_REPLY_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
const DEFAULT_THREAD_EXECUTION_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
function normalizeTimeoutMs(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.round(parsed);
|
||||
}
|
||||
|
||||
export function resolveMasterAgentTaskTimeoutMs(config = {}, task = {}) {
|
||||
if (task?.projectUnderstandingTargetProjectId) {
|
||||
return (
|
||||
normalizeTimeoutMs(config.projectUnderstandingTaskTimeoutMs) ??
|
||||
DEFAULT_PROJECT_UNDERSTANDING_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
task?.taskType === "dispatch_execution" ||
|
||||
(task?.taskType === "conversation_reply" && task?.projectId !== "master-agent")
|
||||
) {
|
||||
return (
|
||||
normalizeTimeoutMs(config.threadExecutionTaskTimeoutMs) ??
|
||||
DEFAULT_THREAD_EXECUTION_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
return normalizeTimeoutMs(config.masterAgentReplyTimeoutMs) ?? DEFAULT_MASTER_REPLY_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
export async function runWithTaskTimeout({ timeoutMs, label, onTimeout }, operation) {
|
||||
const normalizedTimeoutMs = normalizeTimeoutMs(timeoutMs);
|
||||
if (!normalizedTimeoutMs) {
|
||||
return await operation();
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback, value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
callback(value);
|
||||
};
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
await onTimeout?.();
|
||||
} catch {
|
||||
// Timeout cleanup is best-effort. The caller still gets a timeout error.
|
||||
}
|
||||
finish(
|
||||
reject,
|
||||
new Error(`${label || "master_agent_task"} exceeded timeout after ${normalizedTimeoutMs}ms`),
|
||||
);
|
||||
}, normalizedTimeoutMs);
|
||||
|
||||
Promise.resolve()
|
||||
.then(operation)
|
||||
.then((value) => finish(resolve, value))
|
||||
.catch((error) => finish(reject, error));
|
||||
});
|
||||
}
|
||||
77
local-agent/master-task-timeout.test.mjs
Normal file
77
local-agent/master-task-timeout.test.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveMasterAgentTaskTimeoutMs, runWithTaskTimeout } from "./master-task-timeout.mjs";
|
||||
|
||||
test("resolveMasterAgentTaskTimeoutMs prefers short timeout for project understanding sync", () => {
|
||||
const timeoutMs = resolveMasterAgentTaskTimeoutMs(
|
||||
{},
|
||||
{
|
||||
taskType: "conversation_reply",
|
||||
projectId: "master-agent",
|
||||
projectUnderstandingTargetProjectId: "project-1",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(timeoutMs, 8 * 60 * 1000);
|
||||
});
|
||||
|
||||
test("resolveMasterAgentTaskTimeoutMs uses thread execution timeout for child thread work", () => {
|
||||
const timeoutMs = resolveMasterAgentTaskTimeoutMs(
|
||||
{},
|
||||
{
|
||||
taskType: "conversation_reply",
|
||||
projectId: "project-1",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(timeoutMs, 30 * 60 * 1000);
|
||||
});
|
||||
|
||||
test("resolveMasterAgentTaskTimeoutMs honors config overrides", () => {
|
||||
const timeoutMs = resolveMasterAgentTaskTimeoutMs(
|
||||
{
|
||||
projectUnderstandingTaskTimeoutMs: 1234,
|
||||
masterAgentReplyTimeoutMs: 2345,
|
||||
threadExecutionTaskTimeoutMs: 3456,
|
||||
},
|
||||
{
|
||||
taskType: "conversation_reply",
|
||||
projectId: "master-agent",
|
||||
projectUnderstandingTargetProjectId: "project-1",
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(timeoutMs, 1234);
|
||||
});
|
||||
|
||||
test("runWithTaskTimeout resolves before timeout when operation finishes", async () => {
|
||||
const result = await runWithTaskTimeout(
|
||||
{
|
||||
timeoutMs: 100,
|
||||
label: "quick-task",
|
||||
},
|
||||
async () => "ok",
|
||||
);
|
||||
|
||||
assert.equal(result, "ok");
|
||||
});
|
||||
|
||||
test("runWithTaskTimeout rejects and invokes timeout cleanup when operation hangs", async () => {
|
||||
let timeoutCleanupCalled = 0;
|
||||
|
||||
await assert.rejects(
|
||||
runWithTaskTimeout(
|
||||
{
|
||||
timeoutMs: 20,
|
||||
label: "hung-task",
|
||||
onTimeout: async () => {
|
||||
timeoutCleanupCalled += 1;
|
||||
},
|
||||
},
|
||||
async () => await new Promise(() => {}),
|
||||
),
|
||||
/hung-task exceeded timeout after 20ms/,
|
||||
);
|
||||
|
||||
assert.equal(timeoutCleanupCalled, 1);
|
||||
});
|
||||
@@ -7,11 +7,37 @@ import os from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { discoverCodexProjectCandidatesInWorker } from "./codex-session-discovery.mjs";
|
||||
import { prepareCodexTaskExecution } from "./codex-task-runner.mjs";
|
||||
import { appendBossUserMessageToCodexThreadRollout } from "./codex-thread-rollout-writer.mjs";
|
||||
import {
|
||||
executeOmxTeamTask,
|
||||
getOmxTeamTaskRunnerConfig,
|
||||
shouldUseOmxTeamTaskRunner,
|
||||
} from "./omx-team-task-runner.mjs";
|
||||
import {
|
||||
canHandleBrowserControlTask,
|
||||
executeBrowserControlTask,
|
||||
getBrowserControlTaskRunnerConfig,
|
||||
} from "./browser-control-task-runner.mjs";
|
||||
import {
|
||||
canHandleComputerUseTask,
|
||||
executeComputerUseTask,
|
||||
getComputerUseTaskRunnerConfig,
|
||||
} from "./computer-use-task-runner.mjs";
|
||||
import {
|
||||
executeCodexDesktopRefreshBridge,
|
||||
} from "./codex-desktop-refresh-bridge.mjs";
|
||||
import {
|
||||
executeSkillLifecycleRequest,
|
||||
getSkillLifecycleRunnerConfig,
|
||||
} from "./skill-lifecycle-runner.mjs";
|
||||
import {
|
||||
sanitizeSensitiveTaskFailureDetailForLog,
|
||||
sanitizeSensitiveTaskFailureDetailForTransport,
|
||||
} from "./master-task-output-sanitizer.mjs";
|
||||
import {
|
||||
resolveMasterAgentTaskTimeoutMs,
|
||||
runWithTaskTimeout,
|
||||
} from "./master-task-timeout.mjs";
|
||||
import { createSerializedRunner } from "./serialized-runner.mjs";
|
||||
|
||||
async function loadConfig(configPath) {
|
||||
@@ -47,9 +73,11 @@ async function resolveHeartbeatProjects(config, runtime) {
|
||||
runtime.lastProjectDiscoveryAt = new Date().toISOString();
|
||||
runtime.lastProjectDiscoveryOk = true;
|
||||
runtime.lastProjectDiscoverySummary = `${mergedCandidates.length} threads / ${mergedProjects.length} folders`;
|
||||
runtime.lastCodexGuiConnected = discovered.guiConnected === true;
|
||||
return {
|
||||
projects: mergedProjects,
|
||||
projectCandidates: mergedCandidates,
|
||||
guiConnected: discovered.guiConnected === true,
|
||||
};
|
||||
} catch (error) {
|
||||
runtime.lastProjectDiscoveryAt = new Date().toISOString();
|
||||
@@ -65,6 +93,7 @@ async function resolveHeartbeatProjects(config, runtime) {
|
||||
return {
|
||||
projects: staticProjects,
|
||||
projectCandidates: staticCandidates,
|
||||
guiConnected: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -75,6 +104,11 @@ async function postHeartbeat(config, runtime, heartbeatProjects) {
|
||||
config.preferredExecutionMode === "gui" || config.preferredExecutionMode === "cli"
|
||||
? config.preferredExecutionMode
|
||||
: undefined;
|
||||
const browserControlRuntime = getBrowserControlTaskRunnerConfig(process.env, config);
|
||||
const computerUseRuntime = getComputerUseTaskRunnerConfig(process.env, config);
|
||||
const guiConnected =
|
||||
config.guiConnected === true ||
|
||||
(config.guiConnected !== false && heartbeatProjects.guiConnected === true);
|
||||
const response = await fetch(`${config.controlPlaneUrl.replace(/\/$/, "")}/api/device-heartbeat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -90,7 +124,7 @@ async function postHeartbeat(config, runtime, heartbeatProjects) {
|
||||
quota7d: config.quota7d,
|
||||
capabilities: {
|
||||
gui: {
|
||||
connected: Boolean(config.guiConnected),
|
||||
connected: guiConnected,
|
||||
lastSeenAt: now,
|
||||
lastActiveProjectId: "",
|
||||
},
|
||||
@@ -99,6 +133,16 @@ async function postHeartbeat(config, runtime, heartbeatProjects) {
|
||||
lastSeenAt: now,
|
||||
lastActiveProjectId: "",
|
||||
},
|
||||
browserAutomation: {
|
||||
connected: config.browserAutomationConnected !== false || Boolean(browserControlRuntime.enabled && browserControlRuntime.command),
|
||||
lastSeenAt: now,
|
||||
lastActiveProjectId: "",
|
||||
},
|
||||
computerUse: {
|
||||
connected: Boolean(config.computerUseConnected) || Boolean(computerUseRuntime.enabled && computerUseRuntime.command),
|
||||
lastSeenAt: now,
|
||||
lastActiveProjectId: "",
|
||||
},
|
||||
},
|
||||
preferredExecutionMode,
|
||||
projects: heartbeatProjects.projects,
|
||||
@@ -311,7 +355,54 @@ async function completeMasterAgentTask(config, runtime, payload) {
|
||||
dispatchExecutionId: payload.dispatchExecutionId,
|
||||
targetProjectId: payload.targetProjectId,
|
||||
targetThreadId: payload.targetThreadId,
|
||||
targetUrl: payload.targetUrl,
|
||||
targetApp: payload.targetApp,
|
||||
rawThreadReply: payload.rawThreadReply,
|
||||
executionProgress: payload.executionProgress,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body: await response.text(),
|
||||
};
|
||||
}
|
||||
|
||||
async function claimSkillLifecycleRequest(config, runtime) {
|
||||
const response = await fetch(
|
||||
`${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/devices/${config.deviceId}/skill-requests/claim`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...deviceTokenHeaders(config, runtime),
|
||||
},
|
||||
body: JSON.stringify({ deviceId: config.deviceId }),
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body: await response.text(),
|
||||
};
|
||||
}
|
||||
|
||||
async function completeSkillLifecycleRequest(config, runtime, request, result) {
|
||||
const response = await fetch(
|
||||
`${config.controlPlaneUrl.replace(/\/$/, "")}/api/v1/devices/${config.deviceId}/skill-requests/${request.requestId}/complete`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...deviceTokenHeaders(config, runtime),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: result.status === "failed" ? "failed" : "completed",
|
||||
resultSummary: result.resultSummary,
|
||||
error: result.error,
|
||||
}),
|
||||
},
|
||||
);
|
||||
@@ -372,14 +463,108 @@ function buildRemoteExecutionCompletionPayload(task, payload) {
|
||||
typeof payload.targetProjectId === "string" ? payload.targetProjectId.trim() || undefined : undefined,
|
||||
targetThreadId:
|
||||
typeof payload.targetThreadId === "string" ? payload.targetThreadId.trim() || undefined : undefined,
|
||||
targetUrl:
|
||||
typeof payload.targetUrl === "string" ? payload.targetUrl.trim() || undefined : undefined,
|
||||
targetApp:
|
||||
typeof payload.targetApp === "string" ? payload.targetApp.trim() || undefined : undefined,
|
||||
rawThreadReply:
|
||||
typeof payload.rawThreadReply === "string" ? payload.rawThreadReply.trim() || undefined : undefined,
|
||||
executionProgress:
|
||||
payload.executionProgress && typeof payload.executionProgress === "object"
|
||||
? payload.executionProgress
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function runShortCommand(command, args, options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd || process.cwd(),
|
||||
env: process.env,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const timeout = setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, options.timeoutMs || 1500);
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({ ok: false, stdout, stderr: error.message });
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
ok: code === 0,
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseGitShortstat(shortstat) {
|
||||
const text = String(shortstat || "");
|
||||
const changedFiles = Number((text.match(/(\d+)\s+files?\s+changed/) || [])[1]);
|
||||
const additions = Number((text.match(/(\d+)\s+insertions?\(\+\)/) || [])[1]);
|
||||
const deletions = Number((text.match(/(\d+)\s+deletions?\(-\)/) || [])[1]);
|
||||
return {
|
||||
changedFiles: Number.isFinite(changedFiles) ? changedFiles : undefined,
|
||||
additions: Number.isFinite(additions) ? additions : undefined,
|
||||
deletions: Number.isFinite(deletions) ? deletions : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function collectArtifactsFromReply(text) {
|
||||
const matches = new Set();
|
||||
const source = String(text || "");
|
||||
const pattern = /(?:[\w.-]+\/)*[\w.-]+\.(?:md|txt|ts|tsx|js|mjs|java|kt|json|png|jpe?g|webp|svg|apk|aab)\b/gi;
|
||||
let match;
|
||||
while ((match = pattern.exec(source)) && matches.size < 12) {
|
||||
const label = match[0].split("/").filter(Boolean).pop();
|
||||
if (label) {
|
||||
matches.add(label);
|
||||
}
|
||||
}
|
||||
return Array.from(matches).map((label) => ({
|
||||
label,
|
||||
kind: /\.(png|jpe?g|webp|svg)$/i.test(label) ? "image" : "file",
|
||||
}));
|
||||
}
|
||||
|
||||
async function collectLocalExecutionProgress(cwd, replyBody) {
|
||||
const [diffShortstat, statusShort, ghVersion] = await Promise.all([
|
||||
runShortCommand("git", ["diff", "--shortstat"], { cwd }),
|
||||
runShortCommand("git", ["status", "--short"], { cwd }),
|
||||
runShortCommand("gh", ["--version"], { cwd }),
|
||||
]);
|
||||
const parsedDiff = diffShortstat.ok ? parseGitShortstat(diffShortstat.stdout) : {};
|
||||
const hasGitState = diffShortstat.ok || statusShort.ok;
|
||||
return {
|
||||
branch: hasGitState
|
||||
? {
|
||||
...parsedDiff,
|
||||
gitStatus: statusShort.ok && statusShort.stdout ? "有未提交变更" : "工作区干净",
|
||||
githubCliStatus: ghVersion.ok ? "available" : "unavailable",
|
||||
}
|
||||
: {
|
||||
githubCliStatus: ghVersion.ok ? "available" : "unavailable",
|
||||
},
|
||||
artifacts: collectArtifactsFromReply(replyBody),
|
||||
};
|
||||
}
|
||||
|
||||
async function runMasterAgentTask(config, runtime, task) {
|
||||
const outputFile = join(os.tmpdir(), `${task.taskId}.reply.txt`);
|
||||
const stderrChunks = [];
|
||||
const taskTimeoutMs = resolveMasterAgentTaskTimeoutMs(config, task);
|
||||
runtime.activeMasterTask = {
|
||||
taskId: task.taskId,
|
||||
status: "running",
|
||||
@@ -387,51 +572,142 @@ async function runMasterAgentTask(config, runtime, task) {
|
||||
};
|
||||
|
||||
try {
|
||||
let replyBody;
|
||||
let dispatchExecutionCompletion = null;
|
||||
|
||||
if (shouldUseOmxTeamTaskRunner(task)) {
|
||||
const omxResult = await executeOmxTeamTask(getOmxTeamTaskRunnerConfig(process.env, config), task);
|
||||
if (omxResult.status === "failed") {
|
||||
throw new Error(omxResult.errorMessage || "OMX_EXECUTION_FAILED");
|
||||
let activeChild = null;
|
||||
const executionResult = await (async () => {
|
||||
if (canHandleBrowserControlTask(task)) {
|
||||
const browserResult = await executeBrowserControlTask(task, config);
|
||||
if (browserResult.status === "failed") {
|
||||
throw new Error(browserResult.errorMessage || "BROWSER_CONTROL_FAILED");
|
||||
}
|
||||
return {
|
||||
replyBody: browserResult.replyBody,
|
||||
dispatchExecutionCompletion: {
|
||||
targetUrl: browserResult.targetUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
replyBody = omxResult.replyBody ?? omxResult.rawThreadReply;
|
||||
dispatchExecutionCompletion = {
|
||||
rawThreadReply: omxResult.rawThreadReply,
|
||||
replyBody: omxResult.replyBody,
|
||||
};
|
||||
} else {
|
||||
|
||||
if (canHandleComputerUseTask(task)) {
|
||||
const computerUseResult = await executeComputerUseTask(task, config);
|
||||
if (computerUseResult.status === "failed") {
|
||||
throw new Error(computerUseResult.errorMessage || "COMPUTER_USE_FAILED");
|
||||
}
|
||||
return {
|
||||
replyBody: computerUseResult.replyBody,
|
||||
dispatchExecutionCompletion: {
|
||||
targetApp: computerUseResult.targetApp,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (shouldUseOmxTeamTaskRunner(task)) {
|
||||
const omxResult = await executeOmxTeamTask(getOmxTeamTaskRunnerConfig(process.env, config), task);
|
||||
if (omxResult.status === "failed") {
|
||||
throw new Error(omxResult.errorMessage || "OMX_EXECUTION_FAILED");
|
||||
}
|
||||
return {
|
||||
replyBody: omxResult.replyBody ?? omxResult.rawThreadReply,
|
||||
dispatchExecutionCompletion: {
|
||||
rawThreadReply: omxResult.rawThreadReply,
|
||||
replyBody: omxResult.replyBody,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const codexPreparation = await prepareCodexTaskExecution(config, task, outputFile);
|
||||
if (!codexPreparation.ok) {
|
||||
throw new Error(codexPreparation.error.message);
|
||||
}
|
||||
const codexExecution = codexPreparation.execution;
|
||||
await new Promise((resolveTask, rejectTask) => {
|
||||
const child = spawn("codex", codexExecution.args, {
|
||||
cwd: codexExecution.cwd,
|
||||
env: process.env,
|
||||
if (codexExecution.desktopMirror?.enabled) {
|
||||
const mirrorResult = await appendBossUserMessageToCodexThreadRollout({
|
||||
stateDbPath: config.codexStateDbPath,
|
||||
sessionsDir: config.codexSessionsDir,
|
||||
targetThreadRef: codexExecution.desktopMirror.targetThreadRef,
|
||||
sourceMessageId: codexExecution.desktopMirror.sourceMessageId,
|
||||
message: codexExecution.desktopMirror.sourceMessageBody,
|
||||
sentAt: codexExecution.desktopMirror.sourceMessageSentAt,
|
||||
});
|
||||
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderrChunks.push(String(chunk));
|
||||
});
|
||||
|
||||
child.on("error", rejectTask);
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolveTask();
|
||||
return;
|
||||
try {
|
||||
const refreshResult = await executeCodexDesktopRefreshBridge(
|
||||
{
|
||||
targetThreadRef: codexExecution.desktopMirror.targetThreadRef,
|
||||
sourceMessageId: codexExecution.desktopMirror.sourceMessageId,
|
||||
rolloutPath: mirrorResult.rolloutPath,
|
||||
threadTouchStatus: mirrorResult.threadTouch?.status,
|
||||
},
|
||||
config,
|
||||
);
|
||||
if (refreshResult.status === "failed") {
|
||||
await postAppLog(config, runtime, {
|
||||
projectId: task.projectId,
|
||||
level: "warn",
|
||||
category: "local_agent.codex_desktop_refresh_failed",
|
||||
message: "Codex 桌面刷新提示未完成,消息已写入线程记录。",
|
||||
detail: refreshResult.detail,
|
||||
mirrorToMaster: false,
|
||||
});
|
||||
}
|
||||
rejectTask(new Error(stderrChunks.join("").trim() || `codex exit code ${code}`));
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
await postAppLog(config, runtime, {
|
||||
projectId: task.projectId,
|
||||
level: "warn",
|
||||
category: "local_agent.codex_desktop_refresh_failed",
|
||||
message: "Codex 桌面刷新提示执行失败,消息已写入线程记录。",
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
mirrorToMaster: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
await runWithTaskTimeout(
|
||||
{
|
||||
timeoutMs: taskTimeoutMs,
|
||||
label: `master task ${task.taskId}`,
|
||||
onTimeout: async () => {
|
||||
if (activeChild && !activeChild.killed) {
|
||||
activeChild.kill("SIGKILL");
|
||||
}
|
||||
},
|
||||
},
|
||||
async () =>
|
||||
await new Promise((resolveTask, rejectTask) => {
|
||||
const child = spawn("codex", codexExecution.args, {
|
||||
cwd: codexExecution.cwd,
|
||||
env: process.env,
|
||||
});
|
||||
activeChild = child;
|
||||
|
||||
replyBody = (await readFile(outputFile, "utf8")).trim();
|
||||
dispatchExecutionCompletion =
|
||||
task.taskType === "dispatch_execution"
|
||||
? parseDispatchExecutionCompletion(replyBody)
|
||||
: null;
|
||||
}
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderrChunks.push(String(chunk));
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
activeChild = null;
|
||||
rejectTask(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
activeChild = null;
|
||||
if (code === 0) {
|
||||
resolveTask();
|
||||
return;
|
||||
}
|
||||
rejectTask(new Error(stderrChunks.join("").trim() || `codex exit code ${code}`));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const replyBody = (await readFile(outputFile, "utf8")).trim();
|
||||
const executionProgress = await collectLocalExecutionProgress(codexExecution.cwd, replyBody);
|
||||
return {
|
||||
replyBody,
|
||||
executionProgress,
|
||||
dispatchExecutionCompletion:
|
||||
task.taskType === "dispatch_execution"
|
||||
? parseDispatchExecutionCompletion(replyBody)
|
||||
: null,
|
||||
};
|
||||
})();
|
||||
const { replyBody, dispatchExecutionCompletion, executionProgress } = executionResult;
|
||||
|
||||
const completion = await completeMasterAgentTask(
|
||||
config,
|
||||
@@ -442,7 +718,10 @@ async function runMasterAgentTask(config, runtime, task) {
|
||||
dispatchExecutionId: task.dispatchExecutionId,
|
||||
targetProjectId: task.targetProjectId,
|
||||
targetThreadId: task.targetThreadId,
|
||||
targetUrl: dispatchExecutionCompletion?.targetUrl,
|
||||
targetApp: dispatchExecutionCompletion?.targetApp,
|
||||
rawThreadReply: dispatchExecutionCompletion?.rawThreadReply,
|
||||
executionProgress,
|
||||
}),
|
||||
);
|
||||
runtime.activeMasterTask = {
|
||||
@@ -461,18 +740,20 @@ async function runMasterAgentTask(config, runtime, task) {
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
const transportDetail = sanitizeSensitiveTaskFailureDetailForTransport(detail);
|
||||
const logDetail = sanitizeSensitiveTaskFailureDetailForLog(detail);
|
||||
runtime.activeMasterTask = {
|
||||
taskId: task.taskId,
|
||||
status: "failed",
|
||||
completedAt: new Date().toISOString(),
|
||||
detail,
|
||||
detail: logDetail ?? transportDetail ?? "MASTER_AGENT_TASK_FAILED",
|
||||
};
|
||||
await completeMasterAgentTask(
|
||||
config,
|
||||
runtime,
|
||||
buildRemoteExecutionCompletionPayload(task, {
|
||||
status: "failed",
|
||||
errorMessage: detail,
|
||||
errorMessage: transportDetail,
|
||||
dispatchExecutionId: task.dispatchExecutionId,
|
||||
targetProjectId: task.targetProjectId,
|
||||
targetThreadId: task.targetThreadId,
|
||||
@@ -483,7 +764,7 @@ async function runMasterAgentTask(config, runtime, task) {
|
||||
level: "error",
|
||||
category: "local_agent.master_agent_task_failed",
|
||||
message: `Master Codex Node 执行主 Agent 任务失败:${task.taskId}`,
|
||||
detail,
|
||||
detail: logDetail,
|
||||
mirrorToMaster: true,
|
||||
});
|
||||
} finally {
|
||||
@@ -532,6 +813,82 @@ async function pollMasterAgentTasks(config, runtime) {
|
||||
}
|
||||
}
|
||||
|
||||
async function pollSkillLifecycleRequests(config, runtime) {
|
||||
const runnerConfig = getSkillLifecycleRunnerConfig(process.env, config);
|
||||
if (!runnerConfig.enabled || runtime.skillLifecycleBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const claim = await claimSkillLifecycleRequest(config, runtime);
|
||||
runtime.lastSkillLifecyclePoll = {
|
||||
at: new Date().toISOString(),
|
||||
ok: claim.ok,
|
||||
status: claim.status,
|
||||
body: claim.body,
|
||||
};
|
||||
if (!claim.ok) {
|
||||
return;
|
||||
}
|
||||
const parsed = JSON.parse(claim.body);
|
||||
if (!parsed.request) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtime.skillLifecycleBusy = true;
|
||||
runtime.activeSkillLifecycleRequest = {
|
||||
requestId: parsed.request.requestId,
|
||||
action: parsed.request.action,
|
||||
status: "running",
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
let result = await executeSkillLifecycleRequest(parsed.request, config, runtime);
|
||||
if (result.status !== "failed") {
|
||||
const skills = await discoverSkills(config);
|
||||
runtime.lastSkills = skills;
|
||||
const skillSyncResult = await postSkills(config, runtime, skills);
|
||||
runtime.lastSkillSyncAt = new Date().toISOString();
|
||||
runtime.lastSkillSyncOk = skillSyncResult.ok;
|
||||
runtime.lastSkillSyncStatus = skillSyncResult.status;
|
||||
runtime.lastSkillSyncBody = skillSyncResult.body;
|
||||
if (!skillSyncResult.ok) {
|
||||
result = {
|
||||
status: "failed",
|
||||
error: `SKILL_SYNC_FAILED:${skillSyncResult.status}:${skillSyncResult.body}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const completion = await completeSkillLifecycleRequest(config, runtime, parsed.request, result);
|
||||
runtime.activeSkillLifecycleRequest = {
|
||||
requestId: parsed.request.requestId,
|
||||
action: parsed.request.action,
|
||||
status: completion.ok ? result.status : "complete_failed",
|
||||
completedAt: new Date().toISOString(),
|
||||
detail: completion.body,
|
||||
};
|
||||
await postAppLog(config, runtime, {
|
||||
level: result.status === "failed" ? "error" : "info",
|
||||
category: result.status === "failed"
|
||||
? "local_agent.skill_lifecycle_failed"
|
||||
: "local_agent.skill_lifecycle_completed",
|
||||
message: `Skill 远程治理任务${result.status === "failed" ? "失败" : "完成"}:${parsed.request.action}`,
|
||||
detail: result.resultSummary ?? result.error,
|
||||
mirrorToMaster: result.status === "failed",
|
||||
});
|
||||
} catch (error) {
|
||||
runtime.lastSkillLifecyclePoll = {
|
||||
at: new Date().toISOString(),
|
||||
ok: false,
|
||||
status: 0,
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
runtime.skillLifecycleBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
const configPath = process.argv[2];
|
||||
if (!configPath) {
|
||||
console.error("Usage: node local-agent/server.mjs <config.json>");
|
||||
@@ -556,6 +913,9 @@ const runtime = {
|
||||
masterTaskBusy: false,
|
||||
activeMasterTask: null,
|
||||
lastMasterTaskPoll: null,
|
||||
skillLifecycleBusy: false,
|
||||
activeSkillLifecycleRequest: null,
|
||||
lastSkillLifecyclePoll: null,
|
||||
lastProjectDiscoveryAt: null,
|
||||
lastProjectDiscoveryOk: false,
|
||||
lastProjectDiscoverySummary: null,
|
||||
@@ -636,6 +996,12 @@ async function performHeartbeat() {
|
||||
}
|
||||
|
||||
const heartbeat = createSerializedRunner(performHeartbeat);
|
||||
const masterTaskPoll = createSerializedRunner(async () => {
|
||||
await pollMasterAgentTasks(config, runtime);
|
||||
});
|
||||
const skillLifecyclePoll = createSerializedRunner(async () => {
|
||||
await pollSkillLifecycleRequests(config, runtime);
|
||||
});
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
if (request.url === "/health") {
|
||||
@@ -699,7 +1065,8 @@ server.listen(config.port, config.bindHost, () => {
|
||||
|
||||
void (async () => {
|
||||
await heartbeat();
|
||||
await pollMasterAgentTasks(config, runtime);
|
||||
await masterTaskPoll();
|
||||
await skillLifecyclePoll();
|
||||
})();
|
||||
|
||||
setInterval(() => {
|
||||
@@ -707,5 +1074,9 @@ setInterval(() => {
|
||||
}, config.heartbeatIntervalMs ?? 15000);
|
||||
|
||||
setInterval(() => {
|
||||
void pollMasterAgentTasks(config, runtime);
|
||||
}, config.masterAgentPollIntervalMs ?? 3000);
|
||||
void masterTaskPoll();
|
||||
}, config.masterAgentPollIntervalMs ?? 1000);
|
||||
|
||||
setInterval(() => {
|
||||
void skillLifecyclePoll();
|
||||
}, config.skillLifecyclePollIntervalMs ?? 5000);
|
||||
|
||||
425
local-agent/skill-lifecycle-runner.mjs
Normal file
425
local-agent/skill-lifecycle-runner.mjs
Normal file
@@ -0,0 +1,425 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { cp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import { basename, dirname, join, relative, resolve } from "node:path";
|
||||
|
||||
function trimToDefined(value) {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function parseSourceList(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(trimToDefined).filter(Boolean);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.split(",").map(trimToDefined).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseTrustedSources(value) {
|
||||
if (!value) {
|
||||
return {};
|
||||
}
|
||||
if (typeof value === "object" && !Array.isArray(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([key, source]) => [trimToDefined(key), trimToDefined(source)])
|
||||
.filter(([key, source]) => key && source),
|
||||
);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
return parseTrustedSources(JSON.parse(value));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function getSkillLifecycleRunnerConfig(env = process.env, config = {}) {
|
||||
const enabledValue = config.skillLifecycleEnabled ?? env.BOSS_SKILL_LIFECYCLE_ENABLED;
|
||||
const enabled = enabledValue === undefined ? true : enabledValue !== false && enabledValue !== "false";
|
||||
const skillsDir = resolve(
|
||||
trimToDefined(config.skillsDir || env.BOSS_SKILLS_DIR) ?? join(os.homedir(), ".codex", "skills"),
|
||||
);
|
||||
const timeoutMs = Number(config.skillLifecycleTimeoutMs ?? env.BOSS_SKILL_LIFECYCLE_TIMEOUT_MS ?? 120_000);
|
||||
const allowedSources = parseSourceList(
|
||||
config.skillLifecycleAllowedSources ?? env.BOSS_SKILL_LIFECYCLE_ALLOWED_SOURCES,
|
||||
);
|
||||
const trustedSources = parseTrustedSources(
|
||||
config.skillLifecycleTrustedSources ?? env.BOSS_SKILL_LIFECYCLE_TRUSTED_SOURCES,
|
||||
);
|
||||
return {
|
||||
enabled,
|
||||
skillsDir,
|
||||
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 120_000,
|
||||
allowedSources,
|
||||
trustedSources,
|
||||
};
|
||||
}
|
||||
|
||||
export function slugifySkillName(value) {
|
||||
return String(value || "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/gi, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 48) || "skill";
|
||||
}
|
||||
|
||||
function isInside(parent, child) {
|
||||
const diff = relative(resolve(parent), resolve(child));
|
||||
return diff === "" || (!diff.startsWith("..") && !diff.startsWith("/"));
|
||||
}
|
||||
|
||||
function assertInsideSkillsDir(skillsDir, targetPath) {
|
||||
if (!isInside(skillsDir, targetPath)) {
|
||||
throw new Error("SKILL_PATH_OUTSIDE_SKILLS_DIR");
|
||||
}
|
||||
}
|
||||
|
||||
function sourceName(sourceUrl) {
|
||||
try {
|
||||
const parsed = new URL(sourceUrl);
|
||||
return basename(parsed.pathname).replace(/\.git$/i, "") || "remote-skill";
|
||||
} catch {
|
||||
return basename(sourceUrl).replace(/\.git$/i, "") || "remote-skill";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRequestSourceUrl(request, runnerConfig) {
|
||||
const sourceUrl = trimToDefined(request.sourceUrl);
|
||||
if (sourceUrl) {
|
||||
return sourceUrl;
|
||||
}
|
||||
const trustedSource = trimToDefined(request.trustedSource ?? request.trustedSourceId);
|
||||
return trustedSource ? runnerConfig.trustedSources[trustedSource] : undefined;
|
||||
}
|
||||
|
||||
function parseUrlOrNull(value) {
|
||||
try {
|
||||
return new URL(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTrailingSlashes(value) {
|
||||
return String(value ?? "").replace(/[\\/]+$/g, "");
|
||||
}
|
||||
|
||||
function urlMatchesSourceBoundary(sourceUrl, allowedSource) {
|
||||
const source = parseUrlOrNull(sourceUrl);
|
||||
const allowed = parseUrlOrNull(allowedSource);
|
||||
if (!source || !allowed || source.origin !== allowed.origin) {
|
||||
return false;
|
||||
}
|
||||
if (allowed.search || allowed.hash) {
|
||||
return source.href === allowed.href;
|
||||
}
|
||||
const sourcePath = normalizeTrailingSlashes(source.pathname);
|
||||
const allowedPath = normalizeTrailingSlashes(allowed.pathname);
|
||||
return sourcePath === allowedPath || sourcePath.startsWith(`${allowedPath}/`);
|
||||
}
|
||||
|
||||
function sourceLooksLikePath(value) {
|
||||
return value.startsWith("/") || value.startsWith("./") || value.startsWith("../") || value.startsWith("~");
|
||||
}
|
||||
|
||||
function sourceMatchesAllowedBoundary(sourceUrl, allowedSource) {
|
||||
const source = trimToDefined(sourceUrl);
|
||||
const allowed = trimToDefined(allowedSource);
|
||||
if (!source || !allowed) {
|
||||
return false;
|
||||
}
|
||||
if (source === allowed) {
|
||||
return true;
|
||||
}
|
||||
if (parseUrlOrNull(source) || parseUrlOrNull(allowed)) {
|
||||
return urlMatchesSourceBoundary(source, allowed);
|
||||
}
|
||||
if (sourceLooksLikePath(source) || sourceLooksLikePath(allowed)) {
|
||||
return isInside(resolve(allowed), resolve(source));
|
||||
}
|
||||
const normalizedAllowed = normalizeTrailingSlashes(allowed);
|
||||
return source === normalizedAllowed
|
||||
|| source.startsWith(`${normalizedAllowed}/`)
|
||||
|| source.startsWith(`${normalizedAllowed}\\`);
|
||||
}
|
||||
|
||||
function isAllowedSource(sourceUrl, runnerConfig) {
|
||||
if (!sourceUrl) {
|
||||
return true;
|
||||
}
|
||||
return runnerConfig.allowedSources.some((allowedSource) => sourceMatchesAllowedBoundary(sourceUrl, allowedSource))
|
||||
|| Object.values(runnerConfig.trustedSources).some((trustedSource) => sourceUrl === trustedSource);
|
||||
}
|
||||
|
||||
function skillIdFor(deviceId, skill) {
|
||||
return `${deviceId}:${slugifySkillName(skill?.name)}`;
|
||||
}
|
||||
|
||||
function findRuntimeSkill(runtime, deviceId, skillId) {
|
||||
return (runtime.lastSkills ?? []).find((skill) => skillIdFor(deviceId, skill) === skillId);
|
||||
}
|
||||
|
||||
async function ensureSkillDirectory(skillsDir, skill) {
|
||||
if (!skill?.path) {
|
||||
throw new Error("SKILL_NOT_FOUND_ON_DEVICE");
|
||||
}
|
||||
const skillFile = resolve(skill.path);
|
||||
assertInsideSkillsDir(skillsDir, skillFile);
|
||||
const skillDir = dirname(skillFile);
|
||||
await stat(skillFile);
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
async function fileExists(filePath) {
|
||||
try {
|
||||
await stat(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checksumFileForSkill(targetDir) {
|
||||
const manifestPath = join(targetDir, "manifest.json");
|
||||
if (await fileExists(manifestPath)) {
|
||||
return manifestPath;
|
||||
}
|
||||
const skillPath = join(targetDir, "SKILL.md");
|
||||
if (await fileExists(skillPath)) {
|
||||
return skillPath;
|
||||
}
|
||||
throw new Error("SKILL_CHECKSUM_TARGET_NOT_FOUND");
|
||||
}
|
||||
|
||||
async function verifyChecksumIfRequested(request, targetDir) {
|
||||
const expectedChecksum = trimToDefined(request.expectedChecksum ?? request.checksum);
|
||||
if (!expectedChecksum) {
|
||||
return;
|
||||
}
|
||||
const checksumPath = await checksumFileForSkill(targetDir);
|
||||
const actualChecksum = createHash("sha256").update(await readFile(checksumPath)).digest("hex");
|
||||
if (actualChecksum.toLowerCase() !== expectedChecksum.toLowerCase()) {
|
||||
throw new Error("SKILL_CHECKSUM_MISMATCH");
|
||||
}
|
||||
}
|
||||
|
||||
async function backupSkillDirectory(runnerConfig, skillDir, skillName) {
|
||||
assertInsideSkillsDir(runnerConfig.skillsDir, skillDir);
|
||||
const backupsDir = resolve(runnerConfig.skillsDir, ".boss-skill-backups");
|
||||
assertInsideSkillsDir(runnerConfig.skillsDir, backupsDir);
|
||||
await mkdir(backupsDir, { recursive: true });
|
||||
const backupName = `${new Date().toISOString().replace(/[:.]/g, "-")}-${slugifySkillName(skillName || basename(skillDir))}`;
|
||||
const backupDir = resolve(backupsDir, backupName);
|
||||
assertInsideSkillsDir(backupsDir, backupDir);
|
||||
await cp(skillDir, backupDir, { recursive: true, force: false });
|
||||
return backupDir;
|
||||
}
|
||||
|
||||
async function restoreSkillBackup(runnerConfig, skillDir, backupDir) {
|
||||
if (!backupDir) {
|
||||
return;
|
||||
}
|
||||
assertInsideSkillsDir(runnerConfig.skillsDir, skillDir);
|
||||
assertInsideSkillsDir(resolve(runnerConfig.skillsDir, ".boss-skill-backups"), backupDir);
|
||||
await rm(skillDir, { recursive: true, force: true });
|
||||
await cp(backupDir, skillDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function runCommand(command, args, options = {}) {
|
||||
const timeoutMs = options.timeoutMs ?? 120_000;
|
||||
return await new Promise((resolveCommand, rejectCommand) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd: options.cwd,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
rejectCommand(new Error(`${command} timeout after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
rejectCommand(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (code === 0) {
|
||||
resolveCommand({ stdout, stderr });
|
||||
return;
|
||||
}
|
||||
rejectCommand(new Error(stderr.trim() || stdout.trim() || `${command} exited ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function git(args, options) {
|
||||
return runCommand("git", args, options);
|
||||
}
|
||||
|
||||
async function installOrUpdateFromSource(request, runnerConfig, existingSkill) {
|
||||
const sourceUrl = resolveRequestSourceUrl(request, runnerConfig);
|
||||
if (!sourceUrl && !existingSkill) {
|
||||
throw new Error("SOURCE_URL_REQUIRED");
|
||||
}
|
||||
if (sourceUrl && !isAllowedSource(sourceUrl, runnerConfig)) {
|
||||
throw new Error("SKILL_SOURCE_NOT_ALLOWED");
|
||||
}
|
||||
await mkdir(join(runnerConfig.skillsDir, "remote"), { recursive: true });
|
||||
|
||||
const targetDir = existingSkill
|
||||
? await ensureSkillDirectory(runnerConfig.skillsDir, existingSkill)
|
||||
: resolve(runnerConfig.skillsDir, "remote", slugifySkillName(sourceName(sourceUrl)));
|
||||
assertInsideSkillsDir(runnerConfig.skillsDir, targetDir);
|
||||
const backupDir = existingSkill ? await backupSkillDirectory(runnerConfig, targetDir, existingSkill.name) : null;
|
||||
|
||||
try {
|
||||
if (sourceUrl && !existingSkill) {
|
||||
await git(["clone", "--depth", "1", sourceUrl, targetDir], {
|
||||
timeoutMs: runnerConfig.timeoutMs,
|
||||
});
|
||||
} else {
|
||||
await git(["fetch", "--all", "--tags", "--prune"], {
|
||||
cwd: targetDir,
|
||||
timeoutMs: runnerConfig.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
const targetVersion = trimToDefined(request.targetVersion);
|
||||
if (targetVersion) {
|
||||
await git(["checkout", targetVersion], {
|
||||
cwd: targetDir,
|
||||
timeoutMs: runnerConfig.timeoutMs,
|
||||
});
|
||||
} else if (existingSkill) {
|
||||
await git(["pull", "--ff-only"], {
|
||||
cwd: targetDir,
|
||||
timeoutMs: runnerConfig.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
await verifyChecksumIfRequested(request, targetDir);
|
||||
} catch (error) {
|
||||
if (existingSkill) {
|
||||
await restoreSkillBackup(runnerConfig, targetDir, backupDir).catch(() => {});
|
||||
} else {
|
||||
await rm(targetDir, { recursive: true, force: true });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return `Skill 已${existingSkill ? "更新" : "安装"}:${targetDir}`;
|
||||
}
|
||||
|
||||
async function rollbackSkill(request, runnerConfig, runtime) {
|
||||
const skill = findRuntimeSkill(runtime, request.deviceId, request.skillId);
|
||||
const targetVersion = trimToDefined(request.rollbackToVersion);
|
||||
if (!targetVersion) {
|
||||
throw new Error("ROLLBACK_VERSION_REQUIRED");
|
||||
}
|
||||
const skillDir = await ensureSkillDirectory(runnerConfig.skillsDir, skill);
|
||||
const backupDir = await backupSkillDirectory(runnerConfig, skillDir, skill?.name);
|
||||
try {
|
||||
await git(["fetch", "--all", "--tags", "--prune"], {
|
||||
cwd: skillDir,
|
||||
timeoutMs: runnerConfig.timeoutMs,
|
||||
});
|
||||
await git(["checkout", targetVersion], {
|
||||
cwd: skillDir,
|
||||
timeoutMs: runnerConfig.timeoutMs,
|
||||
});
|
||||
await verifyChecksumIfRequested(request, skillDir);
|
||||
} catch (error) {
|
||||
await restoreSkillBackup(runnerConfig, skillDir, backupDir).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return `Skill 已回滚到 ${targetVersion}`;
|
||||
}
|
||||
|
||||
async function uninstallSkill(request, runnerConfig, runtime) {
|
||||
const skill = findRuntimeSkill(runtime, request.deviceId, request.skillId);
|
||||
const skillDir = await ensureSkillDirectory(runnerConfig.skillsDir, skill);
|
||||
const backupDir = await backupSkillDirectory(runnerConfig, skillDir, skill?.name);
|
||||
try {
|
||||
await rm(skillDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
await restoreSkillBackup(runnerConfig, skillDir, backupDir).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return `Skill 已卸载:${skill?.name ?? request.skillId}`;
|
||||
}
|
||||
|
||||
async function lockSkillVersion(request, runnerConfig) {
|
||||
const lockedVersion = trimToDefined(request.lockedVersion);
|
||||
if (!lockedVersion) {
|
||||
throw new Error("LOCKED_VERSION_REQUIRED");
|
||||
}
|
||||
const lockPath = resolve(runnerConfig.skillsDir, ".boss-skill-locks.json");
|
||||
assertInsideSkillsDir(runnerConfig.skillsDir, lockPath);
|
||||
let locks = {};
|
||||
try {
|
||||
locks = JSON.parse(await readFile(lockPath, "utf8"));
|
||||
} catch {
|
||||
locks = {};
|
||||
}
|
||||
locks[request.skillId || request.sourceUrl] = {
|
||||
lockedVersion,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await mkdir(runnerConfig.skillsDir, { recursive: true });
|
||||
await writeFile(lockPath, `${JSON.stringify(locks, null, 2)}\n`, "utf8");
|
||||
return `Skill 已锁定版本:${lockedVersion}`;
|
||||
}
|
||||
|
||||
export async function executeSkillLifecycleRequest(request, config, runtime) {
|
||||
const runnerConfig = getSkillLifecycleRunnerConfig(process.env, config);
|
||||
if (!runnerConfig.enabled) {
|
||||
return {
|
||||
status: "failed",
|
||||
error: "SKILL_LIFECYCLE_RUNNER_DISABLED",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const existingSkill = request.skillId
|
||||
? findRuntimeSkill(runtime, request.deviceId, request.skillId)
|
||||
: null;
|
||||
let resultSummary = "";
|
||||
if (request.action === "install") {
|
||||
resultSummary = await installOrUpdateFromSource(request, runnerConfig, existingSkill);
|
||||
} else if (request.action === "update") {
|
||||
resultSummary = await installOrUpdateFromSource(request, runnerConfig, existingSkill);
|
||||
} else if (request.action === "uninstall") {
|
||||
resultSummary = await uninstallSkill(request, runnerConfig, runtime);
|
||||
} else if (request.action === "rollback") {
|
||||
resultSummary = await rollbackSkill(request, runnerConfig, runtime);
|
||||
} else if (request.action === "version_lock") {
|
||||
resultSummary = await lockSkillVersion(request, runnerConfig);
|
||||
} else {
|
||||
throw new Error("SKILL_LIFECYCLE_ACTION_UNSUPPORTED");
|
||||
}
|
||||
return {
|
||||
status: "completed",
|
||||
resultSummary,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "failed",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
2646
package-lock.json
generated
2646
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -9,34 +9,45 @@
|
||||
"postbuild": "mkdir -p .next/standalone/.next && rm -rf .next/standalone/.next/static .next/standalone/public && cp -R .next/static .next/standalone/.next/static && cp -R public .next/standalone/public",
|
||||
"start": "BOSS_RUNTIME_ROOT=\"$PWD\" BOSS_STATE_FILE=\"$PWD/data/boss-state.json\" node .next/standalone/server.js",
|
||||
"lint": "eslint",
|
||||
"admin:web:dev": "cd apps/boss-admin-web && npm run dev",
|
||||
"admin:web:build": "cd apps/boss-admin-web && npm run build",
|
||||
"admin:web:publish": "npm --prefix apps/boss-admin-web run build",
|
||||
"test:master-agent-controls": "tsx --test tests/master-agent-chat-controls.test.ts",
|
||||
"apk:debug": "cd android && ./gradlew assembleDebug && cd .. && zsh ./scripts/publish-apk-to-public.sh",
|
||||
"apk:release": "zsh ./scripts/build-release-apk.sh",
|
||||
"aab:release": "zsh ./scripts/build-release-aab.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@capacitor/android": "^8.2.0",
|
||||
"@capacitor/app": "^8.1.0",
|
||||
"@capacitor/cli": "^8.2.0",
|
||||
"@capacitor/core": "^8.2.0",
|
||||
"@capacitor/preferences": "^8.0.1",
|
||||
"ali-oss": "^6.23.0",
|
||||
"@refinedev/core": "^5.0.12",
|
||||
"@refinedev/nextjs-router": "^7.0.5",
|
||||
"@tanstack/react-query": "^5.100.5",
|
||||
"antd": "^5.29.3",
|
||||
"clsx": "^2.1.1",
|
||||
"next": "16.2.1",
|
||||
"proxy-agent": "^5.0.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"next": "16.2.4",
|
||||
"pg": "^8.20.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/ali-oss": "^6.23.3",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.1",
|
||||
"eslint-config-next": "16.2.4",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss": "8.5.12"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user