10 Commits

2101 changed files with 16458 additions and 309132 deletions

15
.gitignore vendored
View File

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

View File

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

View File

@@ -2,25 +2,15 @@
<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
android:name=".BossApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:forceDarkAllowed="false">
<service
android:name=".BossBackgroundRealtimeService"
android:exported="false"
android:foregroundServiceType="dataSync" />
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
@@ -58,15 +48,13 @@
<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" />
<activity android:name=".MasterAgentMemoryActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".MasterAgentEvolutionActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".OpsCenterActivity" android:exported="false" android:screenOrientation="portrait" />
<activity android:name=".AboutActivity" android:exported="false" android:screenOrientation="portrait" />

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -66,53 +66,6 @@ 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"));
@@ -130,17 +83,6 @@ 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",
@@ -165,36 +107,22 @@ 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 requestWithRestoreRaw(
"GET",
"/api/v1/projects/" + encode(projectId),
null,
DEFAULT_CONNECT_TIMEOUT_MS,
CONVERSATIONS_READ_TIMEOUT_MS
);
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null);
}
public ApiResponse getProjectMessages(String projectId) throws IOException, JSONException {
return requestWithRestoreRaw(
"GET",
"/api/v1/projects/" + encode(projectId) + "/messages",
null,
DEFAULT_CONNECT_TIMEOUT_MS,
CONVERSATIONS_READ_TIMEOUT_MS
);
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/messages", null);
}
public ApiResponse getDispatchPlans(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/dispatch-plans", null);
}
public ApiResponse getConversationParticipants(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null);
}
public ApiResponse getProjectAgentControls(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/agent-controls", null);
}
@@ -210,6 +138,76 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
}
public ApiResponse updateProjectAgentControls(
String projectId,
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
@Nullable String fastModelOverride,
@Nullable String fastReasoningEffortOverride,
@Nullable String smartModelOverride,
@Nullable String smartReasoningEffortOverride
) throws IOException, JSONException {
JSONObject payload = buildProjectAgentControlsPayload(
modelOverride,
reasoningEffortOverride,
fastModelOverride,
fastReasoningEffortOverride,
smartModelOverride,
smartReasoningEffortOverride,
false
);
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
}
public ApiResponse updateProjectAgentControls(
String projectId,
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
@Nullable String fastModelOverride,
@Nullable String fastReasoningEffortOverride,
@Nullable String smartModelOverride,
@Nullable String smartReasoningEffortOverride,
boolean includeAdvancedOverrides
) throws IOException, JSONException {
JSONObject payload = buildProjectAgentControlsPayload(
modelOverride,
reasoningEffortOverride,
fastModelOverride,
fastReasoningEffortOverride,
smartModelOverride,
smartReasoningEffortOverride,
includeAdvancedOverrides
);
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
}
static JSONObject buildProjectAgentControlsPayload(
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride,
@Nullable String fastModelOverride,
@Nullable String fastReasoningEffortOverride,
@Nullable String smartModelOverride,
@Nullable String smartReasoningEffortOverride,
boolean includeAdvancedOverrides
) throws JSONException {
JSONObject payload = new JSONObject();
payload.put("modelOverride", modelOverride == null ? JSONObject.NULL : modelOverride);
payload.put("reasoningEffortOverride", reasoningEffortOverride == null ? JSONObject.NULL : reasoningEffortOverride);
boolean hasAdvancedOverrides =
fastModelOverride != null
|| fastReasoningEffortOverride != null
|| smartModelOverride != null
|| smartReasoningEffortOverride != null;
if (includeAdvancedOverrides || hasAdvancedOverrides) {
payload.put("fastModelOverride", fastModelOverride == null ? JSONObject.NULL : fastModelOverride);
payload.put("fastReasoningEffortOverride", fastReasoningEffortOverride == null ? JSONObject.NULL : fastReasoningEffortOverride);
payload.put("smartModelOverride", smartModelOverride == null ? JSONObject.NULL : smartModelOverride);
payload.put("smartReasoningEffortOverride", smartReasoningEffortOverride == null ? JSONObject.NULL : smartReasoningEffortOverride);
}
return payload;
}
public ApiResponse updateProjectAgentControls(
String projectId,
@Nullable String modelOverride,
@@ -223,22 +221,6 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
}
public ApiResponse updateMasterAgentModeModels(
@Nullable String fastModelOverride,
@Nullable String deepModelOverride,
@Nullable String modelOverride,
@Nullable String reasoningEffortOverride
) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("fastModelOverride", fastModelOverride == null ? JSONObject.NULL : fastModelOverride);
payload.put("deepModelOverride", deepModelOverride == null ? JSONObject.NULL : deepModelOverride);
if (modelOverride != null || reasoningEffortOverride != null) {
payload.put("modelOverride", modelOverride == null ? JSONObject.NULL : modelOverride);
payload.put("reasoningEffortOverride", reasoningEffortOverride == null ? JSONObject.NULL : reasoningEffortOverride);
}
return requestWithRestore("POST", "/api/v1/projects/master-agent/agent-controls", payload);
}
public ApiResponse updateProjectTakeoverSettings(
String projectId,
@Nullable Boolean takeoverEnabled,
@@ -283,6 +265,32 @@ public class BossApiClient {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/memories", null);
}
public ApiResponse getMasterAgentEvolution() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/master-agent/evolution", null);
}
public ApiResponse updateMasterAgentEvolutionMode(String mode) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("mode", mode);
return requestWithRestore("POST", "/api/v1/master-agent/evolution/config", payload);
}
public ApiResponse approveMasterAgentEvolutionProposal(String proposalId) throws IOException, JSONException {
return requestWithRestore(
"POST",
"/api/v1/master-agent/evolution/proposals/" + encode(proposalId) + "/approve",
new JSONObject()
);
}
public ApiResponse rejectMasterAgentEvolutionProposal(String proposalId) throws IOException, JSONException {
return requestWithRestore(
"POST",
"/api/v1/master-agent/evolution/proposals/" + encode(proposalId) + "/reject",
new JSONObject()
);
}
public ApiResponse createMasterAgentMemory(String projectId, JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/memories", payload);
}
@@ -330,16 +338,6 @@ 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",
@@ -365,10 +363,6 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/group-chats", payload == null ? new JSONObject() : payload);
}
public ApiResponse getConversationParticipants(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/participants", null);
}
public ApiResponse getThreadStatus(String projectId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/thread-status", null);
}
@@ -385,18 +379,6 @@ 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);
@@ -410,14 +392,6 @@ 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,
@@ -556,30 +530,10 @@ public class BossApiClient {
return updateDevice(deviceId, payload);
}
public ApiResponse queueCodexRemoteControl(String deviceId, String action, String reason) throws IOException, JSONException {
JSONObject payload = new JSONObject();
payload.put("action", action);
payload.put("confirmed", true);
payload.put("reason", reason == null ? "" : reason);
return requestWithRestore(
"POST",
"/api/v1/devices/" + encode(deviceId) + "/codex-remote-control",
payload
);
}
public ApiResponse getDeviceSkills(String deviceId) throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/devices/" + encode(deviceId) + "/skills", null);
}
public ApiResponse getSkillLifecycleRequests() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/admin/skills/requests", null);
}
public ApiResponse createSkillLifecycleRequest(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/admin/skills/requests", payload == null ? new JSONObject() : payload);
}
public ApiResponse getDeviceEnrollments() throws IOException, JSONException {
return requestWithRestore("GET", "/api/v1/devices/enrollments", null);
}
@@ -610,14 +564,6 @@ 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);
}
@@ -640,10 +586,6 @@ public class BossApiClient {
return requestWithRestore("POST", "/api/v1/accounts/" + encode(accountId) + "/validate", new JSONObject());
}
public ApiResponse validateDraftAccount(JSONObject payload) throws IOException, JSONException {
return requestWithRestore("POST", "/api/v1/accounts/validate-draft", payload);
}
public ApiResponse onboardOpenAiApiAccount(JSONObject payload) throws IOException, JSONException {
return onboardAccount("/api/v1/accounts/onboard/openai-api", payload);
}
@@ -680,14 +622,6 @@ 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);
}
@@ -705,19 +639,13 @@ public class BossApiClient {
}
public ApiResponse logout() throws IOException, JSONException {
try {
return request("POST", "/api/auth/logout", new JSONObject(), false);
} finally {
clearSession();
}
}
public void clearLocalAuthState() {
ApiResponse response = request("POST", "/api/auth/logout", new JSONObject(), false);
clearSession();
return response;
}
public String getAccountLabel() {
return prefs.getString(KEY_ACCOUNT, "krisolo");
return prefs.getString(KEY_ACCOUNT, "17600003315");
}
public String getDisplayName() {
@@ -762,9 +690,9 @@ public class BossApiClient {
int readTimeoutMs
) throws IOException, JSONException {
ApiResponse response = requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
if (response.statusCode == 401) {
ApiResponse recovered = !getRestoreToken().isEmpty() ? restoreSession() : autoLogin();
if (recovered.ok()) {
if (response.statusCode == 401 && !getRestoreToken().isEmpty()) {
ApiResponse restored = restoreSession();
if (restored.ok()) {
return requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
}
}
@@ -857,16 +785,7 @@ public class BossApiClient {
private ApiResponse executeConnection(HttpURLConnection connection, boolean expectProtected) throws IOException, JSONException {
int statusCode = connection.getResponseCode();
captureSessionCookie(connection.getHeaderFields());
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;
}
JSONObject json = readJson(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
if (statusCode == 401 && !expectProtected) {
clearSession();
@@ -979,12 +898,8 @@ 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 JsonBody(new JSONObject(), true);
return new JSONObject();
}
StringBuilder builder = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
@@ -995,13 +910,9 @@ public class BossApiClient {
}
String raw = builder.toString().trim();
if (raw.isEmpty()) {
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();
}
return new JSONObject(raw);
}
private String readText(InputStream stream) throws IOException {
@@ -1035,13 +946,9 @@ public class BossApiClient {
private void captureSessionCookie(Map<String, List<String>> headers) {
if (headers == null) return;
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;
}
List<String> setCookieHeaders = headers.get("Set-Cookie");
if (setCookieHeaders == null) {
setCookieHeaders = headers.get("set-cookie");
}
if (setCookieHeaders == null) return;
@@ -1105,8 +1012,6 @@ public class BossApiClient {
prefs.edit()
.remove(KEY_SESSION_COOKIE)
.remove(KEY_RESTORE_TOKEN)
.remove(KEY_ACCOUNT)
.remove(KEY_DISPLAY_NAME)
.apply();
}
@@ -1136,16 +1041,6 @@ public class BossApiClient {
}
}
private static class JsonBody {
final JSONObject json;
final boolean validJson;
JsonBody(JSONObject json, boolean validJson) {
this.json = json;
this.validJson = validJson;
}
}
public static class DownloadedAttachment {
public final int statusCode;
public final String fileName;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -18,7 +18,6 @@ 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;
@@ -77,7 +76,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
try {
LoadedConversation loadedConversation = loadConversation();
BossApiClient.ApiResponse detailResponse = loadedConversation.detailResponse;
BossApiClient.ApiResponse participantsResponse = loadedConversation.participantsResponse;
JSONObject participantsPayload = loadedConversation.participantsPayload;
JSONObject threadStatusPayload = null;
try {
BossApiClient.ApiResponse threadStatusResponse = loadedConversation.threadStatusResponse;
@@ -88,7 +87,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
threadStatusPayload = null;
}
JSONObject finalThreadStatusPayload = threadStatusPayload;
runOnUiThread(() -> renderConversation(detailResponse.json, participantsResponse.json, finalThreadStatusPayload));
runOnUiThread(() -> renderConversation(detailResponse.json, participantsPayload, finalThreadStatusPayload));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
@@ -179,9 +178,17 @@ 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();
appendConversationInfoItem(BossUi.buildWechatMenuRow(
appendContent(BossUi.buildWechatMenuRow(
this,
"发起群聊",
"选择其他线程加入新群",
@@ -190,7 +197,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
v -> openGroupCreate()
));
appendConversationInfoItem(BossUi.buildWechatMenuRow(
appendContent(BossUi.buildWechatMenuRow(
this,
"线程详情",
"查看当前线程聊天与项目",
@@ -199,7 +206,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
v -> openProject(projectId, projectName)
));
appendConversationInfoItem(BossUi.buildWechatMenuRow(
appendContent(BossUi.buildWechatMenuRow(
this,
"线程状态",
"状态文档和最近进展事件",
@@ -208,7 +215,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
v -> openThreadStatus()
));
appendConversationInfoItem(BossUi.buildWechatMenuRow(
appendContent(BossUi.buildWechatMenuRow(
this,
"参与线程",
participantCount <= 0 ? "暂无参与线程" : "" + participantCount + "",
@@ -218,7 +225,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
));
if (participants == null || participants.length() == 0) {
appendConversationInfoItem(BossUi.buildWechatMenuRow(
appendContent(BossUi.buildWechatMenuRow(
this,
"暂无参与线程",
"下拉刷新后重试",
@@ -230,7 +237,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
for (int i = 0; i < participants.length(); i++) {
JSONObject participant = participants.optJSONObject(i);
if (participant == null) continue;
appendConversationInfoItem(buildParticipantRow(participant));
appendContent(buildParticipantRow(participant));
}
}
@@ -239,34 +246,86 @@ public class ConversationInfoActivity extends BossScreenActivity {
private void appendTakeoverControl() {
SwitchCompat takeoverSwitch = new SwitchCompat(this);
takeoverSwitch.setShowText(false);
takeoverSwitch.setText(null);
takeoverSwitch.setText("开启");
takeoverSwitch.setChecked(takeoverEnabled);
takeoverSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> saveTakeoverSetting(isChecked));
appendConversationInfoItem(BossUi.buildWechatSwitchRow(
appendContent(BossUi.buildFormCell(
this,
"主 Agent 协同接管",
takeoverInheritedFromGlobal
? "跟随全局默认开启"
: "线程单独开启",
? "当前跟随全局默认开启。主 Agent 会协同推进,但不会抢走你直接控制线程开发的能力。"
: "这个线程单独开启主 Agent 协同推进。不会抢走你直接控制线程开发的能力。",
takeoverSwitch
));
}
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
);
private void appendThreadStatusSummary(@Nullable JSONObject threadStatusPayload) {
if (threadStatusPayload == null) {
return;
}
params.bottomMargin = BossUi.dp(this, 8);
view.setLayoutParams(params);
appendContent(view);
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();
}
private LinearLayout buildParticipantRow(JSONObject participant) {
@@ -386,10 +445,6 @@ 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();
});
@@ -416,13 +471,14 @@ public class ConversationInfoActivity extends BossScreenActivity {
throw new IllegalStateException(detailResponse.message());
}
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId);
if (!participantsResponse.ok()) {
throw new IllegalStateException(participantsResponse.message());
}
JSONObject participantsPayload = extractParticipantsPayload(detailResponse.json);
BossApiClient.ApiResponse threadStatusResponse = apiClient.getThreadStatus(projectId);
return new LoadedConversation(detailResponse, participantsResponse, threadStatusResponse);
return new LoadedConversation(detailResponse, participantsPayload, threadStatusResponse);
}
private JSONObject extractParticipantsPayload(JSONObject detailPayload) {
JSONObject participantsPayload = detailPayload == null ? null : detailPayload.optJSONObject("participantsPayload");
return participantsPayload == null ? new JSONObject() : participantsPayload;
}
private BossApiClient.ApiResponse saveTakeoverSettingsWithRetry(
@@ -462,6 +518,25 @@ 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", "");
@@ -474,16 +549,16 @@ public class ConversationInfoActivity extends BossScreenActivity {
private static final class LoadedConversation {
private final BossApiClient.ApiResponse detailResponse;
private final BossApiClient.ApiResponse participantsResponse;
private final JSONObject participantsPayload;
private final BossApiClient.ApiResponse threadStatusResponse;
private LoadedConversation(
BossApiClient.ApiResponse detailResponse,
BossApiClient.ApiResponse participantsResponse,
JSONObject participantsPayload,
BossApiClient.ApiResponse threadStatusResponse
) {
this.detailResponse = detailResponse;
this.participantsResponse = participantsResponse;
this.participantsPayload = participantsPayload;
this.threadStatusResponse = threadStatusResponse;
}
}

View File

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

View File

@@ -11,6 +11,7 @@ import android.widget.TextView;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
@@ -70,9 +71,10 @@ public class GroupCreateActivity extends BossScreenActivity {
runOnUiThread(() -> renderCreatePage(null, conversationsResponse.json, true));
return;
}
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(sourceProjectId);
if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message());
runOnUiThread(() -> renderCreatePage(participantsResponse.json, conversationsResponse.json, true));
BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(sourceProjectId);
if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message());
JSONObject participantsPayload = extractParticipantsPayload(detailResponse.json, sourceProjectId);
runOnUiThread(() -> renderCreatePage(participantsPayload, conversationsResponse.json, true));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
@@ -155,6 +157,23 @@ public class GroupCreateActivity extends BossScreenActivity {
updateCreateButtonState();
}
private JSONObject extractParticipantsPayload(JSONObject detailPayload, String fallbackProjectId) {
JSONObject participantsPayload = detailPayload == null ? null : detailPayload.optJSONObject("participantsPayload");
if (participantsPayload != null) {
return participantsPayload;
}
JSONObject fallback = new JSONObject();
JSONObject project = detailPayload == null ? null : detailPayload.optJSONObject("project");
JSONObject threadMeta = project == null ? null : project.optJSONObject("threadMeta");
try {
fallback.put("projectId", fallbackProjectId == null ? "" : fallbackProjectId);
fallback.put("threadMeta", threadMeta == null ? new JSONObject() : threadMeta);
fallback.put("participants", new JSONArray());
} catch (JSONException ignored) {
}
return fallback;
}
private View buildHeaderView(
boolean hasSourceProject,
@Nullable String sourceProjectId,

View File

@@ -22,6 +22,7 @@ public class GroupInfoActivity extends BossScreenActivity {
private String projectId;
private String projectName;
private boolean groupRepairJustApplied;
private @Nullable BossRealtimeClient realtimeClient;
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
@@ -72,13 +73,12 @@ public class GroupInfoActivity extends BossScreenActivity {
try {
BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(projectId);
if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message());
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId);
if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message());
JSONObject participantsPayload = extractParticipantsPayload(detailResponse.json);
BossApiClient.ApiResponse orchestrationResponse = apiClient.getProjectOrchestrationBackend(projectId);
JSONObject orchestrationBackend = orchestrationResponse.ok()
? orchestrationResponse.json
: buildFallbackOrchestrationBackendPayload(orchestrationResponse.message());
runOnUiThread(() -> renderGroup(detailResponse.json, participantsResponse.json, orchestrationBackend));
runOnUiThread(() -> renderGroup(detailResponse.json, participantsPayload, orchestrationBackend));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
@@ -149,6 +149,11 @@ public class GroupInfoActivity extends BossScreenActivity {
}
}
private JSONObject extractParticipantsPayload(JSONObject detailPayload) {
JSONObject participantsPayload = detailPayload == null ? null : detailPayload.optJSONObject("participantsPayload");
return participantsPayload == null ? new JSONObject() : participantsPayload;
}
private void renderGroup(JSONObject detail, JSONObject participantsPayload) {
renderGroup(detail, participantsPayload, null);
}
@@ -174,6 +179,11 @@ public class GroupInfoActivity extends BossScreenActivity {
int invalidParticipantCount = participantsPayload.optInt("invalidParticipantCount", 0);
configureScreen("群资料", buildSubtitle(folderName, participantCount));
if (groupRepairJustApplied) {
appendContent(BossUi.buildEmptyCard(this, "群成员已更新,当前群聊已经切换到新的真实线程成员。"));
groupRepairJustApplied = false;
}
appendContent(BossUi.buildSimpleProfileHeader(
this,
projectName,
@@ -367,6 +377,7 @@ public class GroupInfoActivity extends BossScreenActivity {
BossApiClient.ApiResponse response = apiClient.replaceConversationParticipants(projectId, memberProjectIds);
if (!response.ok()) throw new IllegalStateException(response.message());
runOnUiThread(() -> {
groupRepairJustApplied = true;
showMessage("群成员已更新");
reload();
});

View File

@@ -1,25 +1,16 @@
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.graphics.drawable.Drawable;
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;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.view.inputmethod.InputMethodManager;
import android.widget.Button;
import android.widget.EditText;
import android.widget.FrameLayout;
@@ -30,7 +21,6 @@ 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;
@@ -53,18 +43,14 @@ import java.util.function.Supplier;
public class MainActivity extends AppCompatActivity {
public static final String EXTRA_INITIAL_TAB = "initial_tab";
public static final String EXTRA_FORCE_LOGOUT = "force_logout";
private static final int REQUEST_POST_NOTIFICATIONS = 2101;
private static final String UI_PREFS = "boss_native_client";
private static final String 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;
@@ -76,16 +62,7 @@ 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;
@@ -113,7 +90,6 @@ 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;
@@ -129,11 +105,9 @@ 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;
@@ -166,45 +140,18 @@ public class MainActivity extends AppCompatActivity {
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
apiClient = createApiClient();
realtimeClient = createRealtimeClient(apiClient);
apiClient = new BossApiClient(this);
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
bindViews();
bindActions();
configureBackNavigation();
if (isForceLogoutIntent(getIntent())) {
forceLogoutToLoginPanel();
return;
}
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);
}
@Override
public void onRealtimeConnectionChanged(boolean connected) {
runOnUiThread(() -> handleRealtimeConnectionChanged(connected));
}
});
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
if (isForceLogoutIntent(intent)) {
forceLogoutToLoginPanel();
return;
}
applyInitialTab(intent);
if (contentPanel.getVisibility() == View.VISIBLE) {
maybeApplyPreferredEntry();
@@ -212,57 +159,32 @@ public class MainActivity extends AppCompatActivity {
}
}
private boolean isForceLogoutIntent(@Nullable Intent intent) {
return intent != null && intent.getBooleanExtra(EXTRA_FORCE_LOGOUT, false);
}
private void forceLogoutToLoginPanel() {
apiClient.clearLocalAuthState();
sessionData = null;
conversationsData = null;
devicesData = null;
otaData = null;
showLogin("已退出登录。点击登录可重新进入系统。");
}
private void configureBackNavigation() {
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
if (handleRootBackPressed()) {
return;
}
setEnabled(false);
getOnBackPressedDispatcher().onBackPressed();
}
});
}
private boolean handleRootBackPressed() {
@Override
public void onBackPressed() {
if (contentPanel.getVisibility() == View.VISIBLE && conversationSearchMode) {
exitConversationSearchMode(true);
return true;
return;
}
if (contentPanel.getVisibility() == View.VISIBLE && conversationQuickActionsVisible) {
hideConversationQuickActions(true);
return true;
return;
}
if (contentPanel.getVisibility() == View.VISIBLE && !"conversations".equals(activeTab)) {
setActiveTab("conversations", false);
persistLastRootTab("conversations");
return true;
return;
}
if (contentPanel.getVisibility() == View.VISIBLE) {
long now = System.currentTimeMillis();
if (now - lastRootBackPressedAt < ROOT_BACK_EXIT_WINDOW_MS) {
moveTaskToBack(true);
return true;
return;
}
lastRootBackPressedAt = now;
showMessage("再按一次返回,应用进入后台");
return true;
return;
}
return false;
super.onBackPressed();
}
@Override
@@ -270,7 +192,6 @@ public class MainActivity extends AppCompatActivity {
cancelConversationAutoRefresh();
cancelRealtimeRefreshSchedule();
stopRealtimeUpdates();
sessionExecutor.shutdownNow();
executor.shutdownNow();
super.onDestroy();
}
@@ -281,17 +202,6 @@ 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
@@ -310,16 +220,7 @@ 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);
@@ -346,17 +247,12 @@ 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 -> performPrimaryAuthAction());
loginSendCodeButton.setOnClickListener(v -> sendAuthVerificationCode());
loginModeButton.setOnClickListener(v -> setAuthMode("login", "请输入账号和密码登录。"));
registerModeButton.setOnClickListener(v -> setAuthMode("register", "注册后会自动登录并进入会话。"));
forgotModeButton.setOnClickListener(v -> setAuthMode("forgot", "通过验证码重置密码后再登录。"));
loginButton.setOnClickListener(v -> performAutoLogin());
backButton.setVisibility(View.GONE);
backButton.setOnClickListener(v -> {
if (conversationSearchMode) {
@@ -440,7 +336,7 @@ public class MainActivity extends AppCompatActivity {
}
setLoginLoading(true, "正在恢复上次登录状态...");
sessionExecutor.execute(() -> {
executor.execute(() -> {
try {
BossApiClient.ApiResponse sessionResponse = apiClient.getSession();
if (!sessionResponse.ok()) {
@@ -457,52 +353,15 @@ public class MainActivity extends AppCompatActivity {
} catch (Exception ignored) {
// Fall back to login panel.
}
runOnUiThread(() -> setLoginLoading(false, "登录已过期,请重新输入账号密码。"));
runOnUiThread(() -> setLoginLoading(false, WechatSurfaceMapper.loginHintText()));
});
}
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(() -> {
private void performAutoLogin() {
setLoginLoading(true, "正在创建会话...");
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.loginWithPassword(account, password);
BossApiClient.ApiResponse response = apiClient.autoLogin();
if (response.ok()) {
JSONObject session = response.json.optJSONObject("session");
runOnUiThread(() -> {
@@ -518,78 +377,6 @@ 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;
@@ -614,11 +401,9 @@ 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;
}
@@ -628,7 +413,6 @@ public class MainActivity extends AppCompatActivity {
if (fallbackConversations.ok()) {
conversations = fallbackConversations;
conversationsOk = true;
usedGroupedHomeFeed = false;
}
} catch (Exception ignored) {
conversationsOk = false;
@@ -637,24 +421,18 @@ 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
: finalUsedGroupedHomeFeed
? finalConversations.json.optJSONArray("conversations")
: WechatSurfaceMapper.normalizeConversationHomeFeed(
finalConversations.json.optJSONArray("conversations")
);
: WechatSurfaceMapper.normalizeConversationHomeFeed(
finalConversations.json.optJSONArray("conversations")
);
conversationsData = WechatSurfaceMapper.resolveRefreshValue(
conversationsData,
refreshedConversations,
finalConversationsOk
);
if (finalConversationsOk) {
conversationRootUsesGroupedHomeFeed = finalUsedGroupedHomeFeed;
}
maybeApplyPreferredEntry();
renderCurrentTab();
startRefreshing(false);
@@ -822,11 +600,7 @@ public class MainActivity extends AppCompatActivity {
return false;
}
JSONObject conversationItem = event.payload.optJSONObject("conversationItem");
JSONObject threadConversationItem = event.payload.optJSONObject("threadConversationItem");
JSONObject patchItem = conversationRootUsesGroupedHomeFeed
? (conversationItem != null ? conversationItem : threadConversationItem)
: (threadConversationItem != null ? threadConversationItem : conversationItem);
if (patchItem == null) {
if (conversationItem == null) {
return false;
}
runOnUiThread(() -> {
@@ -836,7 +610,7 @@ public class MainActivity extends AppCompatActivity {
}
conversationsData = WechatSurfaceMapper.mergeConversationHomeItem(
conversationsData,
patchItem,
conversationItem,
affectedProjectId
);
renderCurrentTab();
@@ -879,9 +653,7 @@ public class MainActivity extends AppCompatActivity {
private boolean shouldRefreshConversationsTab(BossRealtimeEvent event) {
if ("conversation.context_indicator.updated".equals(event.eventName)) {
return hasProjectId(event)
|| hasDeviceId(event)
|| event.payload.optJSONArray("conversations") != null;
return false;
}
if ("conversation.updated".equals(event.eventName)) {
return hasProjectId(event) || hasDeviceId(event);
@@ -946,7 +718,6 @@ 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;
@@ -954,7 +725,6 @@ public class MainActivity extends AppCompatActivity {
try {
conversations = apiClient.getConversationHome();
conversationsOk = conversations.ok();
usedGroupedHomeFeed = conversationsOk;
} catch (Exception ignored) {
conversationsOk = false;
}
@@ -964,7 +734,6 @@ public class MainActivity extends AppCompatActivity {
if (fallbackConversations.ok()) {
conversations = fallbackConversations;
conversationsOk = true;
usedGroupedHomeFeed = false;
}
} catch (Exception ignored) {
conversationsOk = false;
@@ -995,7 +764,6 @@ 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;
@@ -1003,19 +771,14 @@ public class MainActivity extends AppCompatActivity {
sessionData = finalSession;
JSONArray refreshedConversations = finalConversations == null
? null
: finalUsedGroupedHomeFeed
? finalConversations.json.optJSONArray("conversations")
: WechatSurfaceMapper.normalizeConversationHomeFeed(
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"),
@@ -1091,7 +854,6 @@ 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();
}
@@ -1102,76 +864,15 @@ 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);
loginSendCodeButton.setEnabled(!loading);
loginModeButton.setEnabled(!loading);
registerModeButton.setEnabled(!loading);
forgotModeButton.setEnabled(!loading);
loginButton.setText(loading ? "处理中..." : primaryAuthButtonLabel());
loginButton.setText(loading ? "处理中..." : WechatSurfaceMapper.loginButtonLabel());
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();
@@ -1230,27 +931,14 @@ public class MainActivity extends AppCompatActivity {
}
private void updateTabStyles() {
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);
styleTab(tabConversations, "conversations".equals(activeTab));
styleTab(tabDevices, "devices".equals(activeTab));
styleTab(tabMe, "me".equals(activeTab));
}
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(10);
button.setAllCaps(false);
button.setGravity(android.view.Gravity.CENTER);
Drawable topIcon = getDrawable(iconRes);
if (topIcon != null) {
int iconSize = BossUi.dp(this, 18);
topIcon.setBounds(0, 0, iconSize, iconSize);
}
button.setCompoundDrawables(null, topIcon, null, null);
button.setCompoundDrawablePadding(BossUi.dp(this, 2));
button.setCompoundDrawableTintList(ColorStateList.valueOf(color));
button.setPadding(0, BossUi.dp(this, 3), 0, BossUi.dp(this, 1));
private void 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 configureTopAction(WechatSurfaceMapper.RootTopAction action) {
@@ -1271,7 +959,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, false, currentSessionRole());
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, false);
refreshButton.setVisibility(View.VISIBLE);
configureTopAction(action);
}
@@ -1306,7 +994,7 @@ public class MainActivity extends AppCompatActivity {
refreshButton.setEnabled(true);
return;
}
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode, currentSessionRole());
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode);
configureTopAction(action);
refreshButton.setEnabled(!"refresh".equals(action.actionKey) || !refreshing);
refreshButton.setAlpha(refreshing && "refresh".equals(action.actionKey) ? 0.45f : 1f);
@@ -1337,7 +1025,7 @@ public class MainActivity extends AppCompatActivity {
toggleConversationQuickActions();
return;
}
String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode, currentSessionRole()).actionKey;
String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode).actionKey;
if ("add_device".equals(actionKey)) {
startActivity(new Intent(this, DeviceEnrollmentActivity.class));
return;
@@ -1478,24 +1166,6 @@ public class MainActivity extends AppCompatActivity {
showMessage("缺少 folderKey");
return;
}
if (conversationSearchMode) {
String matchedProjectId = item.optString("searchMatchProjectId", "").trim();
String matchedProjectLabel = item.optString("searchMatchLabel", "").trim();
if (!matchedProjectId.isEmpty() && !matchedProjectLabel.isEmpty()) {
openProject(matchedProjectId, matchedProjectLabel);
exitConversationSearchMode(true);
return;
}
openConversationFolder(
folderKey,
resolveConversationFolderName(item, row),
item.optString("searchMatchProjectId", ""),
item.optJSONArray("searchMatchProjectIds"),
item.optString("searchMatchLabel", "")
);
exitConversationSearchMode(true);
return;
}
openConversationFolder(
folderKey,
resolveConversationFolderName(item, row),
@@ -1510,9 +1180,6 @@ public class MainActivity extends AppCompatActivity {
return;
}
String projectName = finalDisplayRow.threadTitle.isEmpty() ? "未命名会话" : finalDisplayRow.threadTitle;
if (conversationSearchMode) {
exitConversationSearchMode(true);
}
openProject(projectId, projectName);
})
));
@@ -1580,14 +1247,14 @@ public class MainActivity extends AppCompatActivity {
if (count > 0) {
TextView selectedView = new TextView(this);
selectedView.setText("已选 " + count + " 个线程");
selectedView.setTextSize(12);
selectedView.setTextSize(13);
selectedView.setTextColor(getColor(R.color.boss_text_primary));
summaryWrap.addView(selectedView);
}
if (count < 2) {
TextView hintView = new TextView(this);
hintView.setText("至少选择 2 个线程");
hintView.setTextSize(11);
hintView.setTextSize(12);
hintView.setTextColor(getColor(R.color.boss_text_muted));
if (count > 0) {
hintView.setPadding(0, BossUi.dp(this, 4), 0, 0);
@@ -1625,7 +1292,10 @@ public class MainActivity extends AppCompatActivity {
hideConversationQuickActions(false);
conversationSearchMode = true;
syncTopActionVisualState(screenRefresh.isRefreshing());
showConversationSearchKeyboard();
topSearchInput.post(() -> {
topSearchInput.requestFocus();
topSearchInput.setSelection(topSearchInput.getText().length());
});
}
private void exitConversationSearchMode(boolean clearQuery) {
@@ -1638,40 +1308,12 @@ public class MainActivity extends AppCompatActivity {
conversationSearchQuery = "";
topSearchInput.setText("");
}
hideConversationSearchKeyboard();
syncTopActionVisualState(screenRefresh != null && screenRefresh.isRefreshing());
if (queryChanged && "conversations".equals(activeTab) && contentPanel.getVisibility() == View.VISIBLE) {
renderConversationsRoot();
}
}
private void showConversationSearchKeyboard() {
if (topSearchInput == null) {
return;
}
topSearchInput.post(() -> {
topSearchInput.requestFocus();
topSearchInput.setSelection(topSearchInput.getText().length());
InputMethodManager inputMethodManager =
(InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
if (inputMethodManager != null) {
inputMethodManager.showSoftInput(topSearchInput, InputMethodManager.SHOW_IMPLICIT);
}
});
}
private void hideConversationSearchKeyboard() {
if (topSearchInput == null) {
return;
}
InputMethodManager inputMethodManager =
(InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
if (inputMethodManager != null) {
inputMethodManager.hideSoftInputFromWindow(topSearchInput.getWindowToken(), 0);
}
topSearchInput.clearFocus();
}
private void toggleConversationSelection(String projectId) {
if (selectedConversationProjectIds.contains(projectId)) {
selectedConversationProjectIds.remove(projectId);
@@ -1852,7 +1494,6 @@ 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));
@@ -1863,7 +1504,6 @@ 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) {
@@ -2017,7 +1657,7 @@ public class MainActivity extends AppCompatActivity {
(roleLabel.isEmpty() ? "主控账号已启用安全保护" : roleLabel + " · 主控账号已启用安全保护")
));
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItemsForRole(currentSessionRole())) {
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItems()) {
screenContent.addView(BossUi.buildWechatMenuRow(
this,
item.title,
@@ -2167,66 +1807,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()
&& !rootTabRefreshInFlight
&& screenRefresh != null
&& !screenRefresh.isRefreshing()) {
refreshCurrentTab();
}
updateConversationAutoRefresh();
}
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);
case "master_agent_prompt":
intent = new Intent(this, MasterAgentPromptActivity.class);
intent.putExtra(MasterAgentPromptActivity.EXTRA_PROJECT_ID, "master-agent");
intent.putExtra(MasterAgentPromptActivity.EXTRA_PROJECT_NAME, "主 Agent");
break;
case "master_agent_memory":
intent = new Intent(this, MasterAgentMemoryActivity.class);
intent.putExtra(MasterAgentMemoryActivity.EXTRA_PROJECT_ID, "master-agent");
intent.putExtra(MasterAgentMemoryActivity.EXTRA_PROJECT_NAME, "主 Agent");
break;
case "master_agent_takeover":
intent = new Intent(this, MasterAgentTakeoverActivity.class);
intent.putExtra(MasterAgentTakeoverActivity.EXTRA_PROJECT_ID, "master-agent");
intent.putExtra(MasterAgentTakeoverActivity.EXTRA_PROJECT_NAME, "主 Agent");
break;
case "master_agent_evolution":
intent = new Intent(this, MasterAgentEvolutionActivity.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;
@@ -2246,13 +1853,6 @@ public class MainActivity extends AppCompatActivity {
startActivity(intent);
}
private String currentSessionRole() {
if (sessionData == null) {
return "member";
}
return sessionData.optString("role", "member");
}
private void openSkillInventoryFromMe() {
String targetDeviceId = resolveSkillTargetDeviceId();
if (targetDeviceId == null || targetDeviceId.isEmpty()) {

View File

@@ -0,0 +1,378 @@
package com.hyzq.boss;
import android.os.Bundle;
import android.widget.Button;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
public class MasterAgentEvolutionActivity extends BossScreenActivity {
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
private LinearLayout contentRoot;
private @Nullable BossRealtimeClient realtimeClient;
private long lastRealtimeReloadAt;
private boolean contentLoaded;
private @Nullable String currentMode;
private @Nullable String statusMessage;
private boolean statusIsError;
private boolean canManageEvolution = true;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureScreen("主 Agent 自动进化", "信号、提案与生效规则");
setHeaderAction("刷新", v -> reload());
contentRoot = new LinearLayout(this);
contentRoot.setOrientation(LinearLayout.VERTICAL);
replaceContent(contentRoot);
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
reload();
}
@Override
protected void onResume() {
super.onResume();
updateRealtimeSubscription();
}
@Override
protected void onPause() {
stopRealtimeUpdates();
super.onPause();
}
@Override
protected void onDestroy() {
stopRealtimeUpdates();
super.onDestroy();
}
@Override
protected void reload() {
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.getMasterAgentEvolution();
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
clearStatusMessage();
renderDashboard(response.json);
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
contentLoaded = false;
showStatusMessage("自动进化加载失败:" + error.getMessage(), true);
renderLoadErrorState(error.getMessage());
});
}
});
}
private void updateRealtimeSubscription() {
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
realtimeClient.start();
return;
}
stopRealtimeUpdates();
}
private void stopRealtimeUpdates() {
if (realtimeClient != null) {
realtimeClient.stop();
}
}
void handleRealtimeEvent(BossRealtimeEvent event) {
if (event == null || !"master_agent.settings.updated".equals(event.eventName)) {
return;
}
long now = System.currentTimeMillis();
if (now - lastRealtimeReloadAt < REALTIME_RELOAD_THROTTLE_MS) {
return;
}
lastRealtimeReloadAt = now;
runOnUiThread(this::reload);
}
private void renderDashboard(JSONObject payload) {
JSONObject config = payload.optJSONObject("config");
JSONArray signals = payload.optJSONArray("signals");
JSONArray proposals = payload.optJSONArray("proposals");
JSONArray rules = payload.optJSONArray("rules");
canManageEvolution = payload.optBoolean("canManage", true);
currentMode = config == null ? "controlled" : config.optString("mode", "controlled");
boolean autoApplyLowRiskRules = config != null && config.optBoolean("autoApplyLowRiskRules", false);
replaceContent(contentRoot);
contentRoot.removeAllViews();
contentRoot.addView(BossUi.buildSimpleProfileHeader(
this,
"主 Agent 自动进化",
"最近在学什么、打算怎么改、已经生效了什么",
canManageEvolution
? "支持在这里切换 controlled / autonomous并直接审核待处理提案。"
: "当前是只读视角,可以查看主 Agent 正在学习和生效的规则。"
));
contentRoot.addView(BossUi.buildSoftPanel(
this,
"当前模式",
"autonomous".equals(currentMode) ? "完全自我进化" : "受控自动进化",
autoApplyLowRiskRules ? "低风险提案会自动采纳。" : "所有提案都需要人工确认。"
));
maybeRenderStatusBanner();
if (canManageEvolution) {
Button controlledButton = BossUi.buildMiniActionButton(this, "切到受控模式", false);
controlledButton.setEnabled(!"controlled".equals(currentMode));
controlledButton.setOnClickListener(v -> switchMode("controlled"));
Button autonomousButton = BossUi.buildMiniActionButton(this, "切到全自动模式", true);
autonomousButton.setEnabled(!"autonomous".equals(currentMode));
autonomousButton.setOnClickListener(v -> switchMode("autonomous"));
contentRoot.addView(BossUi.buildInlineActionRow(this, controlledButton, autonomousButton));
} else {
contentRoot.addView(BossUi.buildEmptyCard(this, "你当前没有管理权限,模式切换和提案审批仅管理员可操作。"));
}
contentRoot.addView(BossUi.buildWechatMenuRow(
this,
"待处理提案",
String.valueOf(countPendingProposals(proposals)) + "",
"待审核的策略变更会在这里集中展示。",
null,
null
));
renderPendingProposals(proposals);
contentRoot.addView(BossUi.buildWechatMenuRow(
this,
"最近信号",
signals == null ? "0 条" : signals.length() + "",
"主 Agent 最近捕获到的问题和自我修正线索。",
null,
null
));
renderSignals(signals);
contentRoot.addView(BossUi.buildWechatMenuRow(
this,
"已生效规则",
rules == null ? "0 条" : rules.length() + "",
"已经落进系统并开始影响主 Agent 行为的规则。",
null,
null
));
renderRules(rules);
contentLoaded = true;
setRefreshing(false);
}
private void renderPendingProposals(@Nullable JSONArray proposals) {
if (proposals == null || proposals.length() == 0) {
contentRoot.addView(BossUi.buildEmptyCard(this, "当前没有待审批提案。"));
return;
}
boolean rendered = false;
for (int i = 0; i < proposals.length(); i++) {
JSONObject proposal = proposals.optJSONObject(i);
if (proposal == null || !"pending_review".equals(proposal.optString("status", ""))) {
continue;
}
rendered = true;
String proposalId = proposal.optString("proposalId", "");
contentRoot.addView(BossUi.buildWechatMenuRow(
this,
proposal.optString("title", "待审批提案"),
proposal.optString("summary", "暂无摘要"),
proposal.optString("proposalType", "-")
+ " · " + proposal.optString("riskLevel", "-")
+ " · " + formatTime(proposal.optString("createdAt", "-")),
null,
null
));
if (canManageEvolution) {
Button rejectButton = BossUi.buildMiniActionButton(this, "拒绝", false);
rejectButton.setOnClickListener(v -> reviewProposal(proposalId, false));
Button approveButton = BossUi.buildMiniActionButton(this, "批准", true);
approveButton.setOnClickListener(v -> reviewProposal(proposalId, true));
contentRoot.addView(BossUi.buildInlineActionRow(this, rejectButton, approveButton));
}
}
if (!rendered) {
contentRoot.addView(BossUi.buildEmptyCard(this, "当前没有待审批提案。"));
}
}
private void renderSignals(@Nullable JSONArray signals) {
if (signals == null || signals.length() == 0) {
contentRoot.addView(BossUi.buildEmptyCard(this, "当前还没有进化信号。"));
return;
}
for (int i = 0; i < Math.min(signals.length(), 8); i++) {
JSONObject signal = signals.optJSONObject(i);
if (signal == null) continue;
contentRoot.addView(BossUi.buildWechatMenuRow(
this,
signal.optString("kind", "signal"),
signal.optString("requestText", ""),
formatTime(signal.optString("createdAt", "-")),
null,
null
));
}
}
private void renderRules(@Nullable JSONArray rules) {
if (rules == null || rules.length() == 0) {
contentRoot.addView(BossUi.buildEmptyCard(this, "当前还没有已生效规则。"));
return;
}
for (int i = 0; i < Math.min(rules.length(), 8); i++) {
JSONObject rule = rules.optJSONObject(i);
if (rule == null) continue;
contentRoot.addView(BossUi.buildWechatMenuRow(
this,
rule.optString("ruleType", "rule"),
rule.optString("sourceProposalId", "直接创建"),
formatTime(rule.optString("createdAt", "-")),
null,
null
));
}
}
private int countPendingProposals(@Nullable JSONArray proposals) {
if (proposals == null) {
return 0;
}
int count = 0;
for (int i = 0; i < proposals.length(); i++) {
JSONObject proposal = proposals.optJSONObject(i);
if (proposal != null && "pending_review".equals(proposal.optString("status", ""))) {
count += 1;
}
}
return count;
}
private void switchMode(String mode) {
if (!contentLoaded) {
showMessage("自动进化尚未加载完成。");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = apiClient.updateMasterAgentEvolutionMode(mode);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
showStatusMessage("已切到 " + ("autonomous".equals(mode) ? "完全自我进化" : "受控自动进化"), false);
setResult(RESULT_OK);
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showStatusMessage("切换失败:" + error.getMessage(), true);
});
}
});
}
private void reviewProposal(String proposalId, boolean approve) {
if (proposalId == null || proposalId.isEmpty()) {
showMessage("缺少 proposalId");
return;
}
setRefreshing(true);
executor.execute(() -> {
try {
BossApiClient.ApiResponse response = approve
? apiClient.approveMasterAgentEvolutionProposal(proposalId)
: apiClient.rejectMasterAgentEvolutionProposal(proposalId);
if (!response.ok()) {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
showStatusMessage(approve ? "提案已批准" : "提案已拒绝", false);
setResult(RESULT_OK);
reload();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showStatusMessage((approve ? "批准失败:" : "拒绝失败:") + error.getMessage(), true);
});
}
});
}
private void maybeRenderStatusBanner() {
if (statusMessage == null || statusMessage.isEmpty()) {
return;
}
contentRoot.addView(BossUi.buildSoftPanel(
this,
statusIsError ? "最近状态" : "最近操作",
statusMessage,
statusIsError ? "你可以直接点顶部刷新重试,或继续切换模式/审批提案。" : "本页已经同步到最新自动进化状态。"
));
}
private void renderLoadErrorState(String message) {
replaceContent(contentRoot);
contentRoot.removeAllViews();
contentRoot.addView(BossUi.buildSimpleProfileHeader(
this,
"主 Agent 自动进化",
"信号、提案与生效规则",
"当前加载失败,保留在这个页面直接重试即可。"
));
maybeRenderStatusBanner();
Button retryButton = BossUi.buildMiniActionButton(this, "重新加载", true);
retryButton.setOnClickListener(v -> reload());
contentRoot.addView(BossUi.buildInlineActionRow(this, retryButton));
contentRoot.addView(BossUi.buildEmptyCard(this, "自动进化中心暂时不可用:" + message));
}
private void showStatusMessage(String message, boolean isError) {
statusMessage = message;
statusIsError = isError;
showMessage(message);
}
private void clearStatusMessage() {
if (!statusIsError) {
statusMessage = null;
}
statusIsError = false;
}
private String formatTime(String value) {
if (value == null || value.isEmpty() || "-".equals(value)) {
return "-";
}
String normalized = value.replace('T', ' ');
int plusIndex = normalized.indexOf('+');
if (plusIndex > 0) {
return normalized.substring(0, plusIndex);
}
int zIndex = normalized.indexOf('Z');
if (zIndex > 0) {
return normalized.substring(0, zIndex);
}
return normalized;
}
}

View File

@@ -104,7 +104,7 @@ public class MasterAgentMemoryActivity extends BossScreenActivity {
appendContent(BossUi.buildSoftPanel(
this,
"记忆说明",
"主 Agent 会自动沉淀长期有用的信息。你也可以在这里手动新增、编辑或删除",
"主 Agent 会自动沉淀长期有用的信息。你也可以在这里手动新增、编辑或归档",
"底层是结构化存储,项目记忆会显示真实 projectId。"
));
@@ -242,23 +242,23 @@ public class MasterAgentMemoryActivity extends BossScreenActivity {
));
if (memory != null) {
builder.setNeutralButton("删除", (dialog, which) -> confirmDeleteMemory(memory));
builder.setNeutralButton("归档", (dialog, which) -> confirmArchiveMemory(memory));
}
builder.show();
}
private void confirmDeleteMemory(JSONObject memory) {
private void confirmArchiveMemory(JSONObject memory) {
final String memoryId = memory.optString("memoryId", "");
if (memoryId.isEmpty()) {
showMessage("缺少 memoryId");
return;
}
new AlertDialog.Builder(this)
.setTitle("删除记忆")
.setMessage("确定删除这条记忆吗?")
.setTitle("归档记忆")
.setMessage("确定归档这条记忆吗?归档后会从当前列表移除,不是永久删除。")
.setNegativeButton("取消", null)
.setPositiveButton("删除", (dialog, which) -> deleteMemory(memoryId))
.setPositiveButton("归档", (dialog, which) -> archiveMemory(memoryId))
.show();
}
@@ -329,7 +329,7 @@ public class MasterAgentMemoryActivity extends BossScreenActivity {
});
}
private void deleteMemory(String memoryId) {
private void archiveMemory(String memoryId) {
setRefreshing(true);
executor.execute(() -> {
try {
@@ -338,14 +338,14 @@ public class MasterAgentMemoryActivity extends BossScreenActivity {
throw new IllegalStateException(response.message());
}
runOnUiThread(() -> {
showMessage("记忆已删除");
showMessage("记忆已归档");
setResult(RESULT_OK);
finish();
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("记忆删除失败:" + error.getMessage());
showMessage("记忆归档失败:" + error.getMessage());
});
}
});

View File

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

View File

@@ -34,6 +34,8 @@ public class MasterAgentPromptActivity extends BossScreenActivity {
private @Nullable String backendOverrideText;
private boolean clawSelectable;
private @Nullable String clawReasonLabel;
private boolean hermesSelectable;
private @Nullable String hermesReasonLabel;
private final List<String> backendOverrideValues = new ArrayList<>();
private EditText userPromptInput;
private EditText projectPromptInput;
@@ -84,6 +86,7 @@ public class MasterAgentPromptActivity extends BossScreenActivity {
userPrompt = payload.optJSONObject("userPrompt");
projectControls = payload.optJSONObject("projectControls");
JSONObject clawAvailability = payload.optJSONObject("clawAvailability");
JSONObject hermesAvailability = payload.optJSONObject("hermesAvailability");
adminPromptText = promptPolicy == null ? null : promptPolicy.optString("globalPrompt", "");
userPromptText = userPrompt == null ? "" : userPrompt.optString("content", "");
projectPromptOverrideText = payload.optString(
@@ -93,6 +96,8 @@ public class MasterAgentPromptActivity extends BossScreenActivity {
backendOverrideText = projectControls == null ? "" : projectControls.optString("backendOverride", "");
clawSelectable = clawAvailability != null && clawAvailability.optBoolean("selectable", false);
clawReasonLabel = clawAvailability == null ? "" : clawAvailability.optString("reasonLabel", "");
hermesSelectable = hermesAvailability != null && hermesAvailability.optBoolean("selectable", false);
hermesReasonLabel = hermesAvailability == null ? "" : hermesAvailability.optString("reasonLabel", "");
replaceContent();
appendContent(BossUi.buildSimpleProfileHeader(
@@ -138,6 +143,10 @@ public class MasterAgentPromptActivity extends BossScreenActivity {
backendOverrideValues.add("claw-runtime");
backendLabels.add("Claw Runtime");
}
if (hermesSelectable) {
backendOverrideValues.add("hermes-runtime");
backendLabels.add("Hermes Runtime");
}
backendSpinner = new Spinner(this);
backendSpinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, backendLabels));
@@ -170,6 +179,16 @@ public class MasterAgentPromptActivity extends BossScreenActivity {
: "恢复可用后,执行后端下拉框会重新出现 Claw Runtime。"
));
}
if (!hermesSelectable) {
appendContent(BossUi.buildSoftPanel(
this,
"Hermes Runtime 当前不可用",
TextUtils.isEmpty(hermesReasonLabel) ? "当前环境未满足 Hermes Runtime 的启动条件。" : hermesReasonLabel,
TextUtils.equals(backendOverrideText, "hermes-runtime")
? "当前对话之前保存过 Hermes Runtime运行时会自动回退到默认后端。"
: "恢复可用后,执行后端下拉框会重新出现 Hermes Runtime。"
));
}
previewTextView = new TextView(this);
previewTextView.setText(buildPreviewText());
@@ -235,6 +254,8 @@ public class MasterAgentPromptActivity extends BossScreenActivity {
builder.append("【执行后端】\n").append(backendValue).append("\n\n");
} else if (TextUtils.equals(backendOverrideText, "claw-runtime") && !clawSelectable) {
builder.append("【执行后端】\n默认Claw Runtime 当前不可用,运行时会自动回退)\n\n");
} else if (TextUtils.equals(backendOverrideText, "hermes-runtime") && !hermesSelectable) {
builder.append("【执行后端】\n默认Hermes Runtime 当前不可用,运行时会自动回退)\n\n");
}
if (builder.length() == 0) {
return "当前没有任何提示词内容。";

View File

@@ -14,30 +14,6 @@ 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;
@@ -55,7 +31,6 @@ 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;
@@ -67,7 +42,6 @@ public final class ProjectChatUiState {
boolean showMultiSelectBar,
boolean showRefresh,
boolean showHeaderAction,
boolean copyEnabled,
boolean forwardEnabled,
String backLabel,
String title,
@@ -78,7 +52,6 @@ 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;
@@ -89,10 +62,26 @@ public final class ProjectChatUiState {
public static final class ReplyWaitSpec {
public final boolean shouldWait;
public final String baselineMessageId;
public final List<String> executionIds;
private ReplyWaitSpec(boolean shouldWait, @Nullable String baselineMessageId) {
this.shouldWait = shouldWait && !isBlank(baselineMessageId);
this.baselineMessageId = this.shouldWait ? baselineMessageId.trim() : "";
private ReplyWaitSpec(
boolean shouldWait,
@Nullable String baselineMessageId,
@Nullable List<String> executionIds
) {
ArrayList<String> normalizedExecutionIds = new ArrayList<>();
if (executionIds != null) {
for (String executionId : executionIds) {
if (!isBlank(executionId)) {
normalizedExecutionIds.add(executionId.trim());
}
}
}
boolean hasBaseline = !isBlank(baselineMessageId);
boolean hasExecutionIds = !normalizedExecutionIds.isEmpty();
this.shouldWait = shouldWait && (hasBaseline || hasExecutionIds);
this.baselineMessageId = hasBaseline ? baselineMessageId.trim() : "";
this.executionIds = Collections.unmodifiableList(normalizedExecutionIds);
}
}
@@ -108,77 +97,6 @@ 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 "当前线程命中冲突保护";
@@ -247,10 +165,6 @@ 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
@@ -283,7 +197,6 @@ public final class ProjectChatUiState {
true,
false,
false,
canCopySelection(selectionState),
canForwardSelection(selectionState),
"取消",
"已选 " + selectedCount + "",
@@ -297,7 +210,6 @@ public final class ProjectChatUiState {
!conversationInfoReady,
conversationInfoReady,
false,
false,
"返回",
isBlank(defaultTitle) ? "项目详情" : defaultTitle,
isBlank(defaultSubtitle) ? "原生页面" : defaultSubtitle
@@ -514,37 +426,81 @@ public final class ProjectChatUiState {
public static ReplyWaitSpec resolveReplyWaitAfterSend(@Nullable JSONObject response) {
if (response == null) {
return new ReplyWaitSpec(false, null);
return new ReplyWaitSpec(false, null, null);
}
JSONObject task = response.optJSONObject("task");
if (task == null) {
return new ReplyWaitSpec(false, null);
return new ReplyWaitSpec(false, null, null);
}
String taskStatus = task.optString("status", "");
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);
}
return new ReplyWaitSpec(false, null, null);
}
JSONObject message = response.optJSONObject("message");
return new ReplyWaitSpec(true, message == null ? null : message.optString("id", ""));
return new ReplyWaitSpec(true, message == null ? null : message.optString("id", ""), null);
}
public static ReplyWaitSpec resolveReplyWaitAfterDispatchConfirm(@Nullable JSONObject response) {
if (response == null) {
return new ReplyWaitSpec(false, null);
return new ReplyWaitSpec(false, null, null);
}
JSONArray executions = response.optJSONArray("executions");
if (executions == null || executions.length() == 0) {
return new ReplyWaitSpec(false, null);
return new ReplyWaitSpec(false, null, null);
}
JSONObject notice = response.optJSONObject("notice");
return new ReplyWaitSpec(true, notice == null ? null : notice.optString("id", ""));
return new ReplyWaitSpec(
true,
notice == null ? null : notice.optString("id", ""),
collectExecutionIds(executions)
);
}
public static ReplyWaitSpec replyWaitFromBaseline(@Nullable String baselineMessageId) {
return new ReplyWaitSpec(true, baselineMessageId, null);
}
public static boolean hasTrackedDispatchExecutionReply(
@Nullable JSONArray dispatchPlans,
@Nullable List<String> executionIds
) {
if (dispatchPlans == null || executionIds == null || executionIds.isEmpty()) {
return false;
}
LinkedHashSet<String> trackedIds = new LinkedHashSet<>();
for (String executionId : executionIds) {
if (!isBlank(executionId)) {
trackedIds.add(executionId.trim());
}
}
if (trackedIds.isEmpty()) {
return false;
}
for (int i = 0; i < dispatchPlans.length(); i++) {
JSONObject plan = dispatchPlans.optJSONObject(i);
if (plan == null) {
continue;
}
JSONArray executions = plan.optJSONArray("executions");
if (executions == null) {
continue;
}
for (int j = 0; j < executions.length(); j++) {
JSONObject execution = executions.optJSONObject(j);
if (execution == null) {
continue;
}
String executionId = execution.optString("executionId", "").trim();
if (!trackedIds.contains(executionId)) {
continue;
}
String status = execution.optString("status", "").trim();
if ("completed".equals(status) || "failed".equals(status)) {
return true;
}
}
}
return false;
}
public static boolean hasReplyBeyondBaseline(@Nullable JSONObject project, @Nullable String baselineMessageId) {
@@ -555,14 +511,6 @@ 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) {
@@ -576,110 +524,28 @@ public final class ProjectChatUiState {
return messageId.isEmpty() ? null : messageId;
}
private static boolean isThreadProcessMessage(@Nullable JSONObject message) {
if (message == null) {
return false;
private static List<String> collectExecutionIds(@Nullable JSONArray executions) {
ArrayList<String> executionIds = new ArrayList<>();
if (executions == null) {
return executionIds;
}
String kind = message.optString("kind", "").trim();
if ("thread_process".equals(kind)) {
return true;
for (int i = 0; i < executions.length(); i++) {
JSONObject execution = executions.optJSONObject(i);
if (execution == null) {
continue;
}
String executionId = execution.optString("executionId", "").trim();
if (!executionId.isEmpty()) {
executionIds.add(executionId);
}
}
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);
return executionIds;
}
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) {
@@ -687,83 +553,4 @@ public final class ProjectChatUiState {
}
return normalized.substring(0, maxLength) + "";
}
private static final String[] PROCESS_PROGRESS_PREFIXES = new String[] {
"我先",
"我现在",
"我会先",
"我发现",
"我准备",
"接下来",
"正在",
"先看",
"先读",
"我把",
"我再",
"目前在",
"现在在",
"补一组",
"处理一下",
"先确认",
"准备",
"同步一下",
"我这边已经"
};
private static final String[] PROCESS_PROGRESS_CONTAINS = new String[] {
"我继续",
"我已经在",
"正在跑",
"正在检查",
"正在处理",
"正在同步",
"我会直接",
"我先把",
"先补",
"再接"
};
private static final String[] PROCESS_PROGRESS_NUMBERED_HINTS = new String[] {
"",
"",
"接下来",
"然后",
"检查",
"确认",
"处理",
"同步",
"",
"排查",
"推进",
"回你",
"回传",
"会把",
"我会"
};
private static final String[] FOLD_BLOCK_MARKERS = new String[] {
"失败",
"报错",
"错误",
"阻塞",
"不能",
"无法",
"崩溃",
"超时",
"exception",
"error",
"fatal",
"结论",
"最终",
"总结",
"已完成",
"已经完成",
"验证通过",
"测试通过",
"已修复",
"修好了",
"已部署",
"已安装",
"可以直接"
};
}

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ import android.os.Bundle;
import androidx.annotation.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
public class SecurityActivity extends BossScreenActivity {
@@ -23,11 +22,7 @@ public class SecurityActivity extends BossScreenActivity {
try {
BossApiClient.ApiResponse response = apiClient.getSession();
if (!response.ok()) throw new IllegalStateException(response.message());
BossApiClient.ApiResponse sessionsResponse = apiClient.getAuthSessions();
JSONArray sessions = sessionsResponse.ok()
? sessionsResponse.json.optJSONArray("sessions")
: new JSONArray();
runOnUiThread(() -> renderSecurity(response.json.optJSONObject("session"), sessions));
runOnUiThread(() -> renderSecurity(response.json.optJSONObject("session")));
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
@@ -37,7 +32,7 @@ public class SecurityActivity extends BossScreenActivity {
});
}
private void renderSecurity(@Nullable JSONObject session, @Nullable JSONArray sessions) {
private void renderSecurity(@Nullable JSONObject session) {
replaceContent();
appendContent(BossUi.buildWechatMenuRow(
this,
@@ -60,33 +55,6 @@ 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");
@@ -100,57 +68,6 @@ 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.putExtra(MainActivity.EXTRA_FORCE_LOGOUT, true);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
finish();
} else {
reload();
}
});
} catch (Exception error) {
runOnUiThread(() -> {
setRefreshing(false);
showMessage("撤销失败:" + error.getMessage());
});
}
});
}
private void logout() {
setRefreshing(true);
executor.execute(() -> {
@@ -162,7 +79,6 @@ public class SecurityActivity extends BossScreenActivity {
runOnUiThread(() -> {
setRefreshing(false);
Intent intent = new Intent(this, MainActivity.class);
intent.putExtra(MainActivity.EXTRA_FORCE_LOGOUT, true);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
finish();

View File

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

View File

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

View File

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

View File

@@ -12,101 +12,6 @@ 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(
"会话",
"设备",
@@ -114,13 +19,14 @@ public final class WechatSurfaceMapper {
);
private static final List<MeMenuItem> ROOT_ME_MENU_ITEMS = Arrays.asList(
new MeMenuItem("master_agent_prompt", "主 Agent 提示词", "配置全局主提示词和当前对话提示词"),
new MeMenuItem("master_agent_memory", "主 Agent 记忆", "维护用户通用记忆和项目记忆"),
new MeMenuItem("master_agent_takeover", "全局接管", "设置主 Agent 是否默认协同推进线程"),
new MeMenuItem("master_agent_evolution", "主 Agent 自动进化", "查看进化信号、提案与自动采纳规则"),
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 状态与更新内容")
);
@@ -157,20 +63,14 @@ public final class WechatSurfaceMapper {
JSONObject avatar = source.optJSONObject("avatar");
boolean isGroup = source.optBoolean("isGroup", groupAvatarMembers.size() > 1);
String conversationType = source.optString("conversationType", "");
String folderLabel = normalizeConversationTitle(source.optString("folderLabel", ""));
String threadTitle = sanitizeConversationTitle(
source.optString("threadTitle", source.optString("title", source.optString("projectTitle", ""))),
folderLabel,
source.optString("projectTitle", "")
String threadTitle = trimLocalWorkspacePrefix(
source.optString("threadTitle", source.optString("title", 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(
@@ -248,116 +148,6 @@ public final class WechatSurfaceMapper {
return builder.toString();
}
public static boolean hasCodexAppServerCapability(JSONObject device) {
return resolveDeviceCapability(device, "codexAppServer") != null;
}
public static boolean hasCodexAppServerMetadata(JSONObject device) {
return resolveCodexAppServerMetadata(device) != null;
}
public static String deviceCodexAppServerStatusLabel(JSONObject device) {
JSONObject capability = resolveDeviceCapability(device, "codexAppServer");
boolean connected = capability != null && capability.optBoolean("connected", false);
return connected ? "已连接" : "未连接";
}
public static String deviceCodexAppServerDetailLabel(JSONObject device) {
JSONObject capability = resolveDeviceCapability(device, "codexAppServer");
if (capability == null) {
return "等待 boss-agent 上报 App Server 能力";
}
String lastSeenAt = capability.optString("lastSeenAt", "").trim();
if (lastSeenAt.isEmpty()) {
return "线程、模型、Skill 与协议探测";
}
return "最近上报 " + lastSeenAt;
}
public static String deviceCodexModelSummary(JSONObject device) {
JSONObject metadata = resolveCodexAppServerMetadata(device);
int modelCount = metadataArrayLength(metadata, "models");
if (modelCount <= 0) {
return "未发现";
}
return modelCount + ""
+ " · 默认 " + metadataText(metadata, "defaultModelId")
+ " · 快速 " + metadataText(metadata, "fastModelId")
+ " · 深度 " + metadataText(metadata, "deepModelId");
}
public static String deviceCodexExtensionSummary(JSONObject device) {
JSONObject metadata = resolveCodexAppServerMetadata(device);
return "Skill " + metadataArrayLength(metadata, "skills") + ""
+ " · Plugin " + metadataArrayLength(metadata, "plugins") + ""
+ " · App " + metadataArrayLength(metadata, "apps") + "";
}
public static String deviceCodexGovernanceSummary(JSONObject device) {
JSONObject metadata = resolveCodexAppServerMetadata(device);
return "实验特性 " + metadataArrayLength(metadata, "experimentalFeatures") + ""
+ " · 协作模式 " + metadataArrayLength(metadata, "collaborationModes") + ""
+ " · MCP " + metadataArrayLength(metadata, "mcpServers") + ""
+ " · 权限 " + metadataArrayLength(metadata, "permissionProfiles") + "";
}
public static String deviceCodexAccountSummary(JSONObject device) {
JSONObject metadata = resolveCodexAppServerMetadata(device);
JSONObject accountSummary = metadata == null ? null : metadata.optJSONObject("accountSummary");
JSONObject rateLimitSummary = metadata == null ? null : metadata.optJSONObject("rateLimitSummary");
return objectText(accountSummary, "authMode")
+ " · 套餐 " + objectText(accountSummary, "planType")
+ " · 额度 " + objectInt(rateLimitSummary, "maxUsedPercent") + "%";
}
public static String deviceCodexThreadSummary(JSONObject device) {
JSONObject summary = resolveCodexAppServerMetadataObject(device, "threadSummary");
return objectInt(summary, "threadCount") + ""
+ " · 已加载 " + objectInt(summary, "loadedThreadCount") + ""
+ " · 活跃 " + objectInt(summary, "activeThreadCount") + "";
}
public static String deviceCodexTurnSummary(JSONObject device) {
JSONObject summary = resolveCodexAppServerMetadataObject(device, "threadTurnSummary");
return objectInt(summary, "totalTurnCount") + ""
+ " · 运行中 " + objectInt(summary, "runningTurnCount") + ""
+ " · 完成 " + objectInt(summary, "completedTurnCount") + "";
}
public static String deviceCodexThreadActionSummary(JSONObject device) {
JSONObject summary = resolveCodexAppServerMetadataObject(device, "threadActionSummary");
return objectInt(summary, "actionCount") + ""
+ " · 生命周期 " + objectInt(summary, "lifecycleActionCount") + ""
+ " · 活跃干预 " + objectInt(summary, "liveTurnActionCount") + ""
+ " · " + (summary != null && summary.optBoolean("shellActionAvailable", false) ? "Shell 可用" : "Shell 不可用");
}
public static String deviceCodexThreadCollaborationSummary(JSONObject device) {
JSONObject summary = resolveCodexAppServerMetadataObject(device, "threadCollaborationSummary");
if (summary == null) {
return "等待线程协作探测";
}
String broker = summary.optBoolean("bossBrokerAvailable", false) ? "Boss Broker 可用" : "Boss Broker 不可用";
String handler = summary.optBoolean("collabToolCallHandlerAvailable", false) ? "协作事件可处理" : "协作事件不可处理";
String modes = summary.optInt("collaborationModeCount", 0) + " 种模式";
String direct = summary.optBoolean("directThreadChatSupported", false) ? "原生私聊" : "非原生私聊";
return broker + " · " + handler + " · " + modes + " · " + direct;
}
public static String deviceCodexProtocolDriftSummary(JSONObject device) {
JSONObject summary = resolveCodexAppServerMetadataObject(device, "protocolDriftSummary");
if (summary == null) {
return "等待协议探测";
}
String drift = "compatible".equals(summary.optString("driftLevel", "")) ? "兼容" : "告警";
String failedProbeCount = "失败探针 " + summary.optInt("failedProbeCount", 0) + "";
String docFollowupCount = "文档跟进 " + summary.optInt("docFollowupCount", 0) + "";
String fallbackStrategy = summary.optString("fallbackStrategy", "").contains("Boss Broker")
? "Boss Broker 兜底"
: "App Server 兜底";
return drift + " · " + failedProbeCount + " · " + docFollowupCount + " · " + fallbackStrategy;
}
public static String devicePreferredExecutionModeLabel(JSONObject device) {
return "gui".equals(device == null ? "" : device.optString("preferredExecutionMode", "")) ? "GUI" : "CLI";
}
@@ -402,36 +192,10 @@ 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)) {
@@ -441,34 +205,6 @@ 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]);
}
@@ -517,10 +253,6 @@ 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 "必须收尾";
@@ -589,57 +321,12 @@ public final class WechatSurfaceMapper {
return capabilities.optJSONObject(capabilityKey);
}
private static JSONObject resolveCodexAppServerMetadataObject(JSONObject device, String key) {
JSONObject metadata = resolveCodexAppServerMetadata(device);
return metadata == null ? null : metadata.optJSONObject(key);
}
private static JSONObject resolveCodexAppServerMetadata(JSONObject device) {
JSONObject capability = resolveDeviceCapability(device, "codexAppServer");
return capability == null ? null : capability.optJSONObject("metadata");
}
private static int metadataArrayLength(JSONObject metadata, String key) {
if (metadata == null) {
return 0;
}
JSONArray array = metadata.optJSONArray(key);
return array == null ? 0 : array.length();
}
private static String metadataText(JSONObject metadata, String key) {
if (metadata == null) {
return "未知";
}
String value = metadata.optString(key, "").trim();
return value.isEmpty() ? "未知" : value;
}
private static String objectText(JSONObject object, String key) {
if (object == null) {
return "未知";
}
String value = object.optString(key, "").trim();
return value.isEmpty() ? "未知" : value;
}
private static int objectInt(JSONObject object, String key) {
return object == null ? 0 : object.optInt(key, 0);
}
public static RootTopAction rootTopAction(String activeTab, boolean refreshing) {
return rootTopAction(activeTab, refreshing, false);
}
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)) {
@@ -1031,156 +718,9 @@ public final class WechatSurfaceMapper {
if (preview.matches("^已从设备.+导入线程《.+》[。.]?$")) {
return "已导入线程";
}
if (isLikelyProcessPreview(preview)) {
return "";
}
return preview;
}
private static boolean isLikelyProcessPreview(String value) {
String preview = value == null ? "" : value
.replace("\r\n", "\n")
.replace('\r', '\n')
.replaceAll("\\n{2,}", "\n")
.trim();
if (preview.isEmpty()) {
return false;
}
if (containsMarker(preview, PROCESS_PREVIEW_BLOCK_MARKERS)) {
return false;
}
if (isStructuredNumberedProcessPreview(preview)) {
return true;
}
String normalized = preview.toLowerCase(java.util.Locale.ROOT);
for (String marker : PROCESS_PREVIEW_PREFIXES) {
if (normalized.startsWith(marker.toLowerCase(java.util.Locale.ROOT))) {
return true;
}
}
for (String marker : PROCESS_PREVIEW_CONTAINS) {
if (normalized.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
return true;
}
}
return false;
}
private static boolean isStructuredNumberedProcessPreview(String value) {
String[] rawLines = value
.replace("\r\n", "\n")
.replace('\r', '\n')
.split("\n");
ArrayList<String> numberedLines = new ArrayList<>();
for (String rawLine : rawLines) {
String normalizedLine = rawLine == null ? "" : rawLine.trim();
if (normalizedLine.isEmpty()) {
continue;
}
if (normalizedLine.matches("^\\d+[.)、]\\s*.+$")) {
numberedLines.add(normalizedLine);
}
}
if (numberedLines.size() < 2) {
return false;
}
String merged = android.text.TextUtils.join(" ", numberedLines)
.toLowerCase(java.util.Locale.ROOT);
return containsMarker(merged, PROCESS_PREVIEW_NUMBERED_HINTS);
}
private static boolean containsMarker(String value, String[] markers) {
String normalized = value == null ? "" : value.toLowerCase(java.util.Locale.ROOT);
for (String marker : markers) {
if (normalized.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
return true;
}
}
return false;
}
private static String normalizeConversationTitle(String value) {
String source = value == null ? "" : value.replace("\u0000", "");
String[] lines = source.split("\\r?\\n");
for (String line : lines) {
if (line == null) {
continue;
}
String trimmed = line.trim();
if (!trimmed.isEmpty()) {
return trimmed.replaceAll("\\s+", " ");
}
}
return "";
}
private static String stripTrailingConversationTitleNoise(String value) {
return value == null ? "" : value.replaceAll("['\"}\\]]{2,}$", "").trim();
}
private static boolean looksLikeLeakedConversationTitle(String value) {
String normalized = normalizeConversationTitle(value);
if (normalized.isEmpty()) {
return false;
}
for (String marker : LEAKED_TITLE_PREFIXES) {
if (normalized.startsWith(marker)) {
return true;
}
}
for (String marker : LEAKED_TITLE_CONTAINS) {
if (normalized.contains(marker)) {
return true;
}
}
return false;
}
private static String extractWorkspaceProjectName(String value) {
String normalized = normalizeConversationTitle(value).replace('\\', '/');
if (normalized.isEmpty()) {
return "";
}
String[] patterns = new String[] {
".*/Users/[^/]+/code/([^/\\s\"'`,。;!?]+).*",
".*/home/[^/]+/code/([^/\\s\"'`,。;!?]+).*",
".*[A-Za-z]:/Users/[^/]+/code/([^/\\s\"'`,。;!?]+).*"
};
for (String pattern : patterns) {
if (normalized.matches(pattern)) {
return normalized.replaceFirst(pattern, "$1").split("/")[0].trim();
}
}
return "";
}
private static String sanitizeConversationTitle(String value, String... fallbackCandidates) {
String normalized = normalizeConversationTitle(value);
String trimmed = stripTrailingConversationTitleNoise(trimLocalWorkspacePrefix(normalized));
if (!trimmed.isEmpty() && !looksLikeLeakedConversationTitle(normalized) && !looksLikeLeakedConversationTitle(trimmed)) {
return trimmed;
}
String extractedProject = extractWorkspaceProjectName(normalized);
if (!extractedProject.isEmpty() && !looksLikeLeakedConversationTitle(extractedProject)) {
return extractedProject;
}
for (String fallbackCandidate : fallbackCandidates) {
String extractedFallback = extractWorkspaceProjectName(fallbackCandidate);
if (!extractedFallback.isEmpty() && !looksLikeLeakedConversationTitle(extractedFallback)) {
return extractedFallback;
}
String normalizedFallback = stripTrailingConversationTitleNoise(
trimLocalWorkspacePrefix(normalizeConversationTitle(fallbackCandidate))
);
if (!normalizedFallback.isEmpty() && !looksLikeLeakedConversationTitle(normalizedFallback)) {
return normalizedFallback;
}
}
return trimmed;
}
private static String trimLocalWorkspacePrefix(String value) {
String label = value == null ? "" : value.trim();
if (label.isEmpty()) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -81,22 +81,6 @@ 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"));
@@ -130,19 +114,6 @@ 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"));
@@ -199,27 +170,6 @@ public class BossApiClientDispatchPlansTest {
);
}
@Test
public void updateMasterAgentModeModelsWritesFastAndDeepModelMappings() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/agent-controls"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.updateMasterAgentModeModels(
"gpt-4.1",
"gpt-5.1",
"gpt-4.1",
"low"
);
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/master-agent/agent-controls", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(
"{\"fastModelOverride\":\"gpt-4.1\",\"deepModelOverride\":\"gpt-5.1\",\"modelOverride\":\"gpt-4.1\",\"reasoningEffortOverride\":\"low\"}",
connection.requestBody()
);
}
@Test
public void getMasterAgentPromptProfileUsesScopedEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/prompt-profile"));
@@ -261,31 +211,41 @@ public class BossApiClientDispatchPlansTest {
}
@Test
public void getProjectDetailUsesExtendedReadTimeoutForChatPages() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1"));
public void getMasterAgentEvolutionUsesDashboardEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/master-agent/evolution"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getProjectDetail("thread-1");
BossApiClient.ApiResponse response = apiClient.getMasterAgentEvolution();
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/thread-1", apiClient.lastPath);
assertEquals("/api/v1/master-agent/evolution", apiClient.lastPath);
assertEquals("GET", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(30000, connection.readTimeoutValue);
}
@Test
public void getProjectMessagesUsesExtendedReadTimeoutForRealtimeRefreshes() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1/messages"));
public void updateMasterAgentEvolutionModeWritesModePayload() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/master-agent/evolution/config"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getProjectMessages("thread-1");
BossApiClient.ApiResponse response = apiClient.updateMasterAgentEvolutionMode("controlled");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/thread-1/messages", apiClient.lastPath);
assertEquals("GET", connection.requestMethodValue);
assertEquals(12000, connection.connectTimeoutValue);
assertEquals(30000, connection.readTimeoutValue);
assertEquals("/api/v1/master-agent/evolution/config", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals("{\"mode\":\"controlled\"}", connection.requestBody());
}
@Test
public void approveMasterAgentEvolutionProposalUsesProposalEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/master-agent/evolution/proposals/proposal-1/approve"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.approveMasterAgentEvolutionProposal("proposal-1");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/master-agent/evolution/proposals/proposal-1/approve", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals("{}", connection.requestBody());
}
@Test
@@ -339,153 +299,6 @@ 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"));
@@ -512,7 +325,7 @@ public class BossApiClientDispatchPlansTest {
public void rememberIdentityDoesNotOverwriteSessionIdentityFromAiAccountOnboardingResponse() throws Exception {
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
prefs.edit()
.putString("account", "krisolo")
.putString("account", "17600003315")
.putString("display_name", "Boss 超级管理员")
.apply();
BossApiClient apiClient = new BossApiClient(prefs, "https://boss.hyzq.net");
@@ -525,7 +338,7 @@ public class BossApiClientDispatchPlansTest {
apiClient.rememberIdentity(onboardingResponse);
assertEquals("krisolo", apiClient.getAccountLabel());
assertEquals("17600003315", apiClient.getAccountLabel());
assertEquals("Boss 超级管理员", apiClient.getDisplayName());
}
@@ -563,11 +376,7 @@ public class BossApiClientDispatchPlansTest {
private String lastPath = "";
RecordingBossApiClient(RecordingConnection connection) {
this(connection, new InMemorySharedPreferences());
}
RecordingBossApiClient(RecordingConnection connection, SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this.connection = connection;
}
@@ -591,7 +400,6 @@ 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");
@@ -608,7 +416,6 @@ public class BossApiClientDispatchPlansTest {
if (connection == null) {
throw new IllegalStateException("Missing scripted connection for " + path);
}
lastConnection = connection;
return connection;
}
@@ -623,65 +430,6 @@ 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<>();
@@ -691,15 +439,9 @@ 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,\"account\":\"krisolo\",\"displayName\":\"Boss 超级管理员\"}",
"{\"ok\":false}"
);
this(url, 200, "{\"ok\":true}", "{\"ok\":false}");
}
RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
@@ -768,11 +510,6 @@ public class BossApiClientDispatchPlansTest {
return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8));
}
@Override
public Map<String, java.util.List<String>> getHeaderFields() {
return responseHeaders;
}
String requestBody() {
return requestBody.toString(StandardCharsets.UTF_8);
}

View File

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

View File

@@ -0,0 +1,77 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossApiClientTest {
@Test
public void buildProjectAgentControlsPayload_omitsAdvancedOverridesWhenAllAreNull() throws Exception {
JSONObject payload = BossApiClient.buildProjectAgentControlsPayload(
"gpt-5.4-mini",
null,
null,
null,
null,
null,
false
);
assertEquals("gpt-5.4-mini", payload.getString("modelOverride"));
assertTrue(payload.has("reasoningEffortOverride"));
assertFalse(payload.has("fastModelOverride"));
assertFalse(payload.has("fastReasoningEffortOverride"));
assertFalse(payload.has("smartModelOverride"));
assertFalse(payload.has("smartReasoningEffortOverride"));
}
@Test
public void buildProjectAgentControlsPayload_includesAdvancedOverridesWhenPresent() throws Exception {
JSONObject payload = BossApiClient.buildProjectAgentControlsPayload(
null,
null,
"gpt-5.4-mini",
null,
"gpt-5.4",
"high",
false
);
assertTrue(payload.has("fastModelOverride"));
assertEquals("gpt-5.4-mini", payload.getString("fastModelOverride"));
assertTrue(payload.has("smartModelOverride"));
assertEquals("gpt-5.4", payload.getString("smartModelOverride"));
assertTrue(payload.has("smartReasoningEffortOverride"));
assertEquals("high", payload.getString("smartReasoningEffortOverride"));
}
@Test
public void buildProjectAgentControlsPayload_includesAdvancedNullsWhenExplicitlyRequested() throws Exception {
JSONObject payload = BossApiClient.buildProjectAgentControlsPayload(
"gpt-5.4-mini",
null,
null,
null,
null,
null,
true
);
assertTrue(payload.has("fastModelOverride"));
assertTrue(payload.isNull("fastModelOverride"));
assertTrue(payload.has("fastReasoningEffortOverride"));
assertTrue(payload.isNull("fastReasoningEffortOverride"));
assertTrue(payload.has("smartModelOverride"));
assertTrue(payload.isNull("smartModelOverride"));
assertTrue(payload.has("smartReasoningEffortOverride"));
assertTrue(payload.isNull("smartReasoningEffortOverride"));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,10 @@
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;
@@ -16,7 +13,6 @@ 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)
@@ -38,32 +34,29 @@ public class BossUiRootSurfaceTest {
"sessionData",
new JSONObject()
.put("displayName", "Kris")
.put("account", "krisolo")
.put("account", "17600003315")
.put("role", "highest_admin")
);
ReflectionHelpers.callInstanceMethod(activity, "renderMeRoot");
LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
assertEquals("我的页应是资料头 + 9 条菜单", 10, content.getChildCount());
assertEquals("我的页应是资料头 + 10 条菜单", 11, content.getChildCount());
View header = content.getChildAt(0);
assertEquals("资料头不应保留浮层卡片感", 0f, header.getElevation(), 0.01f);
assertTrue("资料头应保持横向布局", header instanceof LinearLayout);
View avatar = ((LinearLayout) header).getChildAt(0);
assertEquals("我的页头像应对标微信资料入口尺寸", BossUi.dp(activity, 52), avatar.getLayoutParams().width);
assertEquals("我的页头像应对标微信资料入口尺寸", BossUi.dp(activity, 52), avatar.getLayoutParams().height);
assertTrue(viewTreeContainsText(header, "Kris"));
assertTrue(viewTreeContainsText(header, "krisolo"));
assertTrue(viewTreeContainsText(header, "17600003315"));
assertTrue(viewTreeContainsText(header, "最高管理员"));
assertTrue(viewTreeContainsText(header, "主控账号已启用安全保护"));
assertTrue(viewTreeContainsText(content, "主 Agent 提示词"));
assertTrue(viewTreeContainsText(content, "主 Agent 记忆"));
assertTrue(viewTreeContainsText(content, "全局接管"));
assertTrue(viewTreeContainsText(content, "主 Agent 自动进化"));
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, "关于"));
@@ -73,46 +66,6 @@ public class BossUiRootSurfaceTest {
}
}
@Test
public void openMeEntry_storageStartsAttachmentStorageSettings() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(
activity,
"sessionData",
new JSONObject().put("role", "highest_admin")
);
ReflectionHelpers.callInstanceMethod(
activity,
"openMeEntry",
ReflectionHelpers.ClassParameter.from(String.class, "storage")
);
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
assertNotNull(started);
assertEquals(StorageSettingsActivity.class.getName(), started.getComponent().getClassName());
}
@Test
public void rootTabs_useWechatIconLabelNavigation() {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Button conversations = activity.findViewById(R.id.tab_conversations);
Button devices = activity.findViewById(R.id.tab_devices);
Button me = activity.findViewById(R.id.tab_me);
assertEquals("会话", conversations.getText().toString());
assertEquals("设备", devices.getText().toString());
assertEquals("我的", me.getText().toString());
assertEquals("顶部标题应对标微信页面标题", 18f, ((TextView) activity.findViewById(R.id.top_title)).getTextSize() / activity.getResources().getDisplayMetrics().scaledDensity, 0.5f);
assertEquals("底栏高度应对标微信底栏有效面积", BossUi.dp(activity, 54), ((View) conversations.getParent()).getLayoutParams().height);
assertNotNull("会话 tab 应显示顶部图标", conversations.getCompoundDrawables()[1]);
assertNotNull("设备 tab 应显示顶部图标", devices.getCompoundDrawables()[1]);
assertNotNull("我的 tab 应显示顶部图标", me.getCompoundDrawables()[1]);
assertEquals("底栏文字应对标微信底栏标签", 10f, conversations.getTextSize() / activity.getResources().getDisplayMetrics().scaledDensity, 0.5f);
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();

View File

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

View File

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

View File

@@ -7,8 +7,6 @@ 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;
@@ -17,7 +15,6 @@ 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;
@@ -40,7 +37,7 @@ import java.util.concurrent.TimeUnit;
@Config(sdk = 34)
public class ConversationInfoActivityTest {
@Test
public void renderConversationOmitsProfileHeaderAndStartsWithUsefulSettings() throws Exception {
public void renderConversationUsesLightweightHeaderMenuAndThreadList() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
@@ -58,81 +55,22 @@ public class ConversationInfoActivityTest {
);
LinearLayout content = activity.findViewById(R.id.screen_content);
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.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), "查看当前线程聊天与项目"));
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, 12), takeoverRow.getPaddingLeft());
assertEquals(BossUi.dp(activity, 12), takeoverRow.getPaddingRight());
assertNotNull(takeoverSwitch);
assertEquals("", String.valueOf(takeoverSwitch.getText()));
}
@Test
public void conversationInfoRowsUseConsistentSpacingAndTakeoverHasNoDividerLines() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestConversationInfoActivity activity = Robolectric
.buildActivity(TestConversationInfoActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderConversation",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildThreadStatusPayload())
);
LinearLayout content = activity.findViewById(R.id.screen_content);
int expectedBottomMargin = BossUi.dp(activity, 8);
for (int index = 0; index < Math.min(content.getChildCount(), 6); index += 1) {
View child = content.getChildAt(index);
assertTrue(child.getLayoutParams() instanceof LinearLayout.LayoutParams);
assertEquals(expectedBottomMargin, ((LinearLayout.LayoutParams) child.getLayoutParams()).bottomMargin);
}
View takeoverRow = content.getChildAt(0);
assertTrue(takeoverRow.getBackground() instanceof ColorDrawable);
assertEquals(Color.WHITE, ((ColorDrawable) takeoverRow.getBackground()).getColor());
}
@Test
public void threadDetailMenuRowStillOpensProjectDetail() throws Exception {
Intent intent = new Intent()
@@ -297,42 +235,6 @@ 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()
@@ -491,23 +393,6 @@ 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;
@@ -589,7 +474,7 @@ public class ConversationInfoActivityTest {
200,
new JSONObject()
.put("ok", true)
.put("session", new JSONObject().put("account", "krisolo"))
.put("session", new JSONObject().put("account", "17600003315"))
);
}

View File

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

View File

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

View File

@@ -1,12 +1,8 @@
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;
@@ -15,7 +11,6 @@ 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)
@@ -28,72 +23,28 @@ public class MainActivityConversationAutoRefreshTest {
MainActivity activity = controller.get();
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
.edit()
.putString("session_cookie", "boss_session=test")
.putString("restore_token", "test-restore-token")
.apply();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
ReflectionHelpers.ClassParameter.from(boolean.class, false));
assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "devices"),
ReflectionHelpers.ClassParameter.from(boolean.class, false));
Shadows.shadowOf(activity.getMainLooper()).idle();
assertFalse(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
ReflectionHelpers.ClassParameter.from(boolean.class, false));
Shadows.shadowOf(activity.getMainLooper()).idle();
assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
controller.pause();
assertFalse(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
}
@Test
public void returningToVisibleConversationRootRefreshesImmediatelyOnResume() {
org.robolectric.android.controller.ActivityController<TestResumeRefreshMainActivity> controller =
Robolectric.buildActivity(TestResumeRefreshMainActivity.class).setup().resume();
TestResumeRefreshMainActivity activity = controller.get();
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
.edit()
.putString("session_cookie", "boss_session=test")
.apply();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
activity.conversationRefreshCount = 0;
controller.pause();
controller.resume();
assertEquals(1, activity.conversationRefreshCount);
}
@Test
public void showContent_doesNotRequestNotificationPermissionInSameTapFrame() {
ShadowApplication.getInstance().denyPermissions(Manifest.permission.POST_NOTIFICATIONS);
org.robolectric.android.controller.ActivityController<MainActivity> controller =
Robolectric.buildActivity(MainActivity.class).setup();
MainActivity activity = controller.get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
assertNull(Shadows.shadowOf(activity).getLastRequestedPermission());
Shadows.shadowOf(activity.getMainLooper()).idleFor(java.time.Duration.ofMillis(500));
assertNotNull(Shadows.shadowOf(activity).getLastRequestedPermission());
assertEquals(1, Shadows.shadowOf(activity).getLastRequestedPermission().requestedPermissions.length);
assertEquals(
Manifest.permission.POST_NOTIFICATIONS,
Shadows.shadowOf(activity).getLastRequestedPermission().requestedPermissions[0]
);
}
public static class TestResumeRefreshMainActivity extends MainActivity {
int conversationRefreshCount;
@Override
void refreshConversationsData() {
conversationRefreshCount += 1;
completeRealtimeTabRefresh();
}
}
}

View File

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

View File

@@ -90,7 +90,6 @@ 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();
@@ -107,27 +106,6 @@ 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);
@@ -210,28 +188,6 @@ public class MainActivityConversationSelectionTest {
return false;
}
private static boolean viewTreeContainsVisibleText(View root, String expectedText) {
if (root.getVisibility() != View.VISIBLE) {
return false;
}
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (expectedText.contentEquals(text)) {
return true;
}
}
if (!(root instanceof LinearLayout)) {
return false;
}
LinearLayout group = (LinearLayout) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsVisibleText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static boolean viewTreeContainsContentDescription(View root, String expectedText) {
CharSequence description = root.getContentDescription();
if (expectedText.contentEquals(description)) {

View File

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

View File

@@ -0,0 +1,79 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import android.content.Intent;
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;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class MainActivityMeEntryNavigationTest {
@Test
public void masterAgentPromptMeEntryOpensPromptActivityForMasterAgent() {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"openMeEntry",
ReflectionHelpers.ClassParameter.from(String.class, "master_agent_prompt")
);
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
assertEquals(MasterAgentPromptActivity.class.getName(), started.getComponent().getClassName());
assertEquals("master-agent", started.getStringExtra(MasterAgentPromptActivity.EXTRA_PROJECT_ID));
assertEquals("主 Agent", started.getStringExtra(MasterAgentPromptActivity.EXTRA_PROJECT_NAME));
}
@Test
public void masterAgentMemoryMeEntryOpensMemoryActivityForMasterAgent() {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"openMeEntry",
ReflectionHelpers.ClassParameter.from(String.class, "master_agent_memory")
);
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
assertEquals(MasterAgentMemoryActivity.class.getName(), started.getComponent().getClassName());
assertEquals("master-agent", started.getStringExtra(MasterAgentMemoryActivity.EXTRA_PROJECT_ID));
assertEquals("主 Agent", started.getStringExtra(MasterAgentMemoryActivity.EXTRA_PROJECT_NAME));
}
@Test
public void masterAgentTakeoverMeEntryOpensTakeoverActivityForMasterAgent() {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"openMeEntry",
ReflectionHelpers.ClassParameter.from(String.class, "master_agent_takeover")
);
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
assertEquals(MasterAgentTakeoverActivity.class.getName(), started.getComponent().getClassName());
assertEquals("master-agent", started.getStringExtra(MasterAgentTakeoverActivity.EXTRA_PROJECT_ID));
assertEquals("主 Agent", started.getStringExtra(MasterAgentTakeoverActivity.EXTRA_PROJECT_NAME));
}
@Test
public void masterAgentEvolutionMeEntryOpensEvolutionActivity() {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"openMeEntry",
ReflectionHelpers.ClassParameter.from(String.class, "master_agent_evolution")
);
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
assertEquals(MasterAgentEvolutionActivity.class.getName(), started.getComponent().getClassName());
}
}

View File

@@ -3,7 +3,6 @@ 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;
@@ -16,15 +15,17 @@ import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
import java.io.IOException;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import java.util.function.BooleanSupplier;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class MainActivityRealtimeTest {
@Test
public void conversationRealtimeEventRefreshesVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
private static void showConversationTab(MainActivity activity) {
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
.edit()
.putString("restore_token", "test-restore-token")
.apply();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
@@ -32,9 +33,17 @@ public class MainActivityRealtimeTest {
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
}
private static void flushRealtimeDebounce(MainActivity activity) {
Shadows.shadowOf(activity.getMainLooper()).idleFor(400, TimeUnit.MILLISECONDS);
}
@Test
public void conversationRealtimeEventRefreshesVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -43,9 +52,7 @@ public class MainActivityRealtimeTest {
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
flushRealtimeDebounce(activity);
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
@@ -55,7 +62,8 @@ public class MainActivityRealtimeTest {
@Test
public void devicesRealtimeEventDoesNotRefreshConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -64,7 +72,7 @@ public class MainActivityRealtimeTest {
new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "mac-studio"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(0, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
@@ -73,7 +81,8 @@ public class MainActivityRealtimeTest {
@Test
public void blankProjectIdConversationEventDoesNotRefreshVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -82,7 +91,7 @@ public class MainActivityRealtimeTest {
new BossRealtimeEvent("conversation.updated", new JSONObject())
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(0, activity.conversationRefreshCount);
}
@@ -90,16 +99,8 @@ public class MainActivityRealtimeTest {
@Test
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;
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -108,9 +109,7 @@ public class MainActivityRealtimeTest {
new BossRealtimeEvent("conversation.updated", new JSONObject().put("deviceId", "mac-studio"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
flushRealtimeDebounce(activity);
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
@@ -119,7 +118,8 @@ public class MainActivityRealtimeTest {
@Test
public void contextIndicatorEventRefreshesVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -131,18 +131,17 @@ public class MainActivityRealtimeTest {
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
flushRealtimeDebounce(activity);
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
}
@Test
public void contextIndicatorSnapshotWithoutProjectIdRefreshesVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -154,18 +153,17 @@ public class MainActivityRealtimeTest {
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
flushRealtimeDebounce(activity);
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
}
@Test
public void distinctConversationEventsBackToBackCoalesceIntoSingleVisibleConversationRefresh() throws Exception {
public void distinctConversationEventsBackToBackBothRefreshVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -188,9 +186,7 @@ public class MainActivityRealtimeTest {
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
flushRealtimeDebounce(activity);
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
@@ -199,16 +195,15 @@ public class MainActivityRealtimeTest {
@Test
public void devicesRealtimeEventRefreshesVisibleDevicesTabOnly() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "devices"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -217,11 +212,9 @@ public class MainActivityRealtimeTest {
new BossRealtimeEvent("devices.updated", new JSONObject().put("deviceId", "mac-studio"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(0, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.deviceRefreshCount);
assertEquals(0, activity.meRefreshCount);
}
@@ -229,16 +222,15 @@ public class MainActivityRealtimeTest {
@Test
public void otaRealtimeEventRefreshesVisibleMeTabOnly() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "me"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -247,28 +239,18 @@ public class MainActivityRealtimeTest {
new BossRealtimeEvent("ota.updated", new JSONObject())
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
assertEquals(0, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
assertEquals(0, activity.meRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.meRefreshCount);
}
@Test
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;
showConversationTab(activity);
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.setField(activity, "rootTabRefreshInFlight", true);
ReflectionHelpers.callInstanceMethod(
@@ -279,7 +261,7 @@ public class MainActivityRealtimeTest {
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
flushRealtimeDebounce(activity);
ReflectionHelpers.callInstanceMethod(
activity,
@@ -302,7 +284,6 @@ 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);
@@ -311,28 +292,7 @@ public class MainActivityRealtimeTest {
}
@Test
public void realtimeDisconnectTriggersImmediateConversationFallbackRefresh() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
.edit()
.putString("session_cookie", "boss_session=test")
.apply();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeConnectionChanged",
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
assertEquals(0, activity.meRefreshCount);
}
@Test
public void refreshConversationsData_prefersGroupedHomeFeedOverFlatConversationFeed() throws Exception {
public void refreshConversationsData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
@@ -344,47 +304,18 @@ public class MainActivityRealtimeTest {
Shadows.shadowOf(activity.getMainLooper()).idle();
activity.refreshConversationsData();
waitFor(() -> apiClient.homeCalls > 0);
waitFor(() -> apiClient.homeCalls > 0 || apiClient.conversationsCalls > 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();
RecordingRejectedHomeConversationSourceClient apiClient = new RecordingRejectedHomeConversationSourceClient(
RecordingRejectedConversationSourceClient apiClient = new RecordingRejectedConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -407,7 +338,7 @@ public class MainActivityRealtimeTest {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
RecordingIOExceptionHomeConversationSourceClient apiClient = new RecordingIOExceptionHomeConversationSourceClient(
RecordingIOExceptionConversationSourceClient apiClient = new RecordingIOExceptionConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -426,7 +357,7 @@ public class MainActivityRealtimeTest {
}
@Test
public void refreshAllData_prefersGroupedHomeFeedOverFlatConversationFeed() throws Exception {
public void refreshAllData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
@@ -442,51 +373,18 @@ public class MainActivityRealtimeTest {
"refreshAllData",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject())
);
waitFor(() -> apiClient.homeCalls > 0);
waitFor(() -> apiClient.homeCalls > 0 || apiClient.conversationsCalls > 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();
RecordingRejectedHomeConversationSourceClient apiClient = new RecordingRejectedHomeConversationSourceClient(
RecordingRejectedConversationSourceClient apiClient = new RecordingRejectedConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -513,7 +411,7 @@ public class MainActivityRealtimeTest {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
RecordingIOExceptionHomeConversationSourceClient apiClient = new RecordingIOExceptionHomeConversationSourceClient(
RecordingIOExceptionConversationSourceClient apiClient = new RecordingIOExceptionConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -557,13 +455,6 @@ 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;
@@ -583,28 +474,7 @@ public class MainActivityRealtimeTest {
}
}
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 {
private static final class RecordingRejectedConversationSourceClient extends BossApiClient {
int homeCalls;
int conversationsCalls;
int sessionCalls;
@@ -612,7 +482,7 @@ public class MainActivityRealtimeTest {
int settingsCalls;
int otaCalls;
RecordingRejectedHomeConversationSourceClient(android.content.SharedPreferences prefs) {
RecordingRejectedConversationSourceClient(android.content.SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
}
@@ -629,7 +499,7 @@ public class MainActivityRealtimeTest {
conversationsCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("conversations", RecordingConversationSourceClient.buildFlatConversations()));
.put("conversations", buildFlatConversations()));
}
@Override
@@ -638,7 +508,7 @@ public class MainActivityRealtimeTest {
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("session", new JSONObject()
.put("account", "krisolo")
.put("account", "17600003315")
.put("displayName", "Boss 超级管理员")));
}
@@ -666,6 +536,32 @@ 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 {
@@ -702,7 +598,7 @@ public class MainActivityRealtimeTest {
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("session", new JSONObject()
.put("account", "krisolo")
.put("account", "17600003315")
.put("displayName", "Boss 超级管理员")));
}
@@ -733,15 +629,13 @@ public class MainActivityRealtimeTest {
private static JSONArray buildHomeConversations() throws org.json.JSONException {
return new JSONArray().put(new JSONObject()
.put("projectId", "mac-studio:boss")
.put("projectId", "folder-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"));
}
@@ -773,7 +667,7 @@ public class MainActivityRealtimeTest {
}
}
private static final class RecordingIOExceptionHomeConversationSourceClient extends BossApiClient {
private static final class RecordingIOExceptionConversationSourceClient extends BossApiClient {
int homeCalls;
int conversationsCalls;
int sessionCalls;
@@ -781,7 +675,7 @@ public class MainActivityRealtimeTest {
int settingsCalls;
int otaCalls;
RecordingIOExceptionHomeConversationSourceClient(android.content.SharedPreferences prefs) {
RecordingIOExceptionConversationSourceClient(android.content.SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
}
@@ -796,7 +690,7 @@ public class MainActivityRealtimeTest {
conversationsCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("conversations", RecordingConversationSourceClient.buildFlatConversations()));
.put("conversations", buildFlatConversations()));
}
@Override
@@ -805,7 +699,7 @@ public class MainActivityRealtimeTest {
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("session", new JSONObject()
.put("account", "krisolo")
.put("account", "17600003315")
.put("displayName", "Boss 超级管理员")));
}
@@ -833,5 +727,31 @@ public class MainActivityRealtimeTest {
.put("ok", true)
.put("hasOta", false));
}
private static JSONArray buildFlatConversations() throws org.json.JSONException {
return new JSONArray()
.put(new JSONObject()
.put("projectId", "thread-revert")
.put("conversationType", "single_device")
.put("projectTitle", "发布回滚")
.put("threadTitle", "发布回滚")
.put("folderLabel", "Boss")
.put("folderKey", "mac-studio:boss")
.put("lastMessagePreview", "最近:发布回滚")
.put("latestReplyAt", "2026-04-06T10:00:00.000Z")
.put("latestReplyLabel", "11:00")
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 80).put("level", "watch")))
.put(new JSONObject()
.put("projectId", "thread-ui")
.put("conversationType", "single_device")
.put("projectTitle", "Android UI 收尾")
.put("threadTitle", "Android UI 收尾")
.put("folderLabel", "Boss")
.put("folderKey", "mac-studio:boss")
.put("lastMessagePreview", "最近Android UI 收尾")
.put("latestReplyAt", "2026-04-06T09:59:00.000Z")
.put("latestReplyLabel", "10:59")
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 95).put("level", "safe")));
}
}
}

View File

@@ -0,0 +1,226 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
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.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class MasterAgentEvolutionActivityTest {
@Test
public void renderDashboardShowsModePendingProposalSignalAndRule() throws Exception {
TestMasterAgentEvolutionActivity activity = Robolectric
.buildActivity(TestMasterAgentEvolutionActivity.class, new Intent())
.setup()
.get();
JSONObject payload = new JSONObject()
.put("config", new JSONObject()
.put("mode", "autonomous")
.put("autoApplyLowRiskRules", true))
.put("signals", new JSONArray()
.put(new JSONObject()
.put("signalId", "signal-1")
.put("kind", "repeated_question")
.put("requestText", "当前主节点在线吗")
.put("createdAt", "2026-04-16T12:00:00+08:00")))
.put("proposals", new JSONArray()
.put(new JSONObject()
.put("proposalId", "proposal-1")
.put("status", "pending_review")
.put("proposalType", "fast_path_rule")
.put("riskLevel", "low")
.put("createdAt", "2026-04-16T12:01:00+08:00")
.put("title", "新增 Fast Path")
.put("summary", "把状态查询加入本地直答")))
.put("rules", new JSONArray()
.put(new JSONObject()
.put("ruleId", "rule-1")
.put("ruleType", "routing_preference_patch")
.put("sourceProposalId", "proposal-2")
.put("createdAt", "2026-04-16T12:02:00+08:00")));
ReflectionHelpers.callInstanceMethod(
activity,
"renderDashboard",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "完全自我进化"));
assertTrue(viewTreeContainsText(content, "待处理提案"));
assertTrue(viewTreeContainsText(content, "新增 Fast Path"));
assertTrue(viewTreeContainsText(content, "repeated_question"));
assertTrue(viewTreeContainsText(content, "routing_preference_patch"));
}
@Test
public void matchingRealtimeEventTriggersReload() throws Exception {
TestMasterAgentEvolutionActivity activity = Robolectric
.buildActivity(TestMasterAgentEvolutionActivity.class, new Intent())
.setup()
.resume()
.get();
activity.reloadEnabled = true;
activity.reloadCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent("master_agent.settings.updated", new JSONObject())
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(1, activity.reloadCount);
}
@Test
public void renderDashboardHidesManageActionsForReadonlyView() throws Exception {
TestMasterAgentEvolutionActivity activity = Robolectric
.buildActivity(TestMasterAgentEvolutionActivity.class, new Intent())
.setup()
.get();
JSONObject payload = new JSONObject()
.put("canManage", false)
.put("config", new JSONObject()
.put("mode", "controlled")
.put("autoApplyLowRiskRules", false))
.put("signals", new JSONArray())
.put("proposals", new JSONArray()
.put(new JSONObject()
.put("proposalId", "proposal-1")
.put("status", "pending_review")
.put("proposalType", "fast_path_rule")
.put("riskLevel", "low")
.put("createdAt", "2026-04-16T12:01:00+08:00")
.put("title", "新增 Fast Path")
.put("summary", "把状态查询加入本地直答")))
.put("rules", new JSONArray());
ReflectionHelpers.callInstanceMethod(
activity,
"renderDashboard",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "当前是只读视角"));
assertTrue(viewTreeContainsText(content, "你当前没有管理权限"));
assertFalse(viewTreeContainsText(content, "切到全自动模式"));
assertFalse(viewTreeContainsText(content, "批准"));
}
@Test
public void renderDashboardShowsStatusBannerAndFormattedTimes() throws Exception {
TestMasterAgentEvolutionActivity activity = Robolectric
.buildActivity(TestMasterAgentEvolutionActivity.class, new Intent())
.setup()
.get();
ReflectionHelpers.setField(activity, "statusMessage", "提案已批准");
ReflectionHelpers.setField(activity, "statusIsError", false);
JSONObject payload = new JSONObject()
.put("config", new JSONObject()
.put("mode", "controlled")
.put("autoApplyLowRiskRules", false))
.put("signals", new JSONArray()
.put(new JSONObject()
.put("signalId", "signal-1")
.put("kind", "backend_fallback")
.put("requestText", "主节点暂时不可用")
.put("createdAt", "2026-04-16T12:00:00+08:00")))
.put("proposals", new JSONArray())
.put("rules", new JSONArray()
.put(new JSONObject()
.put("ruleId", "rule-1")
.put("ruleType", "fast_path_rule")
.put("createdAt", "2026-04-16T12:02:00+08:00")));
ReflectionHelpers.callInstanceMethod(
activity,
"renderDashboard",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "最近操作"));
assertTrue(viewTreeContainsText(content, "提案已批准"));
assertTrue(viewTreeContainsText(content, "2026-04-16 12:00:00"));
assertTrue(viewTreeContainsText(content, "2026-04-16 12:02:00"));
}
@Test
public void renderLoadErrorStateKeepsRetryEntryVisible() throws Exception {
TestMasterAgentEvolutionActivity activity = Robolectric
.buildActivity(TestMasterAgentEvolutionActivity.class, new Intent())
.setup()
.get();
ReflectionHelpers.setField(activity, "statusMessage", "自动进化加载失败network down");
ReflectionHelpers.setField(activity, "statusIsError", true);
ReflectionHelpers.callInstanceMethod(
activity,
"renderLoadErrorState",
ReflectionHelpers.ClassParameter.from(String.class, "network down")
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "当前加载失败,保留在这个页面直接重试即可。"));
assertTrue(viewTreeContainsText(content, "重新加载"));
assertTrue(viewTreeContainsText(content, "自动进化中心暂时不可用network down"));
assertTrue(viewTreeContainsText(content, "最近状态"));
}
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;
}
public static class TestMasterAgentEvolutionActivity extends MasterAgentEvolutionActivity {
private boolean reloadEnabled;
private int reloadCount;
@Override
protected void reload() {
if (!reloadEnabled) {
return;
}
reloadCount += 1;
setRefreshing(false);
}
}
}

View File

@@ -193,6 +193,86 @@ public class MasterAgentPromptActivityTest {
assertTrue(viewTreeContainsText(content, "未检测到有效的 Claw 启动脚本"));
}
@Test
public void renderPromptProfileIncludesHermesRuntimeWhenSelectable() throws Exception {
TestMasterAgentPromptActivity activity = Robolectric
.buildActivity(
TestMasterAgentPromptActivity.class,
new Intent()
.putExtra(MasterAgentPromptActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(MasterAgentPromptActivity.EXTRA_PROJECT_NAME, "主 Agent")
)
.setup()
.get();
JSONObject payload = new JSONObject()
.put("promptPolicy", new JSONObject().put("globalPrompt", "全局主提示词"))
.put("userPrompt", new JSONObject().put("content", "用户私有主提示词"))
.put("projectControls", new JSONObject()
.put("promptOverride", "当前对话提示词")
.put("backendOverride", "hermes-runtime"))
.put("clawAvailability", new JSONObject()
.put("status", "ready")
.put("selectable", true)
.put("reasonLabel", "Claw Runtime 可用。"))
.put("hermesAvailability", new JSONObject()
.put("status", "ready")
.put("selectable", true)
.put("reasonLabel", "Hermes Runtime 可用。"));
ReflectionHelpers.callInstanceMethod(
activity,
"renderPromptProfile",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
);
Spinner backendSpinner = ReflectionHelpers.getField(activity, "backendSpinner");
assertEquals(3, backendSpinner.getAdapter().getCount());
assertEquals(2, backendSpinner.getSelectedItemPosition());
assertEquals("Hermes Runtime", String.valueOf(backendSpinner.getAdapter().getItem(2)));
TextView previewTextView = ReflectionHelpers.getField(activity, "previewTextView");
assertTrue(String.valueOf(previewTextView.getText()).contains("【执行后端】"));
assertTrue(String.valueOf(previewTextView.getText()).contains("hermes-runtime"));
}
@Test
public void renderPromptProfileShowsHermesUnavailableHintWhenStoredOverrideCannotBeSelected() throws Exception {
TestMasterAgentPromptActivity activity = Robolectric
.buildActivity(
TestMasterAgentPromptActivity.class,
new Intent()
.putExtra(MasterAgentPromptActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(MasterAgentPromptActivity.EXTRA_PROJECT_NAME, "主 Agent")
)
.setup()
.get();
JSONObject payload = new JSONObject()
.put("promptPolicy", new JSONObject().put("globalPrompt", "全局主提示词"))
.put("userPrompt", new JSONObject().put("content", "用户私有主提示词"))
.put("projectControls", new JSONObject()
.put("promptOverride", "当前对话提示词")
.put("backendOverride", "hermes-runtime"))
.put("hermesAvailability", new JSONObject()
.put("status", "misconfigured")
.put("selectable", false)
.put("reason", "script_not_found")
.put("reasonLabel", "未检测到有效的 Hermes 启动脚本,将自动回退到默认后端。"));
ReflectionHelpers.callInstanceMethod(
activity,
"renderPromptProfile",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
);
Spinner backendSpinner = ReflectionHelpers.getField(activity, "backendSpinner");
assertEquals(1, backendSpinner.getAdapter().getCount());
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "Hermes Runtime 当前不可用"));
assertTrue(viewTreeContainsText(content, "未检测到有效的 Hermes 启动脚本"));
assertTrue(viewTreeContainsText(content, "当前对话之前保存过 Hermes Runtime"));
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@@ -19,7 +18,6 @@ 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;
@@ -47,74 +45,15 @@ public class ProjectDetailActivityMasterAgentMenuTest {
assertMenuItem(listView, 0, "模型");
assertMenuItem(listView, 1, "推理强度");
assertMenuItem(listView, 2, "全局接管");
assertMenuItem(listView, 3, "提示词");
assertMenuItem(listView, 4, "记忆");
assertMenuItem(listView, 5, "会话信息");
assertMenuItem(listView, 6, "刷新");
assertMenuItem(listView, 3, "自动进化");
assertMenuItem(listView, 4, "提示词");
assertMenuItem(listView, 5, "记忆");
assertMenuItem(listView, 6, "会话信息");
assertMenuItem(listView, 7, "刷新");
}
@Test
public void masterAgentModelOptionsIncludeFastAndDeepChoices() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
.setup()
.get();
String[] options = ReflectionHelpers.callInstanceMethod(activity, "buildMasterAgentModelOptions");
assertArrayEquals(
new String[]{"沿用默认", "gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1", "自定义..."},
options
);
}
@Test
public void masterAgentModelOptionsKeepCurrentCustomChoice() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "currentAgentModelOverride", "gpt-4.1-mini");
String[] options = ReflectionHelpers.callInstanceMethod(activity, "buildMasterAgentModelOptions");
assertArrayEquals(
new String[]{"沿用默认", "gpt-4.1-mini", "gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1", "自定义..."},
options
);
}
@Test
public void masterAgentModelPickerShowsFastAndDeepModes() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(activity, "showMasterAgentModelPicker");
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
AlertDialog actionDialog = (AlertDialog) latestDialog;
ListView listView = actionDialog.getListView();
assertMenuItem(listView, 0, "沿用默认");
assertMenuItem(listView, 1, "快速反应gpt-5.4-mini");
assertMenuItem(listView, 2, "深度思考gpt-5.4");
assertMenuItem(listView, 3, "更多模型...");
}
@Test
public void normalConversationHeaderActionOpensConversationInfoDirectly() {
public void normalConversationMoreMenuShowsInfoAndRefresh() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss 移动控制台");
@@ -123,11 +62,15 @@ public class ProjectDetailActivityMasterAgentMenuTest {
.setup()
.get();
ReflectionHelpers.callInstanceMethod(activity, "openConversationInfo");
ReflectionHelpers.callInstanceMethod(activity, "showConversationMoreMenu");
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
assertNotNull(nextIntent);
assertEquals(ConversationInfoActivity.class.getName(), nextIntent.getComponent().getClassName());
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
AlertDialog actionDialog = (AlertDialog) latestDialog;
ListView listView = actionDialog.getListView();
assertMenuItem(listView, 0, "会话信息");
assertMenuItem(listView, 1, "刷新");
}
@Test

View File

@@ -1,19 +1,11 @@
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;
@@ -21,12 +13,8 @@ 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;
@@ -36,6 +24,10 @@ import java.util.function.BooleanSupplier;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class ProjectDetailActivityRealtimeTest {
private static void flushRealtimeDebounce(ProjectDetailActivity activity) {
Shadows.shadowOf(activity.getMainLooper()).idleFor(400, TimeUnit.MILLISECONDS);
}
@Test
public void matchingProjectMessageEventTriggersReload() throws Exception {
Intent intent = new Intent()
@@ -55,11 +47,11 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
)
);
drainRealtimeDebounce(activity);
flushRealtimeDebounce(activity);
waitFor(() -> activity.messageReloadCount == 1);
assertEquals(0, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
assertEquals(1, activity.loadCallCount);
assertEquals(1, activity.renderCount);
}
@Test
@@ -81,7 +73,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
)
);
drainRealtimeDebounce(activity);
flushRealtimeDebounce(activity);
assertEquals(0, activity.reloadCount);
}
@@ -105,7 +97,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-2"))
)
);
drainRealtimeDebounce(activity);
flushRealtimeDebounce(activity);
assertEquals(0, activity.reloadCount);
}
@@ -143,11 +135,11 @@ public class ProjectDetailActivityRealtimeTest {
)
)
);
drainRealtimeDebounce(activity);
flushRealtimeDebounce(activity);
waitFor(() -> activity.loadCallCount == 1);
assertEquals(1, activity.reloadCount);
assertEquals(1, activity.loadCallCount);
assertEquals(0, activity.messageReloadCount);
assertEquals(1, activity.renderCount);
}
@Test
@@ -172,11 +164,11 @@ public class ProjectDetailActivityRealtimeTest {
)
)
);
drainRealtimeDebounce(activity);
flushRealtimeDebounce(activity);
waitFor(() -> activity.loadCallCount == 1);
assertEquals(1, activity.reloadCount);
assertEquals(1, activity.loadCallCount);
assertEquals(0, activity.messageReloadCount);
assertEquals(1, activity.renderCount);
}
@Test
@@ -212,161 +204,11 @@ public class ProjectDetailActivityRealtimeTest {
)
)
);
drainRealtimeDebounce(activity);
flushRealtimeDebounce(activity);
waitFor(() -> activity.messageReloadCount == 1);
assertEquals(0, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
}
@Test
public void dialogGuardInterventionRequiredShowsBlockedSafeActionDialog() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
TestRealtimeProjectDetailActivity activity = Robolectric
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
.setup()
.resume()
.get();
RecordingDialogGuardApiClient apiClient = new RecordingDialogGuardApiClient();
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent(
"desktop.dialog_guard.intervention_required",
new JSONObject()
.put("interventionId", "intervention-1")
.put("dialogId", "dialog-1")
.put("requestId", "request-1")
.put("taskId", "task-1")
.put("deviceId", "mac-studio")
.put("projectId", "project-1")
.put("appName", "微信")
.put("platform", "macos")
.put("risk", "blocked")
.put("summary", "微信正在请求读取敏感通讯录权限")
.put("recommendedAction", "handled_on_device")
.put("availableActions", new JSONArray()
.put("allow_once")
.put("allow_for_device_dialog")
.put("deny")
.put("handled_on_device")
.put("cancel_task"))
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
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());
assertEquals(1, activity.loadCallCount);
assertEquals(1, activity.renderCount);
}
@Test
@@ -390,7 +232,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
)
);
drainRealtimeDebounce(activity);
flushRealtimeDebounce(activity);
assertTrue(activity.awaitFirstLoadStarted());
ReflectionHelpers.callInstanceMethod(
@@ -409,69 +251,18 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-1"))
)
);
drainRealtimeDebounce(activity);
flushRealtimeDebounce(activity);
assertEquals(0, activity.loadCallCount);
assertEquals(1, activity.messageLoadCallCount);
assertEquals(1, activity.loadCallCount);
assertEquals(0, activity.renderCount);
activity.releaseFirstLoad();
waitFor(() -> activity.renderCount == 2 && activity.messageLoadCallCount == 1 && activity.loadCallCount == 1);
waitFor(() -> activity.renderCount == 2 && activity.loadCallCount == 2);
assertEquals(1, activity.loadCallCount);
assertEquals(1, activity.messageLoadCallCount);
assertEquals(2, activity.loadCallCount);
assertEquals(2, activity.renderCount);
}
@Test
public void realtimeDisconnectTriggersImmediateConversationFallbackReload() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
TestRealtimeProjectDetailActivity activity = Robolectric
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
.setup()
.resume()
.get();
activity.getSharedPreferences("boss_native_client", android.content.Context.MODE_PRIVATE)
.edit()
.putString("session_cookie", "boss_session=test")
.apply();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeConnectionChanged",
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
drainRealtimeDebounce(activity);
waitFor(() -> activity.loadCallCount == 1);
assertEquals(1, activity.loadCallCount);
}
@Test
public void reloadSnapshotAfterDestroyDoesNotCrashWhenExecutorsAreShutdown() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
TestRealtimeProjectDetailActivity activity = Robolectric
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
.setup()
.resume()
.pause()
.destroy()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"reloadSnapshot",
ReflectionHelpers.ClassParameter.from(boolean.class, false),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
assertEquals(0, activity.loadCallCount);
}
private static void waitFor(BooleanSupplier condition) throws Exception {
long deadlineAt = System.currentTimeMillis() + 2_000L;
while (System.currentTimeMillis() < deadlineAt) {
@@ -484,54 +275,9 @@ 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;
volatile int loadCallCount;
volatile int messageLoadCallCount;
volatile int renderCount;
private CountDownLatch firstLoadStarted;
private CountDownLatch releaseFirstLoad;
@@ -562,26 +308,6 @@ public class ProjectDetailActivityRealtimeTest {
super.reload();
}
@Override
ProjectSnapshot loadProjectMessagesSnapshotForRefresh() throws Exception {
messageReloadCount += 1;
messageLoadCallCount += 1;
if (messageLoadCallCount == 1 && firstLoadStarted != null && releaseFirstLoad != null) {
firstLoadStarted.countDown();
releaseFirstLoad.await(2, TimeUnit.SECONDS);
}
return new ProjectSnapshot(
new JSONObject().put(
"project",
new JSONObject()
.put("name", "北区试产线")
.put("messages", new JSONArray())
),
null,
null
);
}
@Override
ProjectSnapshot loadProjectSnapshotForRefresh() throws Exception {
loadCallCount += 1;
@@ -601,28 +327,15 @@ public class ProjectDetailActivityRealtimeTest {
);
}
@Override
ProjectSnapshot loadProjectMessagesSnapshotForRefresh() throws Exception {
return loadProjectSnapshotForRefresh();
}
@Override
void renderLoadedProjectSnapshot(ProjectSnapshot snapshot) {
renderCount += 1;
setRefreshing(false);
}
}
private static final class RecordingDialogGuardApiClient extends BossApiClient {
int decisionCallCount;
String lastInterventionId;
String lastDecision;
RecordingDialogGuardApiClient() {
super(RuntimeEnvironment.getApplication().getSharedPreferences("dialog_guard_test", Context.MODE_PRIVATE), "https://boss.hyzq.net");
}
@Override
public ApiResponse decideDialogGuardIntervention(String interventionId, String decision) throws org.json.JSONException {
decisionCallCount += 1;
lastInterventionId = interventionId;
lastDecision = decision;
return new ApiResponse(200, new JSONObject().put("ok", true));
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public class WechatSurfaceMapperMeMenuTest {
@Test
public void rootMeMenuIncludesMasterAgentEvolutionEntryAfterPromptMemory() {
WechatSurfaceMapper.MeMenuItem[] items = WechatSurfaceMapper.rootMeMenuItems();
assertEquals("master_agent_prompt", items[0].key);
assertEquals("主 Agent 提示词", items[0].title);
assertEquals("master_agent_memory", items[1].key);
assertEquals("主 Agent 记忆", items[1].title);
assertEquals("master_agent_takeover", items[2].key);
assertEquals("全局接管", items[2].title);
assertEquals("master_agent_evolution", items[3].key);
assertEquals("主 Agent 自动进化", items[3].title);
}
}

View File

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

View File

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

View File

@@ -1,12 +0,0 @@
<!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>

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,230 +0,0 @@
export interface BossAdminMenuItem {
key: string;
label: string;
children?: BossAdminMenuItem[];
}
export interface BossAdminTaskSlaRow extends Record<string, unknown> {
taskId: string;
riskId: string;
projectId: string;
deviceId: string;
taskType: string;
status: string;
phase: string;
summary: string;
slaLevel: "ok" | "watch" | "breached" | "recoverable" | "terminal";
severity: "info" | "warning" | "critical";
slaDueAt: string;
lastProgressAt: string;
attemptLabel: string;
stale: boolean;
recoverable: boolean;
autoRecoverable: boolean;
recommendedAction: string;
}
export interface BossAdminTaskSlaPanel {
generatedAt: string;
summary: Record<string, number>;
rows: BossAdminTaskSlaRow[];
}
export interface BossAdminBackofficePayload {
ok: boolean;
surface: "platform" | "enterprise";
currentCompany: Record<string, unknown> | null;
menuTree: BossAdminMenuItem[];
insights: {
onboardingSteps: string[];
serviceStatuses: Array<Record<string, unknown>>;
openingPreview: Array<Record<string, unknown>>;
deliveryChecklist: Array<Record<string, unknown>>;
recentCompanies: Array<Record<string, unknown>>;
customerHealthRows: Array<Record<string, unknown>>;
riskAggregates: Array<Record<string, unknown>>;
customerFollowups: Array<Record<string, unknown>>;
enterpriseGoals: Array<Record<string, unknown>>;
organizationUnits: string[];
departmentProgress: Array<Record<string, unknown>>;
masterAgentSummary: string[];
permissionHighlights: string[];
agentFlowSteps: string[];
skillUsageAudit: Array<Record<string, unknown>>;
recoveryActions: string[];
backupStatus: Record<string, unknown>;
dataSafetySummary: Record<string, unknown>;
taskRiskSummary: Record<string, unknown>;
taskSlaPanel: BossAdminTaskSlaPanel;
capabilitySummary: Record<string, number>;
surface: "platform" | "enterprise";
};
workbench: {
summary: Record<string, number>;
companies: Array<Record<string, unknown>>;
devices: Array<Record<string, unknown>>;
risks: Array<Record<string, unknown>>;
notifications: Array<Record<string, unknown>>;
grantsSummary: Record<string, number>;
};
tenants: Array<Record<string, unknown>>;
users: Array<Record<string, unknown>>;
roles: {
builtInRoles: Array<Record<string, unknown>>;
permissionTemplates: Array<Record<string, unknown>>;
};
resourceGroups: {
devices: Array<Record<string, unknown>>;
projects: Array<Record<string, unknown>>;
skills: Array<Record<string, unknown>>;
grants: Record<string, Array<Record<string, unknown>>>;
};
audit: {
risks: Array<Record<string, unknown>>;
notifications: Array<Record<string, unknown>>;
riskTimeline: Array<Record<string, unknown>>;
permissionLogs: Array<Record<string, unknown>>;
};
yudaoMapping: Record<string, string>;
}
export interface BossAdminBackupSnapshot {
snapshotId: string;
fileName: string;
absolutePath: string;
bytes: number;
sha256: string;
createdAt: string;
actorAccount?: string;
reason?: string;
schemaVersion?: number;
}
export interface BossAdminBackupStatus {
mode: "file";
backupDir: string;
stateFile: string;
restorePointCount: number;
lastBackupAt?: string;
status: "ready" | "empty" | "error";
detail?: string;
}
export interface BossAdminBackupsPayload {
ok: boolean;
status: BossAdminBackupStatus;
snapshots: BossAdminBackupSnapshot[];
}
export interface BossAdminSkillLifecycleRequest extends Record<string, unknown> {
requestId: string;
action: string;
status: string;
deviceId: string;
skillId?: string;
sourceUrl?: string;
targetVersion?: string;
rollbackToVersion?: string;
lockedVersion?: string;
requestedAt?: string;
completedAt?: string;
resultSummary?: string;
error?: string;
}
export interface BossAdminSkillLifecycleRequestsPayload {
ok: boolean;
requests: BossAdminSkillLifecycleRequest[];
}
async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(url, {
credentials: "include",
...init,
headers: {
Accept: "application/json",
...(init.body ? { "Content-Type": "application/json" } : {}),
...(init.headers ?? {}),
},
});
if (response.status === 401) {
window.location.href = "/auth/login";
throw new Error("UNAUTHORIZED");
}
if (!response.ok) {
const payload = await response.json().catch(() => null);
throw new Error(payload?.message ?? `HTTP_${response.status}`);
}
return response.json();
}
export async function fetchBossAdminBackoffice(scope: "platform" | "enterprise" = "platform"): Promise<BossAdminBackofficePayload> {
return requestJson<BossAdminBackofficePayload>(`/api/v1/admin/backoffice?scope=${scope}`);
}
export async function postAdminAccess(payload: Record<string, unknown>) {
return requestJson<Record<string, unknown>>("/api/v1/admin/access", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function postRiskAction(payload: Record<string, unknown>) {
return requestJson<Record<string, unknown>>("/api/v1/admin/risks/actions", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function postSkillLifecycleRequest(payload: Record<string, unknown>) {
return requestJson<Record<string, unknown>>("/api/v1/admin/skills/requests", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function fetchSkillLifecycleRequests(): Promise<BossAdminSkillLifecycleRequestsPayload> {
return requestJson<BossAdminSkillLifecycleRequestsPayload>("/api/v1/admin/skills/requests", {
method: "GET",
});
}
export async function postDeviceCodexRemoteControl(
deviceId: string,
payload: { action: "start" | "stop"; reason?: string },
) {
return requestJson<Record<string, unknown>>(
`/api/v1/devices/${encodeURIComponent(deviceId)}/codex-remote-control`,
{
method: "POST",
body: JSON.stringify({
...payload,
confirmed: true,
}),
},
);
}
export async function fetchAdminBackups(): Promise<BossAdminBackupsPayload> {
return requestJson<BossAdminBackupsPayload>("/api/v1/admin/backups");
}
export async function createAdminBackup(reason: string) {
return requestJson<Record<string, unknown>>("/api/v1/admin/backups", {
method: "POST",
body: JSON.stringify({
action: "create_snapshot",
reason,
}),
});
}
export async function restoreAdminBackup(snapshotId: string) {
return requestJson<Record<string, unknown>>("/api/v1/admin/backups", {
method: "POST",
body: JSON.stringify({
action: "restore_snapshot",
snapshotId,
}),
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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