Compare commits
94 Commits
codex/herm
...
codex/wech
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4b5350afa | ||
|
|
3b51641d99 | ||
|
|
bddbe8b5ba | ||
|
|
cfd41b4fbf | ||
|
|
58cc4a1a5a | ||
|
|
1ec6d003d1 | ||
|
|
1edfa6ecd5 | ||
|
|
643da5b738 | ||
|
|
755e30612c | ||
|
|
7973c441e4 | ||
|
|
a7e4b96ce3 | ||
|
|
9e81d8a960 | ||
|
|
684b98c5c1 | ||
|
|
e4e6f6597a | ||
|
|
4e2636ec8b | ||
|
|
9807c7a275 | ||
|
|
eb8961fc3f | ||
|
|
a38b3a3093 | ||
|
|
6f143ea6f9 | ||
|
|
025e749618 | ||
|
|
b93bc22160 | ||
|
|
63338c3d76 | ||
|
|
a5d44b0cac | ||
|
|
3080f57dbc | ||
|
|
0eaf78c3c2 | ||
|
|
5bf2216cb0 | ||
|
|
de9f85bd21 | ||
|
|
dbdaab8d0f | ||
|
|
0c3437a36f | ||
|
|
5537fde7a6 | ||
|
|
0186ef7057 | ||
|
|
cc31b0d836 | ||
|
|
0bcdcbfb9d | ||
|
|
0fb588e339 | ||
|
|
7a30c2a8d9 | ||
|
|
13201e6aee | ||
|
|
142fb2a4b3 | ||
|
|
bc9a586e81 | ||
|
|
b31238b6e2 | ||
|
|
f23ed9f188 | ||
|
|
afeb352fe3 | ||
|
|
ca64a4c498 | ||
|
|
0fdee4bcf7 | ||
|
|
21e514a895 | ||
|
|
ca92133019 | ||
|
|
b0526215c5 | ||
|
|
0071dec860 | ||
|
|
3c6a0c546b | ||
|
|
1ae81fa3af | ||
|
|
74b333ba2f | ||
|
|
c0c88444ec | ||
|
|
88b028ad2b | ||
|
|
94e0cc8bad | ||
|
|
b0a778ee68 | ||
|
|
32a9c9a26a | ||
|
|
5d62560217 | ||
|
|
2ca2737520 | ||
|
|
2a5dccf5cb | ||
|
|
defa3da185 | ||
|
|
26b5e97614 | ||
|
|
591638f35f | ||
|
|
cee1e7938e | ||
|
|
f333676c36 | ||
|
|
4800352e22 | ||
|
|
b9d3cca2e7 | ||
|
|
e1aed590f8 | ||
|
|
67511c31f4 | ||
|
|
04505da747 | ||
|
|
a2d6dbd012 | ||
|
|
a77c70ad0c | ||
|
|
a6d57b683a | ||
|
|
1ac9472c44 | ||
|
|
feba68ac2b | ||
|
|
842c2249a1 | ||
|
|
73327be8b0 | ||
|
|
2ff75087b3 | ||
|
|
8d3f68cebe | ||
|
|
29740f35c7 | ||
|
|
5b3f43014d | ||
|
|
315cc5cd54 | ||
|
|
7c371ed644 | ||
|
|
b12a1c7401 | ||
|
|
1c1140b1fd | ||
|
|
4de64ac01c | ||
|
|
bc199dcf5c | ||
|
|
9c8ffebb92 | ||
|
|
a311280238 | ||
|
|
0757d07521 | ||
|
|
2c719168b6 | ||
|
|
ba83fe0aed | ||
|
|
916528de2b | ||
|
|
a9ed7c911d | ||
|
|
bb237fdd4f | ||
|
|
449f84fcbc |
15
.gitignore
vendored
15
.gitignore
vendored
@@ -19,13 +19,28 @@
|
||||
|
||||
# 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
|
||||
|
||||
89
README.md
89
README.md
@@ -10,8 +10,10 @@
|
||||
2. `docs/architecture/repo_map_cn.md`
|
||||
3. `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
4. `docs/architecture/api_and_service_inventory_cn.md`
|
||||
5. `docs/architecture/boss_server_connection_and_deploy_cn.md`
|
||||
6. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md`
|
||||
5. `docs/architecture/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`
|
||||
|
||||
## 当前有效目录
|
||||
|
||||
@@ -53,17 +55,52 @@
|
||||
- `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` runner:boss-agent 默认打开 `codexAppServerEnabled`,通过 `codex app-server` stdio 接入 `conversation_reply / dispatch_execution`,也可灰度切到 `ws://127.0.0.1:<port>` 或 `unix://PATH` 本机长驻 App Server;WebSocket/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`
|
||||
|
||||
服务器:
|
||||
@@ -103,12 +140,14 @@ Android APK:
|
||||
- `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
|
||||
- 当前最新 release 构建版本:`2.5.11`(`versionCode=24`)
|
||||
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
|
||||
- 真机开发约束:用户已明确切换到当前连接的 OPPO `PHZ110`(ADB serial `U84XJRIB7D65ZH45`);除非用户再次要求切换设备,后续 Android 开发、ADB 安装、交互回归与问题复现统一使用这台 OPPO,不再回退到原 `PLB110`
|
||||
- OPPO 权限回归建议命令:`adb -s U84XJRIB7D65ZH45 devices -l`、`./gradlew :app:assembleDebug`、`adb -s U84XJRIB7D65ZH45 install -r android/app/build/outputs/apk/debug/app-debug.apk`、`adb -s U84XJRIB7D65ZH45 shell am start -W -n com.hyzq.boss/.MainActivity -e initial_tab me`,再从 `我的 > 用户与权限` 确认最高管理员可进入权限页。
|
||||
- Android 真机无线调试如果要尽量稳定,优先使用“同一局域网 + 初次 USB 启用后执行 `adb tcpip 5555` + `adb connect <phone-ip>:5555`”这条链路;它通常比只依赖系统“无线调试配对码”更稳
|
||||
- Android 系统层面对“无线调试”没有真正的永久不掉线开关;重启手机、切 Wi‑Fi、切热点、ADB server 重启、USB 调试被重新切换后,都可能导致无线调试自动失效
|
||||
- 真机调试时建议固定同一 SSID、避免代理/VPN 改路、开发者选项里开启“保持唤醒”,并在需要长时间稳定调试时优先保留 USB 兜底;如果必须完全避免自动断开,不要只依赖无线调试
|
||||
- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、会话信息、群资料、发起群聊、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、主 Agent 提示词 / 记忆、技能、运维中心、关于
|
||||
- 当前原生一级体验已回退到微信式交互:`会话 / 设备 / 我的` 固定底部 tab,会话首页是简单聊天列表,`主 Agent / 审计对话` 以普通置顶会话样式排在最前;项目详情页是聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口
|
||||
- 当前会话首页右上角已切回 `+` 入口:直接从首页发起独立群聊;设备页右上角仍是 `+添加`
|
||||
- 当前会话首页右上角已切回 `+` 入口:直接从首页发起独立群聊;设备页右上角 `+添加` 仅最高管理员可见,子账号只保留刷新。
|
||||
- 当前会话首页已升级成“项目聚合 + 线程下钻”的结构:如果某个 Codex 文件夹只导入了 1 个线程,会话列表直接显示这个线程;如果同一文件夹导入了多个线程,会话首页只显示该文件夹归档项,点进去再看这个项目下的全部线程
|
||||
- 当前会话首页的数据源已分成两层:`/api/v1/conversations` 继续保留平铺线程视图给群聊创建、转发等内部能力使用;首页和原生根页改走 `/api/v1/conversations/home`,文件夹归档详情走 `/api/v1/conversation-folders/[folderKey]`
|
||||
- 当前会话搜索仍然保留线程可达性:如果命中单线程项目,会直接进入该线程;如果命中多线程项目里的某条线程,结果会显示 `项目 / 线程`,点击后先进入项目文件夹页并定位到对应线程,不会把首页重新打平成线程列表
|
||||
@@ -147,6 +186,7 @@ Android APK:
|
||||
- 当前设备导入 `review` 已补 owner/admin 鉴权,并改成真正的异步审核链:`review` 只负责排队 `device_import_resolution` 任务并返回 queued 状态,等 local-agent 完成回写后才把决议写回草稿和会话账本
|
||||
- 当前原生 APP 会话页的“刷新失败”已按当前 tab 的主数据源独立判错:`会话` 只看会话请求本身,`设备` 只看设备请求,`我的` 只在 `settings + ota` 同时失败时才提示刷新失败
|
||||
- 当前 `设备` 和 `我的` 根页已收口为简单列表;`运维与修复 / AI 账号 / 技能` 保留在一级 `我的`,`审计对话` 作为置顶会话保留在会话首页
|
||||
- 当前原生 `我的` 根页已开始按登录角色过滤入口:`member` 只显示个人安全、设置、已授权 Skill 和关于;`admin / highest_admin` 才显示运维、AI 账号、附件存储和 Telegram 管理入口;`用户与权限` 仅 `highest_admin` 可见
|
||||
- 原生客户端当前直接调用 `https://boss.hyzq.net` 的 Boss API,不再打开 WebView
|
||||
- `2.0.1` 已修复华为真机上因 `Theme.SplashScreen` 与 `AppCompatActivity` 不兼容导致的启动闪退
|
||||
- `2.1.0` 已在本机连接的华为真机上完成签名包覆盖安装与启动复核,原生三栏入口和子活动页声明已全部接通
|
||||
@@ -159,6 +199,7 @@ Android APK:
|
||||
- `2.5.1` 继续收口微信式原生 UI:聊天页普通态顶部已隐藏刷新按钮,只保留右上角“信息”;发起群聊页顶部说明和选择区已压成更轻的会话式密度,候选线程继续复用微信式会话卡片
|
||||
- `2.5.2` 继续补齐深层原生页:`项目目标 / 版本迭代记录 / 会话信息 / 群资料` 已进一步向设计图收口;附件消息卡片的分析状态和动作文案也压成了更轻的微信式层级
|
||||
- `2.5.4` 已把 `我的` 根页收口成微信式资料区 + 白底菜单列表,并同步把 `设置 / 账号与安全 / AI 账号 / 技能 / 运维与修复` 的顶部说明从重 `soft panel` 降成轻量列表说明
|
||||
- `2.5.11` 已补齐第一批遗漏功能:聊天长按“删除”接通服务端账本删除与实时刷新;原生 `我的 > 附件与存储` 可直接切换服务器文件存储 / 阿里 OSS;后台通知覆盖所有会话里的主 Agent 回复;browser/desktop runtime 未配置时改为明确失败而不是占位成功
|
||||
- `2.5.5` 已补上群资料页的“修复群成员”主链:历史脏群会明确提示失效成员,并允许重新选择真实线程成员写回群资料;`approval_required` 群聊也已补齐“确认 / 拒绝”两条审批动作
|
||||
|
||||
## 本地启动
|
||||
@@ -188,6 +229,7 @@ npm start
|
||||
- 登录页:[http://127.0.0.1:3000/auth/login](http://127.0.0.1:3000/auth/login)
|
||||
- 会话页:[http://127.0.0.1:3000/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` 仅保留为跳转到根域的兼容入口
|
||||
|
||||
## 设备端本地服务
|
||||
|
||||
@@ -212,6 +254,23 @@ 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 额度和项目列表
|
||||
@@ -221,6 +280,8 @@ device-agent 当前职责:
|
||||
- 轮询云端 `/api/v1/master-agent/tasks/claim`,并用当前电脑已登录的 `codex` 账号执行主 Agent 任务
|
||||
- 将主 Agent 执行结果回写到云端 `/api/v1/master-agent/tasks/[taskId]/complete`
|
||||
- 对普通单线程会话,认领到的 `conversation_reply` 任务会直接恢复到目标 Codex 线程,并把线程原始回复回写到对应聊天窗口
|
||||
- 对已绑定 `codexThreadRef` 的普通单线程会话,`local-agent` 会在执行 `codex exec resume` 前先把 Boss App 里的用户消息镜像进目标 Codex Desktop 线程 rollout,避免 APP 和桌面版同线程历史割裂;定位 rollout 时优先用 `state_5.sqlite`,不可用时回退扫描 `~/.codex/sessions`,并尽量刷新线程活跃时间。镜像成功后会优先调用本机常驻 `Codex Desktop Bridge` endpoint,再打开 `codex://threads/{threadId}` 并发送一次安全刷新提示,让桌面版切到目标线程后重新读取记录;endpoint 不可用时回退原命令式刷新。刷新桥默认对短暂失败重试 2 次、间隔 120ms,并保留 deep link 与尝试次数,便于追踪桌面同步是否真正触发。bridge 同时提供 `GET /api/v1/codex-desktop/events` SSE 和 recent 缓冲,后续 Codex Desktop 插件可直接订阅安全元数据事件;`scripts/codex-desktop-event-consumer.mjs` 可作为本机订阅 smoke
|
||||
- `scripts/codex-desktop-integration-probe.mjs` 可探测本机 Codex Desktop 能力,bridge 也提供 `GET /api/v1/codex-desktop/capabilities`;探测只读 `Info.plist` 和 app 资源,明确不修改 Codex.app 签名包体
|
||||
- 对群聊线程分发任务,认领到的 `dispatch_execution` 任务会把原始线程结果和主 Agent 汇总一起回写到群聊消息账本
|
||||
- `local-agent` 对 `conversation_reply` 当前会优先使用 `codex exec resume <targetCodexThreadRef>`,只有缺失真实线程引用时才退回 `--ephemeral`
|
||||
- `local-agent` 对 `dispatch_execution` 当前会按 `orchestrationBackendId` 分流:默认继续走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行
|
||||
@@ -232,7 +293,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 审核中”并自动刷新,审核失败时保留当前勾选以便重新生成
|
||||
- 提供本地 `/health`、`/api/v1/device`、`/api/v1/skills`、`/api/v1/heartbeat`
|
||||
- 提供本地 `/boss-agent`、`/api/v1/boss-agent/status`、`/api/v1/boss-agent/ota/check`、`/api/v1/boss-agent/ota/apply`、`/health`、`/api/v1/device`、`/api/v1/skills`、`/api/v1/heartbeat`
|
||||
|
||||
当前常驻默认值:
|
||||
|
||||
@@ -249,6 +310,8 @@ device-agent 当前职责:
|
||||
- APK 发布脚本:`scripts/publish-apk-to-public.sh`
|
||||
- `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`
|
||||
@@ -309,6 +372,7 @@ 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`
|
||||
@@ -343,21 +407,21 @@ npm run aab:release
|
||||
- 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;因此会话页会自动出现这台设备当前真实运行的 Codex 线程窗口
|
||||
- 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;如果某个项目下存在多个线程,会话首页会先显示项目归档项,而不是把所有线程平铺在首页
|
||||
- 对已经绑定的生产设备,如果某些自动导入线程已经不再出现在最新 `projectCandidates[]` 中,服务端会在下一次 heartbeat 自动清理这些过时会话,避免旧线程长期滞留首页
|
||||
- 登录页当前已临时切到免验证模式,点击“登录”会直接进入会话首页
|
||||
- 认证现在已经有最小会话链路:登录后会写入 `boss_session` Cookie,默认保持 30 天,`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 接口都要求有效会话
|
||||
- 认证现在已经有最小会话链路:登录后会写入 `boss_session` Cookie,默认保持 30 天,`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 接口都要求有效会话;临时免验证登录默认关闭,仅在显式设置 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 时启用
|
||||
- 新增 `GET /api/auth/session`、`POST /api/auth/logout` 与 `POST /api/auth/restore`
|
||||
- 当前同一账号已经支持多个登录端并存;Web 与原生 Android 的 `我的 > 账号与安全` 可查看和撤销登录会话,最高管理员可以管理所有活跃会话
|
||||
- 原生 Android 客户端当前会把 `boss_session / restore token / account` 存到 `SharedPreferences`,用于重启后恢复会话
|
||||
- 验证码新增防刷与防重放:60 秒冷却、15 分钟窗口限流,登录连续失败 5 次后会锁定 10 分钟
|
||||
- `POST /api/auth/send-code` 现在会先按用途校验账号状态:登录 / 忘记密码要求账号已存在,注册要求账号尚未注册
|
||||
- 当前登录页已临时放开成“一键进入”,账号密码和验证码输入暂时不作为拦截条件
|
||||
- `POST /api/auth/send-code` 与固定验证码 `000000` 仍保留给注册 / 重置密码和后续认证收口,不作为当前登录页前置条件
|
||||
- 当前登录页默认走账号密码或验证码校验,不再把开发兜底作为生产默认能力
|
||||
- `POST /api/auth/send-code` 当前仍支持 fixed 模式,但验证码登录也必须先申请验证码并消费账本里的有效记录;不能只靠固定码直接登录
|
||||
- 新注册和重置密码现在使用 `scrypt` 哈希;历史 `sha256` 密码会在下一次密码登录时自动迁移
|
||||
- 原生 Android 当前把 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等复杂能力下沉到二级或更深层入口,不再把线程预算 / 转发 / 运维说明堆在主聊天页和一级我的页
|
||||
- 原生 OTA 当前除了整包下载和系统安装器拉起,还会在关于页保留本地下载状态;离开关于页再回来时,仍能看到进行中 / 失败 / 待授权 / 可安装状态
|
||||
- Android 本地 Gradle 验证当前必须串行执行,避免并发 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug` 相互踩坏中间产物
|
||||
- 当前默认最高管理员账号:`17600003315`
|
||||
- 当前默认测试密码:`boss123456`
|
||||
- 当前本机 Codex 节点 `mac-studio` 已绑定到 `17600003315`
|
||||
- 当前默认最高管理员账号:`krisolo`
|
||||
- 当前默认测试密码由线上初始化配置管理,文档不再明文记录
|
||||
- 当前本机 Codex 节点 `mac-studio` 已绑定到 `krisolo`
|
||||
- 主 Agent 对话当前真实执行链路是:`Boss Web -> 写入用户消息 -> 返回 queued/running -> master-agent task queue -> local-agent / OpenAI API -> complete task -> project ledger`
|
||||
- `master-agent` 单聊当前已改成“快速入队 + 异步回流”:发送后会立即返回任务包和 `masterReplyState`,前台先显示“主 Agent 思考中”,真实回复稍后自动回写到账本
|
||||
- 原生 Android 当前会把 `master-agent` 的等待态保留在消息流里:发送后常驻显示“主 Agent 思考中”,超时后改成“主 Agent 回复超时 + 重试等待”,收到新回复后会自动清掉,不再只靠 toast 提示
|
||||
@@ -368,7 +432,8 @@ npm run aab:release
|
||||
- 应用内 `GET /api/v1/user/ota` / `POST /api/v1/user/ota` / `GET /api/v1/user/ota/package` 现在已经支持 OTA 状态、检查更新、执行升级和 APK 包下载
|
||||
- `GET /api/v1/app-logs` 现在已支持登录态下按 `deviceId / projectId / level / category / source / cursor` 查询日志分页
|
||||
- 设备写接口 `POST /api/v1/app-logs`、`POST /api/v1/devices/[deviceId]/skills`、`POST /api/v1/workers/[workerId]/thread-context` 现在都要求有效设备 token 或匹配登录会话
|
||||
- 当前认证仍是 MVP:已有最小会话 Cookie,但还没有刷新令牌、跨端会话治理、吊销审计和 CSRF 防护
|
||||
- 当前认证已具备最小会话 Cookie、restore token 轮换、浏览器 CSRF 基础防护、子账号 MFA 开关、基础跨端会话治理和后台高危操作审计;后续仍可继续补企业 SSO / IdP
|
||||
- 当前状态存储默认继续使用 `data/boss-state.json`;已新增 `BOSS_STATE_STORE=postgres` 适配层,生产切换 PostgreSQL 时必须配置 `BOSS_DATABASE_URL`,并先使用 `scripts/boss-state-store-maintenance.mjs` 做备份、dry-run 迁移和回滚演练
|
||||
- 聊天附件当前已支持真实上传、消息落账本、受保护下载和原生打开;默认存储后端为服务器文件存储
|
||||
- 当前用户已可在 `我的 > 附件与存储` 切到阿里 OSS 私有桶,下载链会按附件快照生成签名地址,避免用户后续修改配置后旧附件失效
|
||||
- 图片 / PDF / 文本默认自动进入主 Agent 附件分析;视频 / Office / 大文件默认手动触发
|
||||
|
||||
@@ -2,15 +2,25 @@
|
||||
<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:theme="@style/AppTheme"
|
||||
android:forceDarkAllowed="false">
|
||||
|
||||
<service
|
||||
android:name=".BossBackgroundRealtimeService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||
@@ -48,8 +58,11 @@
|
||||
<activity android:name=".DeviceImportDraftActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".SkillInventoryActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".SecurityActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".AccessManagementActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".SettingsActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".StorageSettingsActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".AiAccountsActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".TelegramIntegrationActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".OpenAiOnboardingActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".MasterAgentPromptActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".MasterAgentTakeoverActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
|
||||
@@ -15,6 +15,7 @@ import android.provider.Settings;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
@@ -76,11 +77,7 @@ public class AboutActivity extends BossScreenActivity {
|
||||
restoreDownloadUiState();
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
IntentFilter filter = new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
registerReceiver(otaDownloadReceiver, filter, Context.RECEIVER_NOT_EXPORTED);
|
||||
} else {
|
||||
registerReceiver(otaDownloadReceiver, filter);
|
||||
}
|
||||
ContextCompat.registerReceiver(this, otaDownloadReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED);
|
||||
reload();
|
||||
}
|
||||
|
||||
@@ -491,11 +488,9 @@ public class AboutActivity extends BossScreenActivity {
|
||||
persistDownloadUiState();
|
||||
refreshDownloadStateSection();
|
||||
|
||||
if (!getPackageManager().canRequestPackageInstalls()) {
|
||||
if (!canInstallDownloadedPackages()) {
|
||||
showMessage("请先允许 Boss 安装未知来源应用,然后重新打开安装包。");
|
||||
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
openUnknownAppSourcesSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -566,7 +561,7 @@ public class AboutActivity extends BossScreenActivity {
|
||||
return OtaDownloadStateMapper.failed(fileName);
|
||||
}
|
||||
if (downloadedApkUri != null) {
|
||||
if (!getPackageManager().canRequestPackageInstalls()) {
|
||||
if (!canInstallDownloadedPackages()) {
|
||||
return OtaDownloadStateMapper.waitingInstallPermission(fileName);
|
||||
}
|
||||
return OtaDownloadStateMapper.readyToInstall(fileName);
|
||||
@@ -580,9 +575,7 @@ public class AboutActivity extends BossScreenActivity {
|
||||
downloadLatestApk();
|
||||
break;
|
||||
case OPEN_INSTALL_PERMISSION:
|
||||
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
openUnknownAppSourcesSettings();
|
||||
break;
|
||||
case INSTALL_APK:
|
||||
installDownloadedApk();
|
||||
@@ -598,11 +591,9 @@ public class AboutActivity extends BossScreenActivity {
|
||||
showMessage("当前没有可安装的更新包");
|
||||
return;
|
||||
}
|
||||
if (!getPackageManager().canRequestPackageInstalls()) {
|
||||
if (!canInstallDownloadedPackages()) {
|
||||
showMessage("请先开启安装未知来源应用权限");
|
||||
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()));
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
openUnknownAppSourcesSettings();
|
||||
return;
|
||||
}
|
||||
Intent installIntent = new Intent(Intent.ACTION_VIEW);
|
||||
@@ -622,6 +613,19 @@ public class AboutActivity extends BossScreenActivity {
|
||||
return "boss-android-latest.apk";
|
||||
}
|
||||
|
||||
private boolean canInstallDownloadedPackages() {
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.O
|
||||
|| getPackageManager().canRequestPackageInstalls();
|
||||
}
|
||||
|
||||
private void openUnknownAppSourcesSettings() {
|
||||
Intent intent = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
? new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()))
|
||||
: new Intent(Settings.ACTION_SECURITY_SETTINGS);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void restoreDownloadUiState() {
|
||||
android.content.SharedPreferences prefs = getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE);
|
||||
activeDownloadId = prefs.getLong(KEY_ACTIVE_DOWNLOAD_ID, -1L);
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.app.AlertDialog;
|
||||
import android.os.Bundle;
|
||||
import android.text.InputType;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.Spinner;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
public class AccessManagementActivity extends BossScreenActivity {
|
||||
@Nullable private JSONObject accessPayload;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("用户与权限", "子账号、设备、项目与 Skill");
|
||||
setHeaderAction("新增", v -> showAccountDialog());
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getAdminAccess();
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> renderAccess(response.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "权限配置加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderAccess(JSONObject payload) {
|
||||
accessPayload = payload;
|
||||
JSONArray accounts = payload.optJSONArray("accounts");
|
||||
JSONArray devices = payload.optJSONArray("devices");
|
||||
JSONArray projects = payload.optJSONArray("projects");
|
||||
JSONArray skills = payload.optJSONArray("skills");
|
||||
JSONArray skillCatalog = payload.optJSONArray("skillCatalog");
|
||||
JSONArray permissionTemplates = payload.optJSONArray("permissionTemplates");
|
||||
|
||||
replaceContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"权限总览",
|
||||
"子账号 " + lengthOf(accounts) + " 个 · 设备 " + lengthOf(devices) + " 台 · 项目 " + lengthOf(projects) + " 个",
|
||||
"Skill 类目 " + lengthOf(skillCatalog) + " 类 · 设备 Skill 实例 " + lengthOf(skills) + " 个",
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
Button accountButton = BossUi.buildMiniActionButton(this, "创建子账号", true);
|
||||
Button deviceButton = BossUi.buildMiniActionButton(this, "授权设备", false);
|
||||
Button projectButton = BossUi.buildMiniActionButton(this, "授权项目", false);
|
||||
Button skillButton = BossUi.buildMiniActionButton(this, "分配 Skill", false);
|
||||
Button templateButton = BossUi.buildMiniActionButton(this, "套用模板", true);
|
||||
accountButton.setOnClickListener(v -> showAccountDialog());
|
||||
deviceButton.setOnClickListener(v -> showDeviceGrantDialog());
|
||||
projectButton.setOnClickListener(v -> showProjectGrantDialog());
|
||||
skillButton.setOnClickListener(v -> showSkillGrantDialog());
|
||||
templateButton.setOnClickListener(v -> showTemplateGrantDialog());
|
||||
appendContent(buildActionRow(accountButton, deviceButton));
|
||||
appendContent(buildActionRow(projectButton, skillButton));
|
||||
if (!isEmpty(permissionTemplates)) {
|
||||
Button refreshAccessButton = BossUi.buildMiniActionButton(this, "刷新权限", false);
|
||||
refreshAccessButton.setOnClickListener(v -> reload());
|
||||
appendContent(buildActionRow(templateButton, refreshAccessButton));
|
||||
templateButton.setOnClickListener(v -> showTemplateGrantDialog());
|
||||
}
|
||||
|
||||
if (!isEmpty(permissionTemplates)) {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"权限模板",
|
||||
lengthOf(permissionTemplates) + " 个模板可用",
|
||||
"一次性给账号分配设备、项目和 Skill 权限",
|
||||
null,
|
||||
v -> showTemplateGrantDialog()
|
||||
));
|
||||
} else {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"暂无权限模板",
|
||||
"模板列表为空,仍可使用单项授权。",
|
||||
"等待服务端同步只读观察员、项目开发者、设备操作者等模板。",
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
appendUnavailableTargetHints(devices, projects, skills);
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"已配置账号",
|
||||
summarizeAccounts(accounts),
|
||||
"点击右上角新增,可创建或更新子账号",
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
JSONArray deviceGrants = grantsArray(payload, "devices");
|
||||
JSONArray projectGrants = grantsArray(payload, "projects");
|
||||
JSONArray skillGrants = grantsArray(payload, "skills");
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"当前授权",
|
||||
"设备 " + lengthOf(deviceGrants) + " 条 · 项目 " + lengthOf(projectGrants) + " 条 · Skill " + lengthOf(skillGrants) + " 条",
|
||||
"点击授权记录可撤销当前这条授权",
|
||||
null,
|
||||
null
|
||||
));
|
||||
appendGrantRows(deviceGrants, "设备");
|
||||
appendGrantRows(projectGrants, "项目");
|
||||
appendGrantRows(skillGrants, "Skill");
|
||||
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void appendUnavailableTargetHints(JSONArray devices, JSONArray projects, JSONArray skills) {
|
||||
if (isEmpty(devices)) {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"暂无可授权设备",
|
||||
"设备列表为空,无法分配 device.view 或 computer.control。",
|
||||
"请先完成设备绑定或等待授权范围刷新。",
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
if (isEmpty(projects)) {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"暂无可授权项目",
|
||||
"项目列表为空,无法分配 project.view、thread.chat 或主 Agent 协同。",
|
||||
"请先导入项目或等待设备线程同步。",
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
if (isEmpty(skills)) {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"暂无可分配 Skill",
|
||||
"Skill 实例为空,无法分配 skill.use。",
|
||||
"请确认 local-agent 已同步 ~/.codex/skills。",
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private LinearLayout buildActionRow(Button left, Button right) {
|
||||
LinearLayout row = new LinearLayout(this);
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
row.setPadding(BossUi.dp(this, 12), 0, BossUi.dp(this, 12), BossUi.dp(this, 10));
|
||||
LinearLayout.LayoutParams rowParams = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
row.setLayoutParams(rowParams);
|
||||
LinearLayout.LayoutParams leftParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f);
|
||||
LinearLayout.LayoutParams rightParams = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f);
|
||||
rightParams.leftMargin = BossUi.dp(this, 8);
|
||||
row.addView(left, leftParams);
|
||||
row.addView(right, rightParams);
|
||||
return row;
|
||||
}
|
||||
|
||||
private void appendGrantRows(JSONArray grants, String scopeLabel) {
|
||||
if (grants == null || grants.length() == 0) {
|
||||
return;
|
||||
}
|
||||
int max = Math.min(8, grants.length());
|
||||
for (int index = 0; index < max; index += 1) {
|
||||
JSONObject grant = grants.optJSONObject(index);
|
||||
if (grant == null) {
|
||||
continue;
|
||||
}
|
||||
String grantId = grant.optString("grantId", "");
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
scopeLabel + "授权 · " + grant.optString("account", ""),
|
||||
grantTargetSummary(grant),
|
||||
joinJsonArray(grant.optJSONArray("permissions")),
|
||||
null,
|
||||
TextUtils.isEmpty(grantId) ? null : v -> confirmRevoke(grantId)
|
||||
));
|
||||
}
|
||||
if (grants.length() > max) {
|
||||
appendContent(BossUi.buildHintPill(this, "还有 " + (grants.length() - max) + " 条授权未展开,可在 Web 端查看完整审计。"));
|
||||
}
|
||||
}
|
||||
|
||||
private void showAccountDialog() {
|
||||
LinearLayout form = buildDialogForm();
|
||||
EditText accountInput = BossUi.buildInput(this, "子账号,例如 worker@example.com", false);
|
||||
EditText displayInput = BossUi.buildInput(this, "显示名", false);
|
||||
EditText passwordInput = BossUi.buildInput(this, "初始密码 / 新密码", false);
|
||||
passwordInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
Spinner roleSpinner = spinnerWith(new String[]{"成员", "管理员"});
|
||||
form.addView(BossUi.buildFormCell(this, "账号", null, accountInput));
|
||||
form.addView(BossUi.buildFormCell(this, "显示名", null, displayInput));
|
||||
form.addView(BossUi.buildFormCell(this, "角色", "最高管理员不在手机端创建,避免误提权。", roleSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "密码", "创建账号时必填;更新账号时留空表示不改密码。", passwordInput));
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("创建 / 更新子账号")
|
||||
.setView(form)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("保存", (dialog, which) -> {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("action", "upsert_account");
|
||||
payload.put("account", accountInput.getText().toString().trim());
|
||||
payload.put("displayName", displayInput.getText().toString().trim());
|
||||
payload.put("password", passwordInput.getText().toString());
|
||||
payload.put("role", roleSpinner.getSelectedItemPosition() == 1 ? "admin" : "member");
|
||||
runAdminAction(payload);
|
||||
} catch (JSONException error) {
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
}
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private void showDeviceGrantDialog() {
|
||||
JSONObject payload = requireAccessPayload();
|
||||
if (payload == null) return;
|
||||
JSONArray accounts = payload.optJSONArray("accounts");
|
||||
JSONArray devices = payload.optJSONArray("devices");
|
||||
if (isEmpty(accounts) || isEmpty(devices)) {
|
||||
showMessage("需要先有账号和设备。");
|
||||
return;
|
||||
}
|
||||
LinearLayout form = buildDialogForm();
|
||||
Spinner accountSpinner = spinnerWith(labelsFor(accounts, "account", "displayName"));
|
||||
Spinner deviceSpinner = spinnerWith(labelsFor(devices, "id", "name"));
|
||||
Spinner permissionSpinner = spinnerWith(new String[]{"只读查看", "管理设备", "允许电脑控制"});
|
||||
form.addView(BossUi.buildFormCell(this, "账号", null, accountSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "设备", null, deviceSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "权限模板", null, permissionSpinner));
|
||||
confirmGrant("授权设备", form, () -> {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("action", "grant_device");
|
||||
body.put("account", valueAt(accounts, accountSpinner.getSelectedItemPosition(), "account"));
|
||||
body.put("deviceId", valueAt(devices, deviceSpinner.getSelectedItemPosition(), "id"));
|
||||
body.put("permissions", new JSONArray(permissionSpinner.getSelectedItemPosition() == 1
|
||||
? Arrays.asList("device.view", "device.manage")
|
||||
: permissionSpinner.getSelectedItemPosition() == 2
|
||||
? Arrays.asList("device.view", "computer.control")
|
||||
: Arrays.asList("device.view")));
|
||||
return body;
|
||||
});
|
||||
}
|
||||
|
||||
private void showProjectGrantDialog() {
|
||||
JSONObject payload = requireAccessPayload();
|
||||
if (payload == null) return;
|
||||
JSONArray accounts = payload.optJSONArray("accounts");
|
||||
JSONArray projects = payload.optJSONArray("projects");
|
||||
if (isEmpty(accounts) || isEmpty(projects)) {
|
||||
showMessage("需要先有账号和项目。");
|
||||
return;
|
||||
}
|
||||
LinearLayout form = buildDialogForm();
|
||||
Spinner accountSpinner = spinnerWith(labelsFor(accounts, "account", "displayName"));
|
||||
Spinner projectSpinner = spinnerWith(labelsFor(projects, "id", "name"));
|
||||
Spinner permissionSpinner = spinnerWith(new String[]{"只读查看", "允许聊天", "主 Agent 协同", "电脑控制"});
|
||||
form.addView(BossUi.buildFormCell(this, "账号", null, accountSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "项目", null, projectSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "权限模板", null, permissionSpinner));
|
||||
confirmGrant("授权项目", form, () -> {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("action", "grant_project");
|
||||
body.put("account", valueAt(accounts, accountSpinner.getSelectedItemPosition(), "account"));
|
||||
body.put("projectId", valueAt(projects, projectSpinner.getSelectedItemPosition(), "id"));
|
||||
body.put("permissions", projectPermissionsFor(permissionSpinner.getSelectedItemPosition()));
|
||||
return body;
|
||||
});
|
||||
}
|
||||
|
||||
private void showSkillGrantDialog() {
|
||||
JSONObject payload = requireAccessPayload();
|
||||
if (payload == null) return;
|
||||
JSONArray accounts = payload.optJSONArray("accounts");
|
||||
JSONArray skills = payload.optJSONArray("skills");
|
||||
if (isEmpty(accounts) || isEmpty(skills)) {
|
||||
showMessage("需要先有账号和已同步 Skill。");
|
||||
return;
|
||||
}
|
||||
LinearLayout form = buildDialogForm();
|
||||
Spinner accountSpinner = spinnerWith(labelsFor(accounts, "account", "displayName"));
|
||||
Spinner skillSpinner = spinnerWith(labelsFor(skills, "skillId", "name"));
|
||||
Spinner permissionSpinner = spinnerWith(new String[]{"可调用", "可管理"});
|
||||
form.addView(BossUi.buildFormCell(this, "账号", null, accountSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "Skill", null, skillSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "权限模板", null, permissionSpinner));
|
||||
confirmGrant("分配 Skill", form, () -> {
|
||||
JSONObject skill = skills.optJSONObject(skillSpinner.getSelectedItemPosition());
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("action", "grant_skill");
|
||||
body.put("account", valueAt(accounts, accountSpinner.getSelectedItemPosition(), "account"));
|
||||
body.put("skillId", skill == null ? "" : skill.optString("skillId", ""));
|
||||
body.put("deviceId", skill == null ? "" : skill.optString("deviceId", ""));
|
||||
body.put("permissions", new JSONArray(permissionSpinner.getSelectedItemPosition() == 1
|
||||
? Arrays.asList("skill.view", "skill.use", "skill.manage")
|
||||
: Arrays.asList("skill.view", "skill.use")));
|
||||
return body;
|
||||
});
|
||||
}
|
||||
|
||||
private void showTemplateGrantDialog() {
|
||||
JSONObject payload = requireAccessPayload();
|
||||
if (payload == null) return;
|
||||
JSONArray accounts = payload.optJSONArray("accounts");
|
||||
JSONArray templates = payload.optJSONArray("permissionTemplates");
|
||||
JSONArray devices = payload.optJSONArray("devices");
|
||||
JSONArray projects = payload.optJSONArray("projects");
|
||||
JSONArray skills = payload.optJSONArray("skills");
|
||||
if (isEmpty(accounts) || isEmpty(templates)) {
|
||||
showMessage("需要先有账号和权限模板。");
|
||||
return;
|
||||
}
|
||||
if (isEmpty(devices) && isEmpty(projects) && isEmpty(skills)) {
|
||||
showMessage("需要至少有设备、项目或 Skill。");
|
||||
return;
|
||||
}
|
||||
|
||||
LinearLayout form = buildDialogForm();
|
||||
Spinner accountSpinner = spinnerWith(labelsFor(accounts, "account", "displayName"));
|
||||
Spinner templateSpinner = spinnerWith(labelsFor(templates, "templateId", "name"));
|
||||
Spinner deviceSpinner = spinnerWith(optionalLabelsFor(devices, "id", "name", "不授权设备"));
|
||||
Spinner projectSpinner = spinnerWith(optionalLabelsFor(projects, "id", "name", "不授权项目"));
|
||||
Spinner skillSpinner = spinnerWith(optionalLabelsFor(skills, "skillId", "name", "不分配 Skill"));
|
||||
if (!isEmpty(devices)) {
|
||||
deviceSpinner.setSelection(1);
|
||||
}
|
||||
if (!isEmpty(projects)) {
|
||||
projectSpinner.setSelection(1);
|
||||
}
|
||||
if (!isEmpty(skills)) {
|
||||
skillSpinner.setSelection(1);
|
||||
}
|
||||
form.addView(BossUi.buildFormCell(this, "账号", null, accountSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "模板", "模板只作用于本次选择的账号和目标,不会全局放行。", templateSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "设备", null, deviceSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "项目", null, projectSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "Skill", null, skillSpinner));
|
||||
|
||||
confirmGrant("套用权限模板", form, () -> buildTemplateApplyPayload(
|
||||
valueAt(accounts, accountSpinner.getSelectedItemPosition(), "account"),
|
||||
objectAt(templates, templateSpinner.getSelectedItemPosition()),
|
||||
optionalObjectAt(devices, deviceSpinner.getSelectedItemPosition()),
|
||||
optionalObjectAt(projects, projectSpinner.getSelectedItemPosition()),
|
||||
optionalObjectAt(skills, skillSpinner.getSelectedItemPosition())
|
||||
));
|
||||
}
|
||||
|
||||
static JSONObject buildTemplateApplyPayload(
|
||||
String account,
|
||||
JSONObject template,
|
||||
@Nullable JSONObject device,
|
||||
@Nullable JSONObject project,
|
||||
@Nullable JSONObject skill
|
||||
) throws JSONException {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("action", "apply_template");
|
||||
body.put("account", account == null ? "" : account.trim());
|
||||
body.put("templateId", template == null ? "" : template.optString("templateId", ""));
|
||||
JSONArray deviceIds = new JSONArray();
|
||||
if (device != null && !TextUtils.isEmpty(device.optString("id", ""))) {
|
||||
deviceIds.put(device.optString("id", ""));
|
||||
}
|
||||
JSONArray projectIds = new JSONArray();
|
||||
if (project != null && !TextUtils.isEmpty(project.optString("id", ""))) {
|
||||
projectIds.put(project.optString("id", ""));
|
||||
}
|
||||
JSONArray skillIds = new JSONArray();
|
||||
if (skill != null && !TextUtils.isEmpty(skill.optString("skillId", ""))) {
|
||||
skillIds.put(skill.optString("skillId", ""));
|
||||
}
|
||||
body.put("deviceIds", deviceIds);
|
||||
body.put("projectIds", projectIds);
|
||||
body.put("skillIds", skillIds);
|
||||
return body;
|
||||
}
|
||||
|
||||
private void confirmGrant(String title, LinearLayout form, PayloadFactory factory) {
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle(title)
|
||||
.setView(form)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("保存", (dialog, which) -> {
|
||||
try {
|
||||
runAdminAction(factory.create());
|
||||
} catch (JSONException error) {
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
}
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private void confirmRevoke(String grantId) {
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("撤销授权")
|
||||
.setMessage("只撤销当前这条授权,不影响其他设备、项目或 Skill。")
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("撤销", (dialog, which) -> {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("action", "revoke_grant");
|
||||
payload.put("grantId", grantId);
|
||||
runAdminAction(payload);
|
||||
} catch (JSONException error) {
|
||||
showMessage("撤销失败:" + error.getMessage());
|
||||
}
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private void runAdminAction(JSONObject payload) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.updateAdminAccess(payload);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage("已保存");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("操作失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private JSONObject requireAccessPayload() {
|
||||
if (accessPayload == null) {
|
||||
showMessage("权限数据还没加载完成。");
|
||||
}
|
||||
return accessPayload;
|
||||
}
|
||||
|
||||
private LinearLayout buildDialogForm() {
|
||||
LinearLayout form = new LinearLayout(this);
|
||||
form.setOrientation(LinearLayout.VERTICAL);
|
||||
return form;
|
||||
}
|
||||
|
||||
private Spinner spinnerWith(String[] values) {
|
||||
Spinner spinner = new Spinner(this);
|
||||
spinner.setAdapter(new ArrayAdapter<>(
|
||||
this,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
values
|
||||
));
|
||||
return spinner;
|
||||
}
|
||||
|
||||
private JSONArray projectPermissionsFor(int position) {
|
||||
if (position == 3) {
|
||||
return new JSONArray(Arrays.asList("project.view", "thread.chat", "master_agent.ask", "master_agent.takeover", "computer.control"));
|
||||
}
|
||||
if (position == 2) {
|
||||
return new JSONArray(Arrays.asList("project.view", "thread.chat", "master_agent.ask", "master_agent.takeover"));
|
||||
}
|
||||
if (position == 1) {
|
||||
return new JSONArray(Arrays.asList("project.view", "thread.chat"));
|
||||
}
|
||||
return new JSONArray(Arrays.asList("project.view"));
|
||||
}
|
||||
|
||||
private String[] labelsFor(JSONArray array, String idKey, String nameKey) {
|
||||
List<String> labels = new ArrayList<>();
|
||||
if (array == null) {
|
||||
return new String[0];
|
||||
}
|
||||
for (int index = 0; index < array.length(); index += 1) {
|
||||
JSONObject item = array.optJSONObject(index);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
String id = item.optString(idKey, "");
|
||||
String name = item.optString(nameKey, "");
|
||||
labels.add(TextUtils.isEmpty(name) || name.equals(id) ? id : name + " · " + id);
|
||||
}
|
||||
return labels.toArray(new String[0]);
|
||||
}
|
||||
|
||||
private String[] optionalLabelsFor(JSONArray array, String idKey, String nameKey, String emptyLabel) {
|
||||
List<String> labels = new ArrayList<>();
|
||||
labels.add(emptyLabel);
|
||||
if (array != null) {
|
||||
labels.addAll(Arrays.asList(labelsFor(array, idKey, nameKey)));
|
||||
}
|
||||
return labels.toArray(new String[0]);
|
||||
}
|
||||
|
||||
private String valueAt(JSONArray array, int position, String key) {
|
||||
JSONObject item = array == null ? null : array.optJSONObject(position);
|
||||
return item == null ? "" : item.optString(key, "");
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private JSONObject objectAt(JSONArray array, int position) {
|
||||
return array == null ? null : array.optJSONObject(position);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private JSONObject optionalObjectAt(JSONArray array, int position) {
|
||||
if (position <= 0 || array == null) {
|
||||
return null;
|
||||
}
|
||||
return array.optJSONObject(position - 1);
|
||||
}
|
||||
|
||||
private JSONArray grantsArray(JSONObject payload, String key) {
|
||||
JSONObject grants = payload.optJSONObject("grants");
|
||||
return grants == null ? new JSONArray() : grants.optJSONArray(key);
|
||||
}
|
||||
|
||||
private String summarizeAccounts(JSONArray accounts) {
|
||||
if (accounts == null || accounts.length() == 0) {
|
||||
return "暂无子账号";
|
||||
}
|
||||
List<String> parts = new ArrayList<>();
|
||||
int max = Math.min(4, accounts.length());
|
||||
for (int index = 0; index < max; index += 1) {
|
||||
JSONObject account = accounts.optJSONObject(index);
|
||||
if (account == null) continue;
|
||||
parts.add(account.optString("displayName", account.optString("account", "")) + " · " + BossUi.formatRoleLabel(account.optString("role", "")));
|
||||
}
|
||||
if (accounts.length() > max) {
|
||||
parts.add("+" + (accounts.length() - max));
|
||||
}
|
||||
return TextUtils.join("\n", parts);
|
||||
}
|
||||
|
||||
private String grantTargetSummary(JSONObject grant) {
|
||||
if (!TextUtils.isEmpty(grant.optString("skillId", ""))) {
|
||||
return "Skill:" + grant.optString("skillId", "");
|
||||
}
|
||||
if (!TextUtils.isEmpty(grant.optString("projectId", ""))) {
|
||||
return "项目:" + grant.optString("projectId", "");
|
||||
}
|
||||
return "设备:" + grant.optString("deviceId", "");
|
||||
}
|
||||
|
||||
private String joinJsonArray(JSONArray values) {
|
||||
if (values == null || values.length() == 0) {
|
||||
return "未设置权限";
|
||||
}
|
||||
List<String> parts = new ArrayList<>();
|
||||
for (int index = 0; index < values.length(); index += 1) {
|
||||
parts.add(values.optString(index, ""));
|
||||
}
|
||||
return TextUtils.join(" / ", parts);
|
||||
}
|
||||
|
||||
private int lengthOf(@Nullable JSONArray array) {
|
||||
return array == null ? 0 : array.length();
|
||||
}
|
||||
|
||||
private boolean isEmpty(@Nullable JSONArray array) {
|
||||
return array == null || array.length() == 0;
|
||||
}
|
||||
|
||||
private interface PayloadFactory {
|
||||
JSONObject create() throws JSONException;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,6 +66,53 @@ public class BossApiClient {
|
||||
return response;
|
||||
}
|
||||
|
||||
public ApiResponse loginWithPassword(String account, String password) throws IOException, JSONException {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("account", account);
|
||||
body.put("password", password);
|
||||
body.put("method", "password");
|
||||
ApiResponse response = request("POST", "/api/auth/login", body, false);
|
||||
if (response.ok()) {
|
||||
rememberIdentity(response.json);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
public ApiResponse sendVerificationCode(String account, String purpose) throws IOException, JSONException {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("account", account);
|
||||
body.put("purpose", purpose);
|
||||
return request("POST", "/api/auth/send-code", body, false);
|
||||
}
|
||||
|
||||
public ApiResponse registerAccount(
|
||||
String account,
|
||||
String password,
|
||||
String confirmPassword,
|
||||
String code
|
||||
) throws IOException, JSONException {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("account", account);
|
||||
body.put("password", password);
|
||||
body.put("confirmPassword", confirmPassword);
|
||||
body.put("code", code);
|
||||
return request("POST", "/api/auth/register", body, false);
|
||||
}
|
||||
|
||||
public ApiResponse resetPassword(
|
||||
String account,
|
||||
String password,
|
||||
String confirmPassword,
|
||||
String code
|
||||
) throws IOException, JSONException {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("account", account);
|
||||
body.put("password", password);
|
||||
body.put("confirmPassword", confirmPassword);
|
||||
body.put("code", code);
|
||||
return request("POST", "/api/auth/forgot-password", body, false);
|
||||
}
|
||||
|
||||
public ApiResponse restoreSession() throws IOException, JSONException {
|
||||
if (getRestoreToken().isEmpty()) {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_RESTORE_TOKEN"));
|
||||
@@ -83,6 +130,17 @@ public class BossApiClient {
|
||||
return request("GET", "/api/auth/session", null, true);
|
||||
}
|
||||
|
||||
public ApiResponse getAuthSessions() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/auth/sessions", null);
|
||||
}
|
||||
|
||||
public ApiResponse revokeAuthSession(String sessionId) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("action", "revoke_session");
|
||||
payload.put("sessionId", sessionId);
|
||||
return requestWithRestore("POST", "/api/v1/auth/sessions", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getConversations() throws IOException, JSONException {
|
||||
return requestWithRestoreRaw(
|
||||
"GET",
|
||||
@@ -107,12 +165,30 @@ public class BossApiClient {
|
||||
return requestWithRestore("GET", "/api/v1/conversation-folders/" + encode(folderKey), null);
|
||||
}
|
||||
|
||||
public ApiResponse markConversationRead(String projectId) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("action", "mark_read");
|
||||
return requestWithRestore("POST", "/api/v1/conversations/" + encode(projectId) + "/actions", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getProjectDetail(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId), null);
|
||||
return requestWithRestoreRaw(
|
||||
"GET",
|
||||
"/api/v1/projects/" + encode(projectId),
|
||||
null,
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
CONVERSATIONS_READ_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse getProjectMessages(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/messages", null);
|
||||
return requestWithRestoreRaw(
|
||||
"GET",
|
||||
"/api/v1/projects/" + encode(projectId) + "/messages",
|
||||
null,
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
CONVERSATIONS_READ_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse getDispatchPlans(String projectId) throws IOException, JSONException {
|
||||
@@ -147,6 +223,22 @@ 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,
|
||||
@@ -238,6 +330,16 @@ public class BossApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse decideDialogGuardIntervention(String interventionId, String decision) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("decision", decision);
|
||||
return requestWithRestore(
|
||||
"POST",
|
||||
"/api/v1/dialog-guard/interventions/" + encode(interventionId) + "/decision",
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse retryDispatchPlan(String projectId, String planId) throws IOException, JSONException {
|
||||
return requestWithRestoreRaw(
|
||||
"POST",
|
||||
@@ -283,6 +385,18 @@ public class BossApiClient {
|
||||
return requestWithRestore("PATCH", "/api/v1/projects/" + encode(projectId) + "/dispatch-reminder", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getAttachmentStorageConfig() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/storage/config", null);
|
||||
}
|
||||
|
||||
public ApiResponse saveAttachmentStorageConfig(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("PATCH", "/api/v1/storage/config", payload);
|
||||
}
|
||||
|
||||
public ApiResponse validateAttachmentStorageConfig(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/storage/config/validate", payload);
|
||||
}
|
||||
|
||||
public ApiResponse sendProjectMessage(String projectId, String body, String kind) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("body", body);
|
||||
@@ -296,6 +410,14 @@ public class BossApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse deleteProjectMessage(String projectId, String messageId) throws IOException, JSONException {
|
||||
return requestWithRestore(
|
||||
"DELETE",
|
||||
"/api/v1/projects/" + encode(projectId) + "/messages?messageId=" + encode(messageId),
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
public ApiResponse uploadAttachment(
|
||||
String projectId,
|
||||
String fileName,
|
||||
@@ -434,10 +556,30 @@ 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);
|
||||
}
|
||||
@@ -468,6 +610,14 @@ public class BossApiClient {
|
||||
return requestWithRestore("GET", "/api/v1/accounts", null);
|
||||
}
|
||||
|
||||
public ApiResponse getAdminAccess() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/admin/access", null);
|
||||
}
|
||||
|
||||
public ApiResponse updateAdminAccess(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/admin/access", payload);
|
||||
}
|
||||
|
||||
public ApiResponse createAccount(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/accounts", payload);
|
||||
}
|
||||
@@ -490,6 +640,10 @@ 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);
|
||||
}
|
||||
@@ -526,6 +680,14 @@ public class BossApiClient {
|
||||
return requestWithRestore("POST", "/api/v1/settings", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getTelegramIntegration() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/integrations/telegram", null);
|
||||
}
|
||||
|
||||
public ApiResponse updateTelegramIntegration(JSONObject payload) throws IOException, JSONException {
|
||||
return requestWithRestore("POST", "/api/v1/integrations/telegram", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getOtaStatus() throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/user/ota", null);
|
||||
}
|
||||
@@ -543,13 +705,19 @@ public class BossApiClient {
|
||||
}
|
||||
|
||||
public ApiResponse logout() throws IOException, JSONException {
|
||||
ApiResponse response = request("POST", "/api/auth/logout", new JSONObject(), false);
|
||||
try {
|
||||
return request("POST", "/api/auth/logout", new JSONObject(), false);
|
||||
} finally {
|
||||
clearSession();
|
||||
}
|
||||
}
|
||||
|
||||
public void clearLocalAuthState() {
|
||||
clearSession();
|
||||
return response;
|
||||
}
|
||||
|
||||
public String getAccountLabel() {
|
||||
return prefs.getString(KEY_ACCOUNT, "17600003315");
|
||||
return prefs.getString(KEY_ACCOUNT, "krisolo");
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
@@ -594,9 +762,9 @@ public class BossApiClient {
|
||||
int readTimeoutMs
|
||||
) throws IOException, JSONException {
|
||||
ApiResponse response = requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
|
||||
if (response.statusCode == 401 && !getRestoreToken().isEmpty()) {
|
||||
ApiResponse restored = restoreSession();
|
||||
if (restored.ok()) {
|
||||
if (response.statusCode == 401) {
|
||||
ApiResponse recovered = !getRestoreToken().isEmpty() ? restoreSession() : autoLogin();
|
||||
if (recovered.ok()) {
|
||||
return requestRaw(method, path, body, true, connectTimeoutMs, readTimeoutMs);
|
||||
}
|
||||
}
|
||||
@@ -689,7 +857,16 @@ public class BossApiClient {
|
||||
private ApiResponse executeConnection(HttpURLConnection connection, boolean expectProtected) throws IOException, JSONException {
|
||||
int statusCode = connection.getResponseCode();
|
||||
captureSessionCookie(connection.getHeaderFields());
|
||||
JSONObject json = readJson(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
|
||||
JsonBody jsonBody = readJsonBody(statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream());
|
||||
JSONObject json = jsonBody.json;
|
||||
if (!jsonBody.validJson) {
|
||||
int normalizedStatusCode = expectProtected && statusCode < 400 ? 401 : statusCode;
|
||||
json = new JSONObject()
|
||||
.put("ok", false)
|
||||
.put("message", "NON_JSON_RESPONSE")
|
||||
.put("statusCode", statusCode);
|
||||
statusCode = normalizedStatusCode;
|
||||
}
|
||||
|
||||
if (statusCode == 401 && !expectProtected) {
|
||||
clearSession();
|
||||
@@ -802,8 +979,12 @@ public class BossApiClient {
|
||||
}
|
||||
|
||||
private JSONObject readJson(InputStream stream) throws IOException, JSONException {
|
||||
return readJsonBody(stream).json;
|
||||
}
|
||||
|
||||
private JsonBody readJsonBody(InputStream stream) throws IOException, JSONException {
|
||||
if (stream == null) {
|
||||
return new JSONObject();
|
||||
return new JsonBody(new JSONObject(), true);
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||
@@ -814,9 +995,13 @@ public class BossApiClient {
|
||||
}
|
||||
String raw = builder.toString().trim();
|
||||
if (raw.isEmpty()) {
|
||||
return new JSONObject();
|
||||
return new JsonBody(new JSONObject(), true);
|
||||
}
|
||||
try {
|
||||
return new JsonBody(new JSONObject(raw), true);
|
||||
} catch (JSONException error) {
|
||||
return new JsonBody(new JSONObject(), false);
|
||||
}
|
||||
return new JSONObject(raw);
|
||||
}
|
||||
|
||||
private String readText(InputStream stream) throws IOException {
|
||||
@@ -850,9 +1035,13 @@ public class BossApiClient {
|
||||
|
||||
private void captureSessionCookie(Map<String, List<String>> headers) {
|
||||
if (headers == null) return;
|
||||
List<String> setCookieHeaders = headers.get("Set-Cookie");
|
||||
if (setCookieHeaders == null) {
|
||||
setCookieHeaders = headers.get("set-cookie");
|
||||
List<String> setCookieHeaders = null;
|
||||
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
|
||||
String headerName = entry.getKey();
|
||||
if (headerName != null && "set-cookie".equalsIgnoreCase(headerName)) {
|
||||
setCookieHeaders = entry.getValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (setCookieHeaders == null) return;
|
||||
|
||||
@@ -916,6 +1105,8 @@ public class BossApiClient {
|
||||
prefs.edit()
|
||||
.remove(KEY_SESSION_COOKIE)
|
||||
.remove(KEY_RESTORE_TOKEN)
|
||||
.remove(KEY_ACCOUNT)
|
||||
.remove(KEY_DISPLAY_NAME)
|
||||
.apply();
|
||||
}
|
||||
|
||||
@@ -945,6 +1136,16 @@ public class BossApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
private static class JsonBody {
|
||||
final JSONObject json;
|
||||
final boolean validJson;
|
||||
|
||||
JsonBody(JSONObject json, boolean validJson) {
|
||||
this.json = json;
|
||||
this.validJson = validJson;
|
||||
}
|
||||
}
|
||||
|
||||
public static class DownloadedAttachment {
|
||||
public final int statusCode;
|
||||
public final String fileName;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
final class BossAppVisibilityTracker {
|
||||
private volatile boolean appInForeground;
|
||||
private volatile @Nullable String visibleProjectId;
|
||||
|
||||
void onAppForegrounded() {
|
||||
appInForeground = true;
|
||||
}
|
||||
|
||||
void onAppBackgrounded() {
|
||||
appInForeground = false;
|
||||
}
|
||||
|
||||
boolean isAppInForeground() {
|
||||
return appInForeground;
|
||||
}
|
||||
|
||||
void setVisibleProjectId(@Nullable String projectId) {
|
||||
if (projectId == null) {
|
||||
visibleProjectId = null;
|
||||
return;
|
||||
}
|
||||
String normalized = projectId.trim();
|
||||
visibleProjectId = normalized.isEmpty() ? null : normalized;
|
||||
}
|
||||
|
||||
void clearVisibleProjectId(@Nullable String projectId) {
|
||||
if (visibleProjectId == null) {
|
||||
return;
|
||||
}
|
||||
if (projectId == null) {
|
||||
visibleProjectId = null;
|
||||
return;
|
||||
}
|
||||
String normalized = projectId.trim();
|
||||
if (normalized.isEmpty() || visibleProjectId.equals(normalized)) {
|
||||
visibleProjectId = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
String getVisibleProjectId() {
|
||||
return visibleProjectId;
|
||||
}
|
||||
}
|
||||
68
android/app/src/main/java/com/hyzq/boss/BossApplication.java
Normal file
68
android/app/src/main/java/com/hyzq/boss/BossApplication.java
Normal file
@@ -0,0 +1,68 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.Application;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
|
||||
public final class BossApplication extends Application {
|
||||
private final BossAppVisibilityTracker visibilityTracker = new BossAppVisibilityTracker();
|
||||
private BossNotificationRouter notificationRouter;
|
||||
private int startedActivityCount;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
|
||||
super.onCreate();
|
||||
notificationRouter = new BossNotificationRouter(this, visibilityTracker);
|
||||
registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
|
||||
@Override
|
||||
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {}
|
||||
|
||||
@Override
|
||||
public void onActivityStarted(Activity activity) {
|
||||
startedActivityCount += 1;
|
||||
if (startedActivityCount == 1) {
|
||||
visibilityTracker.onAppForegrounded();
|
||||
BossBackgroundRealtimeService.stop(BossApplication.this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivityResumed(Activity activity) {}
|
||||
|
||||
@Override
|
||||
public void onActivityPaused(Activity activity) {}
|
||||
|
||||
@Override
|
||||
public void onActivityStopped(Activity activity) {
|
||||
startedActivityCount = Math.max(0, startedActivityCount - 1);
|
||||
if (startedActivityCount == 0) {
|
||||
visibilityTracker.onAppBackgrounded();
|
||||
BossBackgroundRealtimeService.start(BossApplication.this);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {}
|
||||
|
||||
@Override
|
||||
public void onActivityDestroyed(Activity activity) {}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTerminate() {
|
||||
BossBackgroundRealtimeService.stop(this);
|
||||
super.onTerminate();
|
||||
}
|
||||
|
||||
BossAppVisibilityTracker visibilityTracker() {
|
||||
return visibilityTracker;
|
||||
}
|
||||
|
||||
BossNotificationRouter notificationRouter() {
|
||||
return notificationRouter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
|
||||
public class BossBackgroundRealtimeService extends Service {
|
||||
static final String ACTION_START = "com.hyzq.boss.action.START_BACKGROUND_REALTIME";
|
||||
static final String ACTION_STOP = "com.hyzq.boss.action.STOP_BACKGROUND_REALTIME";
|
||||
static final String SERVICE_CHANNEL_ID = "boss_background_sync";
|
||||
static final int SERVICE_NOTIFICATION_ID = 2002;
|
||||
|
||||
interface BossRealtimeRuntime {
|
||||
void start();
|
||||
|
||||
void stop();
|
||||
}
|
||||
|
||||
private @Nullable BossApiClient apiClient;
|
||||
private @Nullable BossRealtimeRuntime realtimeRuntime;
|
||||
private boolean realtimeStarted;
|
||||
|
||||
static void start(Context context) {
|
||||
Intent intent = new Intent(context, BossBackgroundRealtimeService.class).setAction(ACTION_START);
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent);
|
||||
return;
|
||||
}
|
||||
context.startService(intent);
|
||||
}
|
||||
|
||||
static void stop(Context context) {
|
||||
Intent intent = new Intent(context, BossBackgroundRealtimeService.class).setAction(ACTION_STOP);
|
||||
context.startService(intent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
apiClient = createApiClient();
|
||||
BossNotificationRouter notificationRouter = createNotificationRouter();
|
||||
realtimeRuntime = createRealtimeRuntime(apiClient, notificationRouter);
|
||||
}
|
||||
|
||||
BossApiClient createApiClient() {
|
||||
return new BossApiClient(this);
|
||||
}
|
||||
|
||||
BossNotificationRouter createNotificationRouter() {
|
||||
BossAppVisibilityTracker tracker = getApplication() instanceof BossApplication
|
||||
? ((BossApplication) getApplication()).visibilityTracker()
|
||||
: new BossAppVisibilityTracker();
|
||||
return new BossNotificationRouter(this, tracker);
|
||||
}
|
||||
|
||||
BossRealtimeRuntime createRealtimeRuntime(BossApiClient apiClient, BossNotificationRouter router) {
|
||||
BossRealtimeClient realtimeClient = new BossRealtimeClient(apiClient, router::maybeNotifyForRealtimeEvent);
|
||||
return new BossRealtimeRuntime() {
|
||||
@Override
|
||||
public void start() {
|
||||
realtimeClient.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
|
||||
String action = intent == null ? ACTION_START : intent.getAction();
|
||||
if (ACTION_STOP.equals(action)) {
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
if (apiClient == null || realtimeRuntime == null || !apiClient.hasSessionHints()) {
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
startForeground(SERVICE_NOTIFICATION_ID, buildForegroundNotification());
|
||||
if (!realtimeStarted) {
|
||||
realtimeRuntime.start();
|
||||
realtimeStarted = true;
|
||||
}
|
||||
return START_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
if (realtimeRuntime != null && realtimeStarted) {
|
||||
realtimeRuntime.stop();
|
||||
realtimeStarted = false;
|
||||
}
|
||||
stopForeground(STOP_FOREGROUND_REMOVE);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable IBinder onBind(Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
private Notification buildForegroundNotification() {
|
||||
ensureChannel();
|
||||
return new NotificationCompat.Builder(this, SERVICE_CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Boss 后台同步中")
|
||||
.setContentText("主 Agent 新回复会通过系统通知提醒")
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(buildContentIntent())
|
||||
.build();
|
||||
}
|
||||
|
||||
private PendingIntent buildContentIntent() {
|
||||
Intent intent = new Intent(this, MainActivity.class)
|
||||
.putExtra(MainActivity.EXTRA_INITIAL_TAB, "conversations")
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
return PendingIntent.getActivity(
|
||||
this,
|
||||
902,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
||||
);
|
||||
}
|
||||
|
||||
private void ensureChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return;
|
||||
}
|
||||
NotificationManager notificationManager = getSystemService(NotificationManager.class);
|
||||
if (notificationManager == null || notificationManager.getNotificationChannel(SERVICE_CHANNEL_ID) != null) {
|
||||
return;
|
||||
}
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
SERVICE_CHANNEL_ID,
|
||||
"Boss 后台同步",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
);
|
||||
channel.setDescription("保持主 Agent 后台同步与消息提醒");
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.content.Context;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.Typeface;
|
||||
import android.os.Build;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.SpannedString;
|
||||
@@ -27,6 +28,8 @@ 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);
|
||||
|
||||
@@ -43,7 +46,7 @@ public final class BossMarkdown {
|
||||
}
|
||||
Palette palette = Palette.resolve(context, outgoing);
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder();
|
||||
String normalized = markdown.replace("\r\n", "\n").replace('\r', '\n');
|
||||
String normalized = normalizeMarkdownLinks(markdown).replace("\r\n", "\n").replace('\r', '\n');
|
||||
String[] lines = normalized.split("\n", -1);
|
||||
boolean inCodeFence = false;
|
||||
List<String> codeLines = new ArrayList<>();
|
||||
@@ -86,6 +89,12 @@ 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;
|
||||
@@ -109,6 +118,21 @@ 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();
|
||||
@@ -148,8 +172,26 @@ public final class BossMarkdown {
|
||||
ensureBlockSeparation(builder, false);
|
||||
int start = builder.length();
|
||||
appendInlineStyled(builder, TextUtils.isEmpty(text) ? "引用" : text, palette);
|
||||
builder.setSpan(new QuoteSpan(palette.quoteColor, BossUi.dp(palette.context, 3), BossUi.dp(palette.context, 8)),
|
||||
start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
QuoteSpan quoteSpan = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|
||||
? new QuoteSpan(palette.quoteColor, BossUi.dp(palette.context, 3), BossUi.dp(palette.context, 8))
|
||||
: new QuoteSpan(palette.quoteColor);
|
||||
builder.setSpan(quoteSpan, start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.append('\n');
|
||||
}
|
||||
|
||||
private static void appendLabelSection(
|
||||
SpannableStringBuilder builder,
|
||||
String label,
|
||||
String content,
|
||||
Palette palette
|
||||
) {
|
||||
ensureBlockSeparation(builder, true);
|
||||
int labelStart = builder.length();
|
||||
builder.append(label.trim());
|
||||
builder.setSpan(new StyleSpan(Typeface.BOLD), labelStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.setSpan(new RelativeSizeSpan(1.03f), labelStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.append('\n');
|
||||
appendInlineStyled(builder, content.trim(), palette);
|
||||
builder.append('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
final class BossNotificationRouter {
|
||||
static final String CHANNEL_ID = "boss_master_agent_messages";
|
||||
static final int MASTER_AGENT_NOTIFICATION_ID = 2001;
|
||||
|
||||
private final Context appContext;
|
||||
private final BossAppVisibilityTracker visibilityTracker;
|
||||
private @Nullable String lastNotifiedMessageId;
|
||||
|
||||
BossNotificationRouter(Context context, BossAppVisibilityTracker visibilityTracker) {
|
||||
this.appContext = context.getApplicationContext();
|
||||
this.visibilityTracker = visibilityTracker;
|
||||
}
|
||||
|
||||
boolean maybeNotifyForRealtimeEvent(@Nullable BossRealtimeEvent event) {
|
||||
NotificationCandidate candidate = latestMasterAgentMessage(event);
|
||||
if (candidate == null) {
|
||||
return false;
|
||||
}
|
||||
if (candidate.messageId.isEmpty() || TextUtils.equals(candidate.messageId, lastNotifiedMessageId)) {
|
||||
return false;
|
||||
}
|
||||
if (visibilityTracker.isAppInForeground()) {
|
||||
return false;
|
||||
}
|
||||
if (!canPostNotifications()) {
|
||||
return false;
|
||||
}
|
||||
ensureChannel();
|
||||
try {
|
||||
NotificationManagerCompat.from(appContext).notify(
|
||||
MASTER_AGENT_NOTIFICATION_ID,
|
||||
new NotificationCompat.Builder(appContext, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(candidate.title)
|
||||
.setContentText(candidate.body)
|
||||
.setStyle(new NotificationCompat.BigTextStyle().bigText(
|
||||
candidate.body
|
||||
))
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(buildContentIntent(candidate))
|
||||
.build()
|
||||
);
|
||||
} catch (SecurityException ignored) {
|
||||
return false;
|
||||
}
|
||||
lastNotifiedMessageId = candidate.messageId;
|
||||
return true;
|
||||
}
|
||||
|
||||
void resetLastNotifiedMessageId() {
|
||||
lastNotifiedMessageId = null;
|
||||
}
|
||||
|
||||
void clearMasterAgentNotification() {
|
||||
NotificationManagerCompat.from(appContext).cancel(MASTER_AGENT_NOTIFICATION_ID);
|
||||
}
|
||||
|
||||
private boolean canPostNotifications() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
&& ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
return false;
|
||||
}
|
||||
return NotificationManagerCompat.from(appContext).areNotificationsEnabled();
|
||||
}
|
||||
|
||||
private @Nullable NotificationCandidate latestMasterAgentMessage(@Nullable BossRealtimeEvent event) {
|
||||
if (event == null || !"project.messages.updated".equals(event.eventName)) {
|
||||
return null;
|
||||
}
|
||||
String projectId = event.payload.optString("projectId", "").trim();
|
||||
JSONObject projectMessagesPayload = event.payload.optJSONObject("projectMessagesPayload");
|
||||
JSONObject project = projectMessagesPayload == null ? null : projectMessagesPayload.optJSONObject("project");
|
||||
JSONArray messages = project == null ? null : project.optJSONArray("messages");
|
||||
if (messages == null || messages.length() <= 0) {
|
||||
return null;
|
||||
}
|
||||
JSONObject latestMessage = messages.optJSONObject(messages.length() - 1);
|
||||
if (latestMessage == null) {
|
||||
return null;
|
||||
}
|
||||
String sender = latestMessage.optString("sender", "");
|
||||
String senderLabel = latestMessage.optString("senderLabel", "");
|
||||
if (!"master".equals(sender) && !senderLabel.contains("主 Agent")) {
|
||||
return null;
|
||||
}
|
||||
String messageId = latestMessage.optString("id", "").trim();
|
||||
String projectName = project == null ? "" : project.optString("name", "").trim();
|
||||
String title = "master-agent".equals(projectId) || projectName.isEmpty()
|
||||
? "主 Agent"
|
||||
: "主 Agent · " + projectName;
|
||||
String body = latestMessage.optString("body", "你有一条新的主 Agent 回复");
|
||||
return new NotificationCandidate(projectId, projectName, messageId, title, TextUtils.isEmpty(body) ? "你有一条新的主 Agent 回复" : body);
|
||||
}
|
||||
|
||||
private PendingIntent buildContentIntent(NotificationCandidate candidate) {
|
||||
Intent intent = new Intent(appContext, ProjectDetailActivity.class)
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, candidate.projectId)
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, candidate.projectName)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
return PendingIntent.getActivity(
|
||||
appContext,
|
||||
901 + Math.abs(candidate.projectId.hashCode() % 97),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
|
||||
);
|
||||
}
|
||||
|
||||
private void ensureChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return;
|
||||
}
|
||||
NotificationManager notificationManager = appContext.getSystemService(NotificationManager.class);
|
||||
if (notificationManager == null || notificationManager.getNotificationChannel(CHANNEL_ID) != null) {
|
||||
return;
|
||||
}
|
||||
NotificationChannel channel = new NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"主 Agent 消息",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
);
|
||||
channel.setDescription("Boss 主 Agent 后台消息提醒");
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
private static final class NotificationCandidate {
|
||||
final String projectId;
|
||||
final String projectName;
|
||||
final String messageId;
|
||||
final String title;
|
||||
final String body;
|
||||
|
||||
NotificationCandidate(String projectId, String projectName, String messageId, String title, String body) {
|
||||
this.projectId = projectId == null || projectId.trim().isEmpty() ? "master-agent" : projectId.trim();
|
||||
this.projectName = projectName == null || projectName.trim().isEmpty() ? "主 Agent" : projectName.trim();
|
||||
this.messageId = messageId == null ? "" : messageId.trim();
|
||||
this.title = title == null || title.trim().isEmpty() ? "主 Agent" : title.trim();
|
||||
this.body = body == null || body.trim().isEmpty() ? "你有一条新的主 Agent 回复" : body.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ 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;
|
||||
@@ -90,6 +91,11 @@ 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();
|
||||
@@ -130,6 +136,7 @@ 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);
|
||||
@@ -174,6 +181,10 @@ 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
@@ -253,12 +253,6 @@ 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);
|
||||
@@ -269,24 +263,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import java.util.Map;
|
||||
public class ConversationInfoActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
public static final String EXTRA_TAKEOVER_ENABLED = "takeover_enabled";
|
||||
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
|
||||
private String projectId;
|
||||
@@ -178,17 +179,9 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
takeoverInheritedFromGlobal = agentControls != null && agentControls.optBoolean("takeoverInheritedFromGlobal", false);
|
||||
configureScreen("会话信息", buildSubtitle(threadMeta, participantCount));
|
||||
|
||||
appendContent(BossUi.buildSimpleProfileHeader(
|
||||
this,
|
||||
projectName,
|
||||
"单线程会话",
|
||||
buildHeaderDetail(project, threadMeta, participantCount)
|
||||
));
|
||||
|
||||
appendThreadStatusSummary(threadStatusPayload);
|
||||
appendTakeoverControl();
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"发起群聊",
|
||||
"选择其他线程加入新群",
|
||||
@@ -197,7 +190,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
v -> openGroupCreate()
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"线程详情",
|
||||
"查看当前线程聊天与项目",
|
||||
@@ -206,7 +199,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
v -> openProject(projectId, projectName)
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"线程状态",
|
||||
"状态文档和最近进展事件",
|
||||
@@ -215,7 +208,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
v -> openThreadStatus()
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"参与线程",
|
||||
participantCount <= 0 ? "暂无参与线程" : "共 " + participantCount + " 个",
|
||||
@@ -225,7 +218,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
));
|
||||
|
||||
if (participants == null || participants.length() == 0) {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"暂无参与线程",
|
||||
"下拉刷新后重试",
|
||||
@@ -237,7 +230,7 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
for (int i = 0; i < participants.length(); i++) {
|
||||
JSONObject participant = participants.optJSONObject(i);
|
||||
if (participant == null) continue;
|
||||
appendContent(buildParticipantRow(participant));
|
||||
appendConversationInfoItem(buildParticipantRow(participant));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,86 +239,34 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
|
||||
private void appendTakeoverControl() {
|
||||
SwitchCompat takeoverSwitch = new SwitchCompat(this);
|
||||
takeoverSwitch.setText("开启");
|
||||
takeoverSwitch.setShowText(false);
|
||||
takeoverSwitch.setText(null);
|
||||
takeoverSwitch.setChecked(takeoverEnabled);
|
||||
takeoverSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> saveTakeoverSetting(isChecked));
|
||||
appendContent(BossUi.buildFormCell(
|
||||
appendConversationInfoItem(BossUi.buildWechatSwitchRow(
|
||||
this,
|
||||
"主 Agent 协同接管",
|
||||
takeoverInheritedFromGlobal
|
||||
? "当前跟随全局默认开启。主 Agent 会协同推进,但不会抢走你直接控制线程开发的能力。"
|
||||
: "为这个线程单独开启主 Agent 协同推进。不会抢走你直接控制线程开发的能力。",
|
||||
? "跟随全局默认开启"
|
||||
: "为此线程单独开启",
|
||||
takeoverSwitch
|
||||
));
|
||||
}
|
||||
|
||||
private void appendThreadStatusSummary(@Nullable JSONObject threadStatusPayload) {
|
||||
if (threadStatusPayload == null) {
|
||||
return;
|
||||
private void appendConversationInfoItem(android.view.View view) {
|
||||
android.view.ViewGroup.LayoutParams currentParams = view.getLayoutParams();
|
||||
LinearLayout.LayoutParams params;
|
||||
if (currentParams instanceof LinearLayout.LayoutParams) {
|
||||
params = (LinearLayout.LayoutParams) currentParams;
|
||||
} else {
|
||||
params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
}
|
||||
JSONObject document = threadStatusPayload.optJSONObject("threadStatusDocument");
|
||||
if (document == null) {
|
||||
return;
|
||||
}
|
||||
JSONArray recentProgressEvents = threadStatusPayload.optJSONArray("recentProgressEvents");
|
||||
int eventCount = recentProgressEvents == null ? 0 : recentProgressEvents.length();
|
||||
String body = buildThreadStatusSummaryBody(document, eventCount);
|
||||
String meta = buildThreadStatusSummaryMeta(document, eventCount);
|
||||
appendContent(BossUi.buildCard(this, "线程状态摘要", body, meta));
|
||||
}
|
||||
|
||||
private String buildThreadStatusSummaryBody(JSONObject document, int eventCount) {
|
||||
return joinNonEmptyLines(
|
||||
formatSummaryLine("当前目标", document.optString("projectGoal", "")),
|
||||
formatSummaryLine("当前进度", document.optString("currentProgress", "")),
|
||||
formatSummaryLine("当前阻塞", document.optString("currentBlockers", "")),
|
||||
formatSummaryLine("建议下一步", document.optString("recommendedNextStep", "")),
|
||||
eventCount > 0 ? "最近进展:" + eventCount + " 条" : ""
|
||||
);
|
||||
}
|
||||
|
||||
private String buildThreadStatusSummaryMeta(JSONObject document, int eventCount) {
|
||||
return joinNonEmptyParts(
|
||||
projectFolderName,
|
||||
eventCount > 0 ? "最近 " + eventCount + " 条进展" : "暂无进展",
|
||||
document.optString("updatedAt", "").isEmpty() ? "" : "更新于 " + document.optString("updatedAt", "")
|
||||
);
|
||||
}
|
||||
|
||||
private String formatSummaryLine(String label, String value) {
|
||||
String trimmed = value == null ? "" : value.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return label + ":" + trimmed;
|
||||
}
|
||||
|
||||
private String joinNonEmptyLines(String... values) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (String value : values) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append('\n');
|
||||
}
|
||||
builder.append(value.trim());
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String joinNonEmptyParts(String... values) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (String value : values) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(value.trim());
|
||||
}
|
||||
return builder.toString();
|
||||
params.bottomMargin = BossUi.dp(this, 8);
|
||||
view.setLayoutParams(params);
|
||||
appendContent(view);
|
||||
}
|
||||
|
||||
private LinearLayout buildParticipantRow(JSONObject participant) {
|
||||
@@ -445,6 +386,10 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
Intent result = new Intent();
|
||||
result.putExtra(EXTRA_PROJECT_NAME, projectName);
|
||||
result.putExtra(EXTRA_TAKEOVER_ENABLED, enabled);
|
||||
setResult(RESULT_OK, result);
|
||||
showMessage(enabled ? "已开启主 Agent 协同接管" : "已关闭主 Agent 协同接管");
|
||||
reload();
|
||||
});
|
||||
@@ -517,25 +462,6 @@ public class ConversationInfoActivity extends BossScreenActivity {
|
||||
return folder + " · " + suffix;
|
||||
}
|
||||
|
||||
private String buildHeaderDetail(JSONObject project, @Nullable JSONObject threadMeta, int count) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
String threadId = resolveThreadId(project, threadMeta);
|
||||
if (!threadId.isEmpty()) {
|
||||
builder.append(threadId);
|
||||
}
|
||||
if (!projectFolderName.isEmpty()) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(projectFolderName);
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(count <= 0 ? "暂无参与线程" : count + " 个参与线程");
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String resolveThreadId(JSONObject project, @Nullable JSONObject threadMeta) {
|
||||
if (threadMeta != null) {
|
||||
String threadId = threadMeta.optString("threadId", "");
|
||||
|
||||
@@ -178,6 +178,90 @@ 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,
|
||||
"默认执行模式",
|
||||
@@ -186,6 +270,30 @@ 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,
|
||||
@@ -283,6 +391,19 @@ 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 {
|
||||
@@ -298,6 +419,34 @@ public class DeviceDetailActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void queueCodexRemoteControl(String action) {
|
||||
if (deviceId == null || deviceId.trim().isEmpty()) {
|
||||
showMessage("缺少设备 ID");
|
||||
return;
|
||||
}
|
||||
boolean startAction = "start".equals(action);
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.queueCodexRemoteControl(
|
||||
deviceId,
|
||||
action,
|
||||
startAction ? "APP 设备详情页确认启动 Codex 远控" : "APP 设备详情页确认停止 Codex 远控"
|
||||
);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage(startAction ? "已提交启动远控" : "已提交停止远控");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage((startAction ? "启动远控失败:" : "停止远控失败:") + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void savePreferredExecutionMode(String preferredExecutionMode) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
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;
|
||||
@@ -21,6 +30,7 @@ import android.widget.ScrollView;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.OnBackPressedCallback;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.recyclerview.widget.DiffUtil;
|
||||
@@ -43,14 +53,18 @@ 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;
|
||||
@@ -62,7 +76,16 @@ public class MainActivity extends AppCompatActivity {
|
||||
private View mainTopBar;
|
||||
private TextView loginTitle;
|
||||
private TextView loginHint;
|
||||
private EditText loginAccountInput;
|
||||
private EditText loginPasswordInput;
|
||||
private EditText loginConfirmPasswordInput;
|
||||
private EditText loginCodeInput;
|
||||
private View loginCodeRow;
|
||||
private Button loginSendCodeButton;
|
||||
private Button loginButton;
|
||||
private Button loginModeButton;
|
||||
private Button registerModeButton;
|
||||
private Button forgotModeButton;
|
||||
private ProgressBar loginProgress;
|
||||
|
||||
private ImageButton backButton;
|
||||
@@ -90,6 +113,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
private String activeTab = "conversations";
|
||||
private String preferredEntryTab = "conversations";
|
||||
private @Nullable String requestedInitialTab;
|
||||
private String authMode = "login";
|
||||
private boolean userSelectedTab = false;
|
||||
private long lastRootBackPressedAt = 0L;
|
||||
private @Nullable JSONObject sessionData;
|
||||
@@ -105,9 +129,11 @@ public class MainActivity extends AppCompatActivity {
|
||||
private boolean conversationQuickActionsVisible = false;
|
||||
private boolean conversationAutoRefreshArmed = false;
|
||||
private boolean conversationAutoRefreshEnabled = false;
|
||||
private boolean conversationRootUsesGroupedHomeFeed = false;
|
||||
private boolean rootTabRefreshInFlight = false;
|
||||
private boolean pendingRootTabRefresh = false;
|
||||
private boolean realtimeRefreshScheduled = false;
|
||||
private boolean notificationPermissionRequestScheduled = false;
|
||||
private final java.util.HashMap<String, Long> recentRealtimeEventTimestamps = new java.util.HashMap<>();
|
||||
private final Set<String> selectedConversationProjectIds = new LinkedHashSet<>();
|
||||
private @Nullable RootPagerAdapter rootPagerAdapter;
|
||||
@@ -140,18 +166,45 @@ public class MainActivity extends AppCompatActivity {
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
apiClient = new BossApiClient(this);
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
apiClient = createApiClient();
|
||||
realtimeClient = createRealtimeClient(apiClient);
|
||||
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();
|
||||
@@ -159,32 +212,57 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
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() {
|
||||
if (contentPanel.getVisibility() == View.VISIBLE && conversationSearchMode) {
|
||||
exitConversationSearchMode(true);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (contentPanel.getVisibility() == View.VISIBLE && conversationQuickActionsVisible) {
|
||||
hideConversationQuickActions(true);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (contentPanel.getVisibility() == View.VISIBLE && !"conversations".equals(activeTab)) {
|
||||
setActiveTab("conversations", false);
|
||||
persistLastRootTab("conversations");
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (contentPanel.getVisibility() == View.VISIBLE) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - lastRootBackPressedAt < ROOT_BACK_EXIT_WINDOW_MS) {
|
||||
moveTaskToBack(true);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
lastRootBackPressedAt = now;
|
||||
showMessage("再按一次返回,应用进入后台");
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
super.onBackPressed();
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -192,6 +270,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
cancelConversationAutoRefresh();
|
||||
cancelRealtimeRefreshSchedule();
|
||||
stopRealtimeUpdates();
|
||||
sessionExecutor.shutdownNow();
|
||||
executor.shutdownNow();
|
||||
super.onDestroy();
|
||||
}
|
||||
@@ -202,6 +281,17 @@ public class MainActivity extends AppCompatActivity {
|
||||
conversationAutoRefreshEnabled = true;
|
||||
updateConversationAutoRefresh();
|
||||
updateRealtimeSubscription();
|
||||
maybeRequestNotificationPermission();
|
||||
if (
|
||||
contentPanel != null &&
|
||||
contentPanel.getVisibility() == View.VISIBLE &&
|
||||
"conversations".equals(activeTab) &&
|
||||
apiClient != null &&
|
||||
apiClient.hasSessionHints() &&
|
||||
!rootTabRefreshInFlight
|
||||
) {
|
||||
refreshConversationsData();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -220,7 +310,16 @@ public class MainActivity extends AppCompatActivity {
|
||||
mainTopBar = findViewById(R.id.main_top_bar);
|
||||
loginTitle = findViewById(R.id.login_title);
|
||||
loginHint = findViewById(R.id.login_hint);
|
||||
loginAccountInput = findViewById(R.id.login_account_input);
|
||||
loginPasswordInput = findViewById(R.id.login_password_input);
|
||||
loginConfirmPasswordInput = findViewById(R.id.login_confirm_password_input);
|
||||
loginCodeInput = findViewById(R.id.login_code_input);
|
||||
loginCodeRow = findViewById(R.id.login_code_row);
|
||||
loginSendCodeButton = findViewById(R.id.login_send_code_button);
|
||||
loginButton = findViewById(R.id.login_button);
|
||||
loginModeButton = findViewById(R.id.login_mode_button);
|
||||
registerModeButton = findViewById(R.id.register_mode_button);
|
||||
forgotModeButton = findViewById(R.id.forgot_mode_button);
|
||||
loginProgress = findViewById(R.id.login_progress);
|
||||
backButton = findViewById(R.id.back_button);
|
||||
topTitle = findViewById(R.id.top_title);
|
||||
@@ -247,12 +346,17 @@ public class MainActivity extends AppCompatActivity {
|
||||
loginTitle.setText(WechatSurfaceMapper.loginTitle());
|
||||
loginHint.setText(WechatSurfaceMapper.loginHintText());
|
||||
loginButton.setText(WechatSurfaceMapper.loginButtonLabel());
|
||||
setAuthMode("login", WechatSurfaceMapper.loginHintText());
|
||||
BossWindowInsets.applyStatusBarInset(loginShell);
|
||||
BossWindowInsets.applyStatusBarInset(mainTopBar);
|
||||
}
|
||||
|
||||
private void bindActions() {
|
||||
loginButton.setOnClickListener(v -> performAutoLogin());
|
||||
loginButton.setOnClickListener(v -> performPrimaryAuthAction());
|
||||
loginSendCodeButton.setOnClickListener(v -> sendAuthVerificationCode());
|
||||
loginModeButton.setOnClickListener(v -> setAuthMode("login", "请输入账号和密码登录。"));
|
||||
registerModeButton.setOnClickListener(v -> setAuthMode("register", "注册后会自动登录并进入会话。"));
|
||||
forgotModeButton.setOnClickListener(v -> setAuthMode("forgot", "通过验证码重置密码后再登录。"));
|
||||
backButton.setVisibility(View.GONE);
|
||||
backButton.setOnClickListener(v -> {
|
||||
if (conversationSearchMode) {
|
||||
@@ -336,7 +440,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
setLoginLoading(true, "正在恢复上次登录状态...");
|
||||
executor.execute(() -> {
|
||||
sessionExecutor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse sessionResponse = apiClient.getSession();
|
||||
if (!sessionResponse.ok()) {
|
||||
@@ -353,15 +457,52 @@ public class MainActivity extends AppCompatActivity {
|
||||
} catch (Exception ignored) {
|
||||
// Fall back to login panel.
|
||||
}
|
||||
runOnUiThread(() -> setLoginLoading(false, WechatSurfaceMapper.loginHintText()));
|
||||
runOnUiThread(() -> setLoginLoading(false, "登录已过期,请重新输入账号密码。"));
|
||||
});
|
||||
}
|
||||
|
||||
private void performAutoLogin() {
|
||||
setLoginLoading(true, "正在创建会话...");
|
||||
executor.execute(() -> {
|
||||
private void performPrimaryAuthAction() {
|
||||
String account = inputText(loginAccountInput);
|
||||
String password = inputText(loginPasswordInput);
|
||||
String confirmPassword = inputText(loginConfirmPasswordInput);
|
||||
String code = inputText(loginCodeInput);
|
||||
if (account.isEmpty()) {
|
||||
setLoginLoading(false, "请先填写账号。");
|
||||
return;
|
||||
}
|
||||
if (password.isEmpty()) {
|
||||
setLoginLoading(false, "请先填写密码。");
|
||||
return;
|
||||
}
|
||||
if (!"login".equals(authMode) && confirmPassword.isEmpty()) {
|
||||
setLoginLoading(false, "请再次确认密码。");
|
||||
return;
|
||||
}
|
||||
if (!"login".equals(authMode) && !password.equals(confirmPassword)) {
|
||||
setLoginLoading(false, "两次输入的密码不一致。");
|
||||
return;
|
||||
}
|
||||
if (!"login".equals(authMode) && code.isEmpty()) {
|
||||
setLoginLoading(false, "请先填写验证码。");
|
||||
return;
|
||||
}
|
||||
|
||||
if ("register".equals(authMode)) {
|
||||
performRegisterAndLogin(account, password, confirmPassword, code);
|
||||
return;
|
||||
}
|
||||
if ("forgot".equals(authMode)) {
|
||||
performPasswordReset(account, password, confirmPassword, code);
|
||||
return;
|
||||
}
|
||||
performPasswordLogin(account, password);
|
||||
}
|
||||
|
||||
private void performPasswordLogin(String account, String password) {
|
||||
setLoginLoading(true, "正在登录...");
|
||||
sessionExecutor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.autoLogin();
|
||||
BossApiClient.ApiResponse response = apiClient.loginWithPassword(account, password);
|
||||
if (response.ok()) {
|
||||
JSONObject session = response.json.optJSONObject("session");
|
||||
runOnUiThread(() -> {
|
||||
@@ -377,6 +518,78 @@ public class MainActivity extends AppCompatActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void performRegisterAndLogin(String account, String password, String confirmPassword, String code) {
|
||||
setLoginLoading(true, "正在注册...");
|
||||
sessionExecutor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse registerResponse = apiClient.registerAccount(
|
||||
account,
|
||||
password,
|
||||
confirmPassword,
|
||||
code
|
||||
);
|
||||
if (!registerResponse.ok()) {
|
||||
runOnUiThread(() -> setLoginLoading(false, "注册失败:" + registerResponse.message()));
|
||||
return;
|
||||
}
|
||||
BossApiClient.ApiResponse loginResponse = apiClient.loginWithPassword(account, password);
|
||||
if (loginResponse.ok()) {
|
||||
JSONObject session = loginResponse.json.optJSONObject("session");
|
||||
runOnUiThread(() -> {
|
||||
showContent();
|
||||
refreshAllData(session);
|
||||
});
|
||||
return;
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
setAuthMode("login", "注册成功,请用刚才的账号密码登录。");
|
||||
setLoginLoading(false, "注册成功,请用刚才的账号密码登录。");
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> setLoginLoading(false, "注册链路异常:" + error.getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void performPasswordReset(String account, String password, String confirmPassword, String code) {
|
||||
setLoginLoading(true, "正在重置密码...");
|
||||
sessionExecutor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.resetPassword(account, password, confirmPassword, code);
|
||||
if (response.ok()) {
|
||||
runOnUiThread(() -> {
|
||||
clearSecretInputs();
|
||||
setAuthMode("login", "密码已重置,请使用新密码登录。");
|
||||
});
|
||||
return;
|
||||
}
|
||||
runOnUiThread(() -> setLoginLoading(false, "重置失败:" + response.message()));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> setLoginLoading(false, "重置链路异常:" + error.getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void sendAuthVerificationCode() {
|
||||
String account = inputText(loginAccountInput);
|
||||
if (account.isEmpty()) {
|
||||
setLoginLoading(false, "请先填写账号。");
|
||||
return;
|
||||
}
|
||||
String purpose = "forgot".equals(authMode) ? "forgot-password" : "register";
|
||||
setLoginLoading(true, "正在发送验证码...");
|
||||
sessionExecutor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.sendVerificationCode(account, purpose);
|
||||
runOnUiThread(() -> setLoginLoading(false, response.ok()
|
||||
? "验证码已发送,请查看对应邮箱或短信。"
|
||||
: "验证码发送失败:" + response.message()));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> setLoginLoading(false, "验证码链路异常:" + error.getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void refreshCurrentTab() {
|
||||
if (rootTabRefreshInFlight) {
|
||||
pendingRootTabRefresh = true;
|
||||
@@ -401,9 +614,11 @@ public class MainActivity extends AppCompatActivity {
|
||||
JSONObject session = ensureActiveSession();
|
||||
BossApiClient.ApiResponse conversations = null;
|
||||
boolean conversationsOk = false;
|
||||
boolean usedGroupedHomeFeed = false;
|
||||
try {
|
||||
conversations = apiClient.getConversationHome();
|
||||
conversationsOk = conversations.ok();
|
||||
usedGroupedHomeFeed = conversationsOk;
|
||||
} catch (Exception ignored) {
|
||||
conversationsOk = false;
|
||||
}
|
||||
@@ -413,6 +628,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (fallbackConversations.ok()) {
|
||||
conversations = fallbackConversations;
|
||||
conversationsOk = true;
|
||||
usedGroupedHomeFeed = false;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
conversationsOk = false;
|
||||
@@ -421,18 +637,24 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
BossApiClient.ApiResponse finalConversations = conversations;
|
||||
final boolean finalConversationsOk = conversationsOk;
|
||||
final boolean finalUsedGroupedHomeFeed = usedGroupedHomeFeed;
|
||||
runOnUiThread(() -> {
|
||||
sessionData = session;
|
||||
JSONArray refreshedConversations = finalConversations == null
|
||||
? null
|
||||
: WechatSurfaceMapper.normalizeConversationHomeFeed(
|
||||
finalConversations.json.optJSONArray("conversations")
|
||||
);
|
||||
: finalUsedGroupedHomeFeed
|
||||
? finalConversations.json.optJSONArray("conversations")
|
||||
: WechatSurfaceMapper.normalizeConversationHomeFeed(
|
||||
finalConversations.json.optJSONArray("conversations")
|
||||
);
|
||||
conversationsData = WechatSurfaceMapper.resolveRefreshValue(
|
||||
conversationsData,
|
||||
refreshedConversations,
|
||||
finalConversationsOk
|
||||
);
|
||||
if (finalConversationsOk) {
|
||||
conversationRootUsesGroupedHomeFeed = finalUsedGroupedHomeFeed;
|
||||
}
|
||||
maybeApplyPreferredEntry();
|
||||
renderCurrentTab();
|
||||
startRefreshing(false);
|
||||
@@ -600,7 +822,11 @@ public class MainActivity extends AppCompatActivity {
|
||||
return false;
|
||||
}
|
||||
JSONObject conversationItem = event.payload.optJSONObject("conversationItem");
|
||||
if (conversationItem == null) {
|
||||
JSONObject threadConversationItem = event.payload.optJSONObject("threadConversationItem");
|
||||
JSONObject patchItem = conversationRootUsesGroupedHomeFeed
|
||||
? (conversationItem != null ? conversationItem : threadConversationItem)
|
||||
: (threadConversationItem != null ? threadConversationItem : conversationItem);
|
||||
if (patchItem == null) {
|
||||
return false;
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
@@ -610,7 +836,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
conversationsData = WechatSurfaceMapper.mergeConversationHomeItem(
|
||||
conversationsData,
|
||||
conversationItem,
|
||||
patchItem,
|
||||
affectedProjectId
|
||||
);
|
||||
renderCurrentTab();
|
||||
@@ -653,7 +879,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private boolean shouldRefreshConversationsTab(BossRealtimeEvent event) {
|
||||
if ("conversation.context_indicator.updated".equals(event.eventName)) {
|
||||
return false;
|
||||
return hasProjectId(event)
|
||||
|| hasDeviceId(event)
|
||||
|| event.payload.optJSONArray("conversations") != null;
|
||||
}
|
||||
if ("conversation.updated".equals(event.eventName)) {
|
||||
return hasProjectId(event) || hasDeviceId(event);
|
||||
@@ -718,6 +946,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
BossApiClient.ApiResponse ota = null;
|
||||
BossApiClient.ApiResponse settings = null;
|
||||
boolean conversationsOk = false;
|
||||
boolean usedGroupedHomeFeed = false;
|
||||
boolean devicesOk = false;
|
||||
boolean otaOk = false;
|
||||
boolean settingsOk = false;
|
||||
@@ -725,6 +954,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
try {
|
||||
conversations = apiClient.getConversationHome();
|
||||
conversationsOk = conversations.ok();
|
||||
usedGroupedHomeFeed = conversationsOk;
|
||||
} catch (Exception ignored) {
|
||||
conversationsOk = false;
|
||||
}
|
||||
@@ -734,6 +964,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (fallbackConversations.ok()) {
|
||||
conversations = fallbackConversations;
|
||||
conversationsOk = true;
|
||||
usedGroupedHomeFeed = false;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
conversationsOk = false;
|
||||
@@ -764,6 +995,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
BossApiClient.ApiResponse finalOta = ota;
|
||||
BossApiClient.ApiResponse finalSettings = settings;
|
||||
final boolean finalConversationsOk = conversationsOk;
|
||||
final boolean finalUsedGroupedHomeFeed = usedGroupedHomeFeed;
|
||||
final boolean finalDevicesOk = devicesOk;
|
||||
final boolean finalOtaOk = otaOk;
|
||||
final boolean finalSettingsOk = settingsOk;
|
||||
@@ -771,14 +1003,19 @@ public class MainActivity extends AppCompatActivity {
|
||||
sessionData = finalSession;
|
||||
JSONArray refreshedConversations = finalConversations == null
|
||||
? null
|
||||
: WechatSurfaceMapper.normalizeConversationHomeFeed(
|
||||
finalConversations.json.optJSONArray("conversations")
|
||||
);
|
||||
: finalUsedGroupedHomeFeed
|
||||
? finalConversations.json.optJSONArray("conversations")
|
||||
: WechatSurfaceMapper.normalizeConversationHomeFeed(
|
||||
finalConversations.json.optJSONArray("conversations")
|
||||
);
|
||||
conversationsData = WechatSurfaceMapper.resolveRefreshValue(
|
||||
conversationsData,
|
||||
refreshedConversations,
|
||||
finalConversationsOk
|
||||
);
|
||||
if (finalConversationsOk) {
|
||||
conversationRootUsesGroupedHomeFeed = finalUsedGroupedHomeFeed;
|
||||
}
|
||||
devicesData = WechatSurfaceMapper.resolveRefreshValue(
|
||||
devicesData,
|
||||
finalDevices == null ? null : finalDevices.json.optJSONArray("devices"),
|
||||
@@ -854,6 +1091,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
private void showLogin(String hint) {
|
||||
loginPanel.setVisibility(View.VISIBLE);
|
||||
contentPanel.setVisibility(View.GONE);
|
||||
setAuthMode("login", hint);
|
||||
setLoginLoading(false, hint);
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
@@ -864,15 +1102,76 @@ public class MainActivity extends AppCompatActivity {
|
||||
setActiveTab(activeTab, false);
|
||||
updateConversationAutoRefresh();
|
||||
updateRealtimeSubscription();
|
||||
scheduleNotificationPermissionRequest();
|
||||
}
|
||||
|
||||
private void setLoginLoading(boolean loading, String hint) {
|
||||
loginProgress.setVisibility(loading ? View.VISIBLE : View.GONE);
|
||||
loginButton.setEnabled(!loading);
|
||||
loginButton.setText(loading ? "处理中..." : WechatSurfaceMapper.loginButtonLabel());
|
||||
loginSendCodeButton.setEnabled(!loading);
|
||||
loginModeButton.setEnabled(!loading);
|
||||
registerModeButton.setEnabled(!loading);
|
||||
forgotModeButton.setEnabled(!loading);
|
||||
loginButton.setText(loading ? "处理中..." : primaryAuthButtonLabel());
|
||||
loginHint.setText(hint);
|
||||
}
|
||||
|
||||
private void setAuthMode(String mode, String hint) {
|
||||
authMode = ("register".equals(mode) || "forgot".equals(mode)) ? mode : "login";
|
||||
boolean codeMode = !"login".equals(authMode);
|
||||
loginTitle.setText(authTitle());
|
||||
loginButton.setText(primaryAuthButtonLabel());
|
||||
loginPasswordInput.setHint("forgot".equals(authMode) ? "新密码" : "密码");
|
||||
loginConfirmPasswordInput.setVisibility(codeMode ? View.VISIBLE : View.GONE);
|
||||
loginCodeRow.setVisibility(codeMode ? View.VISIBLE : View.GONE);
|
||||
loginHint.setText(hint);
|
||||
tintAuthModeButtons();
|
||||
}
|
||||
|
||||
private String authTitle() {
|
||||
if ("register".equals(authMode)) {
|
||||
return "注册账号";
|
||||
}
|
||||
if ("forgot".equals(authMode)) {
|
||||
return "找回密码";
|
||||
}
|
||||
return "登录 Boss";
|
||||
}
|
||||
|
||||
private String primaryAuthButtonLabel() {
|
||||
if ("register".equals(authMode)) {
|
||||
return "注册并登录";
|
||||
}
|
||||
if ("forgot".equals(authMode)) {
|
||||
return "重置密码";
|
||||
}
|
||||
return "登录";
|
||||
}
|
||||
|
||||
private void tintAuthModeButtons() {
|
||||
int selectedColor = getColor(R.color.boss_green);
|
||||
int mutedColor = getColor(R.color.boss_text_muted);
|
||||
loginModeButton.setTextColor("login".equals(authMode) ? selectedColor : mutedColor);
|
||||
registerModeButton.setTextColor("register".equals(authMode) ? selectedColor : mutedColor);
|
||||
forgotModeButton.setTextColor("forgot".equals(authMode) ? selectedColor : mutedColor);
|
||||
}
|
||||
|
||||
private String inputText(EditText input) {
|
||||
return input == null || input.getText() == null ? "" : input.getText().toString().trim();
|
||||
}
|
||||
|
||||
private void clearSecretInputs() {
|
||||
if (loginPasswordInput != null) {
|
||||
loginPasswordInput.setText("");
|
||||
}
|
||||
if (loginConfirmPasswordInput != null) {
|
||||
loginConfirmPasswordInput.setText("");
|
||||
}
|
||||
if (loginCodeInput != null) {
|
||||
loginCodeInput.setText("");
|
||||
}
|
||||
}
|
||||
|
||||
private void setActiveTab(String tab, boolean fromUser) {
|
||||
if (!"conversations".equals(tab)) {
|
||||
exitConversationSelectionMode();
|
||||
@@ -931,14 +1230,27 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
private void updateTabStyles() {
|
||||
styleTab(tabConversations, "conversations".equals(activeTab));
|
||||
styleTab(tabDevices, "devices".equals(activeTab));
|
||||
styleTab(tabMe, "me".equals(activeTab));
|
||||
styleTab(tabConversations, "conversations".equals(activeTab), R.drawable.ic_boss_tab_chat);
|
||||
styleTab(tabDevices, "devices".equals(activeTab), R.drawable.ic_boss_tab_devices);
|
||||
styleTab(tabMe, "me".equals(activeTab), R.drawable.ic_boss_tab_me);
|
||||
}
|
||||
|
||||
private void styleTab(Button button, boolean active) {
|
||||
button.setBackgroundResource(active ? R.drawable.bg_tab_active : R.drawable.bg_tab_inactive);
|
||||
button.setTextColor(getColor(active ? R.color.boss_green : R.color.boss_text_muted));
|
||||
private void styleTab(Button button, boolean active, int iconRes) {
|
||||
int color = getColor(active ? R.color.boss_green : R.color.boss_text_muted);
|
||||
button.setBackgroundColor(Color.TRANSPARENT);
|
||||
button.setTextColor(color);
|
||||
button.setTextSize(10);
|
||||
button.setAllCaps(false);
|
||||
button.setGravity(android.view.Gravity.CENTER);
|
||||
Drawable topIcon = getDrawable(iconRes);
|
||||
if (topIcon != null) {
|
||||
int iconSize = BossUi.dp(this, 18);
|
||||
topIcon.setBounds(0, 0, iconSize, iconSize);
|
||||
}
|
||||
button.setCompoundDrawables(null, topIcon, null, null);
|
||||
button.setCompoundDrawablePadding(BossUi.dp(this, 2));
|
||||
button.setCompoundDrawableTintList(ColorStateList.valueOf(color));
|
||||
button.setPadding(0, BossUi.dp(this, 3), 0, BossUi.dp(this, 1));
|
||||
}
|
||||
|
||||
private void configureTopAction(WechatSurfaceMapper.RootTopAction action) {
|
||||
@@ -959,7 +1271,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
topSearchInput.setVisibility(View.GONE);
|
||||
backButton.setVisibility(View.GONE);
|
||||
searchButton.setVisibility(View.GONE);
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, false);
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, false, false, currentSessionRole());
|
||||
refreshButton.setVisibility(View.VISIBLE);
|
||||
configureTopAction(action);
|
||||
}
|
||||
@@ -994,7 +1306,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
refreshButton.setEnabled(true);
|
||||
return;
|
||||
}
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode);
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction(activeTab, refreshing, conversationSelectionMode, currentSessionRole());
|
||||
configureTopAction(action);
|
||||
refreshButton.setEnabled(!"refresh".equals(action.actionKey) || !refreshing);
|
||||
refreshButton.setAlpha(refreshing && "refresh".equals(action.actionKey) ? 0.45f : 1f);
|
||||
@@ -1025,7 +1337,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
toggleConversationQuickActions();
|
||||
return;
|
||||
}
|
||||
String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode).actionKey;
|
||||
String actionKey = WechatSurfaceMapper.rootTopAction(activeTab, false, conversationSelectionMode, currentSessionRole()).actionKey;
|
||||
if ("add_device".equals(actionKey)) {
|
||||
startActivity(new Intent(this, DeviceEnrollmentActivity.class));
|
||||
return;
|
||||
@@ -1166,6 +1478,24 @@ 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),
|
||||
@@ -1180,6 +1510,9 @@ public class MainActivity extends AppCompatActivity {
|
||||
return;
|
||||
}
|
||||
String projectName = finalDisplayRow.threadTitle.isEmpty() ? "未命名会话" : finalDisplayRow.threadTitle;
|
||||
if (conversationSearchMode) {
|
||||
exitConversationSearchMode(true);
|
||||
}
|
||||
openProject(projectId, projectName);
|
||||
})
|
||||
));
|
||||
@@ -1247,14 +1580,14 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (count > 0) {
|
||||
TextView selectedView = new TextView(this);
|
||||
selectedView.setText("已选 " + count + " 个线程");
|
||||
selectedView.setTextSize(13);
|
||||
selectedView.setTextSize(12);
|
||||
selectedView.setTextColor(getColor(R.color.boss_text_primary));
|
||||
summaryWrap.addView(selectedView);
|
||||
}
|
||||
if (count < 2) {
|
||||
TextView hintView = new TextView(this);
|
||||
hintView.setText("至少选择 2 个线程");
|
||||
hintView.setTextSize(12);
|
||||
hintView.setTextSize(11);
|
||||
hintView.setTextColor(getColor(R.color.boss_text_muted));
|
||||
if (count > 0) {
|
||||
hintView.setPadding(0, BossUi.dp(this, 4), 0, 0);
|
||||
@@ -1292,10 +1625,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
hideConversationQuickActions(false);
|
||||
conversationSearchMode = true;
|
||||
syncTopActionVisualState(screenRefresh.isRefreshing());
|
||||
topSearchInput.post(() -> {
|
||||
topSearchInput.requestFocus();
|
||||
topSearchInput.setSelection(topSearchInput.getText().length());
|
||||
});
|
||||
showConversationSearchKeyboard();
|
||||
}
|
||||
|
||||
private void exitConversationSearchMode(boolean clearQuery) {
|
||||
@@ -1308,12 +1638,40 @@ 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);
|
||||
@@ -1494,6 +1852,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
private void prepareConversationQuickActionMenu() {
|
||||
quickActionAddDevice.setVisibility("highest_admin".equals(currentSessionRole()) ? View.VISIBLE : View.GONE);
|
||||
conversationQuickActionsMenu.setVisibility(View.VISIBLE);
|
||||
conversationQuickActionsMenu.setAlpha(0f);
|
||||
conversationQuickActionsMenu.setTranslationY(-BossUi.dp(this, 6));
|
||||
@@ -1504,6 +1863,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
conversationQuickActionsMenu.setAlpha(0f);
|
||||
conversationQuickActionsMenu.setTranslationY(-BossUi.dp(this, 6));
|
||||
conversationQuickActionsMenu.setVisibility(View.GONE);
|
||||
quickActionAddDevice.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
static boolean matchesConversationQuery(JSONObject item, String rawQuery) {
|
||||
@@ -1657,7 +2017,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
(roleLabel.isEmpty() ? "主控账号已启用安全保护" : roleLabel + " · 主控账号已启用安全保护")
|
||||
));
|
||||
|
||||
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItems()) {
|
||||
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItemsForRole(currentSessionRole())) {
|
||||
screenContent.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
item.title,
|
||||
@@ -1807,15 +2167,66 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private void maybeRequestNotificationPermission() {
|
||||
notificationPermissionRequestScheduled = false;
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
return;
|
||||
}
|
||||
if (contentPanel == null || contentPanel.getVisibility() != View.VISIBLE) {
|
||||
return;
|
||||
}
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
|
||||
return;
|
||||
}
|
||||
android.content.SharedPreferences prefs = getSharedPreferences(UI_PREFS, Context.MODE_PRIVATE);
|
||||
if (prefs.getBoolean(KEY_NOTIFICATION_PERMISSION_REQUESTED, false)) {
|
||||
return;
|
||||
}
|
||||
prefs.edit().putBoolean(KEY_NOTIFICATION_PERMISSION_REQUESTED, true).apply();
|
||||
requestPermissions(new String[]{Manifest.permission.POST_NOTIFICATIONS}, REQUEST_POST_NOTIFICATIONS);
|
||||
}
|
||||
|
||||
private void scheduleNotificationPermissionRequest() {
|
||||
if (notificationPermissionRequestScheduled) {
|
||||
return;
|
||||
}
|
||||
notificationPermissionRequestScheduled = true;
|
||||
uiHandler.postDelayed(this::maybeRequestNotificationPermission, 450L);
|
||||
}
|
||||
|
||||
void handleRealtimeConnectionChanged(boolean connected) {
|
||||
if (!connected
|
||||
&& shouldMaintainConversationAutoRefresh()
|
||||
&& !rootTabRefreshInFlight
|
||||
&& screenRefresh != null
|
||||
&& !screenRefresh.isRefreshing()) {
|
||||
refreshCurrentTab();
|
||||
}
|
||||
updateConversationAutoRefresh();
|
||||
}
|
||||
|
||||
private void openMeEntry(String key) {
|
||||
if (!WechatSurfaceMapper.canOpenMeEntryForRole(key, currentSessionRole())) {
|
||||
showMessage("当前账号没有权限打开这个入口。");
|
||||
return;
|
||||
}
|
||||
Intent intent;
|
||||
switch (key) {
|
||||
case "security":
|
||||
intent = new Intent(this, SecurityActivity.class);
|
||||
break;
|
||||
case "access":
|
||||
intent = new Intent(this, AccessManagementActivity.class);
|
||||
break;
|
||||
case "ai_accounts":
|
||||
intent = new Intent(this, AiAccountsActivity.class);
|
||||
break;
|
||||
case "storage":
|
||||
intent = new Intent(this, StorageSettingsActivity.class);
|
||||
break;
|
||||
case "telegram":
|
||||
intent = new Intent(this, TelegramIntegrationActivity.class);
|
||||
break;
|
||||
case "settings":
|
||||
intent = new Intent(this, SettingsActivity.class);
|
||||
break;
|
||||
@@ -1835,6 +2246,13 @@ public class MainActivity extends AppCompatActivity {
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private String currentSessionRole() {
|
||||
if (sessionData == null) {
|
||||
return "member";
|
||||
}
|
||||
return sessionData.optString("role", "member");
|
||||
}
|
||||
|
||||
private void openSkillInventoryFromMe() {
|
||||
String targetDeviceId = resolveSkillTargetDeviceId();
|
||||
if (targetDeviceId == null || targetDeviceId.isEmpty()) {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
final class MasterAgentModePresets {
|
||||
static final class ModePreset {
|
||||
final String key;
|
||||
final String label;
|
||||
@Nullable final String modelOverride;
|
||||
@Nullable final String reasoningEffortOverride;
|
||||
|
||||
ModePreset(
|
||||
String key,
|
||||
String label,
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride
|
||||
) {
|
||||
this.key = key;
|
||||
this.label = label;
|
||||
this.modelOverride = modelOverride;
|
||||
this.reasoningEffortOverride = reasoningEffortOverride;
|
||||
}
|
||||
}
|
||||
|
||||
static final ModePreset DEFAULT = new ModePreset("default", "沿用默认", null, null);
|
||||
private static final String DEFAULT_FAST_MODEL = "gpt-5.4-mini";
|
||||
private static final String DEFAULT_DEEP_MODEL = "gpt-5.4";
|
||||
|
||||
private MasterAgentModePresets() {}
|
||||
|
||||
static ModePreset[] primaryChoices(@Nullable String fastModelOverride, @Nullable String deepModelOverride) {
|
||||
return new ModePreset[]{
|
||||
DEFAULT,
|
||||
new ModePreset("fast", "快速反应", resolveFastModel(fastModelOverride), "low"),
|
||||
new ModePreset("deep", "深度思考", resolveDeepModel(deepModelOverride), "high")
|
||||
};
|
||||
}
|
||||
|
||||
static String[] primaryChoiceLabels(@Nullable String fastModelOverride, @Nullable String deepModelOverride) {
|
||||
return new String[]{
|
||||
"沿用默认",
|
||||
"快速反应(" + resolveFastModel(fastModelOverride) + ")",
|
||||
"深度思考(" + resolveDeepModel(deepModelOverride) + ")",
|
||||
"更多模型..."
|
||||
};
|
||||
}
|
||||
|
||||
static int findPrimaryChoiceIndex(
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride,
|
||||
@Nullable String fastModelOverride,
|
||||
@Nullable String deepModelOverride
|
||||
) {
|
||||
ModePreset preset = matchPreset(modelOverride, reasoningEffortOverride, fastModelOverride, deepModelOverride);
|
||||
if (preset == null) {
|
||||
return primaryChoiceLabels(fastModelOverride, deepModelOverride).length - 1;
|
||||
}
|
||||
ModePreset[] choices = primaryChoices(fastModelOverride, deepModelOverride);
|
||||
for (int index = 0; index < choices.length; index += 1) {
|
||||
if (choices[index].key.equals(preset.key)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
static ModePreset matchPreset(
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride,
|
||||
@Nullable String fastModelOverride,
|
||||
@Nullable String deepModelOverride
|
||||
) {
|
||||
String model = normalize(modelOverride);
|
||||
String reasoning = normalize(reasoningEffortOverride);
|
||||
if (TextUtils.isEmpty(model) && TextUtils.isEmpty(reasoning)) {
|
||||
return DEFAULT;
|
||||
}
|
||||
for (ModePreset preset : primaryChoices(fastModelOverride, deepModelOverride)) {
|
||||
if (TextUtils.equals(normalize(preset.modelOverride), model)
|
||||
&& TextUtils.equals(normalize(preset.reasoningEffortOverride), reasoning)) {
|
||||
return preset;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String describeCurrentMode(
|
||||
@Nullable String modelOverride,
|
||||
@Nullable String reasoningEffortOverride,
|
||||
@Nullable String fastModelOverride,
|
||||
@Nullable String deepModelOverride
|
||||
) {
|
||||
ModePreset preset = matchPreset(modelOverride, reasoningEffortOverride, fastModelOverride, deepModelOverride);
|
||||
return preset == null ? "自定义" : preset.label;
|
||||
}
|
||||
|
||||
static String resolveFastModel(@Nullable String fastModelOverride) {
|
||||
String resolved = normalize(fastModelOverride);
|
||||
return TextUtils.isEmpty(resolved) ? DEFAULT_FAST_MODEL : resolved;
|
||||
}
|
||||
|
||||
static String resolveDeepModel(@Nullable String deepModelOverride) {
|
||||
String resolved = normalize(deepModelOverride);
|
||||
return TextUtils.isEmpty(resolved) ? DEFAULT_DEEP_MODEL : resolved;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static String normalize(@Nullable String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
if (trimmed.isEmpty() || "null".equalsIgnoreCase(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,30 @@ import java.util.Set;
|
||||
public final class ProjectChatUiState {
|
||||
private ProjectChatUiState() {}
|
||||
|
||||
public static final class MessageDisplayItem {
|
||||
public static final String TYPE_MESSAGE = "message";
|
||||
public static final String TYPE_PROCESS_GROUP = "process_group";
|
||||
|
||||
public final String type;
|
||||
@Nullable
|
||||
public final JSONObject message;
|
||||
public final List<JSONObject> processMessages;
|
||||
|
||||
private MessageDisplayItem(String type, @Nullable JSONObject message, List<JSONObject> processMessages) {
|
||||
this.type = type;
|
||||
this.message = message;
|
||||
this.processMessages = Collections.unmodifiableList(new ArrayList<>(processMessages));
|
||||
}
|
||||
|
||||
private static MessageDisplayItem message(JSONObject message) {
|
||||
return new MessageDisplayItem(TYPE_MESSAGE, message, Collections.emptyList());
|
||||
}
|
||||
|
||||
private static MessageDisplayItem processGroup(List<JSONObject> processMessages) {
|
||||
return new MessageDisplayItem(TYPE_PROCESS_GROUP, null, processMessages);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class SelectionState {
|
||||
public final boolean multiSelecting;
|
||||
public final Set<String> selectedMessageIds;
|
||||
@@ -31,6 +55,7 @@ public final class ProjectChatUiState {
|
||||
public final boolean showMultiSelectBar;
|
||||
public final boolean showRefresh;
|
||||
public final boolean showHeaderAction;
|
||||
public final boolean copyEnabled;
|
||||
public final boolean forwardEnabled;
|
||||
public final String backLabel;
|
||||
public final String title;
|
||||
@@ -42,6 +67,7 @@ public final class ProjectChatUiState {
|
||||
boolean showMultiSelectBar,
|
||||
boolean showRefresh,
|
||||
boolean showHeaderAction,
|
||||
boolean copyEnabled,
|
||||
boolean forwardEnabled,
|
||||
String backLabel,
|
||||
String title,
|
||||
@@ -52,6 +78,7 @@ public final class ProjectChatUiState {
|
||||
this.showMultiSelectBar = showMultiSelectBar;
|
||||
this.showRefresh = showRefresh;
|
||||
this.showHeaderAction = showHeaderAction;
|
||||
this.copyEnabled = copyEnabled;
|
||||
this.forwardEnabled = forwardEnabled;
|
||||
this.backLabel = backLabel;
|
||||
this.title = title;
|
||||
@@ -81,6 +108,77 @@ public final class ProjectChatUiState {
|
||||
return nearBottom || forced;
|
||||
}
|
||||
|
||||
public static List<MessageDisplayItem> buildMessageDisplayItems(@Nullable JSONArray messages) {
|
||||
ArrayList<MessageDisplayItem> items = new ArrayList<>();
|
||||
if (messages == null || messages.length() == 0) {
|
||||
return items;
|
||||
}
|
||||
ArrayList<JSONObject> pendingProcessMessages = new ArrayList<>();
|
||||
for (int i = 0; i < messages.length(); i++) {
|
||||
JSONObject message = messages.optJSONObject(i);
|
||||
if (message == null) {
|
||||
continue;
|
||||
}
|
||||
if (isThreadProcessMessage(message)) {
|
||||
pendingProcessMessages.add(message);
|
||||
continue;
|
||||
}
|
||||
flushProcessGroup(items, pendingProcessMessages);
|
||||
items.add(MessageDisplayItem.message(message));
|
||||
}
|
||||
flushProcessGroup(items, pendingProcessMessages);
|
||||
return items;
|
||||
}
|
||||
|
||||
public static boolean hasThreadProcessFoldCandidates(@Nullable JSONArray messages, int startIndex) {
|
||||
if (messages == null || messages.length() == 0) {
|
||||
return false;
|
||||
}
|
||||
int firstIndex = Math.max(0, startIndex);
|
||||
for (int i = firstIndex; i < messages.length(); i++) {
|
||||
JSONObject message = messages.optJSONObject(i);
|
||||
if (message != null && isThreadProcessMessage(message)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static String processGroupPreview(@Nullable MessageDisplayItem item) {
|
||||
if (item == null || item.processMessages.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
JSONObject latestMessage = item.processMessages.get(item.processMessages.size() - 1);
|
||||
return truncate(latestMessage.optString("body", ""), 52);
|
||||
}
|
||||
|
||||
public static String processGroupDetail(@Nullable MessageDisplayItem item) {
|
||||
if (item == null || item.processMessages.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < item.processMessages.size(); i++) {
|
||||
JSONObject message = item.processMessages.get(i);
|
||||
String body = compactBody(message.optString("body", ""));
|
||||
if (body.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append("\n\n");
|
||||
}
|
||||
builder.append(i + 1).append(". ").append(body);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static void flushProcessGroup(List<MessageDisplayItem> items, List<JSONObject> pendingProcessMessages) {
|
||||
if (pendingProcessMessages.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
items.add(MessageDisplayItem.processGroup(pendingProcessMessages));
|
||||
pendingProcessMessages.clear();
|
||||
}
|
||||
|
||||
public static String threadExecutionConflictTitle(@Nullable JSONObject conflict) {
|
||||
if (conflict == null) {
|
||||
return "当前线程命中冲突保护";
|
||||
@@ -149,6 +247,10 @@ public final class ProjectChatUiState {
|
||||
return state != null && state.multiSelecting && state.selectedMessageIds.size() >= 2;
|
||||
}
|
||||
|
||||
public static boolean canCopySelection(@Nullable SelectionState state) {
|
||||
return state != null && state.multiSelecting && !state.selectedMessageIds.isEmpty();
|
||||
}
|
||||
|
||||
public static SelectionState reconcileSelection(
|
||||
@Nullable SelectionState current,
|
||||
@Nullable List<String> availableMessageIds
|
||||
@@ -181,6 +283,7 @@ public final class ProjectChatUiState {
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
canCopySelection(selectionState),
|
||||
canForwardSelection(selectionState),
|
||||
"取消",
|
||||
"已选 " + selectedCount + " 条",
|
||||
@@ -194,6 +297,7 @@ public final class ProjectChatUiState {
|
||||
!conversationInfoReady,
|
||||
conversationInfoReady,
|
||||
false,
|
||||
false,
|
||||
"返回",
|
||||
isBlank(defaultTitle) ? "项目详情" : defaultTitle,
|
||||
isBlank(defaultSubtitle) ? "原生页面" : defaultSubtitle
|
||||
@@ -420,6 +524,13 @@ public final class ProjectChatUiState {
|
||||
if ("completed".equals(taskStatus) || "failed".equals(taskStatus)) {
|
||||
return new ReplyWaitSpec(false, null);
|
||||
}
|
||||
JSONObject replyMessage = response.optJSONObject("replyMessage");
|
||||
if (replyMessage != null) {
|
||||
String replyMessageId = replyMessage.optString("id", "").trim();
|
||||
if (!replyMessageId.isEmpty()) {
|
||||
return new ReplyWaitSpec(true, replyMessageId);
|
||||
}
|
||||
}
|
||||
JSONObject message = response.optJSONObject("message");
|
||||
return new ReplyWaitSpec(true, message == null ? null : message.optString("id", ""));
|
||||
}
|
||||
@@ -444,6 +555,14 @@ public final class ProjectChatUiState {
|
||||
return !isBlank(latestMessageId) && !baselineMessageId.trim().equals(latestMessageId);
|
||||
}
|
||||
|
||||
public static boolean shouldAutoRefreshConversation(
|
||||
boolean shouldMaintainAutoRefresh,
|
||||
boolean realtimeConnected,
|
||||
boolean trackedMasterReplyTimedOut
|
||||
) {
|
||||
return shouldMaintainAutoRefresh && (!realtimeConnected || trackedMasterReplyTimedOut);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String latestMessageId(@Nullable JSONArray messages) {
|
||||
if (messages == null || messages.length() == 0) {
|
||||
@@ -457,10 +576,110 @@ public final class ProjectChatUiState {
|
||||
return messageId.isEmpty() ? null : messageId;
|
||||
}
|
||||
|
||||
private static boolean isThreadProcessMessage(@Nullable JSONObject message) {
|
||||
if (message == null) {
|
||||
return false;
|
||||
}
|
||||
String kind = message.optString("kind", "").trim();
|
||||
if ("thread_process".equals(kind)) {
|
||||
return true;
|
||||
}
|
||||
if (!isBlank(kind)
|
||||
&& !"text".equals(kind)
|
||||
&& !"conversation_reply".equals(kind)
|
||||
&& !"thread_reply".equals(kind)) {
|
||||
return false;
|
||||
}
|
||||
String sender = message.optString("sender", "").trim().toLowerCase(java.util.Locale.ROOT);
|
||||
String senderLabel = message.optString("senderLabel", "").trim();
|
||||
if ("user".equals(sender)
|
||||
|| "master".equals(sender)
|
||||
|| "ops".equals(sender)
|
||||
|| "audit".equals(sender)
|
||||
|| senderLabel.contains("主 Agent")
|
||||
|| senderLabel.contains("审计")
|
||||
|| senderLabel.contains("你")) {
|
||||
return false;
|
||||
}
|
||||
String body = compactBody(message.optString("body", ""));
|
||||
if (body.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (isStructuredNumberedProcessBody(body)) {
|
||||
return true;
|
||||
}
|
||||
if (containsAny(body, FOLD_BLOCK_MARKERS)) {
|
||||
return false;
|
||||
}
|
||||
return hasProcessProgressMarker(body);
|
||||
}
|
||||
|
||||
private static boolean isBlank(@Nullable String value) {
|
||||
return value == null || value.trim().isEmpty();
|
||||
}
|
||||
|
||||
private static String compactBody(@Nullable String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.replaceAll("\\n{2,}", "\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
private static boolean containsAny(String body, String[] markers) {
|
||||
String normalizedBody = body.toLowerCase(java.util.Locale.ROOT);
|
||||
for (String marker : markers) {
|
||||
if (normalizedBody.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isStructuredNumberedProcessBody(String body) {
|
||||
String[] rawLines = body
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.split("\n");
|
||||
ArrayList<String> numberedLines = new ArrayList<>();
|
||||
for (String rawLine : rawLines) {
|
||||
String normalizedLine = compactBody(rawLine);
|
||||
if (normalizedLine.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedLine.matches("^\\d+[.)、]\\s*.+$")) {
|
||||
numberedLines.add(normalizedLine);
|
||||
}
|
||||
}
|
||||
if (numberedLines.size() < 2) {
|
||||
return false;
|
||||
}
|
||||
String merged = android.text.TextUtils.join(" ", numberedLines)
|
||||
.toLowerCase(java.util.Locale.ROOT);
|
||||
return containsAny(merged, PROCESS_PROGRESS_NUMBERED_HINTS);
|
||||
}
|
||||
|
||||
private static boolean hasProcessProgressMarker(String body) {
|
||||
String normalizedBody = body.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
if (isStructuredNumberedProcessBody(body)) {
|
||||
return true;
|
||||
}
|
||||
for (String marker : PROCESS_PROGRESS_PREFIXES) {
|
||||
if (normalizedBody.startsWith(marker.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (String marker : PROCESS_PROGRESS_CONTAINS) {
|
||||
if (normalizedBody.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String truncate(@Nullable String value, int maxLength) {
|
||||
String normalized = value == null ? "" : value.trim();
|
||||
if (normalized.length() <= maxLength) {
|
||||
@@ -468,4 +687,83 @@ public final class ProjectChatUiState {
|
||||
}
|
||||
return normalized.substring(0, maxLength) + "…";
|
||||
}
|
||||
|
||||
private static final String[] PROCESS_PROGRESS_PREFIXES = new String[] {
|
||||
"我先",
|
||||
"我现在",
|
||||
"我会先",
|
||||
"我发现",
|
||||
"我准备",
|
||||
"接下来",
|
||||
"正在",
|
||||
"先看",
|
||||
"先读",
|
||||
"我把",
|
||||
"我再",
|
||||
"目前在",
|
||||
"现在在",
|
||||
"补一组",
|
||||
"处理一下",
|
||||
"先确认",
|
||||
"准备",
|
||||
"同步一下",
|
||||
"我这边已经"
|
||||
};
|
||||
|
||||
private static final String[] PROCESS_PROGRESS_CONTAINS = new String[] {
|
||||
"我继续",
|
||||
"我已经在",
|
||||
"正在跑",
|
||||
"正在检查",
|
||||
"正在处理",
|
||||
"正在同步",
|
||||
"我会直接",
|
||||
"我先把",
|
||||
"先补",
|
||||
"再接"
|
||||
};
|
||||
|
||||
private static final String[] PROCESS_PROGRESS_NUMBERED_HINTS = new String[] {
|
||||
"先",
|
||||
"再",
|
||||
"接下来",
|
||||
"然后",
|
||||
"检查",
|
||||
"确认",
|
||||
"处理",
|
||||
"同步",
|
||||
"补",
|
||||
"排查",
|
||||
"推进",
|
||||
"回你",
|
||||
"回传",
|
||||
"会把",
|
||||
"我会"
|
||||
};
|
||||
|
||||
private static final String[] FOLD_BLOCK_MARKERS = new String[] {
|
||||
"失败",
|
||||
"报错",
|
||||
"错误",
|
||||
"阻塞",
|
||||
"不能",
|
||||
"无法",
|
||||
"崩溃",
|
||||
"超时",
|
||||
"exception",
|
||||
"error",
|
||||
"fatal",
|
||||
"结论",
|
||||
"最终",
|
||||
"总结",
|
||||
"已完成",
|
||||
"已经完成",
|
||||
"验证通过",
|
||||
"测试通过",
|
||||
"已修复",
|
||||
"修好了",
|
||||
"已部署",
|
||||
"已安装",
|
||||
"可以直接"
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -168,6 +168,25 @@ public class ProjectGoalsActivity extends BossScreenActivity {
|
||||
""
|
||||
));
|
||||
|
||||
JSONObject understanding = project.optJSONObject("projectUnderstanding");
|
||||
if (understanding != null) {
|
||||
String projectGoal = understanding.optString("projectGoal").trim();
|
||||
String currentProgress = understanding.optString("currentProgress").trim();
|
||||
String recommendedNextStep = understanding.optString("recommendedNextStep").trim();
|
||||
if (!projectGoal.isEmpty() || !currentProgress.isEmpty() || !recommendedNextStep.isEmpty()) {
|
||||
StringBuilder summary = new StringBuilder();
|
||||
appendSummaryLine(summary, "项目目标", projectGoal);
|
||||
appendSummaryLine(summary, "当前进度", currentProgress);
|
||||
appendSummaryLine(summary, "建议下一步", recommendedNextStep);
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"同步项目摘要",
|
||||
summary.toString().trim(),
|
||||
understanding.optString("updatedAt", "")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (goals == null || goals.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前项目还没有目标。点击右上角新增即可。"));
|
||||
} else {
|
||||
@@ -187,6 +206,16 @@ 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();
|
||||
@@ -213,7 +242,7 @@ public class ProjectGoalsActivity extends BossScreenActivity {
|
||||
indicator.setLayoutParams(indicatorParams);
|
||||
indicator.setGravity(Gravity.CENTER);
|
||||
indicator.setText(completed ? "✓" : "○");
|
||||
indicator.setTextSize(18);
|
||||
indicator.setTextSize(14);
|
||||
indicator.setTextColor(getColor(completed ? R.color.boss_green : R.color.boss_text_muted));
|
||||
row.addView(indicator);
|
||||
|
||||
@@ -228,14 +257,14 @@ public class ProjectGoalsActivity extends BossScreenActivity {
|
||||
|
||||
TextView title = new TextView(this);
|
||||
title.setText(goal.optString("text", "未命名目标"));
|
||||
title.setTextSize(16);
|
||||
title.setTextSize(14);
|
||||
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(13);
|
||||
note.setTextSize(12);
|
||||
note.setTextColor(getColor(R.color.boss_text_muted));
|
||||
note.setPadding(0, BossUi.dp(this, 8), 0, 0);
|
||||
texts.addView(note);
|
||||
|
||||
@@ -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 GOAL_REFRESH_NOTE = "project_goals.updated";
|
||||
private static final String VERSION_REFRESH_NOTE = "project_versions.updated";
|
||||
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
|
||||
private 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) && GOAL_REFRESH_NOTE.equals(payloadNote);
|
||||
return payloadProjectId.equals(projectId) && VERSION_REFRESH_NOTE.equals(payloadNote);
|
||||
}
|
||||
|
||||
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class SecurityActivity extends BossScreenActivity {
|
||||
@@ -22,7 +23,11 @@ public class SecurityActivity extends BossScreenActivity {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getSession();
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> renderSecurity(response.json.optJSONObject("session")));
|
||||
BossApiClient.ApiResponse sessionsResponse = apiClient.getAuthSessions();
|
||||
JSONArray sessions = sessionsResponse.ok()
|
||||
? sessionsResponse.json.optJSONArray("sessions")
|
||||
: new JSONArray();
|
||||
runOnUiThread(() -> renderSecurity(response.json.optJSONObject("session"), sessions));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
@@ -32,7 +37,7 @@ public class SecurityActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void renderSecurity(@Nullable JSONObject session) {
|
||||
private void renderSecurity(@Nullable JSONObject session, @Nullable JSONArray sessions) {
|
||||
replaceContent();
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
@@ -55,6 +60,33 @@ public class SecurityActivity extends BossScreenActivity {
|
||||
));
|
||||
}
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"登录会话",
|
||||
"当前可管理 " + (sessions == null ? 0 : sessions.length()) + " 个登录端",
|
||||
"点击非当前会话可撤销;撤销当前会话会回到登录页。",
|
||||
null,
|
||||
null
|
||||
));
|
||||
if (sessions != null) {
|
||||
for (int index = 0; index < sessions.length(); index += 1) {
|
||||
JSONObject item = sessions.optJSONObject(index);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
buildSessionTitle(item),
|
||||
item.optString("account", "-")
|
||||
+ " · " + BossUi.formatRoleLabel(item.optString("role", "-")),
|
||||
"最近 " + item.optString("lastSeenAt", "-")
|
||||
+ " · 到期 " + item.optString("expiresAt", "-"),
|
||||
item.optBoolean("current", false) ? "当前" : null,
|
||||
v -> confirmRevokeSession(item.optString("sessionId", ""), item.optBoolean("current", false))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
appendContent(BossUi.buildMenuRow(this, "打开设备页", "查看已绑定设备与状态", null, v -> {
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.putExtra(MainActivity.EXTRA_INITIAL_TAB, "devices");
|
||||
@@ -68,6 +100,57 @@ 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(() -> {
|
||||
@@ -79,6 +162,7 @@ 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();
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
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;
|
||||
@@ -58,8 +64,18 @@ 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;
|
||||
runOnUiThread(() -> renderSkills(response.json));
|
||||
JSONObject finalLifecyclePayload = lifecyclePayload;
|
||||
runOnUiThread(() -> renderSkills(response.json, finalLifecyclePayload));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
@@ -203,9 +219,14 @@ 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);
|
||||
@@ -220,6 +241,10 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
));
|
||||
}
|
||||
|
||||
if (canManageLifecycle) {
|
||||
appendSkillManagementWorkspace(lifecyclePayload);
|
||||
}
|
||||
|
||||
if (skills == null || skills.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前设备还没有同步 Skill。"));
|
||||
setRefreshing(false);
|
||||
@@ -244,8 +269,217 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.text.InputType;
|
||||
import android.text.TextUtils;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class StorageSettingsActivity extends BossScreenActivity {
|
||||
private String storageMode = "server_file";
|
||||
private boolean configLoaded;
|
||||
private LinearLayout ossForm;
|
||||
private Button serverModeButton;
|
||||
private Button ossModeButton;
|
||||
private EditText accessKeyIdField;
|
||||
private EditText accessKeySecretField;
|
||||
private EditText bucketField;
|
||||
private EditText endpointField;
|
||||
private EditText regionField;
|
||||
private EditText prefixField;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("附件与存储", "附件上传位置与 OSS 配置");
|
||||
setHeaderAction("保存", v -> saveConfig(false));
|
||||
buildFormContent();
|
||||
updateSaveAvailability();
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getAttachmentStorageConfig();
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> populate(response.json.optJSONObject("config")));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
configLoaded = false;
|
||||
updateSaveAvailability();
|
||||
replaceContent(BossUi.buildEmptyCard(this, "附件与存储加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void buildFormContent() {
|
||||
serverModeButton = BossUi.buildMiniActionButton(this, "服务器文件存储", true);
|
||||
ossModeButton = BossUi.buildMiniActionButton(this, "阿里 OSS", false);
|
||||
serverModeButton.setOnClickListener(v -> switchMode("server_file"));
|
||||
ossModeButton.setOnClickListener(v -> switchMode("oss"));
|
||||
|
||||
accessKeyIdField = buildTextField("AccessKey ID");
|
||||
accessKeySecretField = buildTextField("AccessKey Secret");
|
||||
accessKeySecretField.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
bucketField = buildTextField("Bucket");
|
||||
endpointField = buildTextField("Endpoint,例如 oss-cn-hangzhou.aliyuncs.com");
|
||||
regionField = buildTextField("Region,例如 oss-cn-hangzhou");
|
||||
prefixField = buildTextField("Prefix,例如 boss/");
|
||||
|
||||
ossForm = new LinearLayout(this);
|
||||
ossForm.setOrientation(LinearLayout.VERTICAL);
|
||||
ossForm.addView(BossUi.buildFormCell(this, "AccessKey ID", "阿里 OSS AccessKey ID", accessKeyIdField));
|
||||
ossForm.addView(BossUi.buildFormCell(this, "AccessKey Secret", "不会回显;留空表示沿用已保存密钥", accessKeySecretField));
|
||||
ossForm.addView(BossUi.buildFormCell(this, "Bucket", "附件所在 Bucket", bucketField));
|
||||
ossForm.addView(BossUi.buildFormCell(this, "Endpoint", "OSS Endpoint,不需要填写 https://", endpointField));
|
||||
ossForm.addView(BossUi.buildFormCell(this, "Region", "Bucket 所在地域", regionField));
|
||||
ossForm.addView(BossUi.buildFormCell(this, "Prefix", "可选,默认 boss/", prefixField));
|
||||
|
||||
Button validateButton = BossUi.buildMiniActionButton(this, "测试并保存", true);
|
||||
validateButton.setOnClickListener(v -> saveConfig(true));
|
||||
|
||||
replaceContent(
|
||||
BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"当前使用方式",
|
||||
"服务器文件存储适合内测;OSS 适合长期附件归档。",
|
||||
"切换后点击保存生效",
|
||||
null,
|
||||
null
|
||||
),
|
||||
BossUi.buildInlineActionRow(this, serverModeButton, ossModeButton),
|
||||
ossForm,
|
||||
BossUi.buildInlineActionRow(this, validateButton)
|
||||
);
|
||||
updateModeUi();
|
||||
}
|
||||
|
||||
private EditText buildTextField(String hint) {
|
||||
EditText field = new EditText(this);
|
||||
field.setSingleLine(true);
|
||||
field.setHint(hint);
|
||||
field.setTextSize(14);
|
||||
field.setInputType(InputType.TYPE_CLASS_TEXT);
|
||||
return field;
|
||||
}
|
||||
|
||||
private void populate(@Nullable JSONObject config) {
|
||||
buildFormContent();
|
||||
if (config != null) {
|
||||
storageMode = config.optString("mode", "server_file");
|
||||
JSONObject aliyunOss = config.optJSONObject("aliyunOss");
|
||||
if (aliyunOss != null) {
|
||||
accessKeyIdField.setText(aliyunOss.optString("accessKeyId", ""));
|
||||
bucketField.setText(aliyunOss.optString("bucket", ""));
|
||||
endpointField.setText(aliyunOss.optString("endpoint", ""));
|
||||
regionField.setText(aliyunOss.optString("region", ""));
|
||||
prefixField.setText(aliyunOss.optString("prefix", "boss/"));
|
||||
}
|
||||
}
|
||||
configLoaded = config != null;
|
||||
updateModeUi();
|
||||
updateSaveAvailability();
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void switchMode(String mode) {
|
||||
storageMode = mode;
|
||||
updateModeUi();
|
||||
}
|
||||
|
||||
private void updateModeUi() {
|
||||
boolean oss = "oss".equals(storageMode);
|
||||
if (serverModeButton != null) {
|
||||
serverModeButton.setText(oss ? "服务器文件存储" : "已选 服务器文件存储");
|
||||
}
|
||||
if (ossModeButton != null) {
|
||||
ossModeButton.setText(oss ? "已选 阿里 OSS" : "阿里 OSS");
|
||||
}
|
||||
if (ossForm != null) {
|
||||
ossForm.setVisibility(oss ? android.view.View.VISIBLE : android.view.View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveConfig(boolean validateFirst) {
|
||||
if (!configLoaded) {
|
||||
showMessage("配置尚未加载完成,请先刷新成功后再保存。");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
JSONObject payload = buildPayload();
|
||||
BossApiClient.ApiResponse response = validateFirst && "oss".equals(storageMode)
|
||||
? apiClient.validateAttachmentStorageConfig(payload)
|
||||
: apiClient.saveAttachmentStorageConfig(payload);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage(validateFirst && "oss".equals(storageMode) ? "测试通过,配置已保存" : "附件存储配置已保存");
|
||||
populate(response.json.optJSONObject("config"));
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private JSONObject buildPayload() throws org.json.JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("mode", storageMode);
|
||||
if (!"oss".equals(storageMode)) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
JSONObject aliyunOss = new JSONObject();
|
||||
aliyunOss.put("accessKeyId", textOf(accessKeyIdField));
|
||||
aliyunOss.put("bucket", textOf(bucketField));
|
||||
aliyunOss.put("endpoint", textOf(endpointField));
|
||||
aliyunOss.put("region", textOf(regionField));
|
||||
aliyunOss.put("prefix", textOf(prefixField));
|
||||
String secret = textOf(accessKeySecretField);
|
||||
if (!TextUtils.isEmpty(secret)) {
|
||||
aliyunOss.put("accessKeySecret", secret);
|
||||
}
|
||||
payload.put("ossProvider", "aliyun_oss");
|
||||
payload.put("aliyunOss", aliyunOss);
|
||||
return payload;
|
||||
}
|
||||
|
||||
private String textOf(EditText field) {
|
||||
return field == null || field.getText() == null ? "" : field.getText().toString().trim();
|
||||
}
|
||||
|
||||
private void updateSaveAvailability() {
|
||||
if (headerActionButton != null) {
|
||||
headerActionButton.setEnabled(configLoaded);
|
||||
headerActionButton.setAlpha(configLoaded ? 1f : 0.45f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.text.InputType;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.SwitchCompat;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class TelegramIntegrationActivity extends BossScreenActivity {
|
||||
private SwitchCompat enabledSwitch;
|
||||
private Spinner modeSpinner;
|
||||
private Spinner dmPolicySpinner;
|
||||
private Spinner groupPolicySpinner;
|
||||
private SwitchCompat requireMentionSwitch;
|
||||
private EditText botTokenInput;
|
||||
private EditText webhookSecretInput;
|
||||
private EditText webhookUrlInput;
|
||||
private EditText defaultProjectIdInput;
|
||||
private EditText allowFromInput;
|
||||
private EditText groupsInput;
|
||||
private EditText groupProjectRoutesInput;
|
||||
|
||||
@Nullable private JSONObject currentTelegram;
|
||||
private boolean telegramLoaded = false;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("Telegram 接入", "Bot 网关与白名单");
|
||||
setHeaderAction("保存", v -> saveTelegram(false));
|
||||
buildFormContent();
|
||||
updateActionAvailability();
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getTelegramIntegration();
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> populate(response.json.optJSONObject("telegram")));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
telegramLoaded = false;
|
||||
updateActionAvailability();
|
||||
replaceContent(BossUi.buildEmptyCard(this, "Telegram 配置加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void buildFormContent() {
|
||||
if (enabledSwitch == null) {
|
||||
enabledSwitch = new SwitchCompat(this);
|
||||
enabledSwitch.setText("开启 Telegram 接入");
|
||||
}
|
||||
if (requireMentionSwitch == null) {
|
||||
requireMentionSwitch = new SwitchCompat(this);
|
||||
requireMentionSwitch.setText("群聊要求 @Bot 或回复 Bot");
|
||||
}
|
||||
if (modeSpinner == null) {
|
||||
modeSpinner = new Spinner(this);
|
||||
modeSpinner.setAdapter(new ArrayAdapter<>(
|
||||
this,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
new String[]{"webhook", "polling"}
|
||||
));
|
||||
}
|
||||
if (dmPolicySpinner == null) {
|
||||
dmPolicySpinner = new Spinner(this);
|
||||
dmPolicySpinner.setAdapter(new ArrayAdapter<>(
|
||||
this,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
new String[]{"allowlist", "open", "disabled"}
|
||||
));
|
||||
}
|
||||
if (groupPolicySpinner == null) {
|
||||
groupPolicySpinner = new Spinner(this);
|
||||
groupPolicySpinner.setAdapter(new ArrayAdapter<>(
|
||||
this,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
new String[]{"allowlist", "open", "disabled"}
|
||||
));
|
||||
}
|
||||
if (botTokenInput == null) {
|
||||
botTokenInput = BossUi.buildInput(this, "输入 Telegram Bot Token", false);
|
||||
botTokenInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
}
|
||||
if (webhookSecretInput == null) {
|
||||
webhookSecretInput = BossUi.buildInput(this, "留空则沿用当前 secret", false);
|
||||
webhookSecretInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
}
|
||||
if (webhookUrlInput == null) {
|
||||
webhookUrlInput = BossUi.buildInput(this, "例如 https://boss.hyzq.net/api/v1/integrations/telegram/webhook", false);
|
||||
}
|
||||
if (defaultProjectIdInput == null) {
|
||||
defaultProjectIdInput = BossUi.buildInput(this, "默认 master-agent", false);
|
||||
}
|
||||
if (allowFromInput == null) {
|
||||
allowFromInput = BossUi.buildInput(this, "每行一个 Telegram 用户 ID", true);
|
||||
}
|
||||
if (groupsInput == null) {
|
||||
groupsInput = BossUi.buildInput(this, "每行一个 Telegram 群 chat id", true);
|
||||
}
|
||||
if (groupProjectRoutesInput == null) {
|
||||
groupProjectRoutesInput = BossUi.buildInput(this, "chatId[#topicId] projectId 可选备注", true);
|
||||
}
|
||||
|
||||
replaceContent(buildStatusRow(currentTelegram));
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"Telegram Bot 网关",
|
||||
"主 Agent 可通过 Telegram 私聊或受控群聊接收消息。",
|
||||
"保存 webhook 模式后会自动同步 Telegram Webhook",
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildWechatSwitchRow(this, "开启接入", "关闭后 Boss 不再接收 Telegram 消息", enabledSwitch));
|
||||
appendContent(BossUi.buildFormCell(this, "接入模式", "Webhook 推荐用于正式运行;Polling 仅作兜底。", modeSpinner));
|
||||
appendContent(BossUi.buildFormCell(this, "Bot Token", "留空表示沿用当前已保存 token,不会主动清空。", botTokenInput));
|
||||
appendContent(BossUi.buildFormCell(this, "Webhook Secret", "Telegram webhook secret,建议启用。", webhookSecretInput));
|
||||
appendContent(BossUi.buildFormCell(this, "Webhook URL", "Webhook 模式下使用的公开地址。", webhookUrlInput));
|
||||
appendContent(BossUi.buildFormCell(this, "默认项目", "当前默认路由到 master-agent。", defaultProjectIdInput));
|
||||
appendContent(BossUi.buildFormCell(this, "私聊策略", "allowlist 更安全。", dmPolicySpinner));
|
||||
appendContent(BossUi.buildFormCell(this, "允许私聊用户 ID", "每行一个 Telegram 用户 ID。", allowFromInput));
|
||||
appendContent(BossUi.buildFormCell(this, "群聊策略", "群白名单建议配合 requireMention 使用。", groupPolicySpinner));
|
||||
appendContent(BossUi.buildFormCell(this, "允许群聊 chat id", "每行一个 Telegram 群 chat id。", groupsInput));
|
||||
appendContent(BossUi.buildFormCell(this, "群 / Topic 路由", "每行格式:chatId[#topicId] projectId 可选备注;未命中时回到默认项目。", groupProjectRoutesInput));
|
||||
appendContent(BossUi.buildWechatSwitchRow(this, "群聊要求 @Bot", "开启后只有 @bot_username 或回复当前 Bot 的消息才会进入主 Agent。", requireMentionSwitch));
|
||||
|
||||
android.widget.Button testButton = BossUi.buildSecondaryButton(this, "测试连接");
|
||||
testButton.setOnClickListener(v -> saveTelegram(true));
|
||||
appendContent(testButton);
|
||||
|
||||
TextView noteView = BossUi.buildHintPill(this, "提示:保存为 webhook 模式时会自动 setWebhook;切回 polling/关闭时会自动 deleteWebhook。");
|
||||
appendContent(noteView);
|
||||
}
|
||||
|
||||
private void populate(@Nullable JSONObject telegram) {
|
||||
currentTelegram = telegram;
|
||||
buildFormContent();
|
||||
|
||||
if (telegram != null) {
|
||||
enabledSwitch.setChecked(telegram.optBoolean("enabled", false));
|
||||
|
||||
String mode = telegram.optString("mode", "webhook");
|
||||
modeSpinner.setSelection("polling".equals(mode) ? 1 : 0);
|
||||
|
||||
String dmPolicy = telegram.optString("dmPolicy", "allowlist");
|
||||
dmPolicySpinner.setSelection(policySelection(dmPolicy));
|
||||
|
||||
String groupPolicy = telegram.optString("groupPolicy", "allowlist");
|
||||
groupPolicySpinner.setSelection(policySelection(groupPolicy));
|
||||
|
||||
requireMentionSwitch.setChecked(telegram.optBoolean("requireMentionInGroups", true));
|
||||
webhookUrlInput.setText(telegram.optString("webhookUrl", ""));
|
||||
defaultProjectIdInput.setText(telegram.optString("defaultProjectId", "master-agent"));
|
||||
allowFromInput.setText(joinLines(telegram.optJSONArray("allowFrom")));
|
||||
groupsInput.setText(joinLines(telegram.optJSONArray("groups")));
|
||||
groupProjectRoutesInput.setText(formatGroupProjectRoutes(telegram.optJSONArray("groupProjectRoutes")));
|
||||
}
|
||||
|
||||
telegramLoaded = telegram != null;
|
||||
updateActionAvailability();
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private LinearLayout buildStatusRow(@Nullable JSONObject telegram) {
|
||||
return BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"当前状态",
|
||||
buildStatusSummary(telegram),
|
||||
buildStatusMeta(telegram),
|
||||
null,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
private String buildStatusSummary(@Nullable JSONObject telegram) {
|
||||
if (telegram == null) {
|
||||
return "接入:加载中\n模式:未加载\nBot:未识别";
|
||||
}
|
||||
String botUsername = telegram.optString("botUsername", "").trim();
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("接入:").append(telegram.optBoolean("enabled", false) ? "已开启" : "已关闭");
|
||||
builder.append("\n模式:").append("polling".equals(telegram.optString("mode", "webhook")) ? "Polling" : "Webhook");
|
||||
builder.append("\nBot:").append(botUsername.isEmpty() ? "未识别" : "@" + botUsername);
|
||||
builder.append("\nToken:").append(telegram.optBoolean("botTokenConfigured", false) ? "已配置" : "未配置");
|
||||
builder.append("\nWebhook Secret:").append(telegram.optBoolean("webhookSecretConfigured", false) ? "已配置" : "未配置");
|
||||
builder.append("\n默认项目:").append(telegram.optString("defaultProjectId", "master-agent"));
|
||||
builder.append("\n已处理 update:").append(telegram.optInt("processedUpdateCount", 0));
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String buildStatusMeta(@Nullable JSONObject telegram) {
|
||||
if (telegram == null) {
|
||||
return "加载完成后可测试连接或保存配置。";
|
||||
}
|
||||
String lastError = telegram.optString("lastError", "").trim();
|
||||
if (!lastError.isEmpty()) {
|
||||
return "最近错误:" + lastError;
|
||||
}
|
||||
return "状态正常时,Telegram 消息会进入主 Agent。";
|
||||
}
|
||||
|
||||
private int policySelection(String policy) {
|
||||
switch (policy) {
|
||||
case "open":
|
||||
return 1;
|
||||
case "disabled":
|
||||
return 2;
|
||||
case "allowlist":
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private String joinLines(@Nullable org.json.JSONArray array) {
|
||||
if (array == null || array.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int index = 0; index < array.length(); index += 1) {
|
||||
String value = array.optString(index, "").trim();
|
||||
if (value.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append("\n");
|
||||
}
|
||||
builder.append(value);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private org.json.JSONArray parseLines(EditText input) {
|
||||
org.json.JSONArray array = new org.json.JSONArray();
|
||||
String[] lines = input.getText().toString().split("\\r?\\n");
|
||||
for (String line : lines) {
|
||||
String trimmed = line == null ? "" : line.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
array.put(trimmed);
|
||||
}
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
private String formatGroupProjectRoutes(@Nullable org.json.JSONArray routes) {
|
||||
if (routes == null || routes.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int index = 0; index < routes.length(); index += 1) {
|
||||
JSONObject route = routes.optJSONObject(index);
|
||||
if (route == null) {
|
||||
continue;
|
||||
}
|
||||
String chatId = route.optString("chatId", "").trim();
|
||||
String projectId = route.optString("projectId", "").trim();
|
||||
if (chatId.isEmpty() || projectId.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append("\n");
|
||||
}
|
||||
builder.append(chatId);
|
||||
if (route.has("threadId")) {
|
||||
builder.append("#").append(route.optInt("threadId"));
|
||||
}
|
||||
builder.append(" ").append(projectId);
|
||||
String label = route.optString("label", "").trim();
|
||||
if (!label.isEmpty()) {
|
||||
builder.append(" ").append(label);
|
||||
}
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private org.json.JSONArray parseGroupProjectRoutes(EditText input) throws org.json.JSONException {
|
||||
org.json.JSONArray array = new org.json.JSONArray();
|
||||
String[] lines = input.getText().toString().split("\\r?\\n");
|
||||
for (String line : lines) {
|
||||
String trimmed = line == null ? "" : line.trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String[] parts = trimmed.split("\\s+", 3);
|
||||
if (parts.length < 2) {
|
||||
continue;
|
||||
}
|
||||
String[] chatParts = parts[0].split("#", 2);
|
||||
String chatId = chatParts[0].trim();
|
||||
String projectId = parts[1].trim();
|
||||
if (chatId.isEmpty() || projectId.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
JSONObject route = new JSONObject();
|
||||
route.put("chatId", chatId);
|
||||
if (chatParts.length > 1) {
|
||||
try {
|
||||
route.put("threadId", Integer.parseInt(chatParts[1].trim()));
|
||||
} catch (NumberFormatException ignored) {
|
||||
// Invalid topic id is ignored so the chat-level route can still be saved.
|
||||
}
|
||||
}
|
||||
route.put("projectId", projectId);
|
||||
if (parts.length > 2 && !parts[2].trim().isEmpty()) {
|
||||
route.put("label", parts[2].trim());
|
||||
}
|
||||
array.put(route);
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
private void saveTelegram(boolean testConnection) {
|
||||
if (!telegramLoaded) {
|
||||
showMessage("配置尚未加载完成,请先刷新成功后再保存。");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("enabled", enabledSwitch.isChecked());
|
||||
payload.put("mode", String.valueOf(modeSpinner.getSelectedItem()));
|
||||
payload.put("botToken", emptyToNull(botTokenInput.getText().toString()));
|
||||
payload.put("webhookSecret", emptyToNull(webhookSecretInput.getText().toString()));
|
||||
payload.put("webhookUrl", emptyToNull(webhookUrlInput.getText().toString()));
|
||||
payload.put("defaultProjectId", emptyToNull(defaultProjectIdInput.getText().toString()));
|
||||
payload.put("dmPolicy", String.valueOf(dmPolicySpinner.getSelectedItem()));
|
||||
payload.put("allowFrom", parseLines(allowFromInput));
|
||||
payload.put("groupPolicy", String.valueOf(groupPolicySpinner.getSelectedItem()));
|
||||
payload.put("groups", parseLines(groupsInput));
|
||||
payload.put("groupProjectRoutes", parseGroupProjectRoutes(groupProjectRoutesInput));
|
||||
payload.put("requireMentionInGroups", requireMentionSwitch.isChecked());
|
||||
payload.put("testConnection", testConnection);
|
||||
BossApiClient.ApiResponse response = apiClient.updateTelegramIntegration(payload);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
JSONObject telegram = response.json.optJSONObject("telegram");
|
||||
populate(telegram);
|
||||
String probeUsername = "";
|
||||
JSONObject probe = response.json.optJSONObject("probe");
|
||||
if (probe != null) {
|
||||
probeUsername = probe.optString("username", "");
|
||||
}
|
||||
showMessage(testConnection
|
||||
? (probeUsername.isEmpty() ? "Telegram 连接测试通过" : "连接测试通过:@" + probeUsername)
|
||||
: "Telegram 配置已保存");
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("Telegram 配置失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private Object emptyToNull(String value) {
|
||||
String trimmed = value == null ? "" : value.trim();
|
||||
return trimmed.isEmpty() ? JSONObject.NULL : trimmed;
|
||||
}
|
||||
|
||||
private void updateActionAvailability() {
|
||||
if (headerActionButton != null) {
|
||||
headerActionButton.setEnabled(telegramLoaded);
|
||||
headerActionButton.setAlpha(telegramLoaded ? 1f : 0.45f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,101 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class WechatSurfaceMapper {
|
||||
private static final String[] PROCESS_PREVIEW_PREFIXES = new String[] {
|
||||
"我先",
|
||||
"我现在",
|
||||
"我会先",
|
||||
"我发现",
|
||||
"我准备",
|
||||
"接下来",
|
||||
"正在",
|
||||
"先看",
|
||||
"先读",
|
||||
"我把",
|
||||
"我再",
|
||||
"目前在",
|
||||
"现在在",
|
||||
"补一组",
|
||||
"处理一下",
|
||||
"先确认",
|
||||
"准备",
|
||||
"同步一下",
|
||||
"我这边已经"
|
||||
};
|
||||
|
||||
private static final String[] PROCESS_PREVIEW_CONTAINS = new String[] {
|
||||
"我继续",
|
||||
"我已经在",
|
||||
"正在跑",
|
||||
"正在检查",
|
||||
"正在处理",
|
||||
"正在同步",
|
||||
"我会直接",
|
||||
"我先把",
|
||||
"先补",
|
||||
"再接"
|
||||
};
|
||||
|
||||
private static final String[] PROCESS_PREVIEW_NUMBERED_HINTS = new String[] {
|
||||
"先",
|
||||
"再",
|
||||
"接下来",
|
||||
"然后",
|
||||
"检查",
|
||||
"确认",
|
||||
"处理",
|
||||
"同步",
|
||||
"补",
|
||||
"排查",
|
||||
"推进",
|
||||
"回你",
|
||||
"回传",
|
||||
"会把",
|
||||
"我会"
|
||||
};
|
||||
|
||||
private static final String[] PROCESS_PREVIEW_BLOCK_MARKERS = new String[] {
|
||||
"失败",
|
||||
"报错",
|
||||
"错误",
|
||||
"阻塞",
|
||||
"不能",
|
||||
"无法",
|
||||
"崩溃",
|
||||
"超时",
|
||||
"exception",
|
||||
"error",
|
||||
"fatal",
|
||||
"结论",
|
||||
"最终",
|
||||
"总结",
|
||||
"已完成",
|
||||
"已经完成",
|
||||
"验证通过"
|
||||
};
|
||||
|
||||
private static final String[] LEAKED_TITLE_PREFIXES = new String[] {
|
||||
"你当前接手的项目根目录是",
|
||||
"你现在接手的项目根目录是",
|
||||
"你现在以目标线程身份直接回复用户",
|
||||
"你正在向主 Agent 同步当前项目状态",
|
||||
"只回复对用户真正有用的内容",
|
||||
"只输出 JSON"
|
||||
};
|
||||
|
||||
private static final String[] LEAKED_TITLE_CONTAINS = new String[] {
|
||||
"不要发送内部字段",
|
||||
"不要自称主 Agent",
|
||||
"不要解释系统如何分发",
|
||||
"不要输出 JSON",
|
||||
"项目名称:",
|
||||
"线程名称:",
|
||||
"文件夹:",
|
||||
"同步原因:",
|
||||
"当前消息:",
|
||||
"用户当前消息:"
|
||||
};
|
||||
|
||||
private static final List<String> ROOT_TAB_LABELS = Arrays.asList(
|
||||
"会话",
|
||||
"设备",
|
||||
@@ -21,8 +116,11 @@ public final class WechatSurfaceMapper {
|
||||
private static final List<MeMenuItem> ROOT_ME_MENU_ITEMS = Arrays.asList(
|
||||
new MeMenuItem("security", "账号与安全", "修改登录密码、设备安全与身份校验"),
|
||||
new MeMenuItem("settings", "设置", "默认首页、提醒方式与危险操作确认"),
|
||||
new MeMenuItem("access", "用户与权限", "分配子账号、设备、项目与 Skill 权限"),
|
||||
new MeMenuItem("ops", "运维与修复", "查看运维会话、修复回放与 standby 切换"),
|
||||
new MeMenuItem("ai_accounts", "AI 账号", "管理主 GPT、备用 GPT 与 API 容灾"),
|
||||
new MeMenuItem("storage", "附件与存储", "配置附件上传位置、服务器文件与阿里 OSS"),
|
||||
new MeMenuItem("telegram", "Telegram 接入", "配置 Telegram Bot、Webhook 与白名单"),
|
||||
new MeMenuItem("skills", "技能", "按设备查看 Skill 清单"),
|
||||
new MeMenuItem("about", "关于", "当前版本、OTA 状态与更新内容")
|
||||
);
|
||||
@@ -59,14 +157,20 @@ public final class WechatSurfaceMapper {
|
||||
JSONObject avatar = source.optJSONObject("avatar");
|
||||
boolean isGroup = source.optBoolean("isGroup", groupAvatarMembers.size() > 1);
|
||||
String conversationType = source.optString("conversationType", "");
|
||||
String threadTitle = trimLocalWorkspacePrefix(
|
||||
source.optString("threadTitle", source.optString("title", source.optString("projectTitle", "")))
|
||||
String folderLabel = normalizeConversationTitle(source.optString("folderLabel", ""));
|
||||
String threadTitle = sanitizeConversationTitle(
|
||||
source.optString("threadTitle", source.optString("title", source.optString("projectTitle", ""))),
|
||||
folderLabel,
|
||||
source.optString("projectTitle", "")
|
||||
);
|
||||
String projectId = source.optString("projectId", "").trim();
|
||||
if ("folder_archive".equals(conversationType)) {
|
||||
threadTitle = source.optString(
|
||||
"projectTitle",
|
||||
source.optString("threadTitle", source.optString("title", source.optString("folderLabel", "")))
|
||||
);
|
||||
} else if (isPinnedSystemProject(projectId)) {
|
||||
threadTitle = source.optString("projectTitle", threadTitle);
|
||||
}
|
||||
String pinnedLabel = source.optString("topPinnedLabel", "");
|
||||
return new ConversationRow(
|
||||
@@ -144,6 +248,116 @@ 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";
|
||||
}
|
||||
@@ -188,10 +402,36 @@ public final class WechatSurfaceMapper {
|
||||
return titles;
|
||||
}
|
||||
|
||||
public static String[] rootMeMenuTitlesForRole(String role) {
|
||||
List<MeMenuItem> items = rootMeMenuItemsForRoleList(role);
|
||||
String[] titles = new String[items.size()];
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
titles[i] = items.get(i).title;
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
|
||||
public static MeMenuItem[] rootMeMenuItems() {
|
||||
return ROOT_ME_MENU_ITEMS.toArray(new MeMenuItem[0]);
|
||||
}
|
||||
|
||||
public static MeMenuItem[] rootMeMenuItemsForRole(String role) {
|
||||
List<MeMenuItem> items = rootMeMenuItemsForRoleList(role);
|
||||
return items.toArray(new MeMenuItem[0]);
|
||||
}
|
||||
|
||||
public static boolean canOpenMeEntryForRole(String key, String role) {
|
||||
if (key == null || key.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (MeMenuItem item : rootMeMenuItemsForRoleList(role)) {
|
||||
if (item.key.equals(key)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static MeMenuItem findMeMenuItem(String key) {
|
||||
for (MeMenuItem item : ROOT_ME_MENU_ITEMS) {
|
||||
if (item.key.equals(key)) {
|
||||
@@ -201,6 +441,34 @@ public final class WechatSurfaceMapper {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static List<MeMenuItem> rootMeMenuItemsForRoleList(String role) {
|
||||
if ("highest_admin".equals(role)) {
|
||||
return ROOT_ME_MENU_ITEMS;
|
||||
}
|
||||
List<MeMenuItem> visible = new ArrayList<>();
|
||||
for (MeMenuItem item : ROOT_ME_MENU_ITEMS) {
|
||||
if (!isHighestAdminOnlyMeEntry(item.key) && (isAdministratorRole(role) || !isAdministratorOnlyMeEntry(item.key))) {
|
||||
visible.add(item);
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
private static boolean isAdministratorRole(String role) {
|
||||
return "highest_admin".equals(role) || "admin".equals(role);
|
||||
}
|
||||
|
||||
private static boolean isAdministratorOnlyMeEntry(String key) {
|
||||
return "ops".equals(key)
|
||||
|| "ai_accounts".equals(key)
|
||||
|| "storage".equals(key)
|
||||
|| "telegram".equals(key);
|
||||
}
|
||||
|
||||
private static boolean isHighestAdminOnlyMeEntry(String key) {
|
||||
return "access".equals(key);
|
||||
}
|
||||
|
||||
public static String[] projectQuickActions() {
|
||||
return PROJECT_QUICK_ACTIONS.toArray(new String[0]);
|
||||
}
|
||||
@@ -249,6 +517,10 @@ public final class WechatSurfaceMapper {
|
||||
return "cancel_on_detach";
|
||||
}
|
||||
|
||||
private static boolean isPinnedSystemProject(String projectId) {
|
||||
return "master-agent".equals(projectId) || "audit-collab".equals(projectId);
|
||||
}
|
||||
|
||||
private static String buildContextStatusLabel(JSONObject source) {
|
||||
if (source.optBoolean("mustFinishBeforeCompaction", false)) {
|
||||
return "必须收尾";
|
||||
@@ -317,12 +589,57 @@ 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)) {
|
||||
@@ -714,9 +1031,156 @@ public final class WechatSurfaceMapper {
|
||||
if (preview.matches("^已从设备.+导入线程《.+》[。.]?$")) {
|
||||
return "已导入线程";
|
||||
}
|
||||
if (isLikelyProcessPreview(preview)) {
|
||||
return "";
|
||||
}
|
||||
return preview;
|
||||
}
|
||||
|
||||
private static boolean isLikelyProcessPreview(String value) {
|
||||
String preview = value == null ? "" : value
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.replaceAll("\\n{2,}", "\n")
|
||||
.trim();
|
||||
if (preview.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (containsMarker(preview, PROCESS_PREVIEW_BLOCK_MARKERS)) {
|
||||
return false;
|
||||
}
|
||||
if (isStructuredNumberedProcessPreview(preview)) {
|
||||
return true;
|
||||
}
|
||||
String normalized = preview.toLowerCase(java.util.Locale.ROOT);
|
||||
for (String marker : PROCESS_PREVIEW_PREFIXES) {
|
||||
if (normalized.startsWith(marker.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (String marker : PROCESS_PREVIEW_CONTAINS) {
|
||||
if (normalized.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isStructuredNumberedProcessPreview(String value) {
|
||||
String[] rawLines = value
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.split("\n");
|
||||
ArrayList<String> numberedLines = new ArrayList<>();
|
||||
for (String rawLine : rawLines) {
|
||||
String normalizedLine = rawLine == null ? "" : rawLine.trim();
|
||||
if (normalizedLine.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedLine.matches("^\\d+[.)、]\\s*.+$")) {
|
||||
numberedLines.add(normalizedLine);
|
||||
}
|
||||
}
|
||||
if (numberedLines.size() < 2) {
|
||||
return false;
|
||||
}
|
||||
String merged = android.text.TextUtils.join(" ", numberedLines)
|
||||
.toLowerCase(java.util.Locale.ROOT);
|
||||
return containsMarker(merged, PROCESS_PREVIEW_NUMBERED_HINTS);
|
||||
}
|
||||
|
||||
private static boolean containsMarker(String value, String[] markers) {
|
||||
String normalized = value == null ? "" : value.toLowerCase(java.util.Locale.ROOT);
|
||||
for (String marker : markers) {
|
||||
if (normalized.contains(marker.toLowerCase(java.util.Locale.ROOT))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String normalizeConversationTitle(String value) {
|
||||
String source = value == null ? "" : value.replace("\u0000", "");
|
||||
String[] lines = source.split("\\r?\\n");
|
||||
for (String line : lines) {
|
||||
if (line == null) {
|
||||
continue;
|
||||
}
|
||||
String trimmed = line.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
return trimmed.replaceAll("\\s+", " ");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String stripTrailingConversationTitleNoise(String value) {
|
||||
return value == null ? "" : value.replaceAll("['\"}\\]]{2,}$", "").trim();
|
||||
}
|
||||
|
||||
private static boolean looksLikeLeakedConversationTitle(String value) {
|
||||
String normalized = normalizeConversationTitle(value);
|
||||
if (normalized.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (String marker : LEAKED_TITLE_PREFIXES) {
|
||||
if (normalized.startsWith(marker)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (String marker : LEAKED_TITLE_CONTAINS) {
|
||||
if (normalized.contains(marker)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String extractWorkspaceProjectName(String value) {
|
||||
String normalized = normalizeConversationTitle(value).replace('\\', '/');
|
||||
if (normalized.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String[] patterns = new String[] {
|
||||
".*/Users/[^/]+/code/([^/\\s\"'`,。;!?]+).*",
|
||||
".*/home/[^/]+/code/([^/\\s\"'`,。;!?]+).*",
|
||||
".*[A-Za-z]:/Users/[^/]+/code/([^/\\s\"'`,。;!?]+).*"
|
||||
};
|
||||
for (String pattern : patterns) {
|
||||
if (normalized.matches(pattern)) {
|
||||
return normalized.replaceFirst(pattern, "$1").split("/")[0].trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static String sanitizeConversationTitle(String value, String... fallbackCandidates) {
|
||||
String normalized = normalizeConversationTitle(value);
|
||||
String trimmed = stripTrailingConversationTitleNoise(trimLocalWorkspacePrefix(normalized));
|
||||
if (!trimmed.isEmpty() && !looksLikeLeakedConversationTitle(normalized) && !looksLikeLeakedConversationTitle(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
String extractedProject = extractWorkspaceProjectName(normalized);
|
||||
if (!extractedProject.isEmpty() && !looksLikeLeakedConversationTitle(extractedProject)) {
|
||||
return extractedProject;
|
||||
}
|
||||
|
||||
for (String fallbackCandidate : fallbackCandidates) {
|
||||
String extractedFallback = extractWorkspaceProjectName(fallbackCandidate);
|
||||
if (!extractedFallback.isEmpty() && !looksLikeLeakedConversationTitle(extractedFallback)) {
|
||||
return extractedFallback;
|
||||
}
|
||||
String normalizedFallback = stripTrailingConversationTitleNoise(
|
||||
trimLocalWorkspacePrefix(normalizeConversationTitle(fallbackCandidate))
|
||||
);
|
||||
if (!normalizedFallback.isEmpty() && !looksLikeLeakedConversationTitle(normalizedFallback)) {
|
||||
return normalizedFallback;
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private static String trimLocalWorkspacePrefix(String value) {
|
||||
String label = value == null ? "" : value.trim();
|
||||
if (label.isEmpty()) {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/boss_surface" />
|
||||
<corners android:radius="24dp" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/boss_card_stroke" />
|
||||
</shape>
|
||||
10
android/app/src/main/res/drawable/ic_boss_arrow_down.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_arrow_down.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF111111"
|
||||
android:pathData="M7.41,8.59L6,10l6,6 6,-6 -1.41,-1.41L12,13.17 7.41,8.59Z" />
|
||||
</vector>
|
||||
10
android/app/src/main/res/drawable/ic_boss_tab_chat.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_tab_chat.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/boss_text_muted"
|
||||
android:pathData="M5,6.5C5,4.57 6.57,3 8.5,3H15.5C17.43,3 19,4.57 19,6.5V11.2C19,13.13 17.43,14.7 15.5,14.7H11.25L7.92,18.03C7.55,18.4 6.92,18.14 6.92,17.62V14.54C5.8,14.04 5,12.91 5,11.6V6.5ZM8.4,8.1C7.82,8.1 7.35,8.57 7.35,9.15C7.35,9.73 7.82,10.2 8.4,10.2C8.98,10.2 9.45,9.73 9.45,9.15C9.45,8.57 8.98,8.1 8.4,8.1ZM12,8.1C11.42,8.1 10.95,8.57 10.95,9.15C10.95,9.73 11.42,10.2 12,10.2C12.58,10.2 13.05,9.73 13.05,9.15C13.05,8.57 12.58,8.1 12,8.1ZM15.6,8.1C15.02,8.1 14.55,8.57 14.55,9.15C14.55,9.73 15.02,10.2 15.6,10.2C16.18,10.2 16.65,9.73 16.65,9.15C16.65,8.57 16.18,8.1 15.6,8.1Z" />
|
||||
</vector>
|
||||
10
android/app/src/main/res/drawable/ic_boss_tab_devices.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_tab_devices.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/boss_text_muted"
|
||||
android:pathData="M12,2.8L20,7.1V16.9L12,21.2L4,16.9V7.1L12,2.8ZM6.2,8.42V15.58L10.9,18.11V10.95L6.2,8.42ZM12,9.05L16.78,6.48L12,3.91L7.22,6.48L12,9.05ZM13.1,10.95V18.11L17.8,15.58V8.42L13.1,10.95Z" />
|
||||
</vector>
|
||||
10
android/app/src/main/res/drawable/ic_boss_tab_me.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_tab_me.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/boss_text_muted"
|
||||
android:pathData="M12,12.1C9.65,12.1 7.75,10.2 7.75,7.85C7.75,5.5 9.65,3.6 12,3.6C14.35,3.6 16.25,5.5 16.25,7.85C16.25,10.2 14.35,12.1 12,12.1ZM4.8,19.5C5.44,16.13 8.39,13.58 12,13.58C15.61,13.58 18.56,16.13 19.2,19.5C19.31,20.09 18.85,20.63 18.25,20.63H5.75C5.15,20.63 4.69,20.09 4.8,19.5Z" />
|
||||
</vector>
|
||||
@@ -12,18 +12,18 @@
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="12dp">
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="7dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
@@ -37,43 +37,49 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="wrap_content"
|
||||
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="22sp"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
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="12sp" />
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="更多"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
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="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="刷新"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_refresh"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="12dp">
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="7dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
@@ -37,43 +37,49 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="wrap_content"
|
||||
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="22sp"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
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="12sp" />
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="更多"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
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="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="刷新"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_refresh"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="12dp">
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="7dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
@@ -37,43 +37,49 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="wrap_content"
|
||||
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="22sp"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
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="12sp" />
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="更多"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
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="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="刷新"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_refresh"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="12dp">
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="7dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
@@ -37,43 +37,49 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="wrap_content"
|
||||
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="22sp"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
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="12sp" />
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="更多"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
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="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="刷新"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_refresh"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -22,23 +22,23 @@
|
||||
android:paddingBottom="40dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:layout_width="60dp"
|
||||
android:layout_height="60dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:gravity="center"
|
||||
android:text="B"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textSize="28sp"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/login_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="22dp"
|
||||
android:layout_marginTop="18dp"
|
||||
android:text=""
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="30sp"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
@@ -50,13 +50,98 @@
|
||||
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="28dp"
|
||||
android:layout_marginTop="18dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<Button
|
||||
@@ -65,13 +150,53 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="22dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingBottom="14dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:text=""
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textSize="18sp"
|
||||
android:textSize="14sp"
|
||||
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>
|
||||
|
||||
@@ -89,19 +214,19 @@
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="12dp">
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="7dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/back_button"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary"
|
||||
android:visibility="gone" />
|
||||
@@ -121,7 +246,7 @@
|
||||
android:maxLines="1"
|
||||
android:text="会话"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="22sp"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
@@ -131,46 +256,46 @@
|
||||
android:layout_marginTop="4dp"
|
||||
android:text=""
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="12sp"
|
||||
android:textSize="11sp"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/top_search_input"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:hint="搜索项目或线程"
|
||||
android:imeOptions="actionSearch"
|
||||
android:inputType="text"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="14dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="7dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="7dp"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textColorHint="@color/boss_text_muted"
|
||||
android:textSize="15sp"
|
||||
android:textSize="14sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/search_button"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="搜索"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_search"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/refresh_button"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="快捷操作"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_add"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
</LinearLayout>
|
||||
@@ -188,51 +313,50 @@
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="72dp"
|
||||
android:layout_height="54dp"
|
||||
android:background="@color/boss_surface"
|
||||
android:elevation="10dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingRight="12dp">
|
||||
android:paddingTop="3dp"
|
||||
android:paddingBottom="3dp"
|
||||
android:paddingLeft="10dp"
|
||||
android:paddingRight="10dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/tab_conversations"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginRight="6dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_tab_active"
|
||||
android:background="@android:color/transparent"
|
||||
android:text="会话"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/tab_devices"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginLeft="6dp"
|
||||
android:layout_marginRight="6dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_tab_inactive"
|
||||
android:background="@android:color/transparent"
|
||||
android:text="设备"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/tab_me"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginLeft="6dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_tab_inactive"
|
||||
android:background="@android:color/transparent"
|
||||
android:text="我的"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -285,7 +409,7 @@
|
||||
android:text="添加设备"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_quick_actions_menu_text"
|
||||
android:textSize="15sp" />
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/quick_action_scan"
|
||||
@@ -299,7 +423,7 @@
|
||||
android:text="扫一扫"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_quick_actions_menu_text"
|
||||
android:textSize="15sp" />
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/quick_action_group_chat"
|
||||
@@ -313,7 +437,7 @@
|
||||
android:text="发起群聊"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_quick_actions_menu_text"
|
||||
android:textSize="15sp" />
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</FrameLayout>
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="12dp">
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="7dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
@@ -40,83 +40,134 @@
|
||||
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="20sp"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
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="12sp" />
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="更多"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
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="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="刷新"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_refresh"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/screen_refresh_layout"
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
android:layout_weight="1"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false">
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/project_chat_scroll"
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/screen_refresh_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="ifContentScrolls">
|
||||
|
||||
android:layout_height="match_parent">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="20dp">
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_quick_actions"
|
||||
android:id="@+id/project_chat_quick_actions_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:orientation="horizontal" />
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_quick_actions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal" />
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/project_chat_scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical" />
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="ifContentScrolls">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="0dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="20dp" />
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/project_chat_scroll_bottom"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_gravity="bottom|left"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="@drawable/bg_chat_scroll_bottom_button"
|
||||
android:contentDescription="回到底部"
|
||||
android:elevation="8dp"
|
||||
android:padding="11dp"
|
||||
android:scaleType="center"
|
||||
android:src="@drawable/ic_boss_arrow_down"
|
||||
android:tint="@color/boss_text_primary"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_mention_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:elevation="8dp"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_composer_row"
|
||||
@@ -126,22 +177,21 @@
|
||||
android:gravity="bottom"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="12dp">
|
||||
android:paddingBottom="10dp">
|
||||
|
||||
<Button
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/project_chat_attach"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:text="+"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
android:contentDescription="发送附件"
|
||||
android:padding="10dp"
|
||||
android:scaleType="center"
|
||||
android:src="@drawable/ic_boss_add"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/project_chat_input"
|
||||
@@ -153,23 +203,25 @@
|
||||
android:hint="输入消息"
|
||||
android:inputType="textCapSentences|textMultiLine"
|
||||
android:maxLines="4"
|
||||
android:minHeight="44dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="14dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:minHeight="40dp"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textColorHint="@color/boss_text_muted" />
|
||||
android:textColorHint="@color/boss_text_muted"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/project_chat_send"
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="44dp"
|
||||
android:layout_width="68dp"
|
||||
android:layout_height="40dp"
|
||||
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>
|
||||
|
||||
@@ -181,19 +233,34 @@
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:paddingBottom="10dp"
|
||||
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="match_parent"
|
||||
android:layout_height="44dp"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:text="转发"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -12,18 +12,18 @@
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="12dp">
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="7dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
@@ -37,43 +37,49 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="wrap_content"
|
||||
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="22sp"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
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="12sp" />
|
||||
android:textSize="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="更多"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
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="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="刷新"
|
||||
android:padding="8dp"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_refresh"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
</LinearLayout>
|
||||
@@ -95,6 +101,8 @@
|
||||
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>
|
||||
|
||||
9
android/app/src/main/res/values-v29/styles.xml
Normal file
9
android/app/src/main/res/values-v29/styles.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:windowBackground">@color/boss_bg_app</item>
|
||||
<item name="android:forceDarkAllowed">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -9,7 +9,7 @@
|
||||
<item name="android:windowBackground">@color/boss_bg_app</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.Light.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:windowBackground">@color/boss_bg_app</item>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class AccessManagementActivityTest {
|
||||
@Test
|
||||
public void renderAccessShowsTemplateApplyEntryWhenTemplatesAreAvailable() throws Exception {
|
||||
TestAccessManagementActivity activity = Robolectric
|
||||
.buildActivity(TestAccessManagementActivity.class, new Intent())
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccess",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAccessPayload())
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "套用模板"));
|
||||
assertTrue(viewTreeContainsText(content, "一次性给账号分配设备、项目和 Skill 权限"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderAccessExplainsUnavailableTargetsInsteadOfBlankState() throws Exception {
|
||||
TestAccessManagementActivity activity = Robolectric
|
||||
.buildActivity(TestAccessManagementActivity.class, new Intent())
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccess",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject()
|
||||
.put("accounts", new JSONArray())
|
||||
.put("devices", new JSONArray())
|
||||
.put("projects", new JSONArray())
|
||||
.put("skills", new JSONArray())
|
||||
.put("skillCatalog", new JSONArray())
|
||||
.put("permissionTemplates", new JSONArray())
|
||||
.put("grants", new JSONObject()
|
||||
.put("devices", new JSONArray())
|
||||
.put("projects", new JSONArray())
|
||||
.put("skills", new JSONArray())))
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "暂无权限模板"));
|
||||
assertTrue(viewTreeContainsText(content, "暂无可授权设备"));
|
||||
assertTrue(viewTreeContainsText(content, "暂无可授权项目"));
|
||||
assertTrue(viewTreeContainsText(content, "暂无可分配 Skill"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void buildTemplateApplyPayloadWritesServerTemplateContract() throws Exception {
|
||||
JSONObject payload = AccessManagementActivity.buildTemplateApplyPayload(
|
||||
"developer@example.com",
|
||||
new JSONObject().put("templateId", "developer"),
|
||||
new JSONObject().put("id", "mac-studio"),
|
||||
new JSONObject().put("id", "master-agent"),
|
||||
new JSONObject().put("skillId", "mac-studio:boss-server-debug")
|
||||
);
|
||||
|
||||
assertEquals("apply_template", payload.optString("action"));
|
||||
assertEquals("developer@example.com", payload.optString("account"));
|
||||
assertEquals("developer", payload.optString("templateId"));
|
||||
assertEquals("mac-studio", payload.optJSONArray("deviceIds").optString(0));
|
||||
assertEquals("master-agent", payload.optJSONArray("projectIds").optString(0));
|
||||
assertEquals("mac-studio:boss-server-debug", payload.optJSONArray("skillIds").optString(0));
|
||||
}
|
||||
|
||||
private static JSONObject buildAccessPayload() throws Exception {
|
||||
return new JSONObject()
|
||||
.put("accounts", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("account", "developer@example.com")
|
||||
.put("displayName", "Developer")
|
||||
.put("role", "member")))
|
||||
.put("devices", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "mac-studio")
|
||||
.put("name", "Mac Studio")))
|
||||
.put("projects", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "master-agent")
|
||||
.put("name", "主 Agent")))
|
||||
.put("skills", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("skillId", "mac-studio:boss-server-debug")
|
||||
.put("deviceId", "mac-studio")
|
||||
.put("name", "boss-server-debug")))
|
||||
.put("skillCatalog", new JSONArray())
|
||||
.put("permissionTemplates", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("templateId", "developer")
|
||||
.put("name", "项目开发者")
|
||||
.put("description", "允许聊天和 Skill 调用")))
|
||||
.put("grants", new JSONObject()
|
||||
.put("devices", new JSONArray())
|
||||
.put("projects", new JSONArray())
|
||||
.put("skills", new JSONArray()));
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
if (text != null && text.toString().contains(expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return false;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static final class TestAccessManagementActivity extends AccessManagementActivity {
|
||||
@Override
|
||||
protected void reload() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
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;
|
||||
@@ -10,7 +12,6 @@ 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;
|
||||
@@ -35,6 +36,7 @@ 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;
|
||||
@@ -46,78 +48,15 @@ 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", "主 GPT")
|
||||
.put("displayName", "OpenAI 平台账号")
|
||||
.put("roleLabel", "主 GPT")
|
||||
.put("providerLabel", "OpenAI API")
|
||||
.put("label", "主Agent")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("statusLabel", "ready")
|
||||
.put("note", "当前账号可直接生成主 Agent 回复。")
|
||||
.put("canGenerate", true);
|
||||
@@ -140,62 +79,547 @@ public class AiAccountsActivityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openAliyunQwenOnboardingDialogUsesPresetModelsWithCustomFallback() throws Exception {
|
||||
public void renderAccountsShowsStructuredSectionsAndExpandedEntries() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("activeIdentity", new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主Agent")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("statusLabel", "ready")
|
||||
.put("canGenerate", true))
|
||||
.put("accounts", new org.json.JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主Agent")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("provider", "chatgpt_oauth")
|
||||
.put("role", "primary")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", true))
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "hyzq-backup")
|
||||
.put("label", "备用API")
|
||||
.put("displayName", "环宇智擎 备用账号")
|
||||
.put("roleLabel", "备用链路")
|
||||
.put("providerLabel", "环宇智擎")
|
||||
.put("provider", "hyzq_api")
|
||||
.put("role", "backup")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", false)
|
||||
.put("apiKeyConfigured", true)
|
||||
.put("apiBaseUrl", "https://api.hyzq2046.com/v1"))
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "master-node")
|
||||
.put("label", "主Agent")
|
||||
.put("displayName", "绑定电脑上的 Codex 节点")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "主Agent 节点")
|
||||
.put("provider", "master_codex_node")
|
||||
.put("role", "primary")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", false)));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
assertNotNull(root);
|
||||
assertTrue(viewTreeContainsText(root, "主要API配置"));
|
||||
assertTrue(viewTreeContainsText(root, "备用API配置"));
|
||||
assertFalse(viewTreeContainsText(root, "OAuth 登录"));
|
||||
assertFalse(viewTreeContainsText(root, "API 接入"));
|
||||
assertFalse(viewTreeContainsText(root, "谷歌登录"));
|
||||
assertFalse(viewTreeContainsText(root, "ChatGPT登录"));
|
||||
assertFalse(viewTreeContainsText(root, "阿里"));
|
||||
assertFalse(viewTreeContainsText(root, "Minimax"));
|
||||
assertFalse(viewTreeContainsText(root, "GLM"));
|
||||
assertFalse(viewTreeContainsText(root, "环宇智擎"));
|
||||
assertFalse(viewTreeContainsText(root, "自定义"));
|
||||
assertFalse(viewTreeContainsText(root, "绑定设备节点"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tappingPrimaryConfigEntryOpensPrimaryDetailPage() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "openAliyunQwenOnboardingDialog");
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
View entry = findClickableViewContainingText(root, "主要API配置");
|
||||
assertNotNull(entry);
|
||||
entry.performClick();
|
||||
|
||||
ShadowActivity shadowActivity = Shadows.shadowOf(activity);
|
||||
Intent nextIntent = shadowActivity.getNextStartedActivity();
|
||||
assertNotNull(nextIntent);
|
||||
assertEquals(AiAccountsActivity.class.getName(), nextIntent.getComponent().getClassName());
|
||||
assertEquals("primary", nextIntent.getStringExtra("ai_accounts_role"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detailPageShowsOnlySelectedRoleConfiguration() throws Exception {
|
||||
Intent intent = new Intent(
|
||||
org.robolectric.RuntimeEnvironment.getApplication(),
|
||||
TestAiAccountsActivity.class
|
||||
);
|
||||
intent.putExtra("ai_accounts_role", "primary");
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("accounts", new org.json.JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主要API")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("provider", "chatgpt_oauth")
|
||||
.put("role", "primary")
|
||||
.put("model", "gpt-5.4-mini")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", true))
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "hyzq-primary")
|
||||
.put("label", "主要API")
|
||||
.put("displayName", "环宇智擎 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "环宇智擎")
|
||||
.put("provider", "hyzq_api")
|
||||
.put("role", "primary")
|
||||
.put("model", "gpt-5.4")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", false)
|
||||
.put("apiKeyConfigured", true)
|
||||
.put("apiBaseUrl", "https://api.hyzq2046.com/v1")));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
assertNotNull(root);
|
||||
assertTrue(viewTreeContainsText(root, "当前使用方式"));
|
||||
assertTrue(viewTreeContainsText(root, "主Agent模式"));
|
||||
assertTrue(viewTreeContainsText(root, "快速反应模型"));
|
||||
assertTrue(viewTreeContainsText(root, "深度思考模型"));
|
||||
assertTrue(viewTreeContainsText(root, "ChatGPT登录"));
|
||||
assertTrue(viewTreeContainsText(root, "OAuth 登录"));
|
||||
assertTrue(viewTreeContainsText(root, "当前模型:gpt-5.4-mini"));
|
||||
assertTrue(viewTreeContainsText(root, "当前:沿用默认"));
|
||||
assertTrue(viewTreeContainsText(root, "当前:gpt-5.4-mini"));
|
||||
assertTrue(viewTreeContainsText(root, "当前:gpt-5.4"));
|
||||
assertTrue(viewTreeContainsText(root, "API 接入"));
|
||||
assertTrue(viewTreeContainsText(root, "已配置:ChatGPT登录"));
|
||||
assertTrue(viewTreeContainsText(root, "已配置:环宇智擎"));
|
||||
assertFalse(viewTreeContainsText(root, "谷歌登录"));
|
||||
assertFalse(viewTreeContainsText(root, "阿里"));
|
||||
assertFalse(viewTreeContainsText(root, "Minimax"));
|
||||
assertFalse(viewTreeContainsText(root, "GLM"));
|
||||
assertFalse(viewTreeContainsText(root, "自定义"));
|
||||
assertFalse(viewTreeContainsText(root, "可编辑配置"));
|
||||
assertFalse(viewTreeContainsText(root, "当前已保存"));
|
||||
assertFalse(viewTreeContainsText(root, "只读状态"));
|
||||
assertFalse(viewTreeContainsText(root, "备用API配置"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void currentMethodEntryOpensCurrentAccountEditor() throws Exception {
|
||||
Intent intent = new Intent(
|
||||
org.robolectric.RuntimeEnvironment.getApplication(),
|
||||
TestAiAccountsActivity.class
|
||||
);
|
||||
intent.putExtra("ai_accounts_role", "primary");
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
|
||||
ReflectionHelpers.setField(activity, "currentMasterAgentModelOverride", "gpt-5.4-mini");
|
||||
ReflectionHelpers.setField(activity, "currentMasterAgentReasoningEffortOverride", "low");
|
||||
ReflectionHelpers.setField(activity, "currentFastModelOverride", "gpt-5.4-mini");
|
||||
ReflectionHelpers.setField(activity, "currentDeepModelOverride", "gpt-5.4");
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("accounts", new org.json.JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主要API")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("provider", "chatgpt_oauth")
|
||||
.put("role", "primary")
|
||||
.put("model", "gpt-5.4-mini")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", true)));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
View entry = findClickableViewContainingText(root, "当前使用方式");
|
||||
assertNotNull(entry);
|
||||
entry.performClick();
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
|
||||
View root = dialog.getWindow().getDecorView();
|
||||
Spinner modelSpinner = findSpinnerContainingItem(root, "qwen3.5-plus");
|
||||
assertNotNull(modelSpinner);
|
||||
SpinnerAdapter adapter = modelSpinner.getAdapter();
|
||||
assertNotNull(adapter);
|
||||
assertEquals(3, adapter.getCount());
|
||||
assertEquals("qwen3.5-plus", adapter.getItem(0).toString());
|
||||
assertEquals("qwen3.5-flash", adapter.getItem(1).toString());
|
||||
assertEquals("自定义模型", adapter.getItem(2).toString());
|
||||
assertEquals("qwen3.5-plus", modelSpinner.getSelectedItem().toString());
|
||||
|
||||
EditText customModelInput = findEditTextWithHint(root, "自定义模型");
|
||||
assertNotNull(customModelInput);
|
||||
View dialogRoot = dialog.getWindow().getDecorView();
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "账号快捷登录"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "选择模型"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openAccountEditorShowsCustomFallbackForNonPresetAliyunModel() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
JSONObject existing = new JSONObject()
|
||||
.put("accountId", "acc-1")
|
||||
.put("label", "备用 GPT")
|
||||
.put("displayName", "阿里百炼备用账号")
|
||||
.put("provider", "aliyun_qwen_api")
|
||||
.put("model", "qwen-custom-x");
|
||||
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,
|
||||
"openAccountEditor",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, existing),
|
||||
"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);
|
||||
assertNotNull(modelSpinner);
|
||||
assertFalse(modelSpinner.isEnabled());
|
||||
assertFalse(modelSpinner.isClickable());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openOauthAccountDialogEnablesModelSelectionWhenAccountIsReady() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
JSONObject existing = new JSONObject()
|
||||
.put("label", "主要API")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("accountIdentifier", "kris@example.com")
|
||||
.put("model", "gpt-5.4")
|
||||
.put("loginStatusNote", "已登录")
|
||||
.put("enabled", true)
|
||||
.put("isActive", true)
|
||||
.put("status", "ready")
|
||||
.put("statusLabel", "ready");
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"openOauthAccountDialog",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "primary"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "chatgpt_oauth"),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, existing)
|
||||
);
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
View root = dialog.getWindow().getDecorView();
|
||||
Spinner modelSpinner = findSpinner(root);
|
||||
assertNotNull(modelSpinner);
|
||||
assertTrue(modelSpinner.isEnabled());
|
||||
assertTrue(modelSpinner.isClickable());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openApiAccountDialogLocksModelSelectionBeforeValidation() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"openApiAccountDialog",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "backup"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api"),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, null)
|
||||
);
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
|
||||
View root = dialog.getWindow().getDecorView();
|
||||
Spinner modelSpinner = findSpinnerContainingItem(root, "自定义模型");
|
||||
assertNotNull(findEditTextWithHint(root, "账号标识 / 备注"));
|
||||
assertNotNull(findEditTextWithHint(root, "API Key"));
|
||||
Spinner modelSpinner = findSpinner(root);
|
||||
assertNotNull(modelSpinner);
|
||||
SpinnerAdapter adapter = modelSpinner.getAdapter();
|
||||
assertNotNull(adapter);
|
||||
assertEquals(3, adapter.getCount());
|
||||
assertEquals("自定义模型", modelSpinner.getSelectedItem().toString());
|
||||
assertFalse(modelSpinner.isEnabled());
|
||||
assertEquals(0, ((android.widget.ArrayAdapter<?>) modelSpinner.getAdapter()).getCount());
|
||||
}
|
||||
|
||||
EditText customModelInput = findEditTextWithHint(root, "自定义模型");
|
||||
assertNotNull(customModelInput);
|
||||
assertEquals("qwen-custom-x", customModelInput.getText().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"));
|
||||
}
|
||||
|
||||
private static final class TestAiAccountsActivity extends AiAccountsActivity {
|
||||
@@ -279,8 +703,6 @@ 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);
|
||||
@@ -301,16 +723,11 @@ public class AiAccountsActivityTest {
|
||||
public void connect() {}
|
||||
|
||||
@Override
|
||||
public void setRequestMethod(String method) throws ProtocolException {
|
||||
requestMethodValue = method;
|
||||
}
|
||||
public void setRequestMethod(String method) throws ProtocolException {}
|
||||
|
||||
@Override
|
||||
public void setRequestProperty(String key, String value) {
|
||||
requestHeaders.put(key, value);
|
||||
if ("Content-Type".equalsIgnoreCase(key)) {
|
||||
contentTypeValue = value;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -337,6 +754,10 @@ 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 {
|
||||
@@ -484,32 +905,6 @@ 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();
|
||||
@@ -529,4 +924,41 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,23 @@ public class BossApiClientDeviceModeTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void queueCodexRemoteControlWritesConfirmedActionBody() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/devices/device-1/codex-remote-control")
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
apiClient.queueCodexRemoteControl("device-1", "start", "APP 设备详情页确认启动");
|
||||
|
||||
assertEquals("/api/v1/devices/device-1/codex-remote-control", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(
|
||||
"{\"action\":\"start\",\"confirmed\":true,\"reason\":\"APP 设备详情页确认启动\"}",
|
||||
connection.requestBody()
|
||||
);
|
||||
}
|
||||
|
||||
private static final class RecordingBossApiClient extends BossApiClient {
|
||||
private final RecordingConnection connection;
|
||||
private String lastPath = "";
|
||||
|
||||
@@ -81,6 +81,22 @@ public class BossApiClientDispatchPlansTest {
|
||||
assertEquals("no-cache", connection.getRequestProperty("Pragma"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void protectedHtmlResponseReturnsJsonErrorInsteadOfThrowing() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/auth/session"),
|
||||
200,
|
||||
"<!DOCTYPE html><html><body>login</body></html>",
|
||||
""
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getSession();
|
||||
|
||||
assertEquals(401, response.statusCode);
|
||||
assertEquals("NON_JSON_RESPONSE", response.message());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void confirmDispatchPlanWritesApprovedTargetProjectIds() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/confirm"));
|
||||
@@ -114,6 +130,19 @@ public class BossApiClientDispatchPlansTest {
|
||||
assertEquals("{}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decideDialogGuardInterventionUsesContractEndpointAndDecisionBody() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/dialog-guard/interventions/intervention-1/decision"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.decideDialogGuardIntervention("intervention-1", "allow_once");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/dialog-guard/interventions/intervention-1/decision", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals("{\"decision\":\"allow_once\"}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void retryDispatchPlanUsesProjectScopedRetryEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/retry"));
|
||||
@@ -170,6 +199,27 @@ public class BossApiClientDispatchPlansTest {
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateMasterAgentModeModelsWritesFastAndDeepModelMappings() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/agent-controls"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.updateMasterAgentModeModels(
|
||||
"gpt-4.1",
|
||||
"gpt-5.1",
|
||||
"gpt-4.1",
|
||||
"low"
|
||||
);
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/master-agent/agent-controls", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(
|
||||
"{\"fastModelOverride\":\"gpt-4.1\",\"deepModelOverride\":\"gpt-5.1\",\"modelOverride\":\"gpt-4.1\",\"reasoningEffortOverride\":\"low\"}",
|
||||
connection.requestBody()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getMasterAgentPromptProfileUsesScopedEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/prompt-profile"));
|
||||
@@ -210,6 +260,34 @@ public class BossApiClientDispatchPlansTest {
|
||||
assertEquals("GET", connection.requestMethodValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getProjectDetailUsesExtendedReadTimeoutForChatPages() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getProjectDetail("thread-1");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/thread-1", apiClient.lastPath);
|
||||
assertEquals("GET", connection.requestMethodValue);
|
||||
assertEquals(12000, connection.connectTimeoutValue);
|
||||
assertEquals(30000, connection.readTimeoutValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getProjectMessagesUsesExtendedReadTimeoutForRealtimeRefreshes() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1/messages"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getProjectMessages("thread-1");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/thread-1/messages", apiClient.lastPath);
|
||||
assertEquals("GET", connection.requestMethodValue);
|
||||
assertEquals(12000, connection.connectTimeoutValue);
|
||||
assertEquals(30000, connection.readTimeoutValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void createMasterAgentMemoryWritesStructuredPayload() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/memories"));
|
||||
@@ -261,6 +339,153 @@ public class BossApiClientDispatchPlansTest {
|
||||
assertEquals(20000, connection.readTimeoutValue);
|
||||
}
|
||||
|
||||
@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"));
|
||||
@@ -287,7 +512,7 @@ public class BossApiClientDispatchPlansTest {
|
||||
public void rememberIdentityDoesNotOverwriteSessionIdentityFromAiAccountOnboardingResponse() throws Exception {
|
||||
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
|
||||
prefs.edit()
|
||||
.putString("account", "17600003315")
|
||||
.putString("account", "krisolo")
|
||||
.putString("display_name", "Boss 超级管理员")
|
||||
.apply();
|
||||
BossApiClient apiClient = new BossApiClient(prefs, "https://boss.hyzq.net");
|
||||
@@ -300,7 +525,7 @@ public class BossApiClientDispatchPlansTest {
|
||||
|
||||
apiClient.rememberIdentity(onboardingResponse);
|
||||
|
||||
assertEquals("17600003315", apiClient.getAccountLabel());
|
||||
assertEquals("krisolo", apiClient.getAccountLabel());
|
||||
assertEquals("Boss 超级管理员", apiClient.getDisplayName());
|
||||
}
|
||||
|
||||
@@ -338,7 +563,11 @@ public class BossApiClientDispatchPlansTest {
|
||||
private String lastPath = "";
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
this(connection, new InMemorySharedPreferences());
|
||||
}
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection, SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@@ -362,6 +591,7 @@ public class BossApiClientDispatchPlansTest {
|
||||
private static final class ScriptedBossApiClient extends BossApiClient {
|
||||
private final Map<String, RecordingConnection> connections;
|
||||
private String lastPath = "";
|
||||
private RecordingConnection lastConnection;
|
||||
|
||||
ScriptedBossApiClient(RecordingConnection... connections) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
@@ -378,6 +608,7 @@ public class BossApiClientDispatchPlansTest {
|
||||
if (connection == null) {
|
||||
throw new IllegalStateException("Missing scripted connection for " + path);
|
||||
}
|
||||
lastConnection = connection;
|
||||
return connection;
|
||||
}
|
||||
|
||||
@@ -392,6 +623,65 @@ public class BossApiClientDispatchPlansTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SequencedBossApiClient extends BossApiClient {
|
||||
private final java.util.ArrayDeque<RecordingConnection> protectedConnections = new java.util.ArrayDeque<>();
|
||||
private int autoLoginCalls;
|
||||
private int protectedRequestCount;
|
||||
|
||||
SequencedBossApiClient(RecordingConnection... protectedConnections) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
Collections.addAll(this.protectedConnections, protectedConnections);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse autoLogin() throws org.json.JSONException {
|
||||
autoLoginCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("account", "krisolo")
|
||||
.put("displayName", "Boss 超级管理员"));
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
if (!"/api/v1/projects/project-1".equals(path)) {
|
||||
throw new IllegalStateException("Unexpected path " + path);
|
||||
}
|
||||
protectedRequestCount += 1;
|
||||
RecordingConnection connection = protectedConnections.pollFirst();
|
||||
if (connection == null) {
|
||||
throw new IllegalStateException("No more scripted protected responses");
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
String encode(String value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
void rememberIdentity(JSONObject json) {
|
||||
// no-op for JVM unit test
|
||||
}
|
||||
}
|
||||
|
||||
private static final class IdentityCapturingBossApiClient extends BossApiClient {
|
||||
private final RecordingConnection connection;
|
||||
private String lastPath = "";
|
||||
|
||||
IdentityCapturingBossApiClient(RecordingConnection connection, SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
lastPath = path;
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConnection extends HttpURLConnection {
|
||||
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
|
||||
private final Map<String, String> requestHeaders = new HashMap<>();
|
||||
@@ -401,9 +691,15 @@ public class BossApiClientDispatchPlansTest {
|
||||
private final int responseCodeValue;
|
||||
private final String responseBody;
|
||||
private final String errorBody;
|
||||
private final Map<String, java.util.List<String>> responseHeaders = new HashMap<>();
|
||||
|
||||
RecordingConnection(URL url) {
|
||||
this(url, 200, "{\"ok\":true}", "{\"ok\":false}");
|
||||
this(
|
||||
url,
|
||||
200,
|
||||
"{\"ok\":true,\"account\":\"krisolo\",\"displayName\":\"Boss 超级管理员\"}",
|
||||
"{\"ok\":false}"
|
||||
);
|
||||
}
|
||||
|
||||
RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
|
||||
@@ -472,6 +768,11 @@ public class BossApiClientDispatchPlansTest {
|
||||
return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, java.util.List<String>> getHeaderFields() {
|
||||
return responseHeaders;
|
||||
}
|
||||
|
||||
String requestBody() {
|
||||
return requestBody.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.ProtocolException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossApiClientLogoutTest {
|
||||
@Test
|
||||
public void logoutClearsAllCachedIdentityHints() throws Exception {
|
||||
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
|
||||
prefs.edit().putString("session_cookie", "boss_session=session-token").apply();
|
||||
BossApiClient apiClient = new RecordingBossApiClient(prefs);
|
||||
apiClient.rememberIdentity(new JSONObject()
|
||||
.put("restoreToken", "restore-token")
|
||||
.put("account", "honor_user")
|
||||
.put("displayName", "荣耀测试账号"));
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.logout();
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertFalse(prefs.contains("session_cookie"));
|
||||
assertFalse(prefs.contains("restore_token"));
|
||||
assertFalse(prefs.contains("account"));
|
||||
assertFalse(prefs.contains("display_name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void logoutClearsLocalAuthEvenWhenServerRequestFails() throws Exception {
|
||||
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
|
||||
prefs.edit()
|
||||
.putString("session_cookie", "boss_session=session-token")
|
||||
.putString("restore_token", "restore-token")
|
||||
.putString("account", "honor_user")
|
||||
.putString("display_name", "荣耀测试账号")
|
||||
.apply();
|
||||
BossApiClient apiClient = new FailingLogoutBossApiClient(prefs);
|
||||
|
||||
try {
|
||||
apiClient.logout();
|
||||
} catch (IOException expected) {
|
||||
// Local logout state must still be cleared if the network request fails.
|
||||
}
|
||||
|
||||
assertFalse(prefs.contains("session_cookie"));
|
||||
assertFalse(prefs.contains("restore_token"));
|
||||
assertFalse(prefs.contains("account"));
|
||||
assertFalse(prefs.contains("display_name"));
|
||||
}
|
||||
|
||||
private static final class RecordingBossApiClient extends BossApiClient {
|
||||
RecordingBossApiClient(SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) throws java.io.IOException {
|
||||
return new RecordingConnection(new URL("https://boss.hyzq.net" + path));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class FailingLogoutBossApiClient extends BossApiClient {
|
||||
FailingLogoutBossApiClient(SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) throws IOException {
|
||||
throw new IOException("network down");
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConnection extends HttpURLConnection {
|
||||
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
|
||||
private String requestMethodValue = "GET";
|
||||
|
||||
RecordingConnection(URL url) {
|
||||
super(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect() {}
|
||||
|
||||
@Override
|
||||
public boolean usingProxy() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect() {}
|
||||
|
||||
@Override
|
||||
public void setRequestMethod(String method) throws ProtocolException {
|
||||
requestMethodValue = method;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRequestMethod() {
|
||||
return requestMethodValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getResponseCode() {
|
||||
return 200;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, List<String>> getHeaderFields() {
|
||||
return Map.of("Set-Cookie", List.of("boss_session=; Max-Age=0; Path=/"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InMemorySharedPreferences implements SharedPreferences {
|
||||
private final Map<String, String> values = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public Map<String, ?> getAll() {
|
||||
return Collections.unmodifiableMap(values);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getString(String key, String defValue) {
|
||||
return values.getOrDefault(key, defValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getStringSet(String key, Set<String> defValues) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getInt(String key, int defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getLong(String key, long defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key, float defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getBoolean(String key, boolean defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String key) {
|
||||
return values.containsKey(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor edit() {
|
||||
return new Editor() {
|
||||
@Override
|
||||
public Editor putString(String key, String value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor remove(String key) {
|
||||
values.remove(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor clear() {
|
||||
values.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply() {}
|
||||
|
||||
@Override
|
||||
public boolean commit() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putStringSet(String key, Set<String> values) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putInt(String key, int value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putLong(String key, long value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putFloat(String key, float value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putBoolean(String key, boolean value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
|
||||
|
||||
@Override
|
||||
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.RuntimeEnvironment;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.shadows.ShadowNotificationManager;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossBackgroundRealtimeServiceTest {
|
||||
@After
|
||||
public void tearDown() {
|
||||
TestBossBackgroundRealtimeService.runtimeOverride = null;
|
||||
}
|
||||
|
||||
@Test
|
||||
public void manifestDeclaresForegroundDataSyncPermission() throws Exception {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
PackageManager packageManager = context.getPackageManager();
|
||||
PackageInfo packageInfo = packageManager.getPackageInfo(
|
||||
context.getPackageName(),
|
||||
PackageManager.GET_PERMISSIONS
|
||||
);
|
||||
|
||||
assertNotNull(packageInfo.requestedPermissions);
|
||||
org.junit.Assert.assertTrue(
|
||||
java.util.Arrays.asList(packageInfo.requestedPermissions)
|
||||
.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC")
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void startCommandStartsForegroundSyncAndRealtimeWhenSessionExists() {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
SharedPreferences prefs = context.getSharedPreferences("boss-background-service", Context.MODE_PRIVATE);
|
||||
prefs.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.putString("restore_token", "restore-test")
|
||||
.apply();
|
||||
RecordingRealtimeRuntime runtime = new RecordingRealtimeRuntime();
|
||||
TestBossBackgroundRealtimeService.runtimeOverride = runtime;
|
||||
|
||||
TestBossBackgroundRealtimeService service = Robolectric
|
||||
.buildService(TestBossBackgroundRealtimeService.class)
|
||||
.create()
|
||||
.startCommand(0, 1)
|
||||
.get();
|
||||
|
||||
ShadowNotificationManager notificationManager = Shadows.shadowOf(
|
||||
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||
);
|
||||
|
||||
assertEquals(1, runtime.startCount);
|
||||
assertEquals(
|
||||
1,
|
||||
notificationManager.size()
|
||||
);
|
||||
assertEquals(
|
||||
"Boss 后台同步中",
|
||||
String.valueOf(
|
||||
notificationManager
|
||||
.getNotification(BossBackgroundRealtimeService.SERVICE_NOTIFICATION_ID)
|
||||
.extras
|
||||
.getCharSequence(android.app.Notification.EXTRA_TITLE)
|
||||
)
|
||||
);
|
||||
|
||||
service.onDestroy();
|
||||
assertEquals(1, runtime.stopCount);
|
||||
}
|
||||
|
||||
public static class TestBossBackgroundRealtimeService extends BossBackgroundRealtimeService {
|
||||
static RecordingRealtimeRuntime runtimeOverride;
|
||||
|
||||
@Override
|
||||
BossRealtimeRuntime createRealtimeRuntime(BossApiClient apiClient, BossNotificationRouter router) {
|
||||
return runtimeOverride == null ? super.createRealtimeRuntime(apiClient, router) : runtimeOverride;
|
||||
}
|
||||
|
||||
@Override
|
||||
BossApiClient createApiClient() {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
SharedPreferences prefs = context.getSharedPreferences("boss-background-service", Context.MODE_PRIVATE);
|
||||
return new BossApiClient(prefs, "https://boss.hyzq.net");
|
||||
}
|
||||
}
|
||||
|
||||
static final class RecordingRealtimeRuntime implements BossBackgroundRealtimeService.BossRealtimeRuntime {
|
||||
int startCount;
|
||||
int stopCount;
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
startCount += 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void stop() {
|
||||
stopCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,4 +60,32 @@ 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.app.Notification;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RuntimeEnvironment;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.shadows.ShadowNotificationManager;
|
||||
import org.robolectric.shadows.ShadowApplication;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossNotificationRouterTest {
|
||||
@Test
|
||||
public void visibilityTrackerMarksForegroundAndVisibleProject() {
|
||||
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
|
||||
|
||||
tracker.onAppForegrounded();
|
||||
tracker.setVisibleProjectId("master-agent");
|
||||
|
||||
assertTrue(tracker.isAppInForeground());
|
||||
assertEquals("master-agent", tracker.getVisibleProjectId());
|
||||
|
||||
tracker.clearVisibleProjectId("master-agent");
|
||||
tracker.onAppBackgrounded();
|
||||
|
||||
assertFalse(tracker.isAppInForeground());
|
||||
assertNull(tracker.getVisibleProjectId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void routerNotifiesOnlyForNewMasterAgentRepliesWhileBackgrounded() throws Exception {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
|
||||
tracker.onAppBackgrounded();
|
||||
BossNotificationRouter router = new BossNotificationRouter(context, tracker);
|
||||
ShadowNotificationManager notificationManager = Shadows.shadowOf(
|
||||
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||
);
|
||||
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
|
||||
|
||||
JSONObject message = new JSONObject()
|
||||
.put("id", "m-2")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent · gpt-5.4-mini")
|
||||
.put("body", "主 Agent 已完成同步。")
|
||||
.put("sentAt", "2026-04-21T10:00:00.000Z");
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("projectId", "master-agent")
|
||||
.put("projectMessagesPayload", new JSONObject().put(
|
||||
"project",
|
||||
new JSONObject().put("messages", new JSONArray().put(message))
|
||||
));
|
||||
|
||||
assertTrue(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
|
||||
assertFalse(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
|
||||
assertEquals(1, notificationManager.size());
|
||||
Notification posted = notificationManager.getNotification(BossNotificationRouter.MASTER_AGENT_NOTIFICATION_ID);
|
||||
assertEquals("主 Agent", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TITLE)));
|
||||
assertEquals("主 Agent 已完成同步。", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TEXT)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void routerNotifiesForMasterAgentRepliesInsideThreadConversationsWhileBackgrounded() throws Exception {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
|
||||
tracker.onAppBackgrounded();
|
||||
BossNotificationRouter router = new BossNotificationRouter(context, tracker);
|
||||
ShadowNotificationManager notificationManager = Shadows.shadowOf(
|
||||
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||
);
|
||||
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
|
||||
|
||||
JSONObject message = new JSONObject()
|
||||
.put("id", "thread-master-reply-1")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent · gpt-5.4-mini")
|
||||
.put("body", "我已接管这个线程,下一步先核对当前目标。");
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("projectId", "aiyanjing-thread")
|
||||
.put("projectMessagesPayload", new JSONObject().put(
|
||||
"project",
|
||||
new JSONObject()
|
||||
.put("name", "AI 眼镜线程")
|
||||
.put("messages", new JSONArray().put(message))
|
||||
));
|
||||
|
||||
assertTrue(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
|
||||
|
||||
Notification posted = notificationManager.getNotification(BossNotificationRouter.MASTER_AGENT_NOTIFICATION_ID);
|
||||
assertEquals("主 Agent · AI 眼镜线程", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TITLE)));
|
||||
assertEquals("我已接管这个线程,下一步先核对当前目标。", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TEXT)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void routerSuppressesNotificationWhileAppIsForeground() throws Exception {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
|
||||
tracker.onAppForegrounded();
|
||||
BossNotificationRouter router = new BossNotificationRouter(context, tracker);
|
||||
ShadowNotificationManager notificationManager = Shadows.shadowOf(
|
||||
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||
);
|
||||
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
|
||||
|
||||
JSONObject message = new JSONObject()
|
||||
.put("id", "m-3")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent · gpt-5.4-mini")
|
||||
.put("body", "这条前台不该弹通知。");
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("projectId", "master-agent")
|
||||
.put("projectMessagesPayload", new JSONObject().put(
|
||||
"project",
|
||||
new JSONObject().put("messages", new JSONArray().put(message))
|
||||
));
|
||||
|
||||
assertFalse(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
|
||||
assertEquals(0, notificationManager.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossRbacVisibilityTest {
|
||||
@Test
|
||||
public void memberMeMenuHidesAdministratorControlEntries() {
|
||||
assertArrayEquals(
|
||||
new String[]{"账号与安全", "设置", "技能", "关于"},
|
||||
WechatSurfaceMapper.rootMeMenuTitlesForRole("member")
|
||||
);
|
||||
|
||||
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("access", "member"));
|
||||
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("ai_accounts", "member"));
|
||||
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("ops", "member"));
|
||||
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("storage", "member"));
|
||||
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("telegram", "member"));
|
||||
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("skills", "member"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void administratorMeMenuKeepsControlEntries() {
|
||||
assertArrayEquals(
|
||||
new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
|
||||
WechatSurfaceMapper.rootMeMenuTitlesForRole("highest_admin")
|
||||
);
|
||||
|
||||
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("access", "highest_admin"));
|
||||
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("access", "admin"));
|
||||
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("ai_accounts", "highest_admin"));
|
||||
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("ops", "admin"));
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,18 @@
|
||||
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 {
|
||||
@@ -37,4 +42,10 @@ 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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,18 +50,29 @@ public class BossUiConversationRowTest {
|
||||
TextView previewView = (TextView) centerColumn.getChildAt(centerColumn.getChildCount() - 1);
|
||||
|
||||
String metrics = String.format(
|
||||
"row=%d center=%d trailing=%d title=%d preview=%d",
|
||||
"row=%d height=%d avatarHeight=%d center=%dx%d trailing=%dx%d title=%dx%d preview=%dx%d",
|
||||
rowView.getMeasuredWidth(),
|
||||
rowView.getMeasuredHeight(),
|
||||
rowView.getChildAt(0).getMeasuredHeight(),
|
||||
centerColumn.getMeasuredWidth(),
|
||||
centerColumn.getMeasuredHeight(),
|
||||
trailingColumn.getMeasuredWidth(),
|
||||
trailingColumn.getMeasuredHeight(),
|
||||
titleView.getMeasuredWidth(),
|
||||
previewView.getMeasuredWidth()
|
||||
titleView.getMeasuredHeight(),
|
||||
previewView.getMeasuredWidth(),
|
||||
previewView.getMeasuredHeight()
|
||||
);
|
||||
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, 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("列表项不应保持卡片式浮层感: " + 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);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertSame;
|
||||
|
||||
import android.content.Context;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.RuntimeEnvironment;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossUiFormCellTest {
|
||||
@Test
|
||||
public void buildFormCell_detachesFieldFromPreviousParentBeforeReusingIt() {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
EditText field = new EditText(context);
|
||||
|
||||
BossUi.buildFormCell(context, "模型", "第一次渲染", field);
|
||||
LinearLayout secondCell = BossUi.buildFormCell(context, "模型", "刷新后重建", field);
|
||||
|
||||
assertSame(secondCell, field.getParent());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
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;
|
||||
@@ -38,4 +40,28 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.view.View;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.Button;
|
||||
import android.widget.TextView;
|
||||
|
||||
import org.json.JSONObject;
|
||||
@@ -13,6 +16,7 @@ import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@@ -21,31 +25,45 @@ public class BossUiRootSurfaceTest {
|
||||
@Test
|
||||
public void renderMeRoot_usesWechatProfileHeaderAndFlatMenuRows() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"setActiveTab",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "me"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
|
||||
ReflectionHelpers.setField(
|
||||
activity,
|
||||
"sessionData",
|
||||
new JSONObject()
|
||||
.put("displayName", "Kris")
|
||||
.put("account", "17600003315")
|
||||
.put("account", "krisolo")
|
||||
.put("role", "highest_admin")
|
||||
);
|
||||
ReflectionHelpers.callInstanceMethod(activity, "renderMeRoot");
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
assertEquals("我的页应是资料头 + 6 条菜单", 7, content.getChildCount());
|
||||
LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
|
||||
assertEquals("我的页应是资料头 + 9 条菜单", 10, content.getChildCount());
|
||||
|
||||
View header = content.getChildAt(0);
|
||||
assertEquals("资料头不应保留浮层卡片感", 0f, header.getElevation(), 0.01f);
|
||||
assertTrue("资料头应保持横向布局", 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, "17600003315"));
|
||||
assertTrue(viewTreeContainsText(header, "krisolo"));
|
||||
assertTrue(viewTreeContainsText(header, "最高管理员"));
|
||||
assertTrue(viewTreeContainsText(header, "主控账号已启用安全保护"));
|
||||
|
||||
assertTrue(viewTreeContainsText(content, "账号与安全"));
|
||||
assertTrue(viewTreeContainsText(content, "设置"));
|
||||
assertTrue(viewTreeContainsText(content, "用户与权限"));
|
||||
assertTrue(viewTreeContainsText(content, "运维与修复"));
|
||||
assertTrue(viewTreeContainsText(content, "AI 账号"));
|
||||
assertTrue(viewTreeContainsText(content, "附件与存储"));
|
||||
assertTrue(viewTreeContainsText(content, "Telegram 接入"));
|
||||
assertTrue(viewTreeContainsText(content, "技能"));
|
||||
assertTrue(viewTreeContainsText(content, "关于"));
|
||||
|
||||
@@ -55,6 +73,46 @@ public class BossUiRootSurfaceTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openMeEntry_storageStartsAttachmentStorageSettings() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(
|
||||
activity,
|
||||
"sessionData",
|
||||
new JSONObject().put("role", "highest_admin")
|
||||
);
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"openMeEntry",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "storage")
|
||||
);
|
||||
|
||||
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
|
||||
assertNotNull(started);
|
||||
assertEquals(StorageSettingsActivity.class.getName(), started.getComponent().getClassName());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootTabs_useWechatIconLabelNavigation() {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
|
||||
Button conversations = activity.findViewById(R.id.tab_conversations);
|
||||
Button devices = activity.findViewById(R.id.tab_devices);
|
||||
Button me = activity.findViewById(R.id.tab_me);
|
||||
|
||||
assertEquals("会话", conversations.getText().toString());
|
||||
assertEquals("设备", devices.getText().toString());
|
||||
assertEquals("我的", me.getText().toString());
|
||||
assertEquals("顶部标题应对标微信页面标题", 18f, ((TextView) activity.findViewById(R.id.top_title)).getTextSize() / activity.getResources().getDisplayMetrics().scaledDensity, 0.5f);
|
||||
assertEquals("底栏高度应对标微信底栏有效面积", BossUi.dp(activity, 54), ((View) conversations.getParent()).getLayoutParams().height);
|
||||
assertNotNull("会话 tab 应显示顶部图标", conversations.getCompoundDrawables()[1]);
|
||||
assertNotNull("设备 tab 应显示顶部图标", devices.getCompoundDrawables()[1]);
|
||||
assertNotNull("我的 tab 应显示顶部图标", me.getCompoundDrawables()[1]);
|
||||
assertEquals("底栏文字应对标微信底栏标签", 10f, conversations.getTextSize() / activity.getResources().getDisplayMetrics().scaledDensity, 0.5f);
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
|
||||
@@ -21,9 +21,9 @@ public class BossUiTopActionStyleTest {
|
||||
|
||||
BossUi.applyTopIconButtonStyle(context, button);
|
||||
|
||||
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());
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -22,6 +23,7 @@ import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.shadows.ShadowDialog;
|
||||
import java.time.Duration;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@@ -53,8 +55,8 @@ public class ConversationFolderActivityTest {
|
||||
assertEquals(View.GONE, refreshButton.getVisibility());
|
||||
assertEquals("Talking", String.valueOf(titleView.getText()));
|
||||
assertEquals("3 个线程", String.valueOf(subtitleView.getText()));
|
||||
assertTrue(viewTreeContainsText(content, "Talking"));
|
||||
assertTrue(viewTreeContainsText(content, "项目内部线程页"));
|
||||
assertFalse("项目抽屉页不应展示冗余说明卡片标题", viewTreeContainsText(content, "项目内部线程页"));
|
||||
assertFalse("项目抽屉页不应展示冗余说明卡片文案", viewTreeContainsText(content, "点击线程后进入具体聊天窗口。"));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showMoreMenu");
|
||||
|
||||
@@ -84,11 +86,12 @@ public class ConversationFolderActivityTest {
|
||||
);
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "已定位到目标线程"));
|
||||
assertFalse("项目抽屉页不应展示搜索定位说明卡片", viewTreeContainsText(content, "已定位到目标线程"));
|
||||
assertFalse("项目抽屉页不应展示匹配项置顶说明文案", viewTreeContainsText(content, "个匹配项已置顶"));
|
||||
assertTrue(viewTreeContainsText(content, "目标线程"));
|
||||
assertTrue(viewTreeContainsText(content, "发布回滚"));
|
||||
assertEquals(2, countTextOccurrences(content, "目标线程"));
|
||||
assertTrue(countTextOccurrences(content, "发布回滚") >= 3);
|
||||
assertEquals(2, countTextOccurrences(content, "发布回滚"));
|
||||
assertEquals(0, countTextOccurrences(content, "project-1"));
|
||||
}
|
||||
|
||||
@@ -112,7 +115,7 @@ public class ConversationFolderActivityTest {
|
||||
);
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "已定位到目标线程"));
|
||||
assertFalse("项目抽屉页不应展示搜索定位说明卡片", viewTreeContainsText(content, "已定位到目标线程"));
|
||||
assertTrue(viewTreeContainsText(content, "日志收口"));
|
||||
assertEquals(0, countTextOccurrences(content, "project-99"));
|
||||
assertEquals(1, countTextOccurrences(content, "目标线程"));
|
||||
@@ -145,7 +148,7 @@ public class ConversationFolderActivityTest {
|
||||
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
|
||||
assertEquals(1, activity.reloadCount);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.graphics.Color;
|
||||
import android.graphics.drawable.ColorDrawable;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageButton;
|
||||
@@ -15,6 +17,7 @@ import android.widget.ListView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.SwitchCompat;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
@@ -37,7 +40,7 @@ import java.util.concurrent.TimeUnit;
|
||||
@Config(sdk = 34)
|
||||
public class ConversationInfoActivityTest {
|
||||
@Test
|
||||
public void renderConversationUsesLightweightHeaderMenuAndThreadList() throws Exception {
|
||||
public void renderConversationOmitsProfileHeaderAndStartsWithUsefulSettings() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
@@ -55,22 +58,81 @@ public class ConversationInfoActivityTest {
|
||||
);
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "北区试产线回归"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "单线程会话"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(1), "线程状态摘要"));
|
||||
assertTrue(viewTreeContainsTextFragment(content.getChildAt(1), "当前进度:已经记录最近 2 条进展"));
|
||||
assertTrue(viewTreeContainsTextFragment(content.getChildAt(1), "建议下一步:继续同步 Android 只读页"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(2), "主 Agent 协同接管"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(3), "发起群聊"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(3), "选择其他线程加入新群"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(4), "线程详情"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(4), "查看当前线程聊天与项目"));
|
||||
assertFalse(viewTreeContainsText(content, "线程状态摘要"));
|
||||
assertFalse(viewTreeContainsTextFragment(content, "当前进度:已经记录最近 2 条进展"));
|
||||
assertFalse(viewTreeContainsTextFragment(content, "建议下一步:继续同步 Android 只读页"));
|
||||
assertFalse(viewTreeContainsText(content, "单线程会话"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "主 Agent 协同接管"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(1), "发起群聊"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(1), "选择其他线程加入新群"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(2), "线程详情"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(2), "查看当前线程聊天与项目"));
|
||||
assertTrue(viewTreeContainsText(content, "参与线程"));
|
||||
assertTrue(viewTreeContainsText(content, "硬件审计协作"));
|
||||
assertFalse(viewTreeContainsText(content, "从当前会话选择其他线程,创建新的独立群聊"));
|
||||
assertFalse(viewTreeContainsText(content, "以下线程参与当前会话,点击可查看对应项目详情。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void takeoverControlUsesWechatRowVisualSystem() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestConversationInfoActivity activity = Robolectric
|
||||
.buildActivity(TestConversationInfoActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderConversation",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildThreadStatusPayload())
|
||||
);
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
LinearLayout takeoverRow = (LinearLayout) content.getChildAt(0);
|
||||
SwitchCompat takeoverSwitch = findFirstSwitch(takeoverRow);
|
||||
|
||||
assertEquals(LinearLayout.HORIZONTAL, takeoverRow.getOrientation());
|
||||
assertEquals(BossUi.dp(activity, 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()
|
||||
@@ -235,6 +297,42 @@ public class ConversationInfoActivityTest {
|
||||
assertEquals(1, apiClient.autoLoginCalls);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void saveTakeoverSettingReturnsUpdatedResultState() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
|
||||
TestConversationInfoActivity activity = Robolectric
|
||||
.buildActivity(TestConversationInfoActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(
|
||||
activity.getSharedPreferences("conversation-info-save-result-test", Context.MODE_PRIVATE),
|
||||
"https://boss.hyzq.net"
|
||||
);
|
||||
apiClient.failFirstLoad = false;
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
ReflectionHelpers.setField(activity, "reloadEnabled", true);
|
||||
ReflectionHelpers.setField(activity, "delegateReloadToSuper", true);
|
||||
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
|
||||
|
||||
activity.reload();
|
||||
ShadowLooper.shadowMainLooper().idle();
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"saveTakeoverSetting",
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, true)
|
||||
);
|
||||
ShadowLooper.shadowMainLooper().idle();
|
||||
|
||||
assertEquals(android.app.Activity.RESULT_OK, Shadows.shadowOf(activity).getResultCode());
|
||||
Intent resultIntent = Shadows.shadowOf(activity).getResultIntent();
|
||||
assertNotNull(resultIntent);
|
||||
assertTrue(resultIntent.getBooleanExtra(ConversationInfoActivity.EXTRA_TAKEOVER_ENABLED, false));
|
||||
assertEquals("北区试产线回归", resultIntent.getStringExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void matchingProjectMessagesUpdatedEventTriggersReload() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
@@ -393,6 +491,23 @@ public class ConversationInfoActivityTest {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static SwitchCompat findFirstSwitch(View root) {
|
||||
if (root instanceof SwitchCompat) {
|
||||
return (SwitchCompat) root;
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return null;
|
||||
}
|
||||
ViewGroup group = (ViewGroup) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
SwitchCompat match = findFirstSwitch(group.getChildAt(index));
|
||||
if (match != null) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static class TestConversationInfoActivity extends ConversationInfoActivity {
|
||||
private boolean reloadEnabled;
|
||||
private boolean delegateReloadToSuper;
|
||||
@@ -474,7 +589,7 @@ public class ConversationInfoActivityTest {
|
||||
200,
|
||||
new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", new JSONObject().put("account", "17600003315"))
|
||||
.put("session", new JSONObject().put("account", "krisolo"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,72 @@ 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
|
||||
@@ -187,6 +253,43 @@ 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
|
||||
@@ -297,7 +400,7 @@ public class DeviceDetailActivityTest {
|
||||
.put("id", "device-1")
|
||||
.put("name", "Mac Studio")
|
||||
.put("avatar", "M")
|
||||
.put("account", "17600003315")
|
||||
.put("account", "krisolo")
|
||||
.put("status", "online")
|
||||
.put("quota5h", 75)
|
||||
.put("quota7d", 88)
|
||||
@@ -333,6 +436,67 @@ 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;
|
||||
@@ -360,6 +524,10 @@ 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);
|
||||
@@ -376,6 +544,19 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.view.View;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class MainActivityBootstrapSessionTest {
|
||||
@Test
|
||||
public void bootstrapSession_withoutSessionHints_showsLoginFormAndDoesNotAutoLogin() throws Exception {
|
||||
TestBootstrapSessionMainActivity activity =
|
||||
Robolectric.buildActivity(TestBootstrapSessionMainActivity.class).setup().get();
|
||||
SharedPreferences prefs = activity.getSharedPreferences("test-bootstrap-session", Context.MODE_PRIVATE);
|
||||
prefs.edit().clear().apply();
|
||||
|
||||
Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(200));
|
||||
|
||||
View loginPanel = activity.findViewById(R.id.login_panel);
|
||||
View contentPanel = activity.findViewById(R.id.content_panel);
|
||||
android.widget.EditText accountInput = activity.findViewById(R.id.login_account_input);
|
||||
android.widget.EditText passwordInput = activity.findViewById(R.id.login_password_input);
|
||||
|
||||
assertEquals(0, activity.apiClient.autoLoginCalls);
|
||||
assertEquals(View.VISIBLE, loginPanel.getVisibility());
|
||||
assertEquals(View.GONE, contentPanel.getVisibility());
|
||||
assertNotNull(accountInput);
|
||||
assertNotNull(passwordInput);
|
||||
assertFalse(accountInput.getHint().toString().isEmpty());
|
||||
assertFalse(passwordInput.getHint().toString().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bootstrapSession_withSessionHints_prefersRestoreAndDoesNotAutoLogin() throws Exception {
|
||||
TestRestoreBootstrapSessionMainActivity activity =
|
||||
Robolectric.buildActivity(TestRestoreBootstrapSessionMainActivity.class).setup().get();
|
||||
|
||||
waitFor(() -> activity.apiClient.restoreCalls > 0 && activity.apiClient.homeCalls > 0);
|
||||
|
||||
View loginPanel = activity.findViewById(R.id.login_panel);
|
||||
View contentPanel = activity.findViewById(R.id.content_panel);
|
||||
JSONObject sessionData = ReflectionHelpers.getField(activity, "sessionData");
|
||||
|
||||
assertEquals(0, activity.apiClient.autoLoginCalls);
|
||||
assertEquals(1, activity.apiClient.getSessionCalls);
|
||||
assertEquals(1, activity.apiClient.restoreCalls);
|
||||
assertEquals(View.GONE, loginPanel.getVisibility());
|
||||
assertEquals(View.VISIBLE, contentPanel.getVisibility());
|
||||
assertNotNull(sessionData);
|
||||
assertEquals("krisolo", sessionData.optString("account", ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void forceLogoutIntentClearsExistingContentSessionAndShowsLogin() throws Exception {
|
||||
TestRestoreBootstrapSessionMainActivity activity =
|
||||
Robolectric.buildActivity(TestRestoreBootstrapSessionMainActivity.class).setup().get();
|
||||
|
||||
waitFor(() -> activity.apiClient.restoreCalls > 0 && activity.apiClient.homeCalls > 0);
|
||||
|
||||
Intent intent = new Intent(activity, MainActivity.class);
|
||||
intent.putExtra("force_logout", true);
|
||||
activity.onNewIntent(intent);
|
||||
Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(200));
|
||||
|
||||
View loginPanel = activity.findViewById(R.id.login_panel);
|
||||
View contentPanel = activity.findViewById(R.id.content_panel);
|
||||
JSONObject sessionData = ReflectionHelpers.getField(activity, "sessionData");
|
||||
|
||||
assertEquals(View.VISIBLE, loginPanel.getVisibility());
|
||||
assertEquals(View.GONE, contentPanel.getVisibility());
|
||||
assertEquals(null, sessionData);
|
||||
}
|
||||
|
||||
private static void waitFor(BooleanSupplier condition) {
|
||||
long deadline = System.currentTimeMillis() + 5_000L;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(50));
|
||||
if (condition.getAsBoolean()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new AssertionError("Condition not met before timeout");
|
||||
}
|
||||
|
||||
public static class TestBootstrapSessionMainActivity extends MainActivity {
|
||||
RecordingBootstrapApiClient apiClient;
|
||||
|
||||
@Override
|
||||
BossApiClient createApiClient() {
|
||||
apiClient = new RecordingBootstrapApiClient(
|
||||
getSharedPreferences("test-bootstrap-session", Context.MODE_PRIVATE)
|
||||
);
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
BossRealtimeClient createRealtimeClient(BossApiClient client) {
|
||||
return new BossRealtimeClient(client, new BossRealtimeClient.Listener() {
|
||||
@Override
|
||||
public void onRealtimeEvent(BossRealtimeEvent event) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public static class TestRestoreBootstrapSessionMainActivity extends MainActivity {
|
||||
RecordingRestoreBootstrapApiClient apiClient;
|
||||
|
||||
@Override
|
||||
BossApiClient createApiClient() {
|
||||
apiClient = new RecordingRestoreBootstrapApiClient(
|
||||
getSharedPreferences("test-bootstrap-session-restore", Context.MODE_PRIVATE)
|
||||
);
|
||||
return apiClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
BossRealtimeClient createRealtimeClient(BossApiClient client) {
|
||||
return new BossRealtimeClient(client, new BossRealtimeClient.Listener() {
|
||||
@Override
|
||||
public void onRealtimeEvent(BossRealtimeEvent event) {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingBootstrapApiClient extends BossApiClient {
|
||||
int autoLoginCalls;
|
||||
int homeCalls;
|
||||
int devicesCalls;
|
||||
int otaCalls;
|
||||
int settingsCalls;
|
||||
|
||||
RecordingBootstrapApiClient(SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasSessionHints() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse autoLogin() throws java.io.IOException, org.json.JSONException {
|
||||
autoLoginCalls += 1;
|
||||
JSONObject session = new JSONObject()
|
||||
.put("account", "krisolo")
|
||||
.put("displayName", "Boss 超级管理员")
|
||||
.put("restoreToken", "restore-auto");
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", session));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse restoreSession() throws java.io.IOException, org.json.JSONException {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_RESTORE_TOKEN"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getSession() throws java.io.IOException, org.json.JSONException {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_SESSION"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getConversationHome() throws java.io.IOException, org.json.JSONException {
|
||||
homeCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("conversations", new JSONArray().put(new JSONObject()
|
||||
.put("projectId", "master-agent")
|
||||
.put("conversationType", "master_agent")
|
||||
.put("projectTitle", "主 Agent")
|
||||
.put("threadTitle", "主 Agent 汇总")
|
||||
.put("lastMessagePreview", "最近会话已恢复")
|
||||
.put("latestReplyLabel", "刚刚"))));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getDevices() throws java.io.IOException, org.json.JSONException {
|
||||
devicesCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("devices", new JSONArray()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getOtaStatus() throws java.io.IOException, org.json.JSONException {
|
||||
otaCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("hasOta", false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getSettings() throws java.io.IOException, org.json.JSONException {
|
||||
settingsCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("settings", new JSONObject().put("preferredEntryPoint", "conversations"))
|
||||
.put("user", new JSONObject()));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingRestoreBootstrapApiClient extends BossApiClient {
|
||||
int autoLoginCalls;
|
||||
int getSessionCalls;
|
||||
int restoreCalls;
|
||||
int homeCalls;
|
||||
int devicesCalls;
|
||||
int otaCalls;
|
||||
int settingsCalls;
|
||||
|
||||
RecordingRestoreBootstrapApiClient(SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
prefs.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.putString("restore_token", "restore-test")
|
||||
.apply();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse autoLogin() throws java.io.IOException, org.json.JSONException {
|
||||
autoLoginCalls += 1;
|
||||
return ApiResponse.error(500, new JSONObject().put("ok", false).put("message", "AUTO_LOGIN_SHOULD_NOT_RUN"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getSession() throws java.io.IOException, org.json.JSONException {
|
||||
getSessionCalls += 1;
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "SESSION_EXPIRED"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse restoreSession() throws java.io.IOException, org.json.JSONException {
|
||||
restoreCalls += 1;
|
||||
JSONObject session = new JSONObject()
|
||||
.put("account", "krisolo")
|
||||
.put("displayName", "Boss 超级管理员")
|
||||
.put("restoreToken", "restore-test");
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", session));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getConversationHome() throws java.io.IOException, org.json.JSONException {
|
||||
homeCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("conversations", new JSONArray().put(new JSONObject()
|
||||
.put("projectId", "master-agent")
|
||||
.put("conversationType", "master_agent")
|
||||
.put("projectTitle", "主 Agent")
|
||||
.put("threadTitle", "主 Agent 汇总")
|
||||
.put("lastMessagePreview", "最近会话已恢复")
|
||||
.put("latestReplyLabel", "刚刚"))));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getDevices() throws java.io.IOException, org.json.JSONException {
|
||||
devicesCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("devices", new JSONArray()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getOtaStatus() throws java.io.IOException, org.json.JSONException {
|
||||
otaCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("hasOta", false));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getSettings() throws java.io.IOException, org.json.JSONException {
|
||||
settingsCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("settings", new JSONObject().put("preferredEntryPoint", "conversations"))
|
||||
.put("user", new JSONObject()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,21 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.shadows.ShadowApplication;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@@ -18,6 +26,10 @@ public class MainActivityConversationAutoRefreshTest {
|
||||
org.robolectric.android.controller.ActivityController<MainActivity> controller =
|
||||
Robolectric.buildActivity(MainActivity.class).setup().resume();
|
||||
MainActivity activity = controller.get();
|
||||
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.apply();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
|
||||
assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
|
||||
@@ -35,4 +47,53 @@ public class MainActivityConversationAutoRefreshTest {
|
||||
controller.pause();
|
||||
assertFalse(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void returningToVisibleConversationRootRefreshesImmediatelyOnResume() {
|
||||
org.robolectric.android.controller.ActivityController<TestResumeRefreshMainActivity> controller =
|
||||
Robolectric.buildActivity(TestResumeRefreshMainActivity.class).setup().resume();
|
||||
TestResumeRefreshMainActivity activity = controller.get();
|
||||
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.apply();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
activity.conversationRefreshCount = 0;
|
||||
|
||||
controller.pause();
|
||||
controller.resume();
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void showContent_doesNotRequestNotificationPermissionInSameTapFrame() {
|
||||
ShadowApplication.getInstance().denyPermissions(Manifest.permission.POST_NOTIFICATIONS);
|
||||
org.robolectric.android.controller.ActivityController<MainActivity> controller =
|
||||
Robolectric.buildActivity(MainActivity.class).setup();
|
||||
MainActivity activity = controller.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
|
||||
assertNull(Shadows.shadowOf(activity).getLastRequestedPermission());
|
||||
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(java.time.Duration.ofMillis(500));
|
||||
|
||||
assertNotNull(Shadows.shadowOf(activity).getLastRequestedPermission());
|
||||
assertEquals(1, Shadows.shadowOf(activity).getLastRequestedPermission().requestedPermissions.length);
|
||||
assertEquals(
|
||||
Manifest.permission.POST_NOTIFICATIONS,
|
||||
Shadows.shadowOf(activity).getLastRequestedPermission().requestedPermissions[0]
|
||||
);
|
||||
}
|
||||
|
||||
public static class TestResumeRefreshMainActivity extends MainActivity {
|
||||
int conversationRefreshCount;
|
||||
|
||||
@Override
|
||||
void refreshConversationsData() {
|
||||
conversationRefreshCount += 1;
|
||||
completeRealtimeTabRefresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ 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;
|
||||
@@ -21,6 +24,9 @@ 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)
|
||||
@@ -124,7 +130,60 @@ public class MainActivityConversationSearchTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void searchHitInsideArchivedProject_keepsProjectContextAndOpensFolderPage() throws Exception {
|
||||
public void searchMode_showsSoftKeyboardWhenActivated() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
ImageButton searchButton = activity.findViewById(R.id.search_button);
|
||||
searchButton.performClick();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
EditText searchInput = activity.findViewById(R.id.top_search_input);
|
||||
InputMethodManager inputMethodManager = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
ShadowInputMethodManager shadowInputMethodManager = Shadow.extract(inputMethodManager);
|
||||
|
||||
assertTrue(searchInput.isFocused());
|
||||
assertTrue(shadowInputMethodManager.isSoftInputVisible());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void searchHitOnSingleThread_exitsSearchModeAndOpensProjectDetail() throws Exception {
|
||||
ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
ImageButton searchButton = activity.findViewById(R.id.search_button);
|
||||
searchButton.performClick();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
EditText searchInput = activity.findViewById(R.id.top_search_input);
|
||||
searchInput.setText("树莓派");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
RecyclerView list = ReflectionHelpers.getField(activity, "screenList");
|
||||
View row = getRecyclerChild(list, 0);
|
||||
row.performClick();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
|
||||
InputMethodManager inputMethodManager = (InputMethodManager) activity.getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||
ShadowInputMethodManager shadowInputMethodManager = Shadow.extract(inputMethodManager);
|
||||
|
||||
assertEquals(ProjectDetailActivity.class.getName(), nextIntent.getComponent().getClassName());
|
||||
assertEquals("p1", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID));
|
||||
assertFalse(ReflectionHelpers.getField(activity, "conversationSearchMode"));
|
||||
assertEquals("", searchInput.getText().toString());
|
||||
assertFalse(shadowInputMethodManager.isSoftInputVisible());
|
||||
assertFalse(activity.isFinishing());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void searchHitInsideArchivedProject_opensMatchedThreadDetailAndClearsSearchState() throws Exception {
|
||||
ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray()
|
||||
.put(new JSONObject()
|
||||
@@ -155,14 +214,51 @@ 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));
|
||||
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));
|
||||
assertFalse(ReflectionHelpers.getField(activity, "conversationSearchMode"));
|
||||
assertEquals("", searchInput.getText().toString());
|
||||
}
|
||||
|
||||
private static JSONArray buildConversations() throws Exception {
|
||||
|
||||
@@ -90,6 +90,7 @@ public class MainActivityConversationSelectionTest {
|
||||
public void topPlusAction_opensWechatStyleDropdownMenu() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
|
||||
ReflectionHelpers.setField(activity, "sessionData", new JSONObject().put("role", "highest_admin"));
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
@@ -106,6 +107,27 @@ public class MainActivityConversationSelectionTest {
|
||||
assertTrue(viewTreeContainsText(menu, "发起群聊"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void topPlusAction_hidesAddDeviceForSubAccount() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
|
||||
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
|
||||
ReflectionHelpers.setField(activity, "sessionData", new JSONObject().put("role", "member"));
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
ImageButton actionButton = activity.findViewById(R.id.refresh_button);
|
||||
actionButton.performClick();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
View overlay = activity.findViewById(R.id.conversation_quick_actions_overlay);
|
||||
View menu = activity.findViewById(R.id.conversation_quick_actions_menu);
|
||||
assertEquals(View.VISIBLE, overlay.getVisibility());
|
||||
assertEquals(View.VISIBLE, menu.getVisibility());
|
||||
assertFalse(viewTreeContainsVisibleText(menu, "添加设备"));
|
||||
assertTrue(viewTreeContainsVisibleText(menu, "扫一扫"));
|
||||
assertTrue(viewTreeContainsVisibleText(menu, "发起群聊"));
|
||||
}
|
||||
|
||||
private static View getRecyclerChild(RecyclerView recyclerView, int position) {
|
||||
RecyclerView.Adapter adapter = recyclerView.getAdapter();
|
||||
int viewType = adapter.getItemViewType(position);
|
||||
@@ -188,6 +210,28 @@ public class MainActivityConversationSelectionTest {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsVisibleText(View root, String expectedText) {
|
||||
if (root.getVisibility() != View.VISIBLE) {
|
||||
return false;
|
||||
}
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
if (expectedText.contentEquals(text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!(root instanceof LinearLayout)) {
|
||||
return false;
|
||||
}
|
||||
LinearLayout group = (LinearLayout) root;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeContainsVisibleText(group.getChildAt(index), expectedText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsContentDescription(View root, String expectedText) {
|
||||
CharSequence description = root.getContentDescription();
|
||||
if (expectedText.contentEquals(description)) {
|
||||
|
||||
@@ -31,7 +31,7 @@ public class MainActivityDevicesRootTest {
|
||||
.put("name", "Mac Studio")
|
||||
.put("status", "online")
|
||||
.put("platform", "macOS")
|
||||
.put("account", "17600003315")));
|
||||
.put("account", "krisolo")));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.hyzq.boss;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.os.Looper;
|
||||
import org.json.JSONObject;
|
||||
import org.json.JSONArray;
|
||||
@@ -15,6 +16,7 @@ import org.robolectric.annotation.Config;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@@ -24,6 +26,15 @@ public class MainActivityRealtimeTest {
|
||||
public void conversationRealtimeEventRefreshesVisibleConversationTab() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"setActiveTab",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
activity.conversationRefreshCount = 0;
|
||||
activity.deviceRefreshCount = 0;
|
||||
activity.meRefreshCount = 0;
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
@@ -33,6 +44,8 @@ public class MainActivityRealtimeTest {
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
@@ -78,6 +91,15 @@ public class MainActivityRealtimeTest {
|
||||
public void deviceScopedConversationEventRefreshesVisibleConversationTab() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"setActiveTab",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
activity.conversationRefreshCount = 0;
|
||||
activity.deviceRefreshCount = 0;
|
||||
activity.meRefreshCount = 0;
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
@@ -87,6 +109,8 @@ public class MainActivityRealtimeTest {
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
@@ -108,6 +132,8 @@ public class MainActivityRealtimeTest {
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
@@ -129,13 +155,15 @@ public class MainActivityRealtimeTest {
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void distinctConversationEventsBackToBackBothRefreshVisibleConversationTab() throws Exception {
|
||||
public void distinctConversationEventsBackToBackCoalesceIntoSingleVisibleConversationRefresh() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
@@ -161,8 +189,10 @@ public class MainActivityRealtimeTest {
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
|
||||
assertEquals(2, activity.conversationRefreshCount);
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
}
|
||||
|
||||
@@ -176,6 +206,9 @@ public class MainActivityRealtimeTest {
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "devices"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
activity.conversationRefreshCount = 0;
|
||||
activity.deviceRefreshCount = 0;
|
||||
activity.meRefreshCount = 0;
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
@@ -187,6 +220,8 @@ public class MainActivityRealtimeTest {
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
assertEquals(1, activity.deviceRefreshCount);
|
||||
assertEquals(0, activity.meRefreshCount);
|
||||
}
|
||||
@@ -201,6 +236,9 @@ public class MainActivityRealtimeTest {
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "me"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
activity.conversationRefreshCount = 0;
|
||||
activity.deviceRefreshCount = 0;
|
||||
activity.meRefreshCount = 0;
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
@@ -213,6 +251,8 @@ public class MainActivityRealtimeTest {
|
||||
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
assertEquals(0, activity.meRefreshCount);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
assertEquals(1, activity.meRefreshCount);
|
||||
}
|
||||
|
||||
@@ -220,6 +260,15 @@ public class MainActivityRealtimeTest {
|
||||
public void burstConversationRealtimeEventsCoalesceIntoSingleFollowUpRefresh() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"setActiveTab",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
activity.conversationRefreshCount = 0;
|
||||
activity.deviceRefreshCount = 0;
|
||||
activity.meRefreshCount = 0;
|
||||
ReflectionHelpers.setField(activity, "rootTabRefreshInFlight", true);
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
@@ -253,6 +302,7 @@ public class MainActivityRealtimeTest {
|
||||
assertEquals(0, activity.conversationRefreshCount);
|
||||
|
||||
activity.completeRealtimeTabRefresh();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
|
||||
waitFor(() -> activity.conversationRefreshCount == 1);
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
@@ -261,7 +311,28 @@ public class MainActivityRealtimeTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshConversationsData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception {
|
||||
public void realtimeDisconnectTriggersImmediateConversationFallbackRefresh() throws Exception {
|
||||
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
|
||||
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.apply();
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeConnectionChanged",
|
||||
ReflectionHelpers.ClassParameter.from(boolean.class, false)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertEquals(1, activity.conversationRefreshCount);
|
||||
assertEquals(0, activity.deviceRefreshCount);
|
||||
assertEquals(0, activity.meRefreshCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshConversationsData_prefersGroupedHomeFeedOverFlatConversationFeed() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
@@ -273,18 +344,47 @@ public class MainActivityRealtimeTest {
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
activity.refreshConversationsData();
|
||||
waitFor(() -> apiClient.homeCalls > 0 || apiClient.conversationsCalls > 0);
|
||||
waitFor(() -> apiClient.homeCalls > 0);
|
||||
|
||||
assertEquals(1, apiClient.homeCalls);
|
||||
assertEquals(0, apiClient.conversationsCalls);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshConversationsData_prefersGroupedHomeFeedForRootList() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
SharedPreferences prefs = activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE);
|
||||
prefs.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.putString("restore_token", "restore-test")
|
||||
.apply();
|
||||
RecordingConversationSourceClient apiClient = new RecordingConversationSourceClient(
|
||||
prefs
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
activity.refreshConversationsData();
|
||||
waitFor(() -> apiClient.homeCalls > 0);
|
||||
|
||||
assertEquals(1, apiClient.homeCalls);
|
||||
assertEquals(0, apiClient.conversationsCalls);
|
||||
JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData");
|
||||
assertEquals(1, conversationsData.length());
|
||||
assertEquals("folder_archive", conversationsData.optJSONObject(0).optString("conversationType", ""));
|
||||
assertEquals("mac-studio:boss", conversationsData.optJSONObject(0).optString("projectId", ""));
|
||||
assertEquals(2, conversationsData.optJSONObject(0).optInt("threadCount", 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshConversationsData_groupsFlatFallbackFeedWhenHomeFeedFails() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
RecordingRejectedConversationSourceClient apiClient = new RecordingRejectedConversationSourceClient(
|
||||
RecordingRejectedHomeConversationSourceClient apiClient = new RecordingRejectedHomeConversationSourceClient(
|
||||
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
@@ -307,7 +407,7 @@ public class MainActivityRealtimeTest {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
RecordingIOExceptionConversationSourceClient apiClient = new RecordingIOExceptionConversationSourceClient(
|
||||
RecordingIOExceptionHomeConversationSourceClient apiClient = new RecordingIOExceptionHomeConversationSourceClient(
|
||||
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
@@ -326,7 +426,7 @@ public class MainActivityRealtimeTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshAllData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception {
|
||||
public void refreshAllData_prefersGroupedHomeFeedOverFlatConversationFeed() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
@@ -342,18 +442,51 @@ public class MainActivityRealtimeTest {
|
||||
"refreshAllData",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject())
|
||||
);
|
||||
waitFor(() -> apiClient.homeCalls > 0 || apiClient.conversationsCalls > 0);
|
||||
waitFor(() -> apiClient.homeCalls > 0);
|
||||
|
||||
assertEquals(1, apiClient.homeCalls);
|
||||
assertEquals(0, apiClient.conversationsCalls);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshAllData_prefersGroupedHomeFeedForRootList() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
SharedPreferences prefs = activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE);
|
||||
prefs.edit()
|
||||
.putString("session_cookie", "boss_session=test")
|
||||
.putString("restore_token", "restore-test")
|
||||
.apply();
|
||||
RecordingConversationSourceClient apiClient = new RecordingConversationSourceClient(
|
||||
prefs
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showContent");
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"refreshAllData",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject())
|
||||
);
|
||||
waitFor(() -> apiClient.homeCalls > 0);
|
||||
|
||||
assertEquals(1, apiClient.homeCalls);
|
||||
assertEquals(0, apiClient.conversationsCalls);
|
||||
JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData");
|
||||
assertEquals(1, conversationsData.length());
|
||||
assertEquals("folder_archive", conversationsData.optJSONObject(0).optString("conversationType", ""));
|
||||
assertEquals("mac-studio:boss", conversationsData.optJSONObject(0).optString("projectId", ""));
|
||||
assertEquals(2, conversationsData.optJSONObject(0).optInt("threadCount", 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void refreshAllData_groupsFlatFallbackFeedWhenHomeFeedFails() throws Exception {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
RecordingRejectedConversationSourceClient apiClient = new RecordingRejectedConversationSourceClient(
|
||||
RecordingRejectedHomeConversationSourceClient apiClient = new RecordingRejectedHomeConversationSourceClient(
|
||||
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
@@ -380,7 +513,7 @@ public class MainActivityRealtimeTest {
|
||||
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
RecordingIOExceptionConversationSourceClient apiClient = new RecordingIOExceptionConversationSourceClient(
|
||||
RecordingIOExceptionHomeConversationSourceClient apiClient = new RecordingIOExceptionHomeConversationSourceClient(
|
||||
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
@@ -424,6 +557,13 @@ public class MainActivityRealtimeTest {
|
||||
int deviceRefreshCount;
|
||||
int meRefreshCount;
|
||||
|
||||
@Override
|
||||
BossApiClient createApiClient() {
|
||||
SharedPreferences prefs = getSharedPreferences("boss_native_client", Context.MODE_PRIVATE);
|
||||
prefs.edit().clear().apply();
|
||||
return new InertBootstrapApiClient(prefs);
|
||||
}
|
||||
|
||||
@Override
|
||||
void refreshConversationsData() {
|
||||
conversationRefreshCount += 1;
|
||||
@@ -443,7 +583,28 @@ public class MainActivityRealtimeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingRejectedConversationSourceClient extends BossApiClient {
|
||||
private static final class InertBootstrapApiClient extends BossApiClient {
|
||||
InertBootstrapApiClient(SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse autoLogin() throws IOException, org.json.JSONException {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "TEST_BOOTSTRAP_DISABLED"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse restoreSession() throws IOException, org.json.JSONException {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "TEST_BOOTSTRAP_DISABLED"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse getSession() throws IOException, org.json.JSONException {
|
||||
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "TEST_BOOTSTRAP_DISABLED"));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingRejectedHomeConversationSourceClient extends BossApiClient {
|
||||
int homeCalls;
|
||||
int conversationsCalls;
|
||||
int sessionCalls;
|
||||
@@ -451,7 +612,7 @@ public class MainActivityRealtimeTest {
|
||||
int settingsCalls;
|
||||
int otaCalls;
|
||||
|
||||
RecordingRejectedConversationSourceClient(android.content.SharedPreferences prefs) {
|
||||
RecordingRejectedHomeConversationSourceClient(android.content.SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@@ -468,7 +629,7 @@ public class MainActivityRealtimeTest {
|
||||
conversationsCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("conversations", buildFlatConversations()));
|
||||
.put("conversations", RecordingConversationSourceClient.buildFlatConversations()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -477,7 +638,7 @@ public class MainActivityRealtimeTest {
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", new JSONObject()
|
||||
.put("account", "17600003315")
|
||||
.put("account", "krisolo")
|
||||
.put("displayName", "Boss 超级管理员")));
|
||||
}
|
||||
|
||||
@@ -505,32 +666,6 @@ public class MainActivityRealtimeTest {
|
||||
.put("ok", true)
|
||||
.put("hasOta", false));
|
||||
}
|
||||
|
||||
private static JSONArray buildFlatConversations() throws org.json.JSONException {
|
||||
return new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "thread-revert")
|
||||
.put("conversationType", "single_device")
|
||||
.put("projectTitle", "发布回滚")
|
||||
.put("threadTitle", "发布回滚")
|
||||
.put("folderLabel", "Boss")
|
||||
.put("folderKey", "mac-studio:boss")
|
||||
.put("lastMessagePreview", "最近:发布回滚")
|
||||
.put("latestReplyAt", "2026-04-06T10:00:00.000Z")
|
||||
.put("latestReplyLabel", "11:00")
|
||||
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 80).put("level", "watch")))
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "thread-ui")
|
||||
.put("conversationType", "single_device")
|
||||
.put("projectTitle", "Android UI 收尾")
|
||||
.put("threadTitle", "Android UI 收尾")
|
||||
.put("folderLabel", "Boss")
|
||||
.put("folderKey", "mac-studio:boss")
|
||||
.put("lastMessagePreview", "最近:Android UI 收尾")
|
||||
.put("latestReplyAt", "2026-04-06T09:59:00.000Z")
|
||||
.put("latestReplyLabel", "10:59")
|
||||
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 95).put("level", "safe")));
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConversationSourceClient extends BossApiClient {
|
||||
@@ -567,7 +702,7 @@ public class MainActivityRealtimeTest {
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", new JSONObject()
|
||||
.put("account", "17600003315")
|
||||
.put("account", "krisolo")
|
||||
.put("displayName", "Boss 超级管理员")));
|
||||
}
|
||||
|
||||
@@ -598,13 +733,15 @@ public class MainActivityRealtimeTest {
|
||||
|
||||
private static JSONArray buildHomeConversations() throws org.json.JSONException {
|
||||
return new JSONArray().put(new JSONObject()
|
||||
.put("projectId", "folder-boss")
|
||||
.put("projectId", "mac-studio:boss")
|
||||
.put("conversationType", "folder_archive")
|
||||
.put("folderKey", "mac-studio:boss")
|
||||
.put("projectTitle", "Boss")
|
||||
.put("threadTitle", "Boss")
|
||||
.put("threadCount", 2)
|
||||
.put("folderLabel", "2 个线程 · 最近:发布回滚")
|
||||
.put("searchAliases", new JSONArray().put("发布回滚").put("Android UI 收尾"))
|
||||
.put("searchTargetProjectIds", new JSONArray().put("thread-revert").put("thread-ui"))
|
||||
.put("lastMessagePreview", "最近:发布回滚")
|
||||
.put("latestReplyLabel", "11:00"));
|
||||
}
|
||||
@@ -636,7 +773,7 @@ public class MainActivityRealtimeTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingIOExceptionConversationSourceClient extends BossApiClient {
|
||||
private static final class RecordingIOExceptionHomeConversationSourceClient extends BossApiClient {
|
||||
int homeCalls;
|
||||
int conversationsCalls;
|
||||
int sessionCalls;
|
||||
@@ -644,7 +781,7 @@ public class MainActivityRealtimeTest {
|
||||
int settingsCalls;
|
||||
int otaCalls;
|
||||
|
||||
RecordingIOExceptionConversationSourceClient(android.content.SharedPreferences prefs) {
|
||||
RecordingIOExceptionHomeConversationSourceClient(android.content.SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@@ -659,7 +796,7 @@ public class MainActivityRealtimeTest {
|
||||
conversationsCalls += 1;
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("conversations", buildFlatConversations()));
|
||||
.put("conversations", RecordingConversationSourceClient.buildFlatConversations()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -668,7 +805,7 @@ public class MainActivityRealtimeTest {
|
||||
return new ApiResponse(200, new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", new JSONObject()
|
||||
.put("account", "17600003315")
|
||||
.put("account", "krisolo")
|
||||
.put("displayName", "Boss 超级管理员")));
|
||||
}
|
||||
|
||||
@@ -696,31 +833,5 @@ public class MainActivityRealtimeTest {
|
||||
.put("ok", true)
|
||||
.put("hasOta", false));
|
||||
}
|
||||
|
||||
private static JSONArray buildFlatConversations() throws org.json.JSONException {
|
||||
return new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "thread-revert")
|
||||
.put("conversationType", "single_device")
|
||||
.put("projectTitle", "发布回滚")
|
||||
.put("threadTitle", "发布回滚")
|
||||
.put("folderLabel", "Boss")
|
||||
.put("folderKey", "mac-studio:boss")
|
||||
.put("lastMessagePreview", "最近:发布回滚")
|
||||
.put("latestReplyAt", "2026-04-06T10:00:00.000Z")
|
||||
.put("latestReplyLabel", "11:00")
|
||||
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 80).put("level", "watch")))
|
||||
.put(new JSONObject()
|
||||
.put("projectId", "thread-ui")
|
||||
.put("conversationType", "single_device")
|
||||
.put("projectTitle", "Android UI 收尾")
|
||||
.put("threadTitle", "Android UI 收尾")
|
||||
.put("folderLabel", "Boss")
|
||||
.put("folderKey", "mac-studio:boss")
|
||||
.put("lastMessagePreview", "最近:Android UI 收尾")
|
||||
.put("latestReplyAt", "2026-04-06T09:59:00.000Z")
|
||||
.put("latestReplyLabel", "10:59")
|
||||
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 95).put("level", "safe")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +176,7 @@ public class MasterAgentTakeoverActivityTest {
|
||||
200,
|
||||
new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("session", new JSONObject().put("account", "17600003315"))
|
||||
.put("session", new JSONObject().put("account", "krisolo"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,14 @@ public class ProjectChatUiStateTest {
|
||||
assertTrue(ProjectChatUiState.canForwardSelection(next));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void copySelectionRequiresAtLeastOneMessage() {
|
||||
assertFalse(ProjectChatUiState.canCopySelection(ProjectChatUiState.emptySelection()));
|
||||
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
|
||||
assertTrue(ProjectChatUiState.canCopySelection(state));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void selectionPreservesInsertionOrder() {
|
||||
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m2");
|
||||
@@ -104,6 +112,7 @@ public class ProjectChatUiStateTest {
|
||||
assertTrue(chromeState.showMultiSelectBar);
|
||||
assertFalse(chromeState.showRefresh);
|
||||
assertFalse(chromeState.showHeaderAction);
|
||||
assertTrue(chromeState.copyEnabled);
|
||||
assertTrue(chromeState.forwardEnabled);
|
||||
assertEquals("取消", chromeState.backLabel);
|
||||
assertEquals("已选 2 条", chromeState.title);
|
||||
@@ -120,6 +129,7 @@ public class ProjectChatUiStateTest {
|
||||
assertFalse(chromeState.showMultiSelectBar);
|
||||
assertFalse(chromeState.showRefresh);
|
||||
assertTrue(chromeState.showHeaderAction);
|
||||
assertFalse(chromeState.copyEnabled);
|
||||
assertFalse(chromeState.forwardEnabled);
|
||||
assertEquals("返回", chromeState.backLabel);
|
||||
assertEquals("北区试产线回归", chromeState.title);
|
||||
@@ -136,6 +146,7 @@ public class ProjectChatUiStateTest {
|
||||
assertFalse(chromeState.showMultiSelectBar);
|
||||
assertTrue(chromeState.showRefresh);
|
||||
assertFalse(chromeState.showHeaderAction);
|
||||
assertFalse(chromeState.copyEnabled);
|
||||
assertFalse(chromeState.forwardEnabled);
|
||||
assertEquals("返回", chromeState.backLabel);
|
||||
assertEquals("北区试产线回归", chromeState.title);
|
||||
@@ -196,9 +207,10 @@ public class ProjectChatUiStateTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void queuedReplyTaskStartsReplyWaitFromRequestMessageId() throws Exception {
|
||||
public void queuedReplyTaskStartsReplyWaitFromImmediateReplyWhenPresent() throws Exception {
|
||||
JSONObject response = new JSONObject()
|
||||
.put("message", new JSONObject().put("id", "msg-user-1"))
|
||||
.put("replyMessage", new JSONObject().put("id", "msg-master-ack-1"))
|
||||
.put("task", new JSONObject()
|
||||
.put("taskId", "task-1")
|
||||
.put("taskType", "conversation_reply")
|
||||
@@ -207,7 +219,7 @@ public class ProjectChatUiStateTest {
|
||||
ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterSend(response);
|
||||
|
||||
assertTrue(waitSpec.shouldWait);
|
||||
assertEquals("msg-user-1", waitSpec.baselineMessageId);
|
||||
assertEquals("msg-master-ack-1", waitSpec.baselineMessageId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -250,6 +262,318 @@ public class ProjectChatUiStateTest {
|
||||
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, ""));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void replyWaitIgnoresDuplicateBaselineMessages() throws Exception {
|
||||
JSONObject project = new JSONObject()
|
||||
.put("messages", new JSONArray()
|
||||
.put(new JSONObject().put("id", "msg-user-1"))
|
||||
.put(new JSONObject().put("id", "msg-user-1")));
|
||||
|
||||
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, "msg-user-1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void timedOutMasterRelayKeepsConversationPollingEvenWhenRealtimeConnected() {
|
||||
assertTrue(ProjectChatUiState.shouldAutoRefreshConversation(true, true, true));
|
||||
assertTrue(ProjectChatUiState.shouldAutoRefreshConversation(true, false, false));
|
||||
assertFalse(ProjectChatUiState.shouldAutoRefreshConversation(true, true, false));
|
||||
assertFalse(ProjectChatUiState.shouldAutoRefreshConversation(false, true, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void threadProcessMessagesAreCollapsedBeforeFinalResult() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "u1")
|
||||
.put("sender", "user")
|
||||
.put("body", "继续"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "我先看一下当前聊天渲染链路和消息结构。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p2")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "接下来我会补一组单元测试,再把折叠 UI 接上。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "这轮已经接好过程折叠,最终结果现在直接显示在主消息流里。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
|
||||
assertEquals("u1", items.get(0).message.optString("id"));
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
|
||||
assertEquals(2, items.get(1).processMessages.size());
|
||||
assertEquals("p1", items.get(1).processMessages.get(0).optString("id"));
|
||||
assertEquals("p2", items.get(1).processMessages.get(1).optString("id"));
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
|
||||
assertEquals("r1", items.get(2).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void errorMessagesStayVisibleInsteadOfBeingCollapsed() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "e1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "当前执行失败,构建报错,需要先补依赖。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(1, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
|
||||
assertEquals("e1", items.get(0).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void processGroupPreviewUsesLatestProgressLine() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("body", "我先检查项目结构。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p2")
|
||||
.put("sender", "device")
|
||||
.put("body", "接下来开始补聊天折叠按钮。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(1, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
|
||||
assertEquals("接下来开始补聊天折叠按钮。", ProjectChatUiState.processGroupPreview(items.get(0)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void explicitThreadProcessKindIsCollapsedEvenWhenCopyLooksLikeACompletionUpdate() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "thread_process")
|
||||
.put("body", "工程骨架已经建好了,我现在开始写核心代码。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p2")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "thread_process")
|
||||
.put("body", "编译错误已定位到导入问题,我已修复并正在重新构建确认。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "text")
|
||||
.put("body", "已完成折叠修复,过程消息会收进按钮里,未读只增加一次。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(2, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
|
||||
assertEquals(2, items.get(0).processMessages.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(1).type);
|
||||
assertEquals("r1", items.get(1).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void executionProgressCardsStayVisibleBetweenProcessGroups() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "thread_process")
|
||||
.put("body", "我先检查当前执行链路。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "progress-1")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent")
|
||||
.put("kind", "execution_progress")
|
||||
.put("body", "执行进度")
|
||||
.put("executionProgress", new JSONObject()
|
||||
.put("status", "running")
|
||||
.put("steps", new JSONArray()
|
||||
.put(new JSONObject().put("text", "接收对话任务").put("status", "done"))
|
||||
.put(new JSONObject().put("text", "等待目标线程回复").put("status", "running")))))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p2")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "thread_process")
|
||||
.put("body", "我继续执行验证。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(1).type);
|
||||
assertEquals("progress-1", items.get(1).message.optString("id"));
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(2).type);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void processGroupKeepsFinalResultVisibleWhenProcessMessagesCarryThreadProcessKind() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "u1")
|
||||
.put("sender", "user")
|
||||
.put("body", "继续推进"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "thread_process")
|
||||
.put("body", "我先检查聊天折叠链路,确认过程消息不会直接展开。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "text")
|
||||
.put("body", "这轮已经完成折叠修复,未读现在只会算最终结果。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
|
||||
assertEquals(1, items.get(1).processMessages.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
|
||||
assertEquals("r1", items.get(2).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void numberedProgressUpdatesAreCollapsedWhenMarkedAsThreadProcess() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "u1")
|
||||
.put("sender", "user")
|
||||
.put("body", "继续处理"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "thread_process")
|
||||
.put("body", "1. 先检查当前消息折叠链路。\\n2. 再确认 Android 端只把最终结果记成未读。\\n3. 处理完成后我会回你最终结果。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "text")
|
||||
.put("body", "这轮已经处理完成,最终结果已回写。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
|
||||
assertEquals(1, items.get(1).processMessages.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
|
||||
assertEquals("r1", items.get(2).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void numberedProgressUpdatesWithoutKindStillCollapseBeforeFinalResult() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "u1")
|
||||
.put("sender", "user")
|
||||
.put("body", "继续处理"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "1. 先检查当前消息折叠链路。\n2. 再确认 Android 端只把最终结果记成未读。\n3. 处理完成后我会回你最终结果。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "这轮已经处理完成,最终结果已回写。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
|
||||
assertEquals(1, items.get(1).processMessages.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
|
||||
assertEquals("r1", items.get(2).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void progressUpdatesStartingWithWoZheBianYiJingStillCollapseIntoProcessGroup() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "u1")
|
||||
.put("sender", "user")
|
||||
.put("body", "继续"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("body", "我这边已经查了,adb 现在还只看到一台 USB 连着的 PHZ110,PLB110 的无线目标还没有被发现出来。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Boss开发主线程")
|
||||
.put("kind", "text")
|
||||
.put("body", "无线调试已经接通,最新 debug 包也装好了。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(3, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
|
||||
assertEquals(1, items.get(1).processMessages.size());
|
||||
assertEquals("p1", items.get(1).processMessages.get(0).optString("id"));
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
|
||||
assertEquals("r1", items.get(2).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void realThreadPlanningCopyIsCollapsedButSavedResultStaysVisible() throws Exception {
|
||||
JSONArray messages = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("id", "p1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Andorid")
|
||||
.put("body", "我发现当前这个仓库快照里没有 ios/ 目录,所以这份报告会明确分成两层。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "p2")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Andorid")
|
||||
.put("body", "我准备新增一份 doc/iOS实时转写开发交接报告_20260419.md。"))
|
||||
.put(new JSONObject()
|
||||
.put("id", "r1")
|
||||
.put("sender", "device")
|
||||
.put("senderLabel", "Andorid")
|
||||
.put("body", "报告已经落盘了。我再快速过一遍这份文档的结构和措辞。"));
|
||||
|
||||
List<ProjectChatUiState.MessageDisplayItem> items =
|
||||
ProjectChatUiState.buildMessageDisplayItems(messages);
|
||||
|
||||
assertEquals(2, items.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
|
||||
assertEquals(2, items.get(0).processMessages.size());
|
||||
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(1).type);
|
||||
assertEquals("r1", items.get(1).message.optString("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void threadExecutionConflictCopyExplainsPreferredGuiModeAsProjectScoped() throws Exception {
|
||||
JSONObject conflict = new JSONObject()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
|
||||
@@ -18,6 +19,7 @@ import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.shadows.ShadowDialog;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
@@ -52,7 +54,67 @@ public class ProjectDetailActivityMasterAgentMenuTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void normalConversationMoreMenuShowsInfoAndRefresh() {
|
||||
public void masterAgentModelOptionsIncludeFastAndDeepChoices() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
String[] options = ReflectionHelpers.callInstanceMethod(activity, "buildMasterAgentModelOptions");
|
||||
|
||||
assertArrayEquals(
|
||||
new String[]{"沿用默认", "gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1", "自定义..."},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masterAgentModelOptionsKeepCurrentCustomChoice() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
ReflectionHelpers.setField(activity, "currentAgentModelOverride", "gpt-4.1-mini");
|
||||
|
||||
String[] options = ReflectionHelpers.callInstanceMethod(activity, "buildMasterAgentModelOptions");
|
||||
|
||||
assertArrayEquals(
|
||||
new String[]{"沿用默认", "gpt-4.1-mini", "gpt-5.4-mini", "gpt-5.4", "gpt-5.1", "gpt-4.1", "自定义..."},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masterAgentModelPickerShowsFastAndDeepModes() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
ProjectDetailActivityUiTest.TestProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(ProjectDetailActivityUiTest.TestProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showMasterAgentModelPicker");
|
||||
|
||||
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
|
||||
assertTrue(latestDialog instanceof AlertDialog);
|
||||
AlertDialog actionDialog = (AlertDialog) latestDialog;
|
||||
ListView listView = actionDialog.getListView();
|
||||
|
||||
assertMenuItem(listView, 0, "沿用默认");
|
||||
assertMenuItem(listView, 1, "快速反应(gpt-5.4-mini)");
|
||||
assertMenuItem(listView, 2, "深度思考(gpt-5.4)");
|
||||
assertMenuItem(listView, 3, "更多模型...");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void normalConversationHeaderActionOpensConversationInfoDirectly() {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss 移动控制台");
|
||||
@@ -61,15 +123,11 @@ public class ProjectDetailActivityMasterAgentMenuTest {
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(activity, "showConversationMoreMenu");
|
||||
ReflectionHelpers.callInstanceMethod(activity, "openConversationInfo");
|
||||
|
||||
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
|
||||
assertTrue(latestDialog instanceof AlertDialog);
|
||||
AlertDialog actionDialog = (AlertDialog) latestDialog;
|
||||
ListView listView = actionDialog.getListView();
|
||||
|
||||
assertMenuItem(listView, 0, "会话信息");
|
||||
assertMenuItem(listView, 1, "刷新");
|
||||
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
|
||||
assertNotNull(nextIntent);
|
||||
assertEquals(ConversationInfoActivity.class.getName(), nextIntent.getComponent().getClassName());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.fail;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.app.NotificationManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Looper;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
@@ -13,8 +21,12 @@ import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.RuntimeEnvironment;
|
||||
import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.shadows.ShadowApplication;
|
||||
import org.robolectric.shadows.ShadowDialog;
|
||||
import org.robolectric.shadows.ShadowNotificationManager;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
@@ -43,9 +55,11 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(1, activity.reloadCount);
|
||||
waitFor(() -> activity.messageReloadCount == 1);
|
||||
assertEquals(0, activity.reloadCount);
|
||||
assertEquals(1, activity.messageReloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -67,7 +81,7 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(0, activity.reloadCount);
|
||||
}
|
||||
@@ -91,7 +105,7 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-2"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(0, activity.reloadCount);
|
||||
}
|
||||
@@ -129,9 +143,11 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
)
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(2, activity.reloadCount);
|
||||
waitFor(() -> activity.loadCallCount == 1);
|
||||
assertEquals(1, activity.loadCallCount);
|
||||
assertEquals(0, activity.messageReloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -156,9 +172,11 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
)
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(1, activity.reloadCount);
|
||||
waitFor(() -> activity.loadCallCount == 1);
|
||||
assertEquals(1, activity.loadCallCount);
|
||||
assertEquals(0, activity.messageReloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -194,9 +212,161 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
)
|
||||
)
|
||||
);
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
waitFor(() -> activity.messageReloadCount == 1);
|
||||
assertEquals(0, activity.reloadCount);
|
||||
assertEquals(1, activity.messageReloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dialogGuardInterventionRequiredShowsBlockedSafeActionDialog() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
|
||||
TestRealtimeProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.resume()
|
||||
.get();
|
||||
RecordingDialogGuardApiClient apiClient = new RecordingDialogGuardApiClient();
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
ReflectionHelpers.ClassParameter.from(
|
||||
BossRealtimeEvent.class,
|
||||
new BossRealtimeEvent(
|
||||
"desktop.dialog_guard.intervention_required",
|
||||
new JSONObject()
|
||||
.put("interventionId", "intervention-1")
|
||||
.put("dialogId", "dialog-1")
|
||||
.put("requestId", "request-1")
|
||||
.put("taskId", "task-1")
|
||||
.put("deviceId", "mac-studio")
|
||||
.put("projectId", "project-1")
|
||||
.put("appName", "微信")
|
||||
.put("platform", "macos")
|
||||
.put("risk", "blocked")
|
||||
.put("summary", "微信正在请求读取敏感通讯录权限")
|
||||
.put("recommendedAction", "handled_on_device")
|
||||
.put("availableActions", new JSONArray()
|
||||
.put("allow_once")
|
||||
.put("allow_for_device_dialog")
|
||||
.put("deny")
|
||||
.put("handled_on_device")
|
||||
.put("cancel_task"))
|
||||
)
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertEquals(1, activity.reloadCount);
|
||||
Dialog latestDialog = ShadowDialog.getLatestDialog();
|
||||
assertTrue(latestDialog instanceof AlertDialog);
|
||||
AlertDialog dialog = (AlertDialog) latestDialog;
|
||||
assertTrue(dialog.isShowing());
|
||||
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "微信"));
|
||||
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "微信正在请求读取敏感通讯录权限"));
|
||||
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "我已在电脑上处理"));
|
||||
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "取消任务"));
|
||||
assertFalse(viewTreeContainsText(dialog.getWindow().getDecorView(), "允许本次"));
|
||||
assertFalse(viewTreeContainsText(dialog.getWindow().getDecorView(), "当前设备此弹窗允许"));
|
||||
|
||||
View handledButton = findClickableViewContainingText(dialog.getWindow().getDecorView(), "我已在电脑上处理");
|
||||
assertNotNull(handledButton);
|
||||
handledButton.performClick();
|
||||
waitFor(() -> apiClient.decisionCallCount == 1);
|
||||
|
||||
assertEquals("intervention-1", apiClient.lastInterventionId);
|
||||
assertEquals("handled_on_device", apiClient.lastDecision);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dialogGuardResolvedEventClosesMatchingDialog() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
|
||||
TestRealtimeProjectDetailActivity activity = Robolectric
|
||||
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.resume()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
ReflectionHelpers.ClassParameter.from(
|
||||
BossRealtimeEvent.class,
|
||||
new BossRealtimeEvent(
|
||||
"desktop.dialog_guard.intervention_required",
|
||||
new JSONObject()
|
||||
.put("interventionId", "intervention-2")
|
||||
.put("projectId", "project-1")
|
||||
.put("appName", "访达")
|
||||
.put("risk", "safe")
|
||||
.put("summary", "确认打开下载文件")
|
||||
.put("availableActions", new JSONArray().put("allow_once").put("deny"))
|
||||
)
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertTrue(dialog.isShowing());
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"handleRealtimeEvent",
|
||||
ReflectionHelpers.ClassParameter.from(
|
||||
BossRealtimeEvent.class,
|
||||
new BossRealtimeEvent(
|
||||
"desktop.dialog_guard.intervention_resolved",
|
||||
new JSONObject()
|
||||
.put("interventionId", "intervention-2")
|
||||
.put("projectId", "project-1")
|
||||
)
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertFalse(dialog.isShowing());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openingMasterAgentConversationClearsPendingMasterAgentNotification() throws Exception {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
BossApplication application = (BossApplication) context.getApplicationContext();
|
||||
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
|
||||
ShadowNotificationManager notificationManager = Shadows.shadowOf(
|
||||
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
|
||||
);
|
||||
application.visibilityTracker().onAppBackgrounded();
|
||||
|
||||
JSONObject message = new JSONObject()
|
||||
.put("id", "master-msg-1")
|
||||
.put("sender", "master")
|
||||
.put("senderLabel", "主 Agent · gpt-5.4-mini")
|
||||
.put("body", "主 Agent 后台回复");
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("projectId", "master-agent")
|
||||
.put("projectMessagesPayload", new JSONObject().put(
|
||||
"project",
|
||||
new JSONObject().put("messages", new JSONArray().put(message))
|
||||
));
|
||||
assertTrue(application.notificationRouter().maybeNotifyForRealtimeEvent(
|
||||
new BossRealtimeEvent("project.messages.updated", payload)
|
||||
));
|
||||
assertEquals(1, notificationManager.size());
|
||||
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
|
||||
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
Robolectric.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
|
||||
.setup()
|
||||
.resume()
|
||||
.get();
|
||||
|
||||
assertEquals(0, notificationManager.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -220,7 +390,7 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
drainRealtimeDebounce(activity);
|
||||
assertTrue(activity.awaitFirstLoadStarted());
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
@@ -239,18 +409,69 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-1"))
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
drainRealtimeDebounce(activity);
|
||||
|
||||
assertEquals(1, activity.loadCallCount);
|
||||
assertEquals(0, activity.loadCallCount);
|
||||
assertEquals(1, activity.messageLoadCallCount);
|
||||
assertEquals(0, activity.renderCount);
|
||||
|
||||
activity.releaseFirstLoad();
|
||||
waitFor(() -> activity.renderCount == 2 && activity.loadCallCount == 2);
|
||||
waitFor(() -> activity.renderCount == 2 && activity.messageLoadCallCount == 1 && activity.loadCallCount == 1);
|
||||
|
||||
assertEquals(2, activity.loadCallCount);
|
||||
assertEquals(1, activity.loadCallCount);
|
||||
assertEquals(1, activity.messageLoadCallCount);
|
||||
assertEquals(2, activity.renderCount);
|
||||
}
|
||||
|
||||
@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) {
|
||||
@@ -263,9 +484,54 @@ 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;
|
||||
@@ -296,6 +562,26 @@ 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;
|
||||
@@ -321,4 +607,22 @@ public class ProjectDetailActivityRealtimeTest {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingDialogGuardApiClient extends BossApiClient {
|
||||
int decisionCallCount;
|
||||
String lastInterventionId;
|
||||
String lastDecision;
|
||||
|
||||
RecordingDialogGuardApiClient() {
|
||||
super(RuntimeEnvironment.getApplication().getSharedPreferences("dialog_guard_test", Context.MODE_PRIVATE), "https://boss.hyzq.net");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse decideDialogGuardIntervention(String interventionId, String decision) throws org.json.JSONException {
|
||||
decisionCallCount += 1;
|
||||
lastInterventionId = interventionId;
|
||||
lastDecision = decision;
|
||||
return new ApiResponse(200, new JSONObject().put("ok", true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,16 +38,48 @@ 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()
|
||||
@@ -106,6 +138,10 @@ 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() {
|
||||
|
||||
@@ -19,7 +19,7 @@ import java.lang.reflect.Method;
|
||||
@Config(sdk = 34)
|
||||
public class ProjectVersionsActivityTest {
|
||||
@Test
|
||||
public void matchingGoalRefreshMarkerTriggersReload() throws Exception {
|
||||
public void matchingVersionRefreshMarkerTriggersReload() 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_goals.updated")
|
||||
.put("note", "project_versions.updated")
|
||||
)
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
@@ -49,7 +49,7 @@ public class ProjectVersionsActivityTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sameProjectNonGoalEventDoesNotTriggerReload() throws Exception {
|
||||
public void sameProjectNonVersionEventDoesNotTriggerReload() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_ID, "project-1")
|
||||
.putExtra(ProjectVersionsActivity.EXTRA_PROJECT_NAME, "树莓派二代接入");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -28,7 +29,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();
|
||||
|
||||
@@ -38,11 +39,18 @@ 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());
|
||||
}
|
||||
@@ -98,6 +106,10 @@ 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() {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
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;
|
||||
@@ -68,6 +73,61 @@ 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;
|
||||
@@ -81,4 +141,23 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class TelegramIntegrationActivityTest {
|
||||
@Test
|
||||
public void populateShowsCurrentTelegramStatusBeforeEditableForm() throws Exception {
|
||||
TestTelegramIntegrationActivity activity = Robolectric
|
||||
.buildActivity(TestTelegramIntegrationActivity.class, new Intent())
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
JSONObject telegram = new JSONObject()
|
||||
.put("enabled", true)
|
||||
.put("mode", "webhook")
|
||||
.put("botTokenConfigured", true)
|
||||
.put("webhookSecretConfigured", true)
|
||||
.put("botUsername", "boss_demo_bot")
|
||||
.put("defaultProjectId", "master-agent")
|
||||
.put("processedUpdateCount", 3)
|
||||
.put("lastError", "上次 webhook 同步失败")
|
||||
.put("allowFrom", new JSONArray().put("123456"))
|
||||
.put("groups", new JSONArray().put("-10001"))
|
||||
.put(
|
||||
"groupProjectRoutes",
|
||||
new JSONArray().put(
|
||||
new JSONObject()
|
||||
.put("chatId", "-10001")
|
||||
.put("threadId", 12)
|
||||
.put("projectId", "audit-collab")
|
||||
.put("label", "审计 Topic")
|
||||
)
|
||||
)
|
||||
.put("dmPolicy", "allowlist")
|
||||
.put("groupPolicy", "allowlist")
|
||||
.put("requireMentionInGroups", true);
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"populate",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, telegram)
|
||||
);
|
||||
|
||||
ViewGroup content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "当前状态"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "接入:已开启"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "模式:Webhook"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "Bot:@boss_demo_bot"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "Token:已配置"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "Webhook Secret:已配置"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "已处理 update:3"));
|
||||
assertTrue(viewTreeContainsText(content.getChildAt(0), "最近错误:上次 webhook 同步失败"));
|
||||
assertTrue(viewTreeContainsText(content, "群 / Topic 路由"));
|
||||
assertTrue(viewTreeContainsText(content, "-10001#12 audit-collab 审计 Topic"));
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View view, String text) {
|
||||
if (view instanceof android.widget.TextView) {
|
||||
CharSequence value = ((android.widget.TextView) view).getText();
|
||||
if (value != null && value.toString().contains(text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (view instanceof ViewGroup) {
|
||||
ViewGroup group = (ViewGroup) view;
|
||||
for (int index = 0; index < group.getChildCount(); index += 1) {
|
||||
if (viewTreeContainsText(group.getChildAt(index), text)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static class TestTelegramIntegrationActivity extends TelegramIntegrationActivity {
|
||||
@Override
|
||||
protected void reload() {
|
||||
// Tests drive rendering directly through populate().
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,13 +137,96 @@ public class WechatSurfaceMapperTest {
|
||||
assertEquals("已导入线程", row.lastMessagePreview);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_sanitizesLeakedPromptTitleToFolderFallback() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("conversationType", "single_device")
|
||||
.put("projectTitle", "你当前接手的项目根目录是:")
|
||||
.put("threadTitle", "你当前接手的项目根目录是:")
|
||||
.put("folderLabel", "boss")
|
||||
.put("latestReplyLabel", "17:35");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("boss", row.threadTitle);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_extractsWorkspaceFolderFromPromptLeakTitle() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("conversationType", "single_device")
|
||||
.put("projectTitle", "你现在接手的项目根目录是 /Users/kris/code/yuandi。")
|
||||
.put("threadTitle", "你现在接手的项目根目录是 /Users/kris/code/yuandi。")
|
||||
.put("latestReplyLabel", "17:36");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("yuandi", row.threadTitle);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_prefersStableMasterAgentProjectTitleOverOperationalThreadTitle() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("projectId", "master-agent")
|
||||
.put("projectTitle", "主 Agent")
|
||||
.put("threadTitle", "主 Agent 汇总")
|
||||
.put("lastMessagePreview", "同步已完成")
|
||||
.put("latestReplyLabel", "10:18");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("主 Agent", row.threadTitle);
|
||||
assertEquals("同步已完成", row.lastMessagePreview);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_prefersStableAuditProjectTitleOverOperationalThreadTitle() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("projectId", "audit-collab")
|
||||
.put("projectTitle", "硬件审计协作")
|
||||
.put("threadTitle", "审计对话")
|
||||
.put("lastMessagePreview", "审计结果已回写")
|
||||
.put("latestReplyLabel", "10:20");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("硬件审计协作", row.threadTitle);
|
||||
assertEquals("审计结果已回写", row.lastMessagePreview);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_hidesProcessLikePreviewFallback() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("projectTitle", "Boss")
|
||||
.put("threadTitle", "Boss开发主线程")
|
||||
.put("lastMessagePreview", "我继续往下收,这一轮先检查折叠链路,再确认未读逻辑,随后回你结果。")
|
||||
.put("latestReplyLabel", "10:20");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("", row.lastMessagePreview);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toConversationRow_keepsFinalSummaryPreviewVisible() throws Exception {
|
||||
JSONObject item = new JSONObject()
|
||||
.put("projectTitle", "Boss")
|
||||
.put("threadTitle", "Boss开发主线程")
|
||||
.put("lastMessagePreview", "折叠修复已部署,未读数现在只按最终结果计数。")
|
||||
.put("latestReplyLabel", "10:22");
|
||||
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
|
||||
assertEquals("折叠修复已部署,未读数现在只按最终结果计数。", row.lastMessagePreview);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toDeviceRow_mapsLegacyWechatThreeLineSummary() throws Exception {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("name", "Mac Studio")
|
||||
.withString("avatar", "M")
|
||||
.withString("status", "online")
|
||||
.withString("account", "17600003315")
|
||||
.withString("account", "krisolo")
|
||||
.withStringArray("projects", "北区试产线回归", "容灾切换验证")
|
||||
.withInt("quota5h", 8)
|
||||
.withInt("quota7d", 22);
|
||||
@@ -151,7 +234,7 @@ public class WechatSurfaceMapperTest {
|
||||
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
|
||||
|
||||
assertEquals("Mac Studio", row.title);
|
||||
assertEquals("账号: 17600003315 · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle);
|
||||
assertEquals("账号: krisolo · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle);
|
||||
assertEquals("额度: 5h 8% · 7d 22%", row.meta);
|
||||
assertEquals("M", row.avatarLabel);
|
||||
assertEquals("online", row.statusKey);
|
||||
@@ -162,12 +245,12 @@ public class WechatSurfaceMapperTest {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("name", "Mac Studio")
|
||||
.withString("status", "abnormal")
|
||||
.withString("account", "17600003315");
|
||||
.withString("account", "krisolo");
|
||||
|
||||
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
|
||||
|
||||
assertEquals("Mac Studio", row.title);
|
||||
assertEquals("账号: 17600003315", row.subtitle);
|
||||
assertEquals("账号: krisolo", row.subtitle);
|
||||
assertEquals("额度: 暂无 · 状态异常", row.meta);
|
||||
assertEquals("abnormal", row.statusKey);
|
||||
}
|
||||
@@ -177,7 +260,7 @@ public class WechatSurfaceMapperTest {
|
||||
JSONObject item = new StubJSONObject()
|
||||
.withString("name", "Mac Studio")
|
||||
.withString("status", "online")
|
||||
.withString("account", "17600003315")
|
||||
.withString("account", "krisolo")
|
||||
.withString("note", "书房主机")
|
||||
.withString("endpoint", "https://boss.hyzq.net/device/mac-studio")
|
||||
.withStringArray("projects", "master-agent", "android-app");
|
||||
@@ -185,14 +268,14 @@ public class WechatSurfaceMapperTest {
|
||||
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(item);
|
||||
|
||||
assertEquals("Mac Studio", summary.title);
|
||||
assertEquals("账号: 17600003315 · 项目: master-agent / android-app", summary.subtitle);
|
||||
assertEquals("账号: krisolo · 项目: master-agent / android-app", summary.subtitle);
|
||||
assertEquals("额度: 暂无 · 书房主机 · https://boss.hyzq.net/device/mac-studio · 项目 master-agent, android-app", summary.meta);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootMeMenuTitles_matchLegacyWechatMenuWithOpsEntry() throws Exception {
|
||||
assertArrayEquals(
|
||||
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
|
||||
new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
|
||||
WechatSurfaceMapper.rootMeMenuTitles()
|
||||
);
|
||||
}
|
||||
@@ -208,7 +291,7 @@ public class WechatSurfaceMapperTest {
|
||||
@Test
|
||||
public void mainPage_keepsOpsEntryInStableWechatMenuOrder() throws Exception {
|
||||
assertArrayEquals(
|
||||
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
|
||||
new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
|
||||
WechatSurfaceMapper.rootMeMenuTitles()
|
||||
);
|
||||
}
|
||||
@@ -292,7 +375,7 @@ public class WechatSurfaceMapperTest {
|
||||
JSONArray devices = new StubObjectArray(
|
||||
new StubJSONObject()
|
||||
.withString("id", "device-b")
|
||||
.withString("account", "17600003315"),
|
||||
.withString("account", "krisolo"),
|
||||
new StubJSONObject()
|
||||
.withString("id", "device-c")
|
||||
.withString("account", "other-account")
|
||||
@@ -311,7 +394,7 @@ public class WechatSurfaceMapperTest {
|
||||
null,
|
||||
"stale-device-id",
|
||||
"missing-bound-device",
|
||||
"17600003315",
|
||||
"krisolo",
|
||||
devices
|
||||
);
|
||||
|
||||
@@ -380,15 +463,20 @@ public class WechatSurfaceMapperTest {
|
||||
public void meMenuItems_useStableKeysInsteadOfDisplayTitlesForRouting() throws Exception {
|
||||
WechatSurfaceMapper.MeMenuItem[] items = WechatSurfaceMapper.rootMeMenuItems();
|
||||
|
||||
assertEquals(6, items.length);
|
||||
assertEquals(9, items.length);
|
||||
assertEquals("security", items[0].key);
|
||||
assertEquals("账号与安全", items[0].title);
|
||||
assertEquals("settings", items[1].key);
|
||||
assertEquals("ops", items[2].key);
|
||||
assertEquals("运维与修复", items[2].title);
|
||||
assertEquals("ai_accounts", items[3].key);
|
||||
assertEquals("skills", items[4].key);
|
||||
assertEquals("about", items[5].key);
|
||||
assertEquals("access", items[2].key);
|
||||
assertEquals("用户与权限", items[2].title);
|
||||
assertEquals("ops", items[3].key);
|
||||
assertEquals("运维与修复", items[3].title);
|
||||
assertEquals("ai_accounts", items[4].key);
|
||||
assertEquals("storage", items[5].key);
|
||||
assertEquals("附件与存储", items[5].title);
|
||||
assertEquals("telegram", items[6].key);
|
||||
assertEquals("skills", items[7].key);
|
||||
assertEquals("about", items[8].key);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -40,6 +40,17 @@ public class WechatSurfaceMapperTopActionTest {
|
||||
assertEquals("add_device", action.actionKey);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootTopAction_hidesAddDeviceForSubAccounts() {
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("devices", false, false, "member");
|
||||
|
||||
assertEquals("刷新", action.label);
|
||||
assertEquals("refresh", action.iconKey);
|
||||
assertFalse(action.primaryStyle);
|
||||
assertTrue(action.compactStyle);
|
||||
assertEquals("refresh", action.actionKey);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootTopAction_keepsRefreshOnMeTab() {
|
||||
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("me", true);
|
||||
|
||||
12
apps/boss-admin-web/index.html
Normal file
12
apps/boss-admin-web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Boss 企业后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1762
apps/boss-admin-web/package-lock.json
generated
Normal file
1762
apps/boss-admin-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
apps/boss-admin-web/package.json
Normal file
23
apps/boss-admin-web/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@boss/admin-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5174",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 4174",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
1602
apps/boss-admin-web/src/App.vue
Normal file
1602
apps/boss-admin-web/src/App.vue
Normal file
File diff suppressed because it is too large
Load Diff
230
apps/boss-admin-web/src/api/bossAdmin.ts
Normal file
230
apps/boss-admin-web/src/api/bossAdmin.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
export interface BossAdminMenuItem {
|
||||
key: string;
|
||||
label: string;
|
||||
children?: BossAdminMenuItem[];
|
||||
}
|
||||
|
||||
export interface BossAdminTaskSlaRow extends Record<string, unknown> {
|
||||
taskId: string;
|
||||
riskId: string;
|
||||
projectId: string;
|
||||
deviceId: string;
|
||||
taskType: string;
|
||||
status: string;
|
||||
phase: string;
|
||||
summary: string;
|
||||
slaLevel: "ok" | "watch" | "breached" | "recoverable" | "terminal";
|
||||
severity: "info" | "warning" | "critical";
|
||||
slaDueAt: string;
|
||||
lastProgressAt: string;
|
||||
attemptLabel: string;
|
||||
stale: boolean;
|
||||
recoverable: boolean;
|
||||
autoRecoverable: boolean;
|
||||
recommendedAction: string;
|
||||
}
|
||||
|
||||
export interface BossAdminTaskSlaPanel {
|
||||
generatedAt: string;
|
||||
summary: Record<string, number>;
|
||||
rows: BossAdminTaskSlaRow[];
|
||||
}
|
||||
|
||||
export interface BossAdminBackofficePayload {
|
||||
ok: boolean;
|
||||
surface: "platform" | "enterprise";
|
||||
currentCompany: Record<string, unknown> | null;
|
||||
menuTree: BossAdminMenuItem[];
|
||||
insights: {
|
||||
onboardingSteps: string[];
|
||||
serviceStatuses: Array<Record<string, unknown>>;
|
||||
openingPreview: Array<Record<string, unknown>>;
|
||||
deliveryChecklist: Array<Record<string, unknown>>;
|
||||
recentCompanies: Array<Record<string, unknown>>;
|
||||
customerHealthRows: Array<Record<string, unknown>>;
|
||||
riskAggregates: Array<Record<string, unknown>>;
|
||||
customerFollowups: Array<Record<string, unknown>>;
|
||||
enterpriseGoals: Array<Record<string, unknown>>;
|
||||
organizationUnits: string[];
|
||||
departmentProgress: Array<Record<string, unknown>>;
|
||||
masterAgentSummary: string[];
|
||||
permissionHighlights: string[];
|
||||
agentFlowSteps: string[];
|
||||
skillUsageAudit: Array<Record<string, unknown>>;
|
||||
recoveryActions: string[];
|
||||
backupStatus: Record<string, unknown>;
|
||||
dataSafetySummary: Record<string, unknown>;
|
||||
taskRiskSummary: Record<string, unknown>;
|
||||
taskSlaPanel: BossAdminTaskSlaPanel;
|
||||
capabilitySummary: Record<string, number>;
|
||||
surface: "platform" | "enterprise";
|
||||
};
|
||||
workbench: {
|
||||
summary: Record<string, number>;
|
||||
companies: Array<Record<string, unknown>>;
|
||||
devices: Array<Record<string, unknown>>;
|
||||
risks: Array<Record<string, unknown>>;
|
||||
notifications: Array<Record<string, unknown>>;
|
||||
grantsSummary: Record<string, number>;
|
||||
};
|
||||
tenants: Array<Record<string, unknown>>;
|
||||
users: Array<Record<string, unknown>>;
|
||||
roles: {
|
||||
builtInRoles: Array<Record<string, unknown>>;
|
||||
permissionTemplates: Array<Record<string, unknown>>;
|
||||
};
|
||||
resourceGroups: {
|
||||
devices: Array<Record<string, unknown>>;
|
||||
projects: Array<Record<string, unknown>>;
|
||||
skills: Array<Record<string, unknown>>;
|
||||
grants: Record<string, Array<Record<string, unknown>>>;
|
||||
};
|
||||
audit: {
|
||||
risks: Array<Record<string, unknown>>;
|
||||
notifications: Array<Record<string, unknown>>;
|
||||
riskTimeline: Array<Record<string, unknown>>;
|
||||
permissionLogs: Array<Record<string, unknown>>;
|
||||
};
|
||||
yudaoMapping: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface BossAdminBackupSnapshot {
|
||||
snapshotId: string;
|
||||
fileName: string;
|
||||
absolutePath: string;
|
||||
bytes: number;
|
||||
sha256: string;
|
||||
createdAt: string;
|
||||
actorAccount?: string;
|
||||
reason?: string;
|
||||
schemaVersion?: number;
|
||||
}
|
||||
|
||||
export interface BossAdminBackupStatus {
|
||||
mode: "file";
|
||||
backupDir: string;
|
||||
stateFile: string;
|
||||
restorePointCount: number;
|
||||
lastBackupAt?: string;
|
||||
status: "ready" | "empty" | "error";
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface BossAdminBackupsPayload {
|
||||
ok: boolean;
|
||||
status: BossAdminBackupStatus;
|
||||
snapshots: BossAdminBackupSnapshot[];
|
||||
}
|
||||
|
||||
export interface BossAdminSkillLifecycleRequest extends Record<string, unknown> {
|
||||
requestId: string;
|
||||
action: string;
|
||||
status: string;
|
||||
deviceId: string;
|
||||
skillId?: string;
|
||||
sourceUrl?: string;
|
||||
targetVersion?: string;
|
||||
rollbackToVersion?: string;
|
||||
lockedVersion?: string;
|
||||
requestedAt?: string;
|
||||
completedAt?: string;
|
||||
resultSummary?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface BossAdminSkillLifecycleRequestsPayload {
|
||||
ok: boolean;
|
||||
requests: BossAdminSkillLifecycleRequest[];
|
||||
}
|
||||
|
||||
async function requestJson<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
credentials: "include",
|
||||
...init,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (response.status === 401) {
|
||||
window.location.href = "/auth/login";
|
||||
throw new Error("UNAUTHORIZED");
|
||||
}
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new Error(payload?.message ?? `HTTP_${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function fetchBossAdminBackoffice(scope: "platform" | "enterprise" = "platform"): Promise<BossAdminBackofficePayload> {
|
||||
return requestJson<BossAdminBackofficePayload>(`/api/v1/admin/backoffice?scope=${scope}`);
|
||||
}
|
||||
|
||||
export async function postAdminAccess(payload: Record<string, unknown>) {
|
||||
return requestJson<Record<string, unknown>>("/api/v1/admin/access", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function postRiskAction(payload: Record<string, unknown>) {
|
||||
return requestJson<Record<string, unknown>>("/api/v1/admin/risks/actions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function postSkillLifecycleRequest(payload: Record<string, unknown>) {
|
||||
return requestJson<Record<string, unknown>>("/api/v1/admin/skills/requests", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSkillLifecycleRequests(): Promise<BossAdminSkillLifecycleRequestsPayload> {
|
||||
return requestJson<BossAdminSkillLifecycleRequestsPayload>("/api/v1/admin/skills/requests", {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
export async function postDeviceCodexRemoteControl(
|
||||
deviceId: string,
|
||||
payload: { action: "start" | "stop"; reason?: string },
|
||||
) {
|
||||
return requestJson<Record<string, unknown>>(
|
||||
`/api/v1/devices/${encodeURIComponent(deviceId)}/codex-remote-control`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
confirmed: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAdminBackups(): Promise<BossAdminBackupsPayload> {
|
||||
return requestJson<BossAdminBackupsPayload>("/api/v1/admin/backups");
|
||||
}
|
||||
|
||||
export async function createAdminBackup(reason: string) {
|
||||
return requestJson<Record<string, unknown>>("/api/v1/admin/backups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
action: "create_snapshot",
|
||||
reason,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function restoreAdminBackup(snapshotId: string) {
|
||||
return requestJson<Record<string, unknown>>("/api/v1/admin/backups", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
action: "restore_snapshot",
|
||||
snapshotId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
7
apps/boss-admin-web/src/main.ts
Normal file
7
apps/boss-admin-web/src/main.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createApp } from "vue";
|
||||
import Antd from "ant-design-vue";
|
||||
import "ant-design-vue/dist/reset.css";
|
||||
import App from "./App.vue";
|
||||
import "./styles.css";
|
||||
|
||||
createApp(App).use(Antd).mount("#app");
|
||||
426
apps/boss-admin-web/src/styles.css
Normal file
426
apps/boss-admin-web/src/styles.css
Normal file
@@ -0,0 +1,426 @@
|
||||
:root {
|
||||
color: #102018;
|
||||
background: #eef4ef;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 1360px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(16, 185, 129, 0.16), transparent 30%),
|
||||
linear-gradient(135deg, #f7fbf8 0%, #eef4ef 54%, #e6f0ec 100%);
|
||||
}
|
||||
|
||||
.boss-admin-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.boss-admin-sidebar {
|
||||
padding: 28px 20px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border-right: 1px solid rgba(16, 32, 24, 0.08);
|
||||
backdrop-filter: blur(22px);
|
||||
}
|
||||
|
||||
.boss-admin-brand {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.boss-admin-brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
color: white;
|
||||
font-weight: 900;
|
||||
background: #10b981;
|
||||
border-radius: 17px;
|
||||
box-shadow: 0 14px 28px rgba(16, 185, 129, 0.24);
|
||||
}
|
||||
|
||||
.boss-admin-brand h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.boss-admin-brand p,
|
||||
.boss-admin-eyebrow {
|
||||
margin: 0;
|
||||
color: #68766e;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.boss-admin-surface-switch {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.boss-admin-surface-card {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
color: #526158;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: rgba(247, 250, 248, 0.78);
|
||||
border: 1px solid rgba(16, 32, 24, 0.08);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.boss-admin-surface-card span {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.boss-admin-surface-card small {
|
||||
color: #7b8780;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.boss-admin-surface-card.active {
|
||||
color: #0b6b4c;
|
||||
background: #e5f8ef;
|
||||
border-color: rgba(16, 185, 129, 0.36);
|
||||
box-shadow: 0 12px 30px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.boss-admin-menu {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.boss-admin-visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.boss-admin-menu-item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
color: #56615a;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.boss-admin-menu-item.active,
|
||||
.boss-admin-menu-item:hover {
|
||||
color: #0f7a55;
|
||||
background: #e7f8ef;
|
||||
}
|
||||
|
||||
.boss-admin-main {
|
||||
padding: 28px 34px 44px;
|
||||
}
|
||||
|
||||
.boss-admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.boss-admin-header h2 {
|
||||
margin: 5px 0 0;
|
||||
font-size: 30px;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.boss-admin-header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.boss-admin-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.boss-admin-section,
|
||||
.boss-admin-section-grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.boss-admin-section-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.boss-admin-hero {
|
||||
grid-column: 1 / -1;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(16, 185, 129, 0.16), rgba(255, 255, 255, 0.94)),
|
||||
white;
|
||||
}
|
||||
|
||||
.boss-admin-hero h3 {
|
||||
max-width: 820px;
|
||||
margin: 8px 0 22px;
|
||||
font-size: 25px;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.boss-admin-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.boss-admin-metrics.compact {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.boss-admin-metric {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border: 1px solid rgba(16, 32, 24, 0.08);
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.boss-admin-metric span {
|
||||
color: #6a766f;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.boss-admin-metric strong {
|
||||
color: #102018;
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.boss-admin-metric.green strong {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.boss-admin-metric.red strong {
|
||||
color: #f04452;
|
||||
}
|
||||
|
||||
.boss-admin-metric.orange strong {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.boss-admin-form {
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
.boss-admin-form-gap {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.boss-admin-steps {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.boss-admin-step {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 14px;
|
||||
color: #51625a;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
border: 1px solid rgba(16, 32, 24, 0.08);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.boss-admin-step span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
color: #0f7a55;
|
||||
background: #dbf7ea;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.boss-admin-step.active {
|
||||
color: #0b6b4c;
|
||||
border-color: rgba(16, 185, 129, 0.34);
|
||||
box-shadow: 0 10px 24px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.boss-admin-action-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.boss-admin-status-list,
|
||||
.boss-admin-check-list,
|
||||
.boss-admin-goal-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.boss-admin-status-row,
|
||||
.boss-admin-check-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
background: #f7faf8;
|
||||
border: 1px solid rgba(16, 32, 24, 0.06);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.boss-admin-check-row {
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.boss-admin-check-row span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #f97316;
|
||||
background: #fff7ed;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.boss-admin-check-row span.done {
|
||||
color: #10b981;
|
||||
background: #dcfce7;
|
||||
}
|
||||
|
||||
.boss-admin-goal-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
background: #f7faf8;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.boss-admin-goal-row > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.boss-admin-org-grid,
|
||||
.boss-admin-capability-grid,
|
||||
.boss-admin-recovery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.boss-admin-org-node,
|
||||
.boss-admin-recovery-card,
|
||||
.boss-admin-capability-grid > div {
|
||||
padding: 16px;
|
||||
color: #18352a;
|
||||
font-weight: 800;
|
||||
background: #f7faf8;
|
||||
border: 1px solid rgba(16, 32, 24, 0.06);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.boss-admin-capability-grid span {
|
||||
display: block;
|
||||
color: #68766e;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.boss-admin-capability-grid strong {
|
||||
color: #10b981;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.boss-admin-permission-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.boss-admin-flow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.boss-admin-flow-node {
|
||||
padding: 14px 16px;
|
||||
color: #0b6b4c;
|
||||
font-weight: 800;
|
||||
background: #e5f8ef;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.boss-admin-flow-arrow {
|
||||
color: #8b9790;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.boss-admin-backup-status {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 18px 50px rgba(16, 32, 24, 0.07);
|
||||
}
|
||||
|
||||
.boss-admin-wide-card {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.ant-card .ant-card-body {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ant-table-wrapper {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table {
|
||||
min-width: 720px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-table-cell {
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ant-table-wrapper .ant-btn,
|
||||
.boss-admin-action-strip .ant-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
17
apps/boss-admin-web/tsconfig.json
Normal file
17
apps/boss-admin-web/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
23
apps/boss-admin-web/vite.config.ts
Normal file
23
apps/boss-admin-web/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/admin-web/",
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
outDir: "../../public/admin-web",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.BOSS_WEB_ORIGIN ?? "http://localhost:3000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/auth": {
|
||||
target: process.env.BOSS_WEB_ORIGIN ?? "http://localhost:3000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
368
apps/boss-agent-mac/Sources/BossAgentApp.swift
Normal file
368
apps/boss-agent-mac/Sources/BossAgentApp.swift
Normal file
@@ -0,0 +1,368 @@
|
||||
import Cocoa
|
||||
import WebKit
|
||||
import ApplicationServices
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate {
|
||||
private var window: NSWindow?
|
||||
private var webView: WKWebView?
|
||||
private var activeTab = "overview"
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleApplicationDidBecomeActive),
|
||||
name: NSApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
NSAppleEventManager.shared().setEventHandler(
|
||||
self,
|
||||
andSelector: #selector(handleGetUrlEvent(_:withReplyEvent:)),
|
||||
forEventClass: AEEventClass(kInternetEventClass),
|
||||
andEventID: AEEventID(kAEGetURL)
|
||||
)
|
||||
|
||||
let webConfiguration = WKWebViewConfiguration()
|
||||
let webView = WKWebView(frame: .zero, configuration: webConfiguration)
|
||||
webView.setValue(false, forKey: "drawsBackground")
|
||||
webView.navigationDelegate = self
|
||||
self.webView = webView
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 1180, height: 780),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "boss-agent"
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.isMovableByWindowBackground = true
|
||||
window.contentView = webView
|
||||
window.center()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
self.window = window
|
||||
|
||||
loadAgentPanel(tab: activeTab)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
handleLaunchPermissionRequestIfNeeded()
|
||||
}
|
||||
|
||||
private func loadAgentPanel(tab: String? = nil) {
|
||||
activeTab = normalizedTab(tab ?? activeTab)
|
||||
var components = URLComponents()
|
||||
components.scheme = "http"
|
||||
components.host = "127.0.0.1"
|
||||
components.port = 4317
|
||||
components.path = "/boss-agent"
|
||||
components.queryItems = [URLQueryItem(name: "tab", value: activeTab)] + nativePermissionQueryItems()
|
||||
guard let url = components.url else {
|
||||
loadFallback()
|
||||
return
|
||||
}
|
||||
webView?.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData))
|
||||
}
|
||||
|
||||
@objc private func handleApplicationDidBecomeActive() {
|
||||
loadAgentPanel(tab: activeTab)
|
||||
}
|
||||
|
||||
private func handleLaunchPermissionRequestIfNeeded() {
|
||||
let arguments = CommandLine.arguments
|
||||
guard
|
||||
let targetIndex = arguments.firstIndex(of: "--request-permission"),
|
||||
arguments.indices.contains(targetIndex + 1)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let target = arguments[targetIndex + 1]
|
||||
let returnTab = commandLineValue(after: "--return-tab", in: arguments) ?? "permissions"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
||||
self?.handlePermissionTarget(target, returnTab: returnTab)
|
||||
}
|
||||
}
|
||||
|
||||
private func commandLineValue(after flag: String, in arguments: [String]) -> String? {
|
||||
guard let index = arguments.firstIndex(of: flag), arguments.indices.contains(index + 1) else {
|
||||
return nil
|
||||
}
|
||||
return arguments[index + 1]
|
||||
}
|
||||
|
||||
@objc private func handleGetUrlEvent(_ event: NSAppleEventDescriptor, withReplyEvent replyEvent: NSAppleEventDescriptor) {
|
||||
guard
|
||||
let urlString = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue,
|
||||
let url = URL(string: urlString),
|
||||
isBossAgentDeepLink(url)
|
||||
else {
|
||||
return
|
||||
}
|
||||
handleBossAgentDeepLink(url)
|
||||
}
|
||||
|
||||
func application(_ application: NSApplication, open urls: [URL]) {
|
||||
for url in urls where isBossAgentDeepLink(url) {
|
||||
handleBossAgentDeepLink(url)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
loadFallback()
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
loadFallback()
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
decidePolicyFor navigationAction: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||||
) {
|
||||
guard let url = navigationAction.request.url else {
|
||||
decisionHandler(.allow)
|
||||
return
|
||||
}
|
||||
|
||||
if isPermissionSetupUrl(url) {
|
||||
decisionHandler(.cancel)
|
||||
handlePermissionSetupNavigation(url)
|
||||
return
|
||||
}
|
||||
|
||||
if isAgentPanelUrl(url) && !hasNativePermissionQuery(url) {
|
||||
decisionHandler(.cancel)
|
||||
loadAgentPanel(tab: queryValue("tab", in: url))
|
||||
return
|
||||
}
|
||||
|
||||
if isAgentPanelUrl(url), let tab = queryValue("tab", in: url) {
|
||||
activeTab = normalizedTab(tab)
|
||||
}
|
||||
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
|
||||
private func isPermissionSetupUrl(_ url: URL) -> Bool {
|
||||
url.path == "/api/v1/boss-agent/permissions/open"
|
||||
}
|
||||
|
||||
private func isBossAgentDeepLink(_ url: URL) -> Bool {
|
||||
url.scheme == "boss-agent"
|
||||
}
|
||||
|
||||
private func handleBossAgentDeepLink(_ url: URL) {
|
||||
if url.host == "permissions" && url.path == "/open" {
|
||||
handlePermissionTarget(
|
||||
queryValue("target", in: url) ?? "core",
|
||||
returnTab: queryValue("returnTab", in: url) ?? "permissions"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if url.host == "tab" {
|
||||
loadAgentPanel(tab: String(url.path.dropFirst()))
|
||||
}
|
||||
}
|
||||
|
||||
private func isAgentPanelUrl(_ url: URL) -> Bool {
|
||||
let host = url.host ?? ""
|
||||
return (host == "127.0.0.1" || host == "localhost") && url.port == 4317 && (url.path == "/boss-agent" || url.path == "/")
|
||||
}
|
||||
|
||||
private func hasNativePermissionQuery(_ url: URL) -> Bool {
|
||||
URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||
.queryItems?
|
||||
.contains(where: { $0.name.hasPrefix("native") }) == true
|
||||
}
|
||||
|
||||
private func queryValue(_ name: String, in url: URL) -> String? {
|
||||
URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||
.queryItems?
|
||||
.first(where: { $0.name == name })?
|
||||
.value
|
||||
}
|
||||
|
||||
private func normalizedTab(_ value: String?) -> String {
|
||||
switch value {
|
||||
case "permissions", "skills", "license", "logs", "overview":
|
||||
return value ?? "overview"
|
||||
default:
|
||||
return "overview"
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePermissionSetupNavigation(_ url: URL) {
|
||||
handlePermissionTarget(
|
||||
queryValue("target", in: url) ?? "core",
|
||||
returnTab: queryValue("returnTab", in: url) ?? activeTab
|
||||
)
|
||||
}
|
||||
|
||||
private func handlePermissionTarget(_ target: String, returnTab rawReturnTab: String) {
|
||||
let permissionTarget = normalizedPermissionTarget(target)
|
||||
let returnTab = normalizedTab(rawReturnTab)
|
||||
activeTab = returnTab
|
||||
|
||||
UserDefaults.standard.set(permissionTarget, forKey: "lastPermissionRequestTarget")
|
||||
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: "lastPermissionRequestAt")
|
||||
NSLog("boss-agent permission request target=%@ returnTab=%@", permissionTarget, returnTab)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
requestNativePermission(for: permissionTarget)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { [weak self] in
|
||||
if let settingsUrl = self?.systemSettingsUrl(for: permissionTarget) {
|
||||
NSWorkspace.shared.open(settingsUrl)
|
||||
}
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
|
||||
self?.loadAgentPanel(tab: returnTab)
|
||||
}
|
||||
}
|
||||
|
||||
private func nativePermissionQueryItems() -> [URLQueryItem] {
|
||||
let accessibility = AXIsProcessTrusted() ? "granted" : "missing"
|
||||
let screenRecording = screenRecordingStatus()
|
||||
UserDefaults.standard.set(accessibility, forKey: "native.accessibility")
|
||||
UserDefaults.standard.set(screenRecording, forKey: "native.screenRecording")
|
||||
|
||||
return [
|
||||
URLQueryItem(name: "nativeAccessibility", value: accessibility),
|
||||
URLQueryItem(name: "nativeScreenRecording", value: screenRecording),
|
||||
]
|
||||
}
|
||||
|
||||
private func screenRecordingStatus() -> String {
|
||||
if #available(macOS 10.15, *) {
|
||||
if CGPreflightScreenCaptureAccess() {
|
||||
return "granted"
|
||||
}
|
||||
if CGRequestScreenCaptureAccess() {
|
||||
return "granted"
|
||||
}
|
||||
return tccPermissionStatus(service: "kTCCServiceScreenCapture") ?? "missing"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
private func tccPermissionStatus(service: String) -> String? {
|
||||
let clients = service == "kTCCServiceScreenCapture"
|
||||
? "'com.hyzq.boss.agent','site.hyzq.boss.computer-use-helper'"
|
||||
: "'com.hyzq.boss.agent'"
|
||||
let query = "select auth_value from access where client in (\(clients)) and service='\(service)' order by auth_value desc limit 1;"
|
||||
let databasePaths = [
|
||||
"/Library/Application Support/com.apple.TCC/TCC.db",
|
||||
"\(NSHomeDirectory())/Library/Application Support/com.apple.TCC/TCC.db",
|
||||
]
|
||||
|
||||
for databasePath in databasePaths where FileManager.default.fileExists(atPath: databasePath) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3")
|
||||
process.arguments = [databasePath, query]
|
||||
let output = Pipe()
|
||||
process.standardOutput = output
|
||||
process.standardError = Pipe()
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
let data = output.fileHandleForReading.readDataToEndOfFile()
|
||||
let value = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value == "2" {
|
||||
return "granted"
|
||||
}
|
||||
if value == "0" {
|
||||
return "missing"
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func requestNativePermission(for target: String) {
|
||||
let targets: [String]
|
||||
if target == "core" {
|
||||
targets = ["accessibility", "screenRecording"]
|
||||
} else {
|
||||
targets = [target]
|
||||
}
|
||||
|
||||
for permission in targets {
|
||||
requestSingleNativePermission(permission)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestSingleNativePermission(_ permission: String) {
|
||||
switch permission {
|
||||
case "accessibility":
|
||||
requestAccessibilityPermission()
|
||||
case "screenRecording":
|
||||
requestScreenRecordingPermission()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func requestAccessibilityPermission() {
|
||||
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
|
||||
let options = [promptKey: true] as CFDictionary
|
||||
_ = AXIsProcessTrustedWithOptions(options)
|
||||
}
|
||||
|
||||
private func requestScreenRecordingPermission() {
|
||||
if #available(macOS 10.15, *) {
|
||||
_ = CGRequestScreenCaptureAccess()
|
||||
} else {
|
||||
_ = CGPreflightScreenCaptureAccess()
|
||||
}
|
||||
}
|
||||
|
||||
private func systemSettingsUrl(for target: String) -> URL? {
|
||||
let mapping = [
|
||||
"core": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Accessibility",
|
||||
"accessibility": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_Accessibility",
|
||||
"screenRecording": "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture",
|
||||
]
|
||||
return URL(string: mapping[normalizedPermissionTarget(target)] ?? mapping["core"]!)
|
||||
}
|
||||
|
||||
private func normalizedPermissionTarget(_ target: String) -> String {
|
||||
switch target {
|
||||
case "accessibility", "screenRecording", "core":
|
||||
return target
|
||||
default:
|
||||
return "core"
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFallback() {
|
||||
let html = """
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { margin:0; min-height:100vh; display:grid; place-items:center; background:#f6f8f5; font-family:-apple-system,BlinkMacSystemFont,'PingFang SC',sans-serif; color:#111418; }
|
||||
.card { width:520px; padding:32px; border-radius:24px; background:white; border:1px solid #e8ece9; box-shadow:0 24px 70px rgba(22,38,29,.12); }
|
||||
h1 { margin:0 0 10px; font-size:28px; letter-spacing:-.04em; }
|
||||
p { color:#707982; line-height:1.7; margin:0; }
|
||||
</style>
|
||||
<body>
|
||||
<section class="card">
|
||||
<h1>boss-agent 未启动</h1>
|
||||
<p>请先启动本机 local-agent 服务,然后重新打开 boss-agent。</p>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
webView?.loadHTMLString(html, baseURL: nil)
|
||||
}
|
||||
}
|
||||
|
||||
let app = NSApplication.shared
|
||||
let delegate = AppDelegate()
|
||||
app.delegate = delegate
|
||||
app.run()
|
||||
@@ -10,6 +10,24 @@ 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
|
||||
|
||||
|
||||
29
deployment/launchd/com.hyzq.boss.codex-desktop-bridge.plist
Normal file
29
deployment/launchd/com.hyzq.boss.codex-desktop-bridge.plist
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.hyzq.boss.codex-desktop-bridge</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>BOSS_CODEX_DESKTOP_BRIDGE_HOST</key>
|
||||
<string>127.0.0.1</string>
|
||||
<key>BOSS_CODEX_DESKTOP_BRIDGE_PORT</key>
|
||||
<string>4318</string>
|
||||
</dict>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/zsh</string>
|
||||
<string>-lc</string>
|
||||
<string>cd __BOSS_AGENT_ROOT__ && 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>
|
||||
@@ -8,7 +8,7 @@
|
||||
<array>
|
||||
<string>/bin/zsh</string>
|
||||
<string>-lc</string>
|
||||
<string>cd /Users/kris/code/boss && ./scripts/start-local-agent.sh __BOSS_AGENT_CONFIG__</string>
|
||||
<string>cd __BOSS_AGENT_ROOT__ && ./scripts/start-local-agent.sh __BOSS_AGENT_CONFIG__</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 964 KiB |
BIN
design/image2/boss-developer-roadmap-20260606.png
Normal file
BIN
design/image2/boss-developer-roadmap-20260606.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
150
docs/architecture/admin_refine_backoffice_cn.md
Normal file
150
docs/architecture/admin_refine_backoffice_cn.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# Boss To B 管理后台接入记录
|
||||
|
||||
更新时间:`2026-04-27`
|
||||
|
||||
## 目标
|
||||
|
||||
为 Boss To B 场景新增系统管理后台,供平台侧查看和管理不同客户公司的账号、设备、权限和风险状态,重点支持快速发现客户电脑 Codex 节点掉线、主 Agent 任务失败、线程上下文风险和运维故障。
|
||||
|
||||
## 技术选型
|
||||
|
||||
- 使用 `@refinedev/core` 作为管理后台资源抽象层。
|
||||
- 使用 `antd` 原生组件作为后台 UI 组件库。
|
||||
- 不使用 `@refinedev/antd`,原因是当前版本会传递引入 `@ant-design/pro-layout -> path-to-regexp@8.2.0` 高危 audit 链。
|
||||
- 不直接接入 `ant-design-pro` 工程,避免把 Umi/Max 路由和权限体系引入现有 `Next.js 16 + App Router` 主工程。
|
||||
|
||||
## 企业级后台独立化方向
|
||||
|
||||
2026-04-30 起,Boss 后台进入独立 PC 管理后台阶段。调研 `YunaiV/yudao-cloud` 后,当前策略是借鉴它的租户、用户、角色、菜单、日志和工作台信息架构,但不直接接入 YuDao 的 Java 微服务后端,避免把现有 `Next.js + 文件状态账本 + local-agent` 运行时拆碎。
|
||||
|
||||
第一批新增:
|
||||
|
||||
- `apps/boss-admin-web`:独立 Vue + Vite + Ant Design Vue 后台工程,面向平台侧运营和客户成功人员。
|
||||
- `/api/v1/admin/backoffice`:企业后台 BFF,把 Boss 当前账本聚合成 YuDao 风格的菜单、工作台、租户、账号、角色权限、资源授权、Skill 中心、风险和审计数据。
|
||||
- `/enterprise-admin`:Next 主站内的受保护入口,只允许 `highest_admin` 访问,并跳转到独立后台静态产物 `/admin-web/index.html`。
|
||||
- `admin:web:dev` / `admin:web:build` / `admin:web:publish`:根工程脚本入口。`admin:web:publish` 会把 Vue 构建产物写入 `public/admin-web`,随 Next standalone 的 `public` 一起发布。
|
||||
|
||||
边界:
|
||||
|
||||
- 旧 `/admin` UI 已删除,`/admin` 仅保留为跳转到根路径 `/` 的兼容入口;生产域名 `https://admin.boss.hyzq.net/` 直接承载新独立 PC 后台。
|
||||
- 独立后台只消费 Admin BFF,不直接读取 `boss-state.json`。
|
||||
- 独立后台当前复用 Boss Cookie 登录态,后续再绑定 `admin.boss.hyzq.net` 的独立部署。
|
||||
- `/api/v1/admin/backoffice` 仍只允许 `highest_admin`,并过滤 `passwordHash`、`mfaSecret` 和 session token。
|
||||
|
||||
## 当前落地范围
|
||||
|
||||
- `/admin` 页面收敛为兼容跳转,不再承载旧 Next 管理 UI。
|
||||
- 新增 `/api/v1/admin/overview` 聚合接口。
|
||||
- 新增 `/api/v1/admin/backoffice` 独立企业后台聚合接口。
|
||||
- 新增 `/api/v1/admin/risks/actions` 风险处理动作接口。
|
||||
- 新增 `/api/v1/admin/notifications/dispatch` 风险通知派发接口。
|
||||
- 新增 `buildAdminOverview(state)` 纯函数,负责从当前文件状态聚合后台数据。
|
||||
- 新增显式 `adminCompanies` 租户账本,支持把账号和设备直接绑定到客户公司,不再只能依赖账号邮箱域名推断。
|
||||
|
||||
当前页面已在 `2026-04-30` 升级为 PC To B 总后台结构,不再是简单的 3 个表格页签。新结构包含 4 个一级区:
|
||||
|
||||
- `平台运营驾驶舱`:平台全局健康、待处理风险、客户健康、节点健康、最近事件。
|
||||
- `客户与账号`:客户公司、账号列表、设备归属和客户开通任务流。
|
||||
- `授权工作台`:复用既有账号 / 设备 / 项目 / Skill 授权能力,但放在更清晰的权限上下文里。
|
||||
- `风险与治理`:风险战情室、SLA、负责人、修复工单,以及 Skill 生命周期治理。
|
||||
|
||||
### 平台运营驾驶舱
|
||||
|
||||
- 展示今日待处理:客户公司、账号、在线设备、开放风险和风险通知。
|
||||
- 展示客户健康排行:按开放风险和设备在线情况优先排列。
|
||||
- 展示关键风险队列:只展示最值得处理的风险,完整队列进入风险战情室。
|
||||
- 展示节点健康:集中查看客户电脑、Codex GUI / CLI 和最近心跳。
|
||||
- 展示最近事件:风险通知和风险时间线,避免平台侧漏跟进。
|
||||
|
||||
### 客户与账号
|
||||
|
||||
- 展示客户公司列表、健康状态、账号数、在线设备、开放风险和客户成功负责人。
|
||||
- 展示客户开通任务流:创建客户公司、开通老板账号、绑定客户电脑、分配项目与 Skill 权限。
|
||||
- 展示账号列表:账号、角色、公司、状态和最近登录。
|
||||
- 展示客户设备:设备状态、GUI / CLI 在线状态、风险数和最近心跳。
|
||||
|
||||
### 授权工作台
|
||||
|
||||
- 继续复用 `/api/v1/admin/access`。
|
||||
- 支持创建 / 更新子账号、公司管理、批量导入、账号归属、设备归属、权限模板、设备 / 项目 / Skill 授权和离职回收。
|
||||
- 高危动作继续保留二次确认和审计记录。
|
||||
|
||||
### 风险与治理
|
||||
|
||||
- 风险战情室按严重程度、客户影响、负责人和 SLA 组织风险。
|
||||
- 风险处理不再使用浏览器 `window.prompt`,改成页面内处理面板。
|
||||
- 处理面板支持指派负责人、设置 SLA、确认、关闭和创建修复工单。
|
||||
- 对暂不支持动作的风险类型保持只读提示,不假装处置成功。
|
||||
- Skill 生命周期治理作为同一区域的第二页签,继续复用 `/api/v1/admin/skills/requests`。
|
||||
|
||||
## 旧版落地范围记录
|
||||
|
||||
以下是第一版落地内容,仍保留作为能力来源说明:
|
||||
|
||||
### 总览
|
||||
|
||||
- 总览统计:公司数、账号数、在线设备、开放风险。
|
||||
- 风险通知:展示由 SLA 扫描生成的超时通知,避免平台侧只看到风险列表、漏掉需要主动跟进的客户事项。
|
||||
- 风险时间线:展示风险通知生成、派发、确认、关闭、负责人和 SLA 调整等最近事件。
|
||||
- 关键风险:展示最高优先级风险。
|
||||
- 风险表:离线设备、未关闭运维故障、线程上下文告警、失败主 Agent 任务。
|
||||
- 风险动作:`ops_fault` 支持指派负责人、设置 SLA、确认、关闭和创建修复工单;`thread_context_alert` 支持指派负责人、设置 SLA、确认和关闭;暂不支持的风险类型会显式失败,不假成功。
|
||||
- 设备表:设备在线状态、CLI/GUI 连接状态、最近心跳和风险数量。
|
||||
- 公司表:优先使用显式 `adminCompanies`,账号和设备未绑定公司时才回退到账号域名或默认公司。
|
||||
- 公司表补齐 To B 运营字段:套餐等级、合同到期时间、客户负责人和客户成功负责人。
|
||||
- 账号表:展示账号、角色、公司、状态、创建/更新时间,不暴露 `passwordHash`。
|
||||
|
||||
### 账号与授权
|
||||
|
||||
- 复用 `/api/v1/admin/access`,支持创建 / 更新 `member` 或 `admin` 子账号。
|
||||
- 支持查看账号状态,并对非主账号执行启用 / 停用;停用账号会同步撤销该账号当前活跃会话。
|
||||
- 支持公司管理、账号归属、设备归属和公司列表。
|
||||
- 支持按公司批量导入成员账号,并支持先预览新增 / 更新 / 异常数量,预览不会写入状态账本。
|
||||
- 支持 CSV 文件导入账号清单,表头为 `account,displayName,role,password`。
|
||||
- 支持对子账号开启 / 关闭 MFA;后台 GET 不返回 `mfaSecret`,仅开启时在本次响应返回一次 `mfaSetupSecret` 供初始化。
|
||||
- 支持最高管理员重置子账号密码;重置后会撤销该账号所有活跃会话,响应不暴露 `passwordHash`。
|
||||
- 支持停用 / 启用客户公司;停用公司会同步禁用该租户下的普通子账号并撤销活跃会话,不会波及平台最高管理员。
|
||||
- 支持离职回收:停用账号、撤销活跃会话,并清理设备 / 项目 / Skill 授权。
|
||||
- 公司停用 / 授权撤销 / 密码重置 / 离职回收等高危动作均在 PC 后台做二次确认。
|
||||
- 支持套用内置权限模板。
|
||||
- 支持设备、项目、Skill 三类授权。
|
||||
- 支持撤销单条授权。
|
||||
- 支持查看最近权限审计记录。
|
||||
|
||||
### Skill 治理
|
||||
|
||||
- 复用 `/api/v1/admin/skills/requests`,支持创建 `install / update / uninstall / rollback / version_lock` 请求。
|
||||
- 设备端仍由 `local-agent` 按既有 lifecycle 链路认领和完成。
|
||||
- 管理后台只负责下发治理请求与查看请求状态,不绕过设备端 allowlist、checksum、备份和回滚约束。
|
||||
- 2026-04-30 起,PC 总后台的 Skill 治理入口改为 `Skill 中心`:先展示 Skill 目录、详情、授权对象和执行轨迹,再通过右侧安装向导创建生命周期请求。
|
||||
- `Skill 中心` 会优先使用 `/api/v1/admin/access` 返回的 `skillCatalog`,没有聚合目录时再由设备 Skill 清单前端兜底聚合,避免最高管理员必须记住每台电脑的原始 `skillId`。
|
||||
- 创建请求仍提交到 `/api/v1/admin/skills/requests`,只是把 `sourceUrl / trustedSourceId / checksum` 等字段放入向导步骤中,降低误操作和填表成本。
|
||||
|
||||
## 权限边界
|
||||
|
||||
- `/admin` 页面直接跳转根路径 `/`,生产根路径由 `admin.boss.hyzq.net` 站点内部 rewrite 到独立后台静态入口。
|
||||
- 非 `highest_admin` 访问 `/enterprise-admin` 时只看到“仅最高管理员可用”提示。
|
||||
- `/api/v1/admin/overview` 未登录返回 `401`,非最高管理员返回 `403`。
|
||||
- `/api/v1/admin/risks/actions` 未登录返回 `401`,非最高管理员返回 `403`。
|
||||
- `/api/v1/admin/risks/scan` 未登录返回 `401`,非最高管理员返回 `403`。
|
||||
- `/api/v1/admin/notifications/dispatch` 未登录返回 `401`,非最高管理员返回 `403`。
|
||||
- 后台 mutation 路由和认证 mutation 路由会拒绝显式跨站浏览器请求;原生 APP 请求通过 `x-boss-native-app: 1` 豁免浏览器 CSRF 检查。
|
||||
- 后台 mutation 路由会把 `x-forwarded-for / x-real-ip / user-agent / x-request-id` 写入 `permissionAuditLogs`;高危动作会额外写入安全化 `beforeJson / afterJson` 快照,便于企业客户追责和回放。
|
||||
|
||||
## 数据来源
|
||||
|
||||
- `authAccounts`:账号与角色。
|
||||
- `adminCompanies`:客户公司 / 租户实体。
|
||||
- `devices`:电脑与 Codex CLI/GUI 能力状态。
|
||||
- `projects`:项目与设备关联。
|
||||
- `opsFaults`:未关闭运维故障。
|
||||
- `threadContextAlerts`:未解决线程上下文告警。
|
||||
- `masterAgentTasks`:失败任务。
|
||||
- `accountDeviceGrants`、`accountProjectGrants`、`accountSkillGrants`:授权汇总和过期授权统计。
|
||||
- `adminNotifications`:风险 SLA 超时通知账本,由 `/api/v1/admin/risks/scan` 幂等生成,并由 `/api/v1/admin/notifications/dispatch` 派发。
|
||||
- `adminRiskTimeline`:风险处理时间线,记录通知生成、派发和人工处置动作。
|
||||
|
||||
## 后续扩展
|
||||
|
||||
- 下一期应接入企业微信 / 飞书 / 短信等更多通知渠道;当前 `BOSS_ADMIN_NOTIFICATION_MODE=email` 可走服务器 sendmail,默认 `disabled` 只记录派发状态。
|
||||
- PostgreSQL 切换仍建议先用 `scripts/boss-state-store-maintenance.mjs` 做备份、dry-run 迁移和回滚演练,再设置 `BOSS_STATE_STORE=postgres`。
|
||||
@@ -19,10 +19,13 @@
|
||||
2. `docs/architecture/repo_map_cn.md`
|
||||
3. `docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
4. `docs/architecture/api_and_service_inventory_cn.md`
|
||||
5. `docs/architecture/boss_server_connection_and_deploy_cn.md`
|
||||
6. `docs/architecture/wechat_project_conversation_mapping_cn.md`
|
||||
7. `docs/architecture/thread_context_budget_and_handoff_protocol_cn.md`
|
||||
8. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md`
|
||||
5. `docs/architecture/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. `docs/architecture/wechat_project_conversation_mapping_cn.md`
|
||||
9. `docs/architecture/thread_context_budget_and_handoff_protocol_cn.md`
|
||||
10. `docs/architecture/dependency_security_audit_cn.md`
|
||||
11. `prompts/codex_fullstack_build_and_deploy_prompt_cn.md`
|
||||
|
||||
## 3. 当前有效实现边界
|
||||
|
||||
@@ -40,6 +43,7 @@
|
||||
- `src/lib/boss-storage-server-file.ts`:服务器文件存储上传 / 读取
|
||||
- `src/lib/boss-storage-aliyun-oss.ts`:阿里 OSS 私有桶上传 / 签名下载
|
||||
- `src/lib/boss-ota.ts`:APK OTA 产物定位与元数据读取
|
||||
- `src/lib/boss-agent-ota.ts`:boss-agent macOS 运行包 OTA 产物定位与元数据读取
|
||||
- `src/lib/boss-projections.ts`:当前聚合 BFF 投影视图
|
||||
- `src/components/app-runtime.tsx`:APP 日志桥、SSE 刷新和 Skill 面板
|
||||
- `local-agent/server.mjs`:设备端心跳和 thread-context 上报服务
|
||||
@@ -58,6 +62,7 @@
|
||||
- `android/app/src/main/java/com/hyzq/boss/AttachmentComposerState.java`:原生附件发送确认规则与待上传附件模型
|
||||
- `android/app/src/main/java/com/hyzq/boss/BossWindowInsets.java`:原生顶部安全区处理,负责把状态栏 / 刘海区让出来
|
||||
- `android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`:原生设备详情与技能入口
|
||||
- `android/app/src/main/java/com/hyzq/boss/AccessManagementActivity.java`:原生最高管理员用户与权限管理页
|
||||
- `android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`:原生 AI 账号管理页
|
||||
- `android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`:原生运维 / 审计中心
|
||||
- `android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`:原生微信式 surface contract
|
||||
@@ -97,10 +102,12 @@
|
||||
- `POST /api/auth/login` 正常,会写入 `boss_session`
|
||||
- `boss_session` 当前默认保持 30 天
|
||||
- `GET /api/auth/session` 正常
|
||||
- `GET/POST /api/v1/auth/sessions` 正常,已支持基础跨端会话治理和单会话撤销
|
||||
- `POST /api/auth/restore` 正常,原生 Android 客户端可用 `restore token` 自动恢复登录态
|
||||
- `GET /api/v1/app-logs` 正常,可按登录态分页读取 APP 日志
|
||||
- `POST /api/v1/projects/master-agent/messages` 正常,已验证通过 `local-agent -> codex exec -> complete` 返回真实主 Agent 回复
|
||||
- `GET /api/v1/user/ota/package` 正常,当前会返回最新 APK
|
||||
- `GET /api/v1/boss-agent/ota` 与 `GET /api/v1/boss-agent/ota/package` 已接入 boss-agent Mac 端 OTA,要求设备 token;打包脚本会发布 `boss-agent-mac-latest.zip/json`
|
||||
- `npm run apk:release` 正常,已能输出 signed release APK
|
||||
- 当前原生 Android 页面已覆盖会话、设备、我的三栏和主要二级页,不再依赖 WebView 承载业务页面
|
||||
- 本地 `device-agent` 正常
|
||||
@@ -137,11 +144,43 @@
|
||||
- 线程改名当前遵循微信最新逻辑:从聊天页右上角进入会话信息页,再进行改名
|
||||
- 当前已支持从单线程会话发起独立群聊:原会话保留,新群聊自动命名并可在群资料页改名
|
||||
- 当前群聊编排主链已经补到第一阶段:群聊消息先进入主 Agent,主 Agent 生成推荐下发方案,用户确认后再创建执行单;执行完成后线程原始结果会回群,主 Agent 再追加汇总
|
||||
- 当前 Boss APP 按“Codex 同一线程客户端”同步桌面记录:APP 直连线程和主 Agent 托管线程都会把用户原文镜像进目标 Codex rollout,供 Codex 桌面版打开/刷新该线程时看到同一段沟通记录;内部 prompt、调度字段和系统约束不得写入桌面可见记录
|
||||
- 当前桌面实时性新增轻量刷新桥:镜像成功后 `local-agent` 会优先调用本机常驻 `Codex Desktop Bridge` endpoint,再由 bridge 打开 `codex://threads/{threadId}` 目标线程深链并发送一次应用刷新快捷键,让 Codex 桌面版切到目标线程后重新感知线程更新;endpoint 不可用时会回退到原命令式刷新。这条桥只做打开/刷新提示,不承担消息输入,失败也不能阻断主链。默认配置会在短暂失败时重试 2 次、间隔 120ms,并保留 deep link 与尝试次数,方便排查桌面端是否收到刷新提示。bridge 还提供本机 SSE:`GET /api/v1/codex-desktop/events`,只广播安全元数据;`scripts/codex-desktop-event-consumer.mjs` 已作为 Desktop 插件/IPC 的消费样例
|
||||
- 当前还新增 `scripts/codex-desktop-integration-probe.mjs` 与 bridge `GET /api/v1/codex-desktop/capabilities`:用于自动探测当前 Codex Desktop 是否支持 `codex://threads/{threadId}` 这类稳定入口,并明确禁止把“修改 Codex.app 签名包体”作为支持能力
|
||||
- 当前设备导入主链已经补到第一阶段:设备 heartbeat 可上报真实候选线程,系统会生成导入草稿;用户勾选后可生成导入决议,并把选中的线程真正落成聊天窗口
|
||||
- 当前设备导入草稿不会再被旧 `projects` 字段绕过;只有 `apply` 之后,候选线程才会真正变成聊天窗口
|
||||
- 当前设备导入 `review` 已经会留下 `device_import_resolution` master task 轨迹,但决议内容仍是服务端 heuristic 版,尚未真正交给 `local-agent -> codex exec`
|
||||
- Web 和原生 Android 当前都已经接上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台页面;已绑定生产设备继续保留 heartbeat 自动导入链路
|
||||
- 原生首页的刷新失败策略当前已改成按当前 tab 独立判错,不会再因为 `设备 / 设置 / OTA` 的旁路请求失败把会话页刷新一并判成失败
|
||||
- 当前量产方向已经明确为“Boss 企业控制面 + 可插拔执行协议”:多租户、权限、审批、审计、备份、回退和 Skill 治理由 Boss 承担,Codex App Server / Codex MCP / Codex CLI / Computer Use / 业务系统 API 都作为 provider 接入;详见 `docs/architecture/enterprise_ai_ops_architecture_cn.md`
|
||||
- 当前 Codex App Server 已完成二十九批接入:boss-agent 默认开启 `local-agent/codex-app-server-runner.mjs` 作为 Codex 绑定入口,优先走 `codex app-server` stdio,也可灰度连接 `ws://127.0.0.1:<port>` 或 `unix://PATH` 同机长驻 App Server;长驻连接支持 `Authorization: Bearer <token>`,配置上优先使用 `codexAppServerAuthTokenFile`。turn 启动前失败才回退 CLI,turn 启动后不重复执行;桌面远程控制默认先走 `codex-computer-use`,失败后回退 `cua-driver-computer-use`。2026-06-04 已按本机 `codex-cli 0.136.0-alpha.2` 重新生成协议快照 `docs/protocol-snapshots/codex-app-server/0.136.0-alpha.2/`,manifest 识别 151 个 method 和当前 schema 暴露的 ThreadItem 类型。
|
||||
- App Server runner 已把 plan、diff、item、approval、warning、file change、thread status、realtime、model route、token usage、MCP、remote control、thread goal、settings、compaction、account、model verification、collab、tool activity、reasoning summary、image generation、hook、Windows sandbox 和 stream delta 归一到 Boss `execution_progress` 卡片;字段白名单只保留安全摘要,不保存 SDP、音频原始数据、raw item、remote installationId、cwd、turnId、配置文件路径、collab 源/目标线程 ID、receiverThreadIds、collab prompt、agentsStates 私有消息、共享 Skill 根绝对路径、hook key/command/sourcePath/statusMessage/hash/error message、tool arguments/result/contentItems、web URL token、命令正文/输出、raw reasoning content、reasoning item id、imageGeneration revisedPrompt/result、Windows sandbox sourcePath/samplePaths、本地绝对路径或未清洗密钥。
|
||||
- Heartbeat discovery 已能缓存 `model/list / skills/list / skills/extraRoots/set / hooks/list / plugin/list / app/list / modelProvider/capabilities/read / experimentalFeature/list / collaborationMode/list / permissionProfile/list / mcpServerStatus/list / account/read / account/rateLimits/read / config/read / configRequirements/read / externalAgentConfig/detect / thread/list / thread/loaded/list / thread/turns/list` 的能力摘要。`thread/turns/list` 固定使用 `itemsView=summary`,只额外提取最终 `agentMessage` 安全摘要,并合并进 `projectCandidates.recentAssistantMessages` 让 Codex Desktop 自己产生的新回复反向同步到 Boss APP;不保存用户正文、reasoning 原文、命令输出、原始 items、内部 prompt 或系统提示词。同批已补 `turn/steer` 活跃 turn 干预和 `POST /api/v1/projects/[projectId]/thread-collaboration` 服务端线程协作排队入口。
|
||||
- 原生 Android `DeviceDetailActivity` 当前已展示 Codex App Server 连接态、模型、扩展、治理、账号、线程、轮次、线程操作、线程协作和协议漂移这些核心 metadata 摘要;更深的插件治理、文件治理、MCP 治理、流式增量等长尾治理摘要仍以 Web 设备详情和后台治理页为主,后续可继续分批补齐到原生端。
|
||||
- 第十九批另补 `threadActionSummary` 线程操作能力摘要:设备详情页会显示 archive / unarchive / fork / compact / rollback / rename / metadata / steer / interrupt / shell / unsubscribe 等能力分组;该字段只读,不在 heartbeat 中调用任何会改变线程状态的 App Server API。
|
||||
- 第二十九批另补 `threadCollaborationSummary` 线程协作口径:Web 与原生 Android 设备详情页会显示 Boss Broker、协作事件 handler、协作模式数量和“非原生私聊”状态。本机 0.136.0-alpha.2 生成 schema 已确认 `app/list`、`app/list/updated`、`configRequirements/read`、`mcpServerStatus/list` 和 `ThreadItem.contextCompaction`,但未声明 `collaborationMode/list`、`thread/turns/list` 或 `ThreadItem.collabToolCall`;因此当前产品层把线程间协作定义为 Boss 受控 Broker + App Server 注入/执行链路,不把它表述成 Codex 原生任意线程 P2P 聊天。
|
||||
- 同批新增 `protocolDriftSummary` 协议漂移摘要:Web 与原生 Android 设备详情页会显示兼容/告警、失败探针数、官方文档跟进项和 Boss Broker 兜底策略。该字段来自 discovery errors 的 method 级安全归一,不保存错误原文、线程 ID、用户正文或内部 prompt;后续 Codex Server 更新时优先看这个摘要决定是否需要补 runner 或前台展示。
|
||||
- 第二十批另补 `pluginGovernanceSummary` 插件治理能力摘要:设备详情页会显示 install / uninstall / read / skill-read / share 等能力分组;该字段只读,不在 heartbeat 中调用任何插件安装、卸载或共享写 API。
|
||||
- 第二十一批另补 `accountGovernanceSummary / configGovernanceSummary` 账号与配置治理能力摘要:设备详情页会显示 login / logout / token refresh / add credits nudge / config write / MCP reload / Skill config write 等能力分组;这些字段只读,不在 heartbeat 中调用任何账号或配置写 API。
|
||||
- 第二十二批另补 `fileSystemGovernanceSummary / commandSessionSummary` 文件系统与命令会话治理能力摘要:设备详情页会显示 file read/write/remove/watch 与 command stdin / resize / terminate / stream 等能力分组;这些字段只读,不在 heartbeat 中调用任何文件读写或命令控制 API。
|
||||
- 第二十三批另补 `externalAgentGovernanceSummary / marketplaceGovernanceSummary / experimentalFeatureGovernanceSummary` 外部 Agent 迁移、Marketplace 和实验特性治理能力摘要:设备详情页会显示 external-agent import、marketplace add/remove/upgrade 和 experimental feature enablement 等能力分组;这些字段只读,不在 heartbeat 中调用任何迁移导入、marketplace 写入或实验特性启用 API。
|
||||
- 第二十四批另补 `reviewGovernanceSummary / windowsSandboxGovernanceSummary / fuzzyFileSearchSummary` 审查、Windows 沙箱和文件搜索事件能力摘要:设备详情页会显示 review start、Windows sandbox readiness/setup 和 fuzzy file search updated/completed 等能力分组;这些字段只读,不在 heartbeat 中调用任何审查启动、沙箱设置或文件搜索动作。
|
||||
- 第二十五批另补 `mcpGovernanceSummary / userInteractionGovernanceSummary / guardianGovernanceSummary` MCP、用户交互和 Guardian 治理能力摘要:设备详情页会显示 MCP OAuth/resource/tool/elicitation、tool requestUserInput、Guardian denied action approval 和 permission request approval 等能力分组;这些字段只读,不在 heartbeat 中调用任何 MCP、用户输入或 Guardian 放行动作。
|
||||
- 第二十六批另补 `runtimeEventSummary / extensionEventSummary / threadLifecycleEventSummary` 运行事件、扩展事件和线程生命周期事件能力摘要:设备详情页会显示 process output/exited、raw response completed、skills changed、plugin installed、thread started/closed/archived/unarchived/name updated 等能力分组;这些字段只读,不在 heartbeat 中主动触发进程、插件、Skill 或线程生命周期动作。
|
||||
- 第二十七批另补 `streamDeltaEventSummary` 流式增量事件能力摘要:设备详情页会显示 agent delta、plan delta、reasoning delta、MCP progress、command output、terminal interaction 和 file output 等能力分组;该字段只读,不保存原始增量文本、命令输出、推理正文或文件输出。
|
||||
- 当前任务执行态也已补 `executionProgress.streamEvents`:App Server runner 会把 agent / plan / reasoning / MCP / command / terminal / file 的流式 delta 归一成计数,Android 进度卡展示“流式增量”,不保存或渲染原始 delta、命令输出、终端输入、推理正文或文件输出。
|
||||
- 当前 App Server 任务取消已从“服务端标记”升级为“真实 turn 中断”:`POST /api/v1/master-agent/tasks/[taskId]/cancel` 仍负责把任务置为 `canceled`,新增 `GET /api/v1/master-agent/tasks/[taskId]/control-state` 供设备端轮询;`local-agent` 在 App Server turn 启动后会按取消状态调用 `turn/interrupt`,并把 `interrupted` 作为干净取消处理,避免取消后长任务继续跑或被误写成失败日志。
|
||||
- 当前本机 `codex remote-control` 已确认为官方 App Server daemon 远控入口;boss-agent 本机状态页会展示 `Codex Remote Control` 托管摘要和 `codex remote-control start --json` 默认启动命令,但状态页刷新不会自动启动 daemon。云端已补 `POST /api/v1/devices/[deviceId]/codex-remote-control`,要求显式 `confirmed=true`、设备在线和 `computer.control` 权限,成功后排 `device_maintenance / codex_remote_control` 任务给目标 local-agent 本机执行,并写入 `task.authorized / task.denied` 审计;独立 PC 后台已在设备表接入启动 / 停止按钮,Android APP 设备详情页也已接入启动 / 停止远控原生确认入口。
|
||||
- 当前已补 Codex App Server 受控线程回滚:`POST /api/v1/projects/[projectId]/thread-rollback` 会创建 `intentCategory=thread_rollback` 任务,`local-agent` 调用 `thread/rollback` 回滚目标线程最近 N 轮;该链路不启动新 turn,不把 thread/turn/items 原文写回 APP,只提示“线程历史已回滚”,且不会自动还原本地文件变更。
|
||||
- 当前已补 Codex App Server 受控线程压缩:`POST /api/v1/projects/[projectId]/thread-compact` 会创建 `intentCategory=thread_compact` 任务,`local-agent` 调用 `thread/compact/start` 发起目标线程上下文压缩;该链路不启动普通 turn,不把 contextCompaction item 原文写回 APP,只提示“上下文压缩已发起”。
|
||||
- 当前已补 Codex App Server 受控线程归档 / 恢复:`POST /api/v1/projects/[projectId]/thread-archive` 会创建 `intentCategory=thread_archive|thread_unarchive` 任务,`local-agent` 直接调用 `thread/archive` 或 `thread/unarchive`;该链路不启动普通 turn,不把 thread 原始字段写回 APP,只提示“线程已归档/已恢复”。
|
||||
- 当前已补 Codex App Server 受控线程改名:`POST /api/v1/projects/[projectId]/rename` 在 `mode=thread` 且绑定真实 `codexThreadRef` 时,会在本地 Boss 改名后创建 `intentCategory=thread_rename` 任务,`local-agent` 直接调用 `thread/name/set`;该链路不启动普通 turn,不把 thread 原始字段写回 APP,只提示“已同步 Codex 线程名称”。设备离线、并发冲突或 App Server 不可用不会回滚 Boss 本地改名。
|
||||
- 当前已补 Codex App Server 受控线程 Git 元数据同步:`POST /api/v1/projects/[projectId]/thread-metadata` 会创建 `intentCategory=thread_metadata_sync` 任务,`local-agent` 直接调用 `thread/metadata/update`;当前只允许同步 `gitInfo.sha / branch / originUrl`,不会启动普通 turn,也不允许写入任意 metadata。
|
||||
- 当前已补 Codex App Server 受控线程分叉:`POST /api/v1/projects/[projectId]/thread-fork` 会创建 `intentCategory=thread_fork` 任务,`local-agent` 直接调用 `thread/fork`;当前不允许远程覆盖 model、sandbox、instructions 或 config,也不会把 path、cwd、turns、instructionSources 写回 APP。新线程进入 Boss 会话列表仍依赖 thread discovery / 导入链路。
|
||||
- 当前已补 Codex App Server 版 Boss 用户消息镜像:普通单线程 `conversation_reply` 任务携带 `mirrorBossUserMessageToCodexDesktop=true` 时,`local-agent/codex-app-server-runner.mjs` 会在 `thread/resume` 后、`turn/start` 前调用 `thread/inject_items`,把 Boss APP 用户原文作为 `role=user` 的 Responses item 写入目标 Codex 线程模型可见历史;任务结果只回传 `threadHistorySync.threadId / injectedItemCount / source`,不回传消息 ID、内部 prompt 或用户原文。CLI rollout 镜像仍保留为 App Server 不可用前的 fallback 链路。
|
||||
- 当前 boss-agent 已支持 Mac OTA:`local-agent/boss-agent-ota-runner.mjs` 默认开启,每 5 分钟检查服务端最新包;状态页可手动检查或下载并安装,安装时保留原绑定配置,只更新版本号和本机 runtime 路径。最新验证版本为 `20260516221619`,已在 MacBook Air `macbook-air` 上确认 OTA 下载校验、暂存、覆盖安装后不会误切到默认 `config.cloud.json`。正式分发脚本已预留 Developer ID 公证路径:`BOSS_AGENT_NOTARIZE=1` 配合 notary profile 或 Apple ID 凭据。
|
||||
- 当前量产治理已补设备撤权和任务可靠性底座:`revoke_device` 会清空设备 token、标记离线并阻断 heartbeat / 任务认领 / Skill 同步 / 日志上报 / boss-agent OTA;`MasterAgentTask` claim 会记录 attempt 和 lease,运行中任务可按租约重试,超过上限转 `timed_out`,用户或管理员可通过 cancel 接口转 `canceled` 且迟到 complete 不覆盖终态。
|
||||
- 当前任务 SLA 面板、失败自动恢复和后台告警已沉淀为独立交接文档:`docs/architecture/task_sla_auto_recovery_admin_alerts_cn.md`。该文档记录 `taskSlaPanel`、`adminNotifications`、pre-turn 安全自动恢复边界、本地验证结果和后续云部署检查清单;当前尚未部署云端,等待新的服务器入口后再按文档执行。
|
||||
- 当前群聊 `dispatch_execution` 完成回写已补幂等,重复完成不会再向群聊重复追加结果
|
||||
- 当前已支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`,单条消息转发显示为普通转发消息,多条消息转发显示为聊天记录卡片
|
||||
- 当前已支持聊天附件主链:原生聊天框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;图片 / PDF / 文本默认自动进入主 Agent 附件分析,视频 / Office / 大文件默认手动触发
|
||||
@@ -150,17 +189,22 @@
|
||||
- 移动端 UI 已去掉假的状态栏与桌面预览壳;底部一级导航固定在视口底部,返回逻辑不会再把 APP 根页直接弹回桌面
|
||||
- `项目目标` 支持用户编辑、主 Agent 复核、完成项自动划线
|
||||
- `版本迭代记录` 只读,由主 Agent 汇总
|
||||
- `我的` 根页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于`
|
||||
- `我的` 根页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 附件与存储 / Telegram 接入 / 技能 / 关于`,其中 `用户与权限` 仅最高管理员可见
|
||||
- `我的 > 账号与安全` 已支持查看和撤销登录会话;最高管理员可管理全部活跃会话,子账号只能管理自己的会话
|
||||
- `我的 > 用户与权限` 与 Web `/me/access` 共用 `/api/v1/admin/access`,可创建子账号、分配设备 / 项目 / Skill 权限,并查看同名 Skill 跨设备聚合;PC 总后台已收敛到 `https://admin.boss.hyzq.net/` 根路径,`/admin` 仅保留跳转兼容
|
||||
- 多用户 / RBAC / Skill / 主 Agent 权限和多设备控制的集中状态、回归矩阵与缺口清单见 `docs/architecture/rbac_skill_regression_matrix_cn.md`
|
||||
- `我的 > 主 Agent 提示词 / 记忆` 当前可编辑管理员全局主提示词、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 项目记忆
|
||||
- `我的 > AI 账号` 必须可查看和切换 `主 GPT / 备用 GPT / API 容灾`
|
||||
- 主 Agent 使用 `Master Codex Node` 时必须优先走授权 Codex 设备池:设备在线且 `Codex App Server / CLI / GUI` 至少一条模型通道在线才可用;首选设备不可用或执行失败会自动切下一台,全部 Codex 设备不可用后才使用用户配置的 API Key;如果两类通道都没有,APP 中提示“当前没有可用的模型渠道”
|
||||
- `我的 > 技能` 必须按绑定设备展示 Skill,并支持一键复制调用语句
|
||||
- Skill 远程治理第一版已经接通最高管理员后端入口和设备端执行:`GET/POST /api/v1/admin/skills/requests` 可创建和查看 `install / update / uninstall / rollback / version_lock` 请求,local-agent 通过 `claim / complete` 认领执行并回写最新 Skill 清单。当前设备端已增加 source allowlist / trusted source、`checksum / expectedChecksum` sha256 校验、更新 / 卸载 / 回滚前备份和失败恢复;仍未做签名校验和依赖安装沙箱
|
||||
- `设备` 页当前只允许出现生产设备,旧演示脏数据不能回流到正式视图
|
||||
- 登录后必须形成最小会话,受保护页面和核心 `/api/v1/*` 接口不能再裸奔
|
||||
- 必须保留登录、注册、忘记密码和验证码入口
|
||||
|
||||
## 6. 当前技术路线
|
||||
|
||||
- Web:`Next.js 16.2.1 + React 19`
|
||||
- Web:`Next.js 16.2.4 + React 19`
|
||||
- 数据:当前是文件型持久化 `data/boss-state.json`
|
||||
- 状态写入:串行事务队列 + 原子写入 + `.bak` 备份恢复
|
||||
- device-agent:原生 Node HTTP 服务
|
||||
@@ -168,7 +212,7 @@
|
||||
- 邮件:`Postfix + Dovecot`
|
||||
- Android:`AppCompatActivity + 原生 XML 布局 + HttpURLConnection`
|
||||
- 原生登录恢复:`SharedPreferences + restore token`
|
||||
- 当前最新原生 APK:`2.5.4`(`versionCode=17`)
|
||||
- 当前最新原生 APK:`2.5.11`(`versionCode=24`)
|
||||
|
||||
当前不要误判成已经用了:
|
||||
|
||||
@@ -187,7 +231,7 @@ npm install
|
||||
npm run build
|
||||
npm run lint
|
||||
curl -sS http://127.0.0.1:3000/api/health
|
||||
curl -sS -H 'Content-Type: application/json' -d '{"account":"17600003315","password":"boss123456","method":"password"}' http://127.0.0.1:3000/api/auth/login
|
||||
curl -sS -H 'Content-Type: application/json' -d '{"account":"krisolo","password":"<admin-password>","method":"password"}' http://127.0.0.1:3000/api/auth/login
|
||||
curl -sS http://127.0.0.1:3000/api/auth/session
|
||||
curl -sS http://127.0.0.1:3000/api/v1/conversations
|
||||
curl -sS http://127.0.0.1:3000/api/v1/projects/master-agent
|
||||
@@ -211,20 +255,21 @@ npm run apk:debug
|
||||
|
||||
## 8. 当前已知未完成项
|
||||
|
||||
- 认证仍是 MVP 级别:虽然已有最小会话 Cookie,但还没有刷新令牌、跨端会话治理和 CSRF 防护
|
||||
- 认证仍是 MVP 级别但已收紧:已有最小会话 Cookie、restore token 轮换、浏览器 CSRF 基础防护、子账号 MFA、基础跨端会话治理和后台高危动作审计;临时免验证登录默认关闭,只能通过 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 显式开启
|
||||
- 当前已补“原生 restore token 自动恢复”,但这仍不是完整的多端会话系统
|
||||
- 当前默认最高管理员账号是 `17600003315`,默认密码 `boss123456`,并已绑定本机 Codex 节点
|
||||
- 当前默认最高管理员账号是 `krisolo`,默认密码由线上初始化配置管理,并已绑定本机 Codex 节点
|
||||
- 主 Agent 实时回复当前依赖被绑定设备的 `local-agent` 在线,并能在本机跑通 `codex exec`
|
||||
- API 容灾当前由用户在 APP 的 `我的 > AI 账号` 中自行配置 `OpenAI API` 账号
|
||||
- 服务器默认固定验证码仍是 `000000`
|
||||
- 服务器默认验证码模式仍是 fixed,但验证码登录也必须先申请验证码,不允许只靠固定码直接登录
|
||||
- 服务器邮件栈已部署完成,应用内也已经支持 email 模式,但默认开关还没切到 email
|
||||
- OTA 版本中心、检查更新、执行升级和 APK 包下载已接通,但当前仍是文件型状态驱动的 MVP
|
||||
- APP 实时日志同步、主 Agent 日志镜像、SSE 自动刷新和 Skill 同步页已经接通,但日志检索、告警和远程 Skill 管理仍未做
|
||||
- 设备导入主链当前已经具备后端闭环和 Web/Android 前台接线,后续重点改成继续细化导入筛选规则和主 Agent 理解策略,而不是再从 0 接页面
|
||||
- 数据库尚未替代文件存储
|
||||
- APP 实时日志同步、主 Agent 日志镜像、SSE 自动刷新和 Skill 同步页已经接通;日志检索已有基础分页,风险 SLA 通知账本已接入,外部通知渠道仍未做
|
||||
- 设备导入主链当前已经具备后端闭环和 Web/Android 前台接线;主 Agent 理解同步已经避免未接管状态下主动问线程,后续重点是继续细化导入筛选规则和用户主动同步体验
|
||||
- Codex App Server 受控线程治理已接入 rollback / compact / archive / unarchive / rename / goal sync / git metadata sync / fork;其中项目目标新增会在单线程且已绑定 `codexThreadRef` 时异步创建 `thread_goal_sync` 任务并调用 `thread/goal/set`,Git 元数据同步通过 `thread_metadata_sync -> thread/metadata/update` 执行,线程分叉通过 `thread_fork -> thread/fork` 执行;这些都不是普通对话 turn,也不代表文件变更或发布完成
|
||||
- 数据库尚未替代文件存储;当前已补 `BOSS_STATE_STORE=postgres` 单行 JSONB 适配层、schema 和 `scripts/boss-state-store-maintenance.mjs` schema 校验 / 文件备份 / dry-run 迁移 / PostgreSQL 备份导出 / 备份恢复 / 文件回滚工具,但生产仍默认文件状态。PostgreSQL 路径必须显式设置 `BOSS_STATE_STORE=postgres`,真实连接 / 写入还必须设置 `BOSS_DATABASE_URL`。最高管理员后台已新增 `GET/POST /api/v1/admin/backups` 文件状态快照能力,可手动创建、列出和恢复快照,恢复前会自动生成 pre-restore 快照;文件状态写入层已默认开启自动 `auto:writeState` 历史快照
|
||||
- 域名入口的代理 / 分裂 DNS 结构仍未完全摸清
|
||||
- 当前只支持服务器文件存储和阿里 OSS,尚未接更多对象存储或更丰富的附件详情页
|
||||
- 认证没有真实 session 和令牌吊销
|
||||
- 认证已有真实 session、restore token 轮换、单会话撤销、CSRF 基础防护和 MFA 开关,但还没有企业 SSO / IdP
|
||||
|
||||
## 9. 继续开发时的工作原则
|
||||
|
||||
|
||||
@@ -16,7 +16,18 @@
|
||||
- 当前原生恢复态:`restore token + SharedPreferences`
|
||||
- 当前执行底座:`src/lib/execution/`,已包含 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现
|
||||
|
||||
### 1.2 boss-android-native
|
||||
### 1.2 boss-admin-web
|
||||
|
||||
- 形态:独立 PC 企业后台前端
|
||||
- 工程目录:`apps/boss-admin-web`
|
||||
- 技术栈:`Vue 3 + Vite + Ant Design Vue`
|
||||
- 本地开发脚本:`npm run admin:web:dev`
|
||||
- 构建脚本:`npm run admin:web:build`
|
||||
- 数据入口:`GET /api/v1/admin/backoffice`
|
||||
- 登录态:复用 `boss_session` HttpOnly Cookie
|
||||
- 当前定位:平台侧 To B 总后台,面向公司、账号、设备、项目、Skill、风险与审计治理;生产入口为 `https://admin.boss.hyzq.net/` 根路径,Caddy 内部 rewrite 到 `/admin-web/index.html`,旧 `/admin` UI 已移除,仅作为跳转到根域的兼容入口
|
||||
|
||||
### 1.3 boss-android-native
|
||||
|
||||
- 形态:原生 Android 客户端
|
||||
- 原生入口:`android/app/src/main/java/com/hyzq/boss/MainActivity.java`
|
||||
@@ -39,8 +50,11 @@
|
||||
- `DeviceEnrollmentActivity`
|
||||
- `SkillInventoryActivity`
|
||||
- `SecurityActivity`
|
||||
- `AccessManagementActivity`
|
||||
- `SettingsActivity`
|
||||
- `StorageSettingsActivity`
|
||||
- `AiAccountsActivity`
|
||||
- `TelegramIntegrationActivity`
|
||||
- `OpenAiOnboardingActivity`
|
||||
- `OpsCenterActivity`
|
||||
- `AboutActivity`
|
||||
@@ -53,16 +67,20 @@
|
||||
- 单线程会话支持按微信最新逻辑改线程名
|
||||
- 当前已经支持从单线程会话发起独立群聊,群聊创建后作为新会话保留,原会话不升级
|
||||
- 当前单线程会话已经支持打开 `线程状态` 只读页,查看主 Agent 当前掌握的线程状态文档和最近进展事件
|
||||
- 当前已经支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`
|
||||
- 当前已经支持微信式消息转发:长按消息可直接 `转发 / 多选 / 复制 / 删除`,其中删除会调用服务端账本删除接口并刷新会话预览
|
||||
- 当前多选模式会切换成微信式 `取消 + 已选数量 + 底部转发` 状态
|
||||
- 当前统一使用 `ForwardTargetActivity` 选择目标会话,替换旧的备注转发主链
|
||||
- 当前已支持聊天附件主链:输入框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;图片 / 视频先确认,文件直接发送
|
||||
- 当前附件消息支持下载、原生打开、手动分析和自动分析状态展示
|
||||
- 当前线程聊天消息会按该线程绑定的 Codex 电脑显示来源头像:单线程会话使用项目绑定设备头像,多设备 / 群聊消息会优先根据发送人里的设备名匹配对应电脑头像;主 Agent 总入口自身仍保留主 Agent 对话样式
|
||||
- 当前已支持 `execution_progress` 执行进度卡:普通线程对话、主 Agent 托管线程和群聊目标线程执行时,会在对应聊天窗口显示“进度 / 线程状态 / 实时状态 / 线程配置 / 线程协作 / 工具活动 / 思考摘要 / 账号状态 / 运行状态 / 安全提醒 / 审批状态 / 文件变更 / 分支详情 / 生成结果 / 后台智能体”结构化卡片;运行状态里也会展示 Codex Windows 沙箱准备摘要;线程过程噪音仍走 `thread_process` 折叠
|
||||
- `线程详情 / 运维调试` 仍保留对应原生活动页,但已退出主聊天面
|
||||
- 当前已补上本地发送中气泡、发送按钮状态控制,以及“只有接近底部才自动滚到底”的消息流行为
|
||||
- 当前根页导航:
|
||||
- `MainActivity` 会记住最近一次停留的 `会话 / 设备 / 我的` tab
|
||||
- 根页返回逻辑已改成“先回会话 tab,再按一次返回进入后台”
|
||||
- 当前设备详情页:
|
||||
- `DeviceDetailActivity` 已同步展示 Codex App Server 连接态、模型、扩展、治理、账号、线程、轮次、线程操作、线程协作口径和协议漂移摘要;线程协作固定表达为 Boss Broker 受控协作,协议漂移只显示兼容/告警、失败探针数量、文档跟进数量和 Boss Broker 兜底,不渲染错误原文、线程 ID、用户正文或内部 prompt
|
||||
- 当前会话列表:
|
||||
- 已切到“线程 = 会话窗口”
|
||||
- 主标题显示线程名
|
||||
@@ -74,17 +92,20 @@
|
||||
- 保留版本与 OTA 操作
|
||||
- 当前已补上 OTA 下载进度、失败重试、安装授权提示和返回关于页后的本地状态恢复
|
||||
- 当前 `我的` 根页:
|
||||
- 保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于`
|
||||
- 已按登录角色过滤入口:`member` 只显示 `账号与安全 / 设置 / 技能 / 关于`
|
||||
- `admin / highest_admin` 额外显示 `运维与修复 / AI 账号 / 附件与存储 / Telegram 接入`
|
||||
- `用户与权限` 仅 `highest_admin` 可见,用于创建子账号和分配设备 / 项目 / Skill 权限
|
||||
- `运维与修复` 直接进入 `OpsCenterActivity`
|
||||
- `技能` 入口会继续依赖服务端 Skill 授权过滤,不在客户端自行扩大可见范围
|
||||
- 当前 `OpenAiOnboardingActivity`:
|
||||
- 会先自动打开 `OpenAI Platform` 登录页
|
||||
- 支持继续打开 `API Keys` 页面
|
||||
- 回 APP 后可直接粘贴 key,并设为当前主控
|
||||
- 登录成功后会直接给出 `测试主 Agent 对话` 入口
|
||||
- 当前登录:临时免验证,点击登录直接创建最高管理员会话
|
||||
- 当前登录:默认要求账号密码或验证码校验;临时开发兜底只允许通过显式环境变量开启
|
||||
- 当前会话恢复:`SharedPreferences` 中保存 `boss_session / restore_token / account`
|
||||
|
||||
### 1.3 boss-local-agent
|
||||
### 1.4 boss-local-agent
|
||||
|
||||
- 形态:Node 原生 HTTP 服务
|
||||
- 本地端口:默认 `4317`
|
||||
@@ -94,21 +115,146 @@
|
||||
- 当前新增职责:递归扫描本机 `~/.codex/skills` 并同步到设备 Skill 接口
|
||||
- 当前完成回写:`conversation_reply / dispatch_execution` 会先标准化成统一远程执行结果,再调用 `/api/v1/master-agent/tasks/[taskId]/complete`
|
||||
- 当前 `dispatch_execution` 会按 `orchestrationBackendId` 分流:默认走 `codex exec resume`,显式选择 `omx-team` 且本机配置可用时改走 `OMX Team Runtime` JSON 协议
|
||||
- 当前 Codex 任务完成回写会附带 `executionProgress` 快照:包含 Git diff 简表、GitHub CLI 可用状态和从执行回复中提取的产物文件名,服务端更新同一张 `execution_progress` 卡片,不重复刷屏
|
||||
- 当前 `RemoteRuntimeAdapter` 还负责拦截固定模式的线程内部环境提示;命中后会直接改写成失败,避免把只读/cwd 这类脏文本写进聊天记录
|
||||
- 当前普通单线程 `conversation_reply` 在真正执行 `codex exec resume` 前,会先把 Boss 用户消息镜像进目标 Codex Desktop rollout;定位优先走 `state_5.sqlite`,不可用时回退扫描 `~/.codex/sessions`,并按 `sourceMessageId` 去重
|
||||
- 当前 Codex Desktop 同步新增常驻刷新桥:`scripts/codex-desktop-refresh-bridge-daemon.mjs` 通过 launchd 监听 `127.0.0.1:4318`,暴露 `POST /api/v1/codex-desktop/refresh`、`GET /api/v1/codex-desktop/events`、`GET /api/v1/codex-desktop/events/recent` 和 `GET /api/v1/codex-desktop/capabilities`;`local-agent` 会优先调用 refresh endpoint,失败时回退到 `scripts/codex-desktop-refresh-hint.mjs` 命令式刷新。SSE 事件只包含线程引用、消息 ID、状态、deep link 等安全元数据,不包含用户正文或内部 prompt;`scripts/codex-desktop-event-consumer.mjs` 可作为 Desktop 插件/IPC 接入前的订阅 smoke;`scripts/codex-desktop-integration-probe.mjs` 负责只读探测 Codex.app 能力
|
||||
- 当前新增 Codex App Server runner:`local-agent/codex-app-server-runner.mjs`。boss-agent 默认配置 `codexAppServerEnabled=true`,会接管 `conversation_reply / dispatch_execution`;它默认通过 stdio 启动 `codex app-server`,也支持 `codexAppServerTransport=ws + codexAppServerUrl=ws://127.0.0.1:<port>` 或 `codexAppServerTransport=unix + codexAppServerUrl=unix:///absolute/path.sock` 连接同机长驻 App Server,bearer token 可通过 `codexAppServerAuthTokenFile` 读取并在握手时发送 `Authorization: Bearer <token>`。runner 执行 `initialize -> thread/resume|thread/start -> turn/start|turn/steer`,并把 `item/agentMessage/delta` 或 `item/completed` 归一成 Boss 任务回复;当 App Server 对单个 JSON-RPC 请求返回 `-32001 / retry later` 时,runner 会做最多 3 次指数退避重试。turn 启动前失败可回退 CLI,turn 启动后失败不回退,避免重复执行。boss-agent 本机状态页另新增 `Codex Remote Control` 摘要:读取 `codexRemoteControlEnabled / codexRemoteControlCommand / codexRemoteControlArgs`,默认展示 `codex remote-control start --json` 作为官方 daemon 远控入口;状态页只展示能力,不因刷新自动启动 daemon。2026-05-31 起,runner 会把 `turn/plan/updated`、`turn/diff/updated`、`item/started|completed`、`thread/started` 归一成 `executionProgress.steps / branch / artifacts / agents`,把 `item/*/requestApproval`、`item/autoApprovalReview/*`、`guardianWarning`、`serverRequest/resolved`、`item/fileChange/patchUpdated` 归一成 `executionProgress.approvals / warnings / fileChanges`,把 `thread/status/changed`、`thread/realtime/started|transcript|outputAudio|itemAdded|error|closed` 归一成 `executionProgress.threadStatus / realtime`,把 `model/rerouted`、`thread/tokenUsage/updated`、`mcpServer/startupStatus/updated`、`remoteControl/status/changed` 归一成 `executionProgress.modelRoute / tokenUsage / mcpServers / remoteControl`,并把 `thread/goal/*`、`thread/settings/updated`、`thread/compacted`、`account/updated`、`account/rateLimits/updated`、`model/verification`、`warning`、`configWarning`、`deprecationNotice`、`ThreadItem.collabToolCall`、`ThreadItem.contextCompaction`、`mcpToolCall`、`dynamicToolCall`、`webSearch`、`imageView`、`imageGeneration`、`hook/started|completed`、`windowsSandbox/setupCompleted`、`enteredReviewMode`、`exitedReviewMode`、`commandExecution`、`ThreadItem.plan`、`ThreadItem.reasoning.summary` 归一成线程配置、账号状态、模型校验、安全提醒、线程协作、上下文压缩、工具活动、图片产物、钩子生命周期、Windows 沙箱准备状态、计划步骤和思考摘要;新版 `ThreadItem.collabToolCall.receiverThreadIds / agentsStates` 只归一为目标数量和 agent 状态集合。2026-06-03 起,runner 还会把 `item/agentMessage/delta`、`item/plan/delta`、`item/reasoning/summaryPartAdded|summaryTextDelta|textDelta`、`item/mcpToolCall/progress`、`command/exec/outputDelta`、`item/commandExecution/outputDelta|terminalInteraction` 和 `item/fileChange/outputDelta` 归一成 `executionProgress.streamEvents` 计数。服务端 complete/progress 回写会与本地 Git/GitHub 进度合并,且不保存 SDP、音频 base64、raw realtime item、remote installationId、cwd、turnId、配置路径、collab 源/目标线程 ID、receiverThreadIds、collab prompt、agentsStates 私有消息、tool arguments/result/contentItems、web URL token、命令正文/输出、raw reasoning content、reasoning item id、原始 delta、terminal input、file output、imageGeneration revisedPrompt/result、hook sourcePath/statusMessage/entries、Windows sandbox sourcePath/samplePaths/本地绝对路径或未清洗的 MCP 错误。heartbeat 同时支持按 TTL 拉取 `model/list / skills/list / hooks/list / plugin/list / app/list / modelProvider/capabilities/read`,并把摘要保存在 `capabilities.codexAppServer.metadata`。
|
||||
- App Server heartbeat discovery 现在还会按 TTL 拉取 `experimentalFeature/list / collaborationMode/list / permissionProfile/list / mcpServerStatus/list`,写入 `capabilities.codexAppServer.metadata.experimentalFeatures / collaborationModes / permissionProfiles / mcpServers`。这些字段用于 APP/后台治理页展示 Codex 当前可用的实验特性、多 Agent/协作模式、权限 profile 和 MCP 服务健康;MCP 请求固定使用 `detail=toolsAndAuthOnly`,服务端状态里不保存 resource URI、工具参数、permission profile 文件规则、本地路径或密钥。
|
||||
- App Server heartbeat discovery 现在还会按 TTL 拉取 `account/read / account/rateLimits/read / config/read / configRequirements/read / externalAgentConfig/detect`,写入 `capabilities.codexAppServer.metadata.accountSummary / rateLimitSummary / appConfigSummary / configRequirements / externalAgentMigration`。这些字段用于 APP/后台展示账号、额度、App 配置、企业托管要求和外部 Agent 迁移候选摘要;当前只做观测,不通过 Boss 远程写 `config.toml` 或执行外部 Agent 导入,且不保存邮箱、完整 config、API key、本地路径或迁移描述。
|
||||
- App Server heartbeat discovery 现在还会按 TTL 拉取 `thread/list / thread/loaded/list`,写入 `capabilities.codexAppServer.metadata.threadSummary`。该字段用于 APP/后台展示 Codex 当前可见线程数量、加载态、活跃态和非归档线程轻量目录;目录只保留 `id / name / sourceKind / status / updatedAt / loaded`,不保存 cwd、本地路径、turn 内容、用户正文或内部 prompt。
|
||||
- App Server heartbeat discovery 现在还会按 TTL 对非归档可见线程拉取 `thread/turns/list`,写入 `capabilities.codexAppServer.metadata.threadTurnSummary`。该字段用于 APP/后台展示 Codex 当前线程 turn 运行态;请求固定 `itemsView=summary`,只保留 turn 计数、运行中 / 完成计数、最近状态、更新时间和最终 `agentMessage` 安全摘要,不保存用户正文、reasoning 原文、命令输出、原始 items、内部 prompt 或系统提示词。
|
||||
- App Server heartbeat discovery 现在还会把最终 `agentMessage` 安全摘要合并进 `projectCandidates.recentAssistantMessages`。服务端根据 `codexThreadRef` 将 Codex Desktop 自己产生的新回复反向同步到 Boss APP 对应会话、preview、lastMessageAt 和未读数;已有本地扫描候选的 folder/thread 映射优先,App Server 只补充最新回复摘要。
|
||||
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.threadActionSummary`。该字段用于 APP/后台展示当前协议下可接入的线程治理动作数量和分组,覆盖 archive / unarchive / fork / compact / rollback / rename / metadata / steer / interrupt / shell / unsubscribe;它只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中调用这些写操作。
|
||||
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.threadCollaborationSummary`。该字段用于 APP/后台展示 Boss Broker、协作事件 handler、协作模式数量和“非原生私聊”边界;当前本机 `codex-cli 0.136.0-alpha.2` schema 未声明 `ThreadItem.collabToolCall`,所以线程协作继续走 Boss 服务端 `thread-collaboration` 入口和受控 App Server 注入/执行链路。
|
||||
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.protocolDriftSummary`。该字段用于 APP/后台展示协议漂移状态,包含 `driftLevel`、`failedProbeCount`、`runtimeFailureMethods`、`docFollowupItems` 和 `fallbackStrategy`;其中 `runtimeFailureMethods` 只保留 method 名,不保存错误原文、线程 ID、用户正文或内部 prompt。
|
||||
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.pluginGovernanceSummary`。该字段用于 APP/后台展示当前协议下可接入的插件治理动作数量和分组,覆盖 install / uninstall / read / skill-read / share;它只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中调用插件安装、卸载或共享写操作。
|
||||
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.accountGovernanceSummary / configGovernanceSummary`。这些字段用于 APP/后台展示当前协议下可接入的账号与配置治理动作数量和分组,覆盖 login / logout / token refresh / add credits nudge / config write / MCP reload / Skill config write;它们只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中调用账号或配置写操作。
|
||||
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.fileSystemGovernanceSummary / commandSessionSummary`。这些字段用于 APP/后台展示当前协议下可接入的文件系统与命令会话动作数量和分组,覆盖 file read/write/remove/watch 以及 command stdin / resize / terminate / stream;它们只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中调用文件读写或命令控制操作。
|
||||
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.externalAgentGovernanceSummary / marketplaceGovernanceSummary / experimentalFeatureGovernanceSummary`。这些字段用于 APP/后台展示当前协议下可接入的外部 Agent 迁移、Marketplace 和实验特性治理动作数量和分组,覆盖 external-agent import、marketplace add/remove/upgrade 和 experimental feature enablement;它们只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中调用迁移导入、marketplace 写入或实验特性启用操作。
|
||||
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.reviewGovernanceSummary / windowsSandboxGovernanceSummary / fuzzyFileSearchSummary`。这些字段用于 APP/后台展示当前协议下可接入的审查、Windows 沙箱和文件搜索事件能力,覆盖 review start、Windows sandbox readiness/setup 和 fuzzy file search updated/completed;它们只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中调用审查启动、沙箱设置或文件搜索动作。
|
||||
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.mcpGovernanceSummary / userInteractionGovernanceSummary / guardianGovernanceSummary`。这些字段用于 APP/后台展示当前协议下可接入的 MCP、用户交互和 Guardian 治理能力,覆盖 MCP OAuth/resource/tool/elicitation、tool requestUserInput、Guardian denied action approval 和 permission request approval;它们只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中调用 MCP、用户输入或 Guardian 放行动作。
|
||||
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.runtimeEventSummary / extensionEventSummary / threadLifecycleEventSummary`。这些字段用于 APP/后台展示当前协议下可接入的运行事件、扩展事件和线程生命周期事件能力,覆盖 process output/exited、raw response completed、skills changed、plugin installed、thread started/closed/archived/unarchived/name updated;它们只来自 runner 安全 catalog 和协议快照,不会在 heartbeat 中主动触发进程、插件、Skill 或线程生命周期动作。
|
||||
- App Server heartbeat discovery 现在还会写入 `capabilities.codexAppServer.metadata.streamDeltaEventSummary`。该字段用于 APP/后台展示当前协议下可接入的流式增量事件能力,覆盖 agent delta、plan delta、reasoning delta、MCP progress、command output、terminal interaction 和 file output;它只来自 runner 安全 catalog 和协议快照,不保存原始增量文本、命令输出、推理正文或文件输出。
|
||||
- App Server heartbeat discovery 现在支持 `skills/extraRoots/set`:配置 `codexAppServerSkillExtraRoots` 或环境变量 `BOSS_CODEX_APP_SERVER_SKILL_EXTRA_ROOTS` 后,runner 会先把共享 Skill 根下发给 App Server,再刷新 `skills/list`,并写入 `capabilities.codexAppServer.metadata.skillExtraRootsSummary`。该字段用于 APP/后台展示企业共享 Skill 根是否已下发;只保留数量、basename 和状态,不保存根目录绝对路径、Skill 文件路径或配置原文。
|
||||
- App Server heartbeat discovery 现在支持 `hooks/list`,写入 `capabilities.codexAppServer.metadata.hookSummary`。该字段用于 APP/后台展示本机 Codex hook 治理状态;只保留 workspace 数、hook 数、启用数、受管 / 可信 / 修改 / 未信任计数、warning / error 计数和事件 / handler 类型,不保存 hook key、command、sourcePath、statusMessage、hash、error message 或本地路径。
|
||||
- 当前 Codex App Server runner 已新增第一版 Boss Inter-Thread Broker:任务携带 `intentCategory=thread_collaboration`、`sourceCodexThreadRef` 和 `targetCodexThreadRef` 时,会先 `thread/read` 源线程,再通过 `thread/inject_items` 向目标线程注入受控摘要,最后 `turn/start` 目标线程;服务端入口是 `POST /api/v1/projects/[projectId]/thread-collaboration`,负责权限、源/目标线程校验和任务排队。这不是假设官方线程 P2P,而是 Boss 自己做线程协作编排。
|
||||
- 当前 Codex App Server runner 已新增 Boss 用户消息镜像:普通 `conversation_reply` 任务携带 `mirrorBossUserMessageToCodexDesktop=true`、`sourceMessageBody` 和目标 `codexThreadRef` 时,会先 `thread/resume`,再 `thread/inject_items` 写入 `role=user` 的 Boss APP 用户原文,最后 `turn/start`;该链路用于让 APP 发起的对话进入 Codex Desktop 同一线程历史。执行结果只保存 `threadHistorySync` 安全摘要,不保存 App Server 原始 item、消息 ID、用户原文、系统提示词或内部调度字段。
|
||||
- 当前 Codex App Server runner 已新增受控线程回滚:任务携带 `intentCategory=thread_rollback`、目标 `codexThreadRef` 和 `rollbackNumTurns` 时,会调用 `thread/rollback` 回滚目标线程最近 N 轮,不会启动新 turn,也不会把 App Server 返回的 thread/turn/items 写回 APP。服务端入口是 `POST /api/v1/projects/[projectId]/thread-rollback`,只保存回滚轮数、原因和执行摘要;边界是只回滚 Codex 线程历史,不自动还原本地文件变更。
|
||||
- 当前 Codex App Server runner 已新增受控线程压缩:任务携带 `intentCategory=thread_compact` 和目标 `codexThreadRef` 时,会调用 `thread/compact/start` 发起上下文压缩,不会启动普通 turn,也不会把 contextCompaction item 的原始字段写回 APP。服务端入口是 `POST /api/v1/projects/[projectId]/thread-compact`,只保存压缩原因和执行摘要;边界是只压缩 Codex 线程上下文,不代表代码修改、文件恢复或版本发布完成。
|
||||
- 当前 Codex App Server runner 已新增受控线程归档 / 恢复:任务携带 `intentCategory=thread_archive|thread_unarchive`、目标 `codexThreadRef` 和 `threadLifecycleAction` 时,会直接调用 `thread/archive` 或 `thread/unarchive`,不会先 resume 已归档线程,也不会启动普通 turn。服务端入口是 `POST /api/v1/projects/[projectId]/thread-archive`,只保存生命周期动作、原因和执行摘要;边界是只改变 Codex 线程生命周期状态,不代表代码修改、文件恢复或版本发布完成。
|
||||
- 当前 Codex App Server runner 已新增受控线程改名:任务携带 `intentCategory=thread_rename`、目标 `codexThreadRef` 和 `threadRenameName` 时,会直接调用 `thread/name/set`,不会先 resume 线程,也不会启动普通 turn。服务端入口复用 `POST /api/v1/projects/[projectId]/rename` 的 `mode=thread` 分支;本地 Boss 会话改名先成功,随后异步创建 Codex 改名任务,设备离线或冲突只返回非致命 `codexThreadRenameError`。
|
||||
- 当前 Codex App Server runner 已新增受控线程目标同步:任务携带 `intentCategory=thread_goal_sync`、目标 `codexThreadRef`、`threadGoalObjective` 和 `threadGoalStatus` 时,会直接调用 `thread/goal/set`,不会启动普通 turn。服务端入口复用 `POST /api/v1/projects/[projectId]/goals`;本地 Boss 项目目标先成功,单线程且已绑定 Codex 线程时再异步创建 Codex goal 同步任务,设备离线或冲突只返回非致命 `codexThreadGoalError`。
|
||||
- 当前 Codex App Server runner 已新增受控线程 Git 元数据同步:任务携带 `intentCategory=thread_metadata_sync`、目标 `codexThreadRef` 和 `threadMetadataGitInfo` 时,会直接调用 `thread/metadata/update`,不会启动普通 turn。服务端入口是 `POST /api/v1/projects/[projectId]/thread-metadata`;当前只允许 patch `gitInfo.sha / branch / originUrl`,不开放任意 metadata 写入。
|
||||
- 当前 Codex App Server runner 已新增受控线程分叉:任务携带 `intentCategory=thread_fork`、目标 `codexThreadRef` 和 `threadForkEphemeral` 时,会直接调用 `thread/fork`,不会启动普通 turn。服务端入口是 `POST /api/v1/projects/[projectId]/thread-fork`;当前只使用源 thread id 分叉,不允许远程覆盖 model、sandbox、instructions 或 config,新 Codex 线程进入 Boss 会话列表仍依赖后续 discovery / 导入链路。
|
||||
- 当前 boss-agent Mac OTA 已接入:`local-agent/boss-agent-ota-runner.mjs` 会用设备 token 调 Boss 服务端 `/api/v1/boss-agent/ota` 检查最新 Mac 运行包,`/api/v1/boss-agent/ota/apply` 会下载 `boss-agent-mac-latest.zip`、校验 sha256、暂存安装 wrapper,并拉起本机安装器;安装脚本会保留绑定配置并只更新版本号与本机 runtime 路径。安装器会优先沿用当前 LaunchAgent active config,并保留所有 `config*.json`,避免多电脑场景中误绑定到默认设备配置。当前最新验证包为 `20260516221619`;构建脚本支持 `BOSS_AGENT_NOTARIZE=1` 的 Developer ID 公证路径。
|
||||
- 当前 `local-agent` 还新增了两条统一电脑控制 runtime:
|
||||
- `local-agent/browser-control-task-runner.mjs`
|
||||
- `local-agent/computer-use-task-runner.mjs`
|
||||
- 当前本机 boss-agent 还新增 Codex Remote Control 显式控制入口:
|
||||
- `POST http://127.0.0.1:4317/api/v1/boss-agent/codex-remote-control/start`
|
||||
- `POST http://127.0.0.1:4317/api/v1/boss-agent/codex-remote-control/stop`
|
||||
- 这两个入口只在本机 agent 上执行 `codex remote-control start|stop --json`,返回和日志都会清洗敏感字段;状态页刷新不会自动调用
|
||||
- 云端已新增 `POST /api/v1/devices/[deviceId]/codex-remote-control` 作为受控排队入口,参数为 `action=start|stop`、`confirmed=true` 和可选 `reason`
|
||||
- 云端入口要求登录态、目标设备在线、当前账号具备该设备 `computer.control` 权限;成功会排入 `device_maintenance / codex_remote_control` 任务,由目标设备 local-agent 认领执行,并写入 `task.authorized` 审计;未授权或离线会写入 `task.denied`
|
||||
- 独立 PC 后台已在 `全局设备 / 电脑与 Codex 接入` 表格中接入 `启动远控 / 停止远控` 操作,当前使用浏览器确认框做二次确认;Android APP 设备详情页已复用同一 API 做 `启动远控 / 停止远控` 原生确认入口
|
||||
- 当前 `browser_control / desktop_control` 任务已经可以被 `local-agent/server.mjs` 识别并分流;当本机配置了对应 runtime 命令时,会通过 JSON stdin/stdout 协议委托给外部进程执行,否则返回明确 runtime disabled 错误,不再回退占位成功结果
|
||||
- 当前 `browser_control / desktop_control` 的完成回写已贯通 `targetUrl / targetApp -> RemoteRuntimeAdapter -> /api/v1/master-agent/tasks/[taskId]/complete -> boss-state.json`,服务端写入 `control_summary` 消息时会保留 `controlTarget`,Android 会话页可直接渲染“目标:URL/应用名”
|
||||
- 相关配置项:
|
||||
- `browserControlEnabled / browserControlCommand / browserControlArgs / browserControlWorkdir / browserControlTimeoutMs`
|
||||
- `computerUseEnabled / computerUseCommand / computerUseArgs / computerUseWorkdir / computerUseTimeoutMs`
|
||||
- `codexComputerUseEnabled / codexComputerUseCommand / codexComputerUseArgs / codexComputerUseWorkdir / codexComputerUseTimeoutMs / codexComputerUseFallbackToCua`
|
||||
- `codexAppServerEnabled / codexAppServerCommand / codexAppServerArgs / codexAppServerWorkdir / codexAppServerTimeoutMs / codexAppServerFallbackToCli / codexAppServerTransport / codexAppServerUrl / codexAppServerAuthTokenFile / codexAppServerSkillExtraRoots / codexAppServerDiscoveryEnabled / codexAppServerDiscoveryTtlMs / codexAppServerDiscoveryLimit`
|
||||
- `codexRemoteControlEnabled / codexRemoteControlCommand / codexRemoteControlArgs`
|
||||
- `scripts/codex-app-server-protocol-snapshot.mjs`:生成本机 Codex App Server help、JSON Schema、TypeScript bindings、协议方法清单和 support matrix;当前快照目录为 `docs/protocol-snapshots/codex-app-server/0.136.0-alpha.2/`
|
||||
|
||||
### 1.4 Caddy
|
||||
#### `POST /api/v1/master-agent/tasks/[taskId]/progress`
|
||||
|
||||
- 用途:设备端在执行中实时刷新同一张 `execution_progress` 卡
|
||||
- 权限:设备 token / 设备写鉴权
|
||||
- 请求体:`deviceId`、可选 `status=queued|running`、可选 `requestId`、可选 `executionProgress`
|
||||
- 当前行为:只更新任务进度卡和实时事件,不把任务置为 completed / failed;最终成功或失败仍必须走 `POST /api/v1/master-agent/tasks/[taskId]/complete`
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/thread-collaboration`
|
||||
|
||||
- 用途:从当前线程发起一次受控线程协作,把源线程上下文注入目标 Codex 线程并让目标线程执行
|
||||
- 权限:登录态;源项目和目标项目都需要 `project.view`,源项目需要 `master_agent.ask`
|
||||
- 请求体:`targetProjectId`、`body` 或 `requestText`
|
||||
- 行为:先在源项目追加用户消息,再创建 `conversation_reply` 任务,任务携带 `intentCategory=thread_collaboration`、源/目标 `threadId`、`codexThreadRef` 和目标 `codexFolderRef`
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/thread-rollback`
|
||||
|
||||
- 用途:对当前会话绑定的 Codex 线程发起一次受控历史回滚,适合误触发、错误继续、接管误操作后的线程级撤回
|
||||
- 权限:登录态;目标项目需要 `project.view` 和 `master_agent.ask`
|
||||
- 请求体:`numTurns`,可选 `reason`
|
||||
- 行为:先在目标项目追加一条用户可见原因消息,再创建 `conversation_reply` 任务,任务携带 `intentCategory=thread_rollback`、目标 `threadId`、`codexThreadRef`、`codexFolderRef`、`rollbackNumTurns` 和 `rollbackReason`
|
||||
- 边界:设备端通过 Codex App Server 调用 `thread/rollback`,只回滚线程历史;不会自动还原本地文件变更,也不会把 App Server 返回的 thread/turn/items 明文写回 APP
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/thread-compact`
|
||||
|
||||
- 用途:对当前会话绑定的 Codex 线程发起一次受控上下文压缩,适合长线程接近上下文上限、继续开发前需要清理上下文的场景
|
||||
- 权限:登录态;目标项目需要 `project.view` 和 `master_agent.ask`
|
||||
- 请求体:可选 `reason`
|
||||
- 行为:先在目标项目追加一条用户可见原因消息,再创建 `conversation_reply` 任务,任务携带 `intentCategory=thread_compact`、目标 `threadId`、`codexThreadRef`、`codexFolderRef` 和 `compactReason`
|
||||
- 边界:设备端通过 Codex App Server 调用 `thread/compact/start`,只发起上下文压缩;不会启动普通 turn,不会把 contextCompaction item 的原始字段写回 APP,也不代表代码修改、文件恢复或版本发布完成
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/thread-archive`
|
||||
|
||||
- 用途:对当前会话绑定的 Codex 线程发起受控归档或恢复,适合项目阶段性结束、误归档恢复或清理会话首页前先同步 Codex 线程生命周期
|
||||
- 权限:登录态;目标项目需要 `project.view` 和 `master_agent.ask`
|
||||
- 请求体:`action=archive|unarchive`,可选 `reason`
|
||||
- 行为:先在目标项目追加一条用户可见原因消息,再创建 `conversation_reply` 任务,任务携带 `intentCategory=thread_archive|thread_unarchive`、目标 `threadId`、`codexThreadRef`、`codexFolderRef`、`threadLifecycleAction` 和 `threadLifecycleReason`
|
||||
- 边界:设备端通过 Codex App Server 调用 `thread/archive` 或 `thread/unarchive`;不会启动普通 turn,不会把 App Server 返回的 thread 原始字段写回 APP,也不代表代码修改、文件恢复或版本发布完成
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/thread-metadata`
|
||||
|
||||
- 用途:对当前会话绑定的 Codex 线程发起受控 Git 元数据同步,适合把 Boss 当前已知的分支、提交和远端仓库信息写回 Codex 线程
|
||||
- 权限:登录态;目标项目需要 `project.view` 和 `master_agent.ask`
|
||||
- 请求体:`gitInfo`,可选字段 `sha`、`branch`、`originUrl`;字段值为字符串表示设置,为 `null` 表示清除,字段缺省表示不改;可选 `reason`
|
||||
- 行为:先在目标项目追加一条用户可见原因消息,再创建 `conversation_reply` 任务,任务携带 `intentCategory=thread_metadata_sync`、目标 `threadId`、`codexThreadRef`、`codexFolderRef`、`threadMetadataGitInfo` 和 `threadMetadataReason`
|
||||
- 边界:设备端通过 Codex App Server 调用 `thread/metadata/update`;不会启动普通 turn,不会把 App Server 返回的 thread 原始字段写回 APP,也不允许写入 Git 信息之外的任意 metadata
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/thread-fork`
|
||||
|
||||
- 用途:对当前会话绑定的 Codex 线程发起受控分叉,适合在不破坏原线程历史的情况下复制当前上下文继续试验
|
||||
- 权限:登录态;目标项目需要 `project.view` 和 `master_agent.ask`
|
||||
- 请求体:可选 `reason`,可选 `ephemeral`
|
||||
- 行为:先在目标项目追加一条用户可见原因消息,再创建 `conversation_reply` 任务,任务携带 `intentCategory=thread_fork`、目标 `threadId`、`codexThreadRef`、`codexFolderRef`、`threadForkEphemeral` 和 `threadForkReason`
|
||||
- 边界:设备端通过 Codex App Server 调用 `thread/fork`;不会启动普通 turn,不会把 App Server 返回的 path、cwd、turns、instructions 写回 APP;当前不允许远程覆盖 model、sandbox、instructions 或 config;新线程进入 Boss 会话列表依赖后续 thread discovery / 导入链路
|
||||
|
||||
- 当前仓库已自带 browser smoke runtime、desktop Cua runtime 和旧 desktop smoke 兜底:
|
||||
- `scripts/browser-control-smoke.mjs`
|
||||
- `scripts/codex-computer-use-runtime.mjs`
|
||||
- `scripts/cua-driver-computer-use-runtime.mjs`
|
||||
- `scripts/computer-use-smoke.mjs`
|
||||
- `scripts/browser-control-smoke.mjs` 当前已支持两段式最小真实动作:
|
||||
- 能从目标 URL 拉取 HTML 标题并回写到 `replyBody / executionSummary`
|
||||
- 在显式配置 opener 命令时可实际执行打开 URL
|
||||
- `scripts/codex-computer-use-runtime.mjs` 当前通过 `codex app-server` 发起 Codex Computer Use 桌面控制,是 boss-agent 的默认桌面控制入口;失败时由 `local-agent/computer-use-task-runner.mjs` 自动回退 CUA
|
||||
- `scripts/cua-driver-computer-use-runtime.mjs` 当前通过外部 `cua-driver` 执行 macOS 桌面 GUI 控制:先 `launch_app`,再按返回窗口做 `get_window_state`,需要写入文本时调用 `type_text` 并再次观测;发送、提交、删除、支付等高风险动作默认返回 `needs_user_action`,不静默下发
|
||||
- `scripts/computer-use-smoke.mjs` 当前已支持识别常见桌面应用名,macOS 下默认用 `osascript` 激活目标应用,并支持把用户请求中的引号文本输入到当前前台应用、按需回车发送;它保留为旧兜底和回归资产
|
||||
- `config.example.json / config.cloud.json` 现默认把 browser smoke runtime 和 desktop Cua runtime 作为 browser/desktop 控制的推荐起步配置
|
||||
- `config.example.json / config.cloud.json` 现同时默认把 `browserAutomationConnected / computerUseConnected` 置为 `true`,让前台设备详情默认按“这台 Mac 已具备浏览器控制 / 桌面控制能力”展示
|
||||
- 这两条 smoke runtime 当前还会返回结构化字段:
|
||||
- browser:`targetUrl / artifacts`
|
||||
- desktop:`targetApp / typedText / artifacts`
|
||||
- 这样前台与后续真实 runtime 可以共用同一套结果形态,而不需要等接入 Playwright / Computer Use 后再改返回协议
|
||||
- heartbeat 的 `browserAutomation / computerUse` 能力上报会同时参考静态 connected 标记和 runtime 配置状态;`codexAppServer` 能力上报会参考 feature flag,stdio 模式校验 app-server 命令可执行性,ws/unix 模式校验 `codexAppServerUrl` 是否已配置
|
||||
|
||||
### 1.5 Caddy
|
||||
|
||||
- 作用:反向代理和 HTTPS 自动续签
|
||||
- 服务器服务名:`caddy.service`
|
||||
- 配置文件:`deployment/Caddyfile`
|
||||
- 当前站点:`boss.hyzq.net` 服务客户 Web / App API;`admin.boss.hyzq.net` 根路径内部 rewrite 到 `/admin-web/index.html`,浏览器地址栏保持 `https://admin.boss.hyzq.net/`,作为平台级 To B 独立后台入口;旧 `/admin` 页面不再渲染旧 UI,只做兼容跳转到根路径
|
||||
|
||||
### 1.5 boss-server-debug skill
|
||||
### 1.6 boss-server-debug skill
|
||||
|
||||
- 作用:跨 Codex 窗口稳定连接 `106.53.170.158`
|
||||
- 路径:`$HOME/.codex/skills/boss-server-debug/SKILL.md`
|
||||
- 密码来源:优先读取 macOS Keychain
|
||||
|
||||
### 1.6 Postfix + Dovecot
|
||||
### 1.7 Postfix + Dovecot
|
||||
|
||||
- 作用:服务器侧邮件发送 / 接收基础设施
|
||||
- SMTP 端口:`25 / 465 / 587`
|
||||
@@ -143,6 +289,7 @@
|
||||
- `GET /me/security`
|
||||
- `GET /me/about`
|
||||
- `GET /me/storage`
|
||||
- `GET /me/access`
|
||||
- `GET /me/ai-accounts`
|
||||
- `GET /me/ops`
|
||||
- `GET /me/ops/audit`
|
||||
@@ -163,9 +310,212 @@
|
||||
#### `GET /api/state`
|
||||
|
||||
- 用途:读取当前完整状态
|
||||
- 注意:这是内部 MVP 调试接口,会直接返回整个 `BossState`
|
||||
- 当前行为:最高管理员可读取完整状态;非最高管理员会返回已按当前账号授权裁剪后的状态快照,设备、项目、线程状态、进展事件、Skill、日志和任务都会尽量限制在可见范围内
|
||||
- 注意:这是内部 MVP 调试接口,仍不建议作为普通业务页面的主数据源;业务页面应优先使用具体 `/api/v1/*` 投影接口
|
||||
|
||||
### 3.1.1 执行底座抽象层
|
||||
### 3.1.1 多用户 RBAC 与 Skill 授权
|
||||
|
||||
- 权限模块:`src/lib/boss-permissions.ts`
|
||||
- 状态字段:
|
||||
- `accountDeviceGrants`
|
||||
- `accountProjectGrants`
|
||||
- `accountSkillGrants`
|
||||
- `skillCatalog`
|
||||
- `permissionAuditLogs`
|
||||
- 当前规则:
|
||||
- `highest_admin` 全局可见
|
||||
- 非管理员必须通过设备、项目或 Skill 授权获得可见性
|
||||
- `device.view` 只提供设备与关联项目只读可见性,不自动放大为聊天、接管、电脑控制或 Skill 使用权限
|
||||
- `thread.chat / master_agent.ask / master_agent.takeover / computer.control / skill.use` 需要显式授权
|
||||
- 当前已接入过滤的接口:
|
||||
- `GET/POST /api/v1/admin/access`(仅最高管理员)
|
||||
- `GET /api/v1/devices`
|
||||
- `GET /api/v1/conversations`
|
||||
- `GET /api/v1/conversations/home`
|
||||
- `GET /api/v1/conversation-folders/[folderKey]`
|
||||
- `GET /api/v1/projects/[projectId]`
|
||||
- `GET/POST /api/v1/projects/[projectId]/messages`
|
||||
- `GET /api/v1/devices/[deviceId]/skills`
|
||||
- `GET /api/state`
|
||||
- 当前主 Agent 行为:执行提示词使用授权快照生成,任务队列会记录 `authorizedDeviceIds / authorizedProjectIds / authorizedSkillIds / requiredPermissions`
|
||||
- 当前前台入口:Web `/me/access` 与原生 Android `AccessManagementActivity` 共用 `/api/v1/admin/access`,仅 `highest_admin` 可见;`admin/member` 不显示入口且直接请求会返回 `403`
|
||||
|
||||
#### `GET /api/v1/admin/access`
|
||||
|
||||
- 用途:最高管理员读取账号与授权管理台所需数据
|
||||
- 权限:仅 `highest_admin`
|
||||
- 返回:
|
||||
- 脱敏 `accounts`,不包含 `passwordHash`
|
||||
- `companies`:显式客户公司 / 租户列表
|
||||
- `devices / projects / skills`
|
||||
- 按同名 Skill 聚合的 `skillCatalog`
|
||||
- 内置 `permissionTemplates`
|
||||
- `grants.devices / grants.projects / grants.skills`
|
||||
- `auditLogs`
|
||||
|
||||
#### `POST /api/v1/admin/access`
|
||||
|
||||
- 用途:最高管理员执行最小授权管理动作
|
||||
- 权限:仅 `highest_admin`
|
||||
- 支持动作:
|
||||
- `upsert_company`:创建或更新客户公司 / 租户
|
||||
- `set_company_status`:启用或停用客户公司;停用时同步禁用该租户普通子账号并撤销活跃会话
|
||||
- `assign_account_company`:把账号绑定到指定客户公司
|
||||
- `assign_device_company`:把设备绑定到指定客户公司
|
||||
- `preview_bulk_import_accounts`:预览批量导入结果,返回新增 / 更新 / 异常数量,不写入状态
|
||||
- `bulk_import_accounts`:按公司批量导入 `member/admin` 子账号
|
||||
- `reset_account_password`:最高管理员重置子账号密码,重置后撤销该账号活跃会话且响应不返回 `passwordHash`
|
||||
- `reclaim_account`:离职回收,停用账号、撤销活跃会话并清理设备 / 项目 / Skill 授权
|
||||
- `upsert_account`:创建或更新子账号
|
||||
- `set_account_status`:启用或停用子账号;停用时撤销该账号当前活跃会话,且禁止停用最高管理员账号
|
||||
- `revoke_device`:吊销指定设备,立即清空设备 token、标记离线、写入 `device.revoked` 审计;旧 token 后续不能 heartbeat、领任务、同步 Skill、上传日志或拉取 boss-agent OTA
|
||||
- `grant_device`:授予设备权限
|
||||
- `grant_project`:授予项目权限
|
||||
- `grant_skill`:授予 Skill 权限
|
||||
- `apply_template`:对指定账号和目标设备 / 项目 / Skill 批量套用内置权限模板
|
||||
- `revoke_grant`:撤销任意设备 / 项目 / Skill 授权
|
||||
- 当前行为:所有变更类动作都会写入 `permissionAuditLogs`,用于后续审计和主 Agent 接手时判断权限来源;后台 mutation 会记录 `ipAddress / userAgent / requestId`,高危动作可记录安全化 `beforeJson / afterJson`
|
||||
|
||||
#### `GET /api/v1/admin/overview`
|
||||
|
||||
- 用途:最高管理员读取 To B 管理后台总览数据
|
||||
- 权限:仅 `highest_admin`
|
||||
- 返回:
|
||||
- `summary`:公司、账号、设备、在线设备、开放风险、风险通知、严重风险数量
|
||||
- `companies[]`:优先使用显式客户公司 / 租户,其次按账号域名或默认公司聚合
|
||||
- `accounts[]`:脱敏账号列表,不包含 `passwordHash`
|
||||
- `devices[]`:设备在线状态、CLI/GUI 能力、项目数和风险数
|
||||
- `risks[]`:离线设备、运维故障、线程上下文风险、失败主 Agent 任务和任务 SLA 告警;运维故障和线程上下文风险会带出负责人和 SLA
|
||||
- `notifications[]`:开放中的风险 SLA 通知,当前由 `/api/v1/admin/risks/scan` 生成
|
||||
- `grantsSummary`:设备 / 项目 / Skill 授权数量与过期授权数量
|
||||
|
||||
#### `GET /api/v1/admin/backoffice`
|
||||
|
||||
- 用途:独立 PC 企业后台读取 YuDao/Vben 风格的总后台契约数据
|
||||
- 权限:仅 `highest_admin`
|
||||
- 返回:
|
||||
- `menuTree`:工作台、租户管理、账号管理、角色权限、资源授权、Skill 中心、风险告警、审计日志、系统设置
|
||||
- `workbench`:平台总览、客户健康、设备健康、风险、通知和授权摘要
|
||||
- `tenants[]`:客户公司 / 租户列表,来自 `adminCompanies` 与现有聚合
|
||||
- `users[]`:脱敏账号列表,不包含 `passwordHash / mfaSecret / authSessions`
|
||||
- `roles`:内置角色与 `BOSS_PERMISSION_TEMPLATES`
|
||||
- `resourceGroups`:设备、项目线程、Skill 聚合目录和授权记录
|
||||
- `insights.taskSlaPanel`:MasterAgentTask 的 SLA 面板,包含状态分布、SLA 截止、空闲时间、尝试次数、是否可自动恢复和建议动作
|
||||
- `audit`:风险、通知、风险时间线和 `permissionAuditLogs`
|
||||
- `yudaoMapping`:Boss 账本字段到后台概念的映射,用于后续数据库化或模块拆分
|
||||
- 当前定位:供 `https://admin.boss.hyzq.net/ -> apps/boss-admin-web` 消费;旧 `/admin` UI 已下线,不再消费 `/api/v1/admin/overview` 和旧数据 provider
|
||||
|
||||
#### `GET/POST /api/v1/admin/backups`
|
||||
|
||||
- 用途:最高管理员做文件状态快照、查看可回退点和执行状态回退
|
||||
- 权限:仅 `highest_admin`
|
||||
- `GET` 返回:
|
||||
- `status`:当前文件状态路径、备份目录、最近快照时间、可回退点数量和校验状态
|
||||
- `snapshots[]`:快照 ID、创建时间、创建人、备注、大小、sha256 和 schema 版本
|
||||
- `POST` 输入:
|
||||
- `action=create_snapshot`:创建当前 `boss-state` 快照,可带 `reason`
|
||||
- `action=restore_snapshot`:恢复到指定 `snapshotId`
|
||||
- 当前行为:恢复前会自动创建 `pre-restore:<snapshotId>` 快照,避免误操作后无法回滚;文件状态写入层默认按 `BOSS_STATE_AUTO_BACKUP_INTERVAL_MS` 自动创建 `auto:writeState` 快照,并按 `BOSS_STATE_AUTO_BACKUP_KEEP` 保留;独立 PC 管理后台的“备份与回退”页已接入创建、刷新和恢复动作。
|
||||
|
||||
#### `POST /api/v1/admin/risks/scan`
|
||||
|
||||
- 用途:扫描当前风险 SLA,幂等生成平台侧待跟进通知
|
||||
- 权限:仅 `highest_admin`
|
||||
- 当前行为:
|
||||
- 扫描未关闭的 `opsFaults` 和 `threadContextAlerts`
|
||||
- 同步检查运行态异常:在线设备 `Computer Use` 不可用会补 `BOSS.COMPUTER_USE.UNAVAILABLE` 运维故障,`boss-agent OTA` 失败日志会补 `BOSS_AGENT.OTA.FAILED` 运维故障
|
||||
- 同步扫描 `MasterAgentTask` SLA:基于 lease、最近进度、尝试次数和 recoverable 标记生成任务 SLA 告警
|
||||
- 只对 `queued / claimed / executor_starting / recoverable_failed` 这类 pre-turn 安全阶段的可恢复任务自动重排队,避免已进入目标线程回复阶段的任务被重复执行
|
||||
- 当 `slaDueAt` 已早于当前时间时,写入 `adminNotifications[]`
|
||||
- 任务 SLA 告警同样写入 `adminNotifications[]`,自动恢复会写入 `adminRiskTimeline[]` 和 `permissionAuditLogs[]`
|
||||
- 同一个 `riskId` 只生成一条 `risk_sla_overdue` 通知,重复扫描不会重复膨胀账本
|
||||
- 生成新通知时发布 `project.context_risk.updated`
|
||||
|
||||
#### `POST /api/v1/admin/risks/actions`
|
||||
|
||||
- 用途:最高管理员在管理后台处理风险
|
||||
- 权限:仅 `highest_admin`
|
||||
- 输入:
|
||||
- `riskId`:当前支持 `ops-fault:<faultId>` 和 `thread-alert:<alertId>`
|
||||
- `action`:`assign_owner | set_sla | ack | resolve | create_repair_ticket`
|
||||
- `ownerAccount`:`assign_owner` 必填
|
||||
- `slaDueAt`:`set_sla` 必填
|
||||
- `note`:可选处理备注
|
||||
- 当前行为:
|
||||
- `ops-fault` 支持指派负责人、设置 SLA、确认、关闭、创建或复用修复工单
|
||||
- `thread-alert` 支持指派负责人、设置 SLA、确认和关闭,关闭时写入 `resolvedAt`
|
||||
- 离线设备、失败主 Agent 任务等暂不支持直接动作,会返回 `RISK_ACTION_UNSUPPORTED`
|
||||
- 当前事件:成功动作会发布 `project.context_risk.updated`
|
||||
|
||||
#### `GET /api/v1/audits/permission-logs`
|
||||
|
||||
- 用途:查询 `permissionAuditLogs` 并返回第一版权限审计风险摘要
|
||||
- 权限:仅 `highest_admin`;普通 `admin/member` 直接返回 `403`
|
||||
- 查询参数:
|
||||
- `action`
|
||||
- `actorAccount`
|
||||
- `targetAccount`
|
||||
- `deviceId`
|
||||
- `projectId`
|
||||
- `skillId`
|
||||
- `cursor`
|
||||
- `limit`,默认 `50`,最大 `200`
|
||||
- 返回:
|
||||
- `logs[]`:按 `createdAt` 最新在前排序后的当前页审计日志
|
||||
- `nextCursor`:下一页游标;没有更多数据时为 `null`
|
||||
- `total`:匹配过滤条件的总数
|
||||
- `riskSummary`:基于现有 `permissionAuditLogs` 和仍存在授权记录生成的 deterministic 摘要
|
||||
- 当前风险规则:
|
||||
- `rapid_permission_grants`:同一 actor / target 在 10 分钟内出现 5 条及以上授权类日志
|
||||
- `skill_lifecycle_failed`:Skill lifecycle 完成日志中可识别失败,或后续写入 `skill.lifecycle.failed`
|
||||
- `expired_grant_present`:设备 / 项目 / Skill 授权记录已过期但仍留存在状态中
|
||||
- `admin_route_denied`:已有 `task.denied` 日志能识别非最高管理员访问 admin route 被拒
|
||||
- 当前限制:权限审计风险摘要仍是查询时实时计算;持久化通知账本只覆盖风险 SLA 超时场景。
|
||||
|
||||
#### `GET /api/v1/admin/skills/requests`
|
||||
|
||||
- 用途:最高管理员读取 Skill 远程治理请求队列
|
||||
- 权限:仅 `highest_admin`;普通 `admin/member` 直接返回 `403`
|
||||
- 返回:
|
||||
- `requests[]`:当前保存在 `boss-state.json` 的 Skill lifecycle 请求
|
||||
- 当前行为:按最新请求在前返回;设备端认领后状态会从 `pending` 变成 `running / completed / failed`
|
||||
|
||||
#### `POST /api/v1/admin/skills/requests`
|
||||
|
||||
- 用途:最高管理员创建 Skill 生命周期治理请求
|
||||
- 权限:仅 `highest_admin`
|
||||
- 支持动作:
|
||||
- `install`
|
||||
- `update`
|
||||
- `uninstall`
|
||||
- `rollback`
|
||||
- `version_lock`
|
||||
- 输入要求:
|
||||
- 必须提供 `deviceId`
|
||||
- 必须提供 `skillId` 或 `sourceUrl` 之一
|
||||
- 可选 `targetVersion / rollbackToVersion / lockedVersion / checksum / expectedChecksum / trustedSource / note`
|
||||
- 当前行为:请求以 `pending` 状态写入 `skillLifecycleRequests`,local-agent 会按设备 token 认领执行,并把 `completed / failed` 与结果摘要写回
|
||||
- 当前设备端安全策略:远程 `install` 或带 `sourceUrl` 的更新必须命中本机 `skillLifecycleAllowedSources` 或 `skillLifecycleTrustedSources`;allowlist 为空时只允许既有本地 Skill 的 `update / rollback / uninstall / version_lock`。如果请求带 `checksum / expectedChecksum`,local-agent 会对 `manifest.json` 或 `SKILL.md` 做 sha256 校验;校验失败会失败回写,并清理半安装目录或尽量从 `skillsDir/.boss-skill-backups` 恢复
|
||||
- 当前限制:第一版仅支持 Git 安装 / 更新、本地目录卸载、Git checkout 回滚和 `.boss-skill-locks.json` 版本锁;尚未做签名校验、依赖安装沙箱或 per-run Skill 执行审计
|
||||
|
||||
#### `POST /api/v1/devices/[deviceId]/skill-requests/claim`
|
||||
|
||||
- 用途:设备端领取下一条属于自己的 Skill 生命周期请求
|
||||
- 权限:设备 token 或具备 `device.manage` 的登录会话
|
||||
- 返回:
|
||||
- `request`:下一条请求;无待处理时为 `null`
|
||||
- 当前行为:只领取当前设备 `pending` 请求,领取后改为 `running`
|
||||
|
||||
#### `POST /api/v1/devices/[deviceId]/skill-requests/[requestId]/complete`
|
||||
|
||||
- 用途:设备端回写 Skill 生命周期请求执行结果
|
||||
- 权限:设备 token 或具备 `device.manage` 的登录会话
|
||||
- 输入:
|
||||
- `status`:`completed` 或 `failed`
|
||||
- `resultSummary` / `error`
|
||||
- 当前行为:写回 `completedAt / updatedAt / resultSummary / error`,并追加 `permissionAuditLogs`
|
||||
|
||||
### 3.1.2 执行底座抽象层
|
||||
|
||||
- 目录:`src/lib/execution/`
|
||||
- 当前默认实现:
|
||||
@@ -182,13 +532,14 @@
|
||||
- 当前状态:
|
||||
- 已在生产代码中被 `boss-master-agent.ts`、`local-agent/server.mjs` 和 `master-agent task complete route` 使用
|
||||
- 当前仍服务 Boss 自身执行链
|
||||
- 当前已补 `browser_control / desktop_control` 两个新的 execution tool,并已纳入统一权限与风险分级判断
|
||||
- 当前已最小接入 `ClawBackendAdapter`,但默认关闭,仅在显式配置且可用性探测通过时才参与执行
|
||||
- 如果历史 `backendOverride=claw-runtime` 当前不可用,运行时会自动回退到默认后端,并把原因回给前台
|
||||
- 当前仓库自带 `scripts/claw-runtime-smoke.mjs` 作为兼容 JSON 协议的 smoke runtime,可用于本地和服务器验证 `ClawBackendAdapter`
|
||||
- 当前已最小接入 `OmxTeamBackendAdapter`,但默认关闭;Web 群聊详情页和原生群资料页已经可以在 `Boss Native` 与 `OMX Team` 间切换编排后端,OMX 不可用时会自动回退到默认后端并返回明确原因
|
||||
- 当前仓库自带 `scripts/omx-team-smoke.mjs`,可用于本地和服务器验证 `OmxTeamBackendAdapter` 的 `dispatch_execution` JSON 协议
|
||||
|
||||
### 3.1.2 线程状态文档与进展事件
|
||||
### 3.1.3 线程状态文档与进展事件
|
||||
|
||||
- 状态字段:
|
||||
- `threadStatusDocuments`
|
||||
@@ -198,7 +549,8 @@
|
||||
- 让 Web / Android 前台能直接查看线程的当前目标、阶段、进度、架构、阻塞、建议下一步
|
||||
- 当前同步策略:
|
||||
- `heartbeat / thread reply` 平时优先写轻量进展事件
|
||||
- 首次理解、状态变薄、长时间未刷新或主 Agent 真正接手时,才补排隐藏全量理解任务
|
||||
- 只有单线程接管、全局接管或用户明确要求同步项目目标 / 版本记录时,才补排隐藏全量理解任务
|
||||
- 关闭接管会同步清理仍在 queued/running 的项目理解同步任务,避免取消接管后继续主动打扰线程
|
||||
|
||||
### 3.2 认证相关
|
||||
|
||||
@@ -208,13 +560,13 @@
|
||||
- 输入:
|
||||
- `account`
|
||||
- `purpose`: `login | register | forgot-password`
|
||||
- 当前行为:在邮件验证码正式切换前,固定验证码为 `000000`
|
||||
- 当前行为:在邮件验证码正式切换前,fixed 模式仍返回固定验证码,但所有验证码登录都必须先通过 `send-code` 生成有效记录
|
||||
- 当前说明:Web 侧已经支持 email 模式,email 模式下会通过本机 `sendmail` 调用 `Postfix` 发信;服务器默认仍保持 fixed
|
||||
- 当前保护:60 秒冷却,同一账号 15 分钟窗口内超过 5 次会被限流
|
||||
- 当前前置校验:
|
||||
- `purpose=login | forgot-password` 时要求账号已存在
|
||||
- `purpose=register` 时要求账号尚未注册
|
||||
- 当前 fixed 模式:登录可直接输入 `000000`,不再依赖先申请验证码;注册和重置密码仍走 `send-code` 申请链路
|
||||
- 当前 fixed 模式:登录、注册和重置密码都必须先走 `send-code` 申请链路,再消费账本里的有效验证码
|
||||
|
||||
#### `POST /api/auth/login`
|
||||
|
||||
@@ -224,16 +576,16 @@
|
||||
- `password`
|
||||
- `code`
|
||||
- 当前行为:
|
||||
- 当前已临时切到免验证模式,点击登录会直接创建 `17600003315` 的最高管理员会话
|
||||
- 默认不再允许临时免验证登录,只有显式配置 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 时才开启开发兜底
|
||||
- 原生 Android 端登录后会持久化 `boss_session + restore token`,用于 30 天登录保持和 OTA / 覆盖安装后的会话恢复
|
||||
- 当前阶段不会因为账号、密码或验证码为空而拒绝登录
|
||||
- 正常模式要求 `password` 或 `code` 校验通过
|
||||
- 校验通过后会写入 `boss_session` Cookie
|
||||
- 当请求头带 `x-boss-native-app: 1` 时,还会额外返回 `restoreToken`
|
||||
- 当前 `boss_session` 默认保持 30 天
|
||||
- 连续失败 5 次后会锁定 10 分钟
|
||||
- 当前密码存储:新注册 / 重置密码使用 `scrypt`;历史 `sha256` 会在下次密码登录时自动迁移
|
||||
- 当前默认管理员账号:`17600003315`
|
||||
- 当前默认测试密码:`boss123456`
|
||||
- 当前默认管理员账号:`krisolo`
|
||||
- 当前默认测试密码由线上初始化配置管理,文档不再明文记录
|
||||
|
||||
#### `GET /api/auth/session`
|
||||
|
||||
@@ -248,6 +600,26 @@
|
||||
- 当请求头带 `x-boss-native-app: 1` 时,还会返回:
|
||||
- `restoreToken`
|
||||
|
||||
#### `GET /api/v1/auth/sessions`
|
||||
|
||||
- 用途:查看可管理的登录会话
|
||||
- 当前行为:
|
||||
- `highest_admin` 可查看全部活跃会话
|
||||
- 其他账号只能查看自己的活跃会话
|
||||
- 返回内容只包含 `sessionId / account / role / displayName / loginMethod / createdAt / expiresAt / lastSeenAt / current`
|
||||
- 不返回 `sessionToken / restoreToken`
|
||||
- 前台入口:Web `/me/security` 与原生 Android `SecurityActivity`
|
||||
|
||||
#### `POST /api/v1/auth/sessions`
|
||||
|
||||
- 用途:撤销单个登录会话
|
||||
- 输入:
|
||||
- `action=revoke_session`
|
||||
- `sessionId`
|
||||
- 当前权限:
|
||||
- `highest_admin` 可撤销任意活跃会话
|
||||
- 其他账号只能撤销自己的会话
|
||||
|
||||
#### `POST /api/auth/restore`
|
||||
|
||||
- 用途:原生 APP 使用 `restore token` 恢复 `boss_session`
|
||||
@@ -300,6 +672,10 @@
|
||||
- 更新设备状态
|
||||
- 若 `pairingCode` 合法,则 claim 设备绑定草稿并返回 token
|
||||
- 若携带 `projectCandidates[]`,则会同步生成或刷新对应设备的 `deviceImportDraft`
|
||||
- 当前保护:
|
||||
- 已存在设备必须携带有效 `token` 或未过期 enrollment 的 `pairingCode`
|
||||
- 未准备 enrollment 的新 `deviceId` 不能通过心跳自注册
|
||||
- 已吊销设备返回 `DEVICE_REVOKED`,不会更新 `lastSeenAt / status / projects / projectCandidates`
|
||||
|
||||
#### `POST /api/projects/[projectId]/goals/[goalId]/toggle`
|
||||
|
||||
@@ -385,13 +761,24 @@
|
||||
- 普通单线程项目当前会在写入用户消息后,继续创建 `taskType=conversation_reply` 的主 Agent 任务
|
||||
- 返回体会附带 `task.taskId / taskType / status`,给 Web 和原生 Android 保持等待真实回写使用
|
||||
- `projectId=master-agent` 且 `kind=text` 时,会先返回 `masterReplyState + task`,真实回复随后异步回写到账本
|
||||
- Telegram Gateway 当前也复用这条主 Agent 链路:Telegram 私聊文本会写入 `master-agent` 项目,快速回复直接返回,异步任务通过 `externalReplyTarget` 在完成后回推 Telegram
|
||||
- 当前主链路优先走 `Master Codex Node`:`task queue -> local-agent -> codex exec -> complete`
|
||||
- 如果当前主控是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 当前会优先尝试已配置的 `OpenAI API / 阿里百炼 Qwen` 账号,避免聊天直接只剩失败日志
|
||||
- 如本机节点未接通,可切到 `OpenAI API` 或 `阿里百炼 Qwen` 备用账号
|
||||
- 如果当前主控是 `Master Codex Node`,主 Agent 会先使用授权范围内的 Codex 设备池:设备在线且 `Codex App Server / CLI / GUI` 至少一条模型通道在线才可作为模型通道;首选设备不可用会切下一台,执行中失败会把同一任务重排到下一台,全部 Codex 设备不可用后才切到已配置的 API 备用链
|
||||
- 如 Codex 设备池和 API Key 都不可用,主 Agent 会在对话里提示“当前没有可用的模型渠道”,不再暴露内部 master 节点账号话术
|
||||
- 群聊项目当前会带上 `collaborationGate`,用于标明当前是否需要先经主 Agent / 用户审批
|
||||
- 群聊文本消息当前还会返回 `dispatchPlan / dispatchRecommendation`,用于展示主 Agent 推荐的线程下发方案
|
||||
- 如果群里已经有一条待确认推荐,接口会直接返回 `409`,要求先确认或拒绝当前推荐,避免审批消息叠加
|
||||
|
||||
#### `DELETE /api/v1/projects/[projectId]/messages`
|
||||
|
||||
- 用途:删除当前项目消息账本里的一条聊天消息
|
||||
- 输入:
|
||||
- `messageId`:优先从 query string 读取,也兼容 JSON body
|
||||
- 当前行为:
|
||||
- 删除成功后会刷新项目预览、更新时间和未读计数
|
||||
- 会发布 `project.messages.updated / conversation.updated`
|
||||
- Android 长按消息的“删除”菜单已接入该接口
|
||||
|
||||
#### `GET /api/v1/projects/[projectId]/agent-controls`
|
||||
|
||||
- 用途:读取当前对话级别的 `modelOverride / reasoningEffortOverride / backendOverride`
|
||||
@@ -465,8 +852,9 @@
|
||||
- `mode`: `thread | group`
|
||||
- `name`
|
||||
- 当前行为:
|
||||
- `mode=thread` 时同步更新线程显示名和会话标题
|
||||
- `mode=thread` 时同步更新 Boss 线程显示名和会话标题;如果该会话已绑定 `codexThreadRef`,会追加创建 `intentCategory=thread_rename` 的 `conversation_reply` 任务,由本机 App Server runner 调用 `thread/name/set` 同步 Codex 线程名称
|
||||
- `mode=group` 时更新群聊名称
|
||||
- 边界:Codex 线程改名任务不会启动普通 turn,不会把线程原始历史写回 APP;设备离线、并发冲突或 App Server 不可用时,本地 Boss 改名仍保留,响应会带非致命 `codexThreadRenameError`
|
||||
|
||||
#### `POST /api/v1/projects/[projectId]/group-chat`
|
||||
|
||||
@@ -660,6 +1048,7 @@
|
||||
#### `GET /api/v1/storage/config`
|
||||
|
||||
- 用途:读取当前登录用户的附件存储配置
|
||||
- 当前入口:Web `我的 > 附件与存储` 与 Android `StorageSettingsActivity`
|
||||
- 返回:
|
||||
- `mode`: `server_file | oss`
|
||||
- `ossProvider`
|
||||
@@ -691,6 +1080,14 @@
|
||||
- 用途:新增项目目标
|
||||
- 输入:
|
||||
- `text`
|
||||
- 当前行为:
|
||||
- 本地 Boss 项目目标先落盘
|
||||
- 如果目标项目是已绑定 `codexThreadRef` 的单线程会话,会追加创建 `intentCategory=thread_goal_sync` 的 `conversation_reply` 任务,由本机 App Server runner 调用 `thread/goal/set` 同步 Codex 线程 goal
|
||||
- 设备离线、并发冲突或 App Server 不可用时,本地目标仍保留,响应会带非致命 `codexThreadGoalError`
|
||||
- 返回:
|
||||
- `goal`
|
||||
- `codexThreadGoalTask`(可选)
|
||||
- `codexThreadGoalError`(可选)
|
||||
|
||||
#### `GET /api/v1/threads/[threadId]/context-budget`
|
||||
|
||||
@@ -848,6 +1245,20 @@
|
||||
- 当前归档:发布脚本还会额外保留 `public/downloads/boss-android-v{versionName}-{flavor}.apk`
|
||||
- 当前保护:要求有效 `boss_session`
|
||||
|
||||
#### `GET /api/v1/boss-agent/ota`
|
||||
|
||||
- 用途:被控电脑上的 boss-agent 用设备 token 检查 Mac agent 运行包 OTA
|
||||
- 输入:`deviceId`、`currentVersion`
|
||||
- 返回:`hasUpdate` 与最新 `boss-agent-mac-latest.zip` 的版本、大小、sha256、下载地址
|
||||
- 当前保护:要求 `x-boss-device-token`
|
||||
|
||||
#### `GET /api/v1/boss-agent/ota/package`
|
||||
|
||||
- 用途:下载当前已发布的最新 boss-agent macOS 运行包
|
||||
- 当前来源:`public/downloads/boss-agent-mac-latest.zip`
|
||||
- 当前元数据:`public/downloads/boss-agent-mac-latest.json`
|
||||
- 当前保护:要求 `x-boss-device-token` 与 `deviceId`
|
||||
|
||||
#### `GET /api/v1/ops/summary`
|
||||
|
||||
- 用途:读取运维 `fault / repair ticket / verification` 聚合数据
|
||||
@@ -883,6 +1294,7 @@
|
||||
|
||||
- 用途:由 local-agent 认领分配给本机的主 Agent 任务
|
||||
- 当前保护:要求 `x-boss-device-token` 或匹配登录会话
|
||||
- 当前可靠性:claim 会写入 `attemptCount / maxAttempts / claimedAt / lastClaimedAt / leaseExpiresAt`;运行中任务租约过期后可重试认领,超过最大次数会转为 `timed_out` 并更新进度卡;被吊销设备不能继续认领
|
||||
|
||||
#### `POST /api/v1/master-agent/tasks/[taskId]/complete`
|
||||
|
||||
@@ -902,7 +1314,64 @@
|
||||
- `taskType=conversation_reply` 时,会把目标 Codex 线程的原始回复写回普通单线程会话
|
||||
- `taskType=dispatch_execution` 时,会把线程原始结果镜像回群聊,再追加一条主 Agent 汇总,并更新对应执行单状态
|
||||
- `failed` 时写入 relay 失败消息,并更新 AI 账号健康状态
|
||||
- 如果任务带有 `externalReplyTarget.provider=telegram`,完成后会尝试调用 Telegram Bot API 把 `replyBody` 回推到原始聊天
|
||||
- 终态任务 `completed / failed / timed_out / canceled` 的迟到重复 complete 会直接返回当前任务,不再覆盖终态或重复写消息
|
||||
- 对群聊分发推荐失败的情况,消息入口当前会额外写入一条 `system_notice`,把“没有真实线程”或“成员引用失效”明确回显给用户
|
||||
|
||||
#### `POST /api/v1/master-agent/tasks/[taskId]/cancel`
|
||||
|
||||
- 用途:取消仍在 `queued / running / needs_user_action` 的主 Agent 任务
|
||||
- 权限:任务请求账号、`highest_admin`,或具备目标设备 `device.manage` 的账号
|
||||
- 当前行为:任务转为 `canceled`,写入 `canceledAt / canceledBy / cancelReason`,清除租约;如果之后设备端迟到回写成功,服务端不会覆盖取消终态
|
||||
|
||||
#### `GET /api/v1/master-agent/tasks/[taskId]/control-state`
|
||||
|
||||
- 用途:设备端在执行中轮询任务控制状态,用于把 APP / Web 的取消动作同步到正在运行的本地 runtime
|
||||
- 权限:目标设备 token,或具备目标设备写权限的会话
|
||||
- 返回:`taskId / status / canceled / cancelReason / canceledAt`
|
||||
- 安全边界:不返回 `requestText / executionPrompt / sourceMessageBody`,避免把用户消息、内部提示词或调度字段泄露给设备外的调用方
|
||||
- 当前 App Server 行为:`local-agent` 在 `turn/start` 后轮询该接口;如果返回 `canceled=true`,会调用当前 App Server 连接的 `turn/interrupt`,把 Codex 活跃 turn 真实中断
|
||||
|
||||
#### `GET /api/v1/integrations/telegram`
|
||||
|
||||
- 用途:读取 Telegram Bot 接入配置
|
||||
- 当前保护:仅 `highest_admin` 可读
|
||||
- 返回:脱敏后的 `enabled / mode / botTokenConfigured / webhookSecretConfigured / allowFrom / groups / defaultProjectId / groupProjectRoutes`
|
||||
|
||||
#### `POST /api/v1/integrations/telegram`
|
||||
|
||||
- 用途:保存 Telegram Bot 接入配置,并可选执行 `getMe` 探测
|
||||
- 当前保护:仅 `highest_admin` 可写
|
||||
- 输入:
|
||||
- `enabled`
|
||||
- `mode`: `webhook | polling`
|
||||
- `botToken`
|
||||
- `dmPolicy`: `allowlist | open | disabled`
|
||||
- `allowFrom`: Telegram user id 字符串数组
|
||||
- `groupPolicy`: `allowlist | open | disabled`
|
||||
- `groups`: Telegram chat id 字符串数组
|
||||
- `requireMentionInGroups`
|
||||
- `defaultProjectId`
|
||||
- `groupProjectRoutes`: 群 / Topic 到 Boss 项目的路由表,单项格式为 `{ chatId, threadId?, projectId, label? }`
|
||||
- `webhookSecret`
|
||||
- `webhookUrl`
|
||||
- `testConnection`
|
||||
- 当前行为:
|
||||
- `mode=webhook` 且提供 `webhookUrl` 时,会自动调用 Telegram `setWebhook`
|
||||
- `mode=polling` 或关闭接入时,会自动调用 Telegram `deleteWebhook`
|
||||
- `testConnection=true` 时会额外调用 `getMe`,并把返回的 bot username 回写到配置视图
|
||||
|
||||
#### `POST /api/v1/integrations/telegram/webhook`
|
||||
|
||||
- 用途:Telegram Bot webhook 入口
|
||||
- 当前保护:优先校验 `x-telegram-bot-api-secret-token`,再执行 DM / group allowlist
|
||||
- 当前行为:
|
||||
- 私聊文本默认桥接到 `master-agent`
|
||||
- 群聊文本需要命中 `groups` 白名单;开启 `requireMentionInGroups` 时,必须 `@Bot` 或直接回复当前 Bot 上一条消息;进入主 Agent 前会自动清洗 bot mention
|
||||
- 如果配置了 `groupProjectRoutes`,会优先按 `chatId + threadId` 精确匹配,再按 `chatId` 匹配,把消息写入指定 Boss 项目;未命中时回到 `defaultProjectId`
|
||||
- 本地 fast path 回复会立即调用 Telegram `sendMessage`
|
||||
- 需要排队的主 Agent 任务会保存 `externalReplyTarget`,任务完成后从 `/api/v1/master-agent/tasks/[taskId]/complete` 自动回推 Telegram
|
||||
- 已处理的 `update_id` 会保留最近 256 条用于幂等去重
|
||||
- 当前保护:要求 `x-boss-device-token` 或匹配登录会话
|
||||
|
||||
#### `GET /api/v1/master-agent/prompt-policy`
|
||||
@@ -1080,6 +1549,7 @@
|
||||
- local-agent 会周期性请求 `POST /api/v1/master-agent/tasks/claim`
|
||||
- 认领到任务后会执行本机 `codex exec`
|
||||
- `conversation_reply` 当前会优先走 `codex exec resume <targetCodexThreadRef>`,把任务恢复到真实 Codex 线程;只有缺失真实线程引用时才退回 `--ephemeral`
|
||||
- 对已绑定 `targetCodexThreadRef` 的普通单线程 `conversation_reply`,local-agent 现在会在 `codex exec resume` 前先把 Boss 用户消息镜像写入目标 Codex Desktop 线程 rollout;镜像按 `sourceMessageId` 去重,不会因任务重试重复写入。rollout 定位优先走 `state_5.sqlite`,不可用时回退扫描 `~/.codex/sessions`;状态库可写且能命中 thread 时会同步刷新线程活跃时间
|
||||
- `dispatch_execution` 当前默认也走 `codex exec resume`,但当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议
|
||||
- `codex exec resume` 前当前还会做目标线程绑定预检;若目标线程缺失、已归档、cwd 不匹配或为只读会话,会直接失败并返回标准化错误,不继续把任务派进错误线程
|
||||
- 如果历史 `worker / explorer` 子线程需要转回可开发线程,除了数据库权限本身,还必须显式补发新的解锁指令覆盖其旧的“只读勘察 / 不改文件”上下文;否则前台看起来像可写,实际执行仍可能被旧上下文限制
|
||||
@@ -1094,15 +1564,41 @@
|
||||
|
||||
- `data/boss-state.json`
|
||||
|
||||
状态存储默认仍走文件模式。PostgreSQL 仅作为显式量产切换路径存在:必须设置 `BOSS_STATE_STORE=postgres`,涉及真实连接 / 写入的维护命令还必须设置 `BOSS_DATABASE_URL`。`scripts/boss-state-store-maintenance.mjs` 当前支持:
|
||||
|
||||
- `validate-schema`:校验 `scripts/postgres-state-schema.sql` 是否包含 `boss_state_snapshots`、`snapshot_key` 主键、`state JSONB` 和更新时间索引
|
||||
- `backup-file / export-file`:在文件模式下导出当前状态文件备份
|
||||
- `migrate-file-to-postgres --dry-run`:只校验文件状态和 schema,不连接数据库;正式迁移会 upsert 到 `boss_state_snapshots`
|
||||
- `export-postgres-backup`:从 PostgreSQL 导出带元数据和 sha256 的 JSON 备份包
|
||||
- `restore-postgres-backup`:把备份包或原始状态 JSON 恢复回 PostgreSQL,可先用 `--dry-run` 验证
|
||||
- `rollback-postgres-to-file`:把 PostgreSQL 当前快照回写到文件,用于数据库切换失败后的文件模式回退
|
||||
|
||||
状态文件当前带有迁移前置元数据:
|
||||
|
||||
- `schemaVersion`:当前 BossState schema 版本
|
||||
- `migratedAt`:最近一次从旧 schema 迁移到当前 schema 的时间
|
||||
|
||||
读取状态时会先经过 `migrateBossState`,用于从无版本或旧版本 JSON 补齐当前结构,并规范化授权和 Skill 生命周期相关数组。这个机制只为后续正式 DB 迁移提供稳定 schema 边界,不表示数据库化已经完成。
|
||||
|
||||
关键对象:
|
||||
|
||||
- `schemaVersion`
|
||||
- `migratedAt`
|
||||
- `user`
|
||||
- `devices`
|
||||
- `projects`
|
||||
- `verificationCodes`
|
||||
- `verificationDispatches`
|
||||
- `adminCompanies`
|
||||
- `adminNotifications`
|
||||
- `adminRiskTimeline`
|
||||
- `authAccounts`
|
||||
- `authSessions`
|
||||
- `accountDeviceGrants`
|
||||
- `accountProjectGrants`
|
||||
- `accountSkillGrants`
|
||||
- `skillCatalog`
|
||||
- `skillLifecycleRequests`
|
||||
- `aiAccounts`
|
||||
- `aiAccountSwitchHistory`
|
||||
- `userAttachmentStorageConfigs`
|
||||
@@ -1130,8 +1626,8 @@
|
||||
|
||||
不要误以为已经存在:
|
||||
|
||||
- 正式数据库
|
||||
- 正式鉴权中间件
|
||||
- 已直接切换完成的正式数据库
|
||||
- 企业 SSO / IdP
|
||||
- 多家对象存储适配(当前只有服务器文件存储和阿里 OSS)
|
||||
- 完整的附件详情页与富预览器
|
||||
- 完整的多端用户会话系统与刷新令牌体系
|
||||
- 完整的多端会话风控平台(当前已有 restore token 轮换、CSRF 基础防护和 MFA 开关)
|
||||
|
||||
195
docs/architecture/codex_server_progress_card_cn.md
Normal file
195
docs/architecture/codex_server_progress_card_cn.md
Normal file
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
# Boss 当前运行与部署状态
|
||||
|
||||
更新时间:`2026-04-03`
|
||||
更新时间:`2026-06-04`
|
||||
|
||||
## 1. 本地状态
|
||||
|
||||
@@ -20,15 +20,47 @@
|
||||
- 登录接口:`POST http://127.0.0.1:3000/api/auth/login`
|
||||
- 登录态接口:`GET http://127.0.0.1:3000/api/auth/session`
|
||||
- 登录恢复接口:`POST http://127.0.0.1:3000/api/auth/restore`
|
||||
- 登录会话治理接口:`GET/POST http://127.0.0.1:3000/api/v1/auth/sessions`
|
||||
- 登出接口:`POST http://127.0.0.1:3000/api/auth/logout`
|
||||
- 管理后台总览接口:`GET http://127.0.0.1:3000/api/v1/admin/overview`
|
||||
- 独立企业后台 BFF:`GET http://127.0.0.1:3000/api/v1/admin/backoffice`
|
||||
- 管理后台授权接口:`GET/POST http://127.0.0.1:3000/api/v1/admin/access`
|
||||
- 管理后台风险 SLA 扫描接口:`POST http://127.0.0.1:3000/api/v1/admin/risks/scan`
|
||||
- 管理后台状态备份与回退接口:`GET/POST http://127.0.0.1:3000/api/v1/admin/backups`,仅 `highest_admin` 可用;支持创建状态快照、列出快照和恢复到指定快照,恢复前会自动创建 pre-restore 快照。文件状态写入层已默认开启自动快照,可用 `BOSS_STATE_AUTO_BACKUP_INTERVAL_MS` 和 `BOSS_STATE_AUTO_BACKUP_KEEP` 调整频率与保留数量
|
||||
- OTA 包下载接口:`GET http://127.0.0.1:3000/api/v1/user/ota/package`
|
||||
- boss-agent Mac OTA 接口:`GET http://127.0.0.1:3000/api/v1/boss-agent/ota?deviceId=...¤tVersion=...` 与 `GET http://127.0.0.1:3000/api/v1/boss-agent/ota/package`
|
||||
- 本地 agent 健康检查:`http://127.0.0.1:4317/health`。当前这台开发机的 `launchd` 常驻已经恢复,`/health` 可在数十毫秒内返回,并且在手动 heartbeat 执行期间也不会再被 Codex 线程扫描卡死
|
||||
- 2026-06-07 已补量产可靠性降载:`local-agent` 的 reliable outbox 会优先保留 `task.complete`,按任务合并重复 `task.progress`,并对同类 `app.log` 做去重和上限保护;`/health` 默认只返回轻量摘要,完整 runtime 只允许通过 `/health?verbose=1` 做诊断;Android SSE 已新增 `message-patch-v1` 能力声明,服务端只对支持该能力的客户端下发 `projectMessagesPatch`,旧客户端继续使用完整 `projectMessagesPayload`
|
||||
- 2026-06-07 已补任务 SLA 企业治理:新增 `src/lib/master-agent-task-sla.ts` 统一计算 MasterAgentTask 的 `ok / watch / breached / recoverable / terminal` 状态、SLA 截止时间、空闲时间、尝试次数和建议动作;`GET /api/v1/admin/backoffice` 会返回 `insights.taskSlaPanel`,独立 Web 管理后台的平台风险页和企业风险页都会展示任务 SLA 面板;`POST /api/v1/admin/risks/scan` 会对 SLA 超时、可恢复失败和终态失败幂等写入 `adminNotifications`,并把可安全重试的 pre-turn recoverable 任务自动重排队,写入 `adminRiskTimeline` 和 `permissionAuditLogs`
|
||||
- 本地 Skill 扫描接口:`http://127.0.0.1:4317/api/v1/skills`
|
||||
- 本地 agent 手动 heartbeat:`POST http://127.0.0.1:4317/api/v1/heartbeat`
|
||||
- `launchd` 已安装:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist`
|
||||
- 当前执行底座抽象层已落地在 `src/lib/execution/`,并已补齐 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现
|
||||
- 当前生产主链仍然沿用 `local-agent -> codex exec resume -> /api/v1/master-agent/tasks/[taskId]/complete`,执行底座重构以“先抽象、不改行为”为准
|
||||
- 当前 Codex server 调研结论已记录在 `docs/architecture/codex_server_progress_card_cn.md`:长期优先方向更新为 `Codex App Server / Remote Control -> Inter-Thread Broker -> CodexMcpBackendAdapter -> codex exec resume` 的分层 provider 策略;当前 boss-agent 默认打开 `Codex App Server` runner 作为 Codex 绑定入口,Boss 仍保留 `codex exec resume` 兜底,并继续用 `execution_progress` 结构化进度卡作为 APP 可见执行态。本机 `codex-cli 0.136.0-alpha.2` 协议快照已生成到 `docs/protocol-snapshots/codex-app-server/0.136.0-alpha.2/`,确认 151 个 method,并支持 WebSocket auth、`thread/inject_items`、`turn/steer`、`turn/interrupt`、`thread/archive`、`thread/unarchive`、`thread/fork`、`thread/compact/start`、`thread/rollback`、`thread/name/set`、`thread/metadata/update`、`thread/shellCommand`、`thread/unsubscribe`、`thread/realtime/*`、`thread/started|closed|archived|unarchived|name/updated`、`process/outputDelta|exited`、`rawResponseItem/completed`、`item/agentMessage/delta`、`item/plan/delta`、`item/reasoning/*Delta`、`item/mcpToolCall/progress`、`command/exec/outputDelta`、`item/commandExecution/terminalInteraction`、`item/fileChange/outputDelta`、`thread/goal/*`、`thread/settings/updated`、`thread/compacted`、`ThreadItem.contextCompaction`、`account/*`、`model/verification`、`configWarning`、`deprecationNotice`、`command/exec`、`command/exec/write`、`command/exec/resize`、`command/exec/terminate`、`model/list`、`skills/changed`、`skills/extraRoots/set`、`hooks/list`、`plugin/installed`、`plugin/install`、`plugin/uninstall`、`plugin/read`、`plugin/skill/read`、`plugin/share/*`、`config/value/write`、`config/batchWrite`、`config/mcpServer/reload`、`skills/config/write`、`fs/*`、`externalAgentConfig/import`、`marketplace/add|remove|upgrade`、`experimentalFeature/enablement/set`、`review/start`、`windowsSandbox/readiness|setupStart`、`fuzzyFileSearch/session*`、`mcpServer/oauth*`、`mcpServer/resource/read`、`mcpServer/tool/call`、`mcpServer/elicitation/request`、`item/tool/requestUserInput` 和 `thread/approveGuardianDeniedAction`
|
||||
- 当前本机 `codex remote-control` 已确认为官方 App Server daemon 远控管理入口;boss-agent 本机状态页会展示 `Codex Remote Control` 托管摘要和启动命令,默认只观测不启动。本批已新增云端受控入口 `POST /api/v1/devices/[deviceId]/codex-remote-control`,要求 `action=start|stop`、`confirmed=true`、设备在线和当前账号具备该设备 `computer.control` 权限;成功会排入 `device_maintenance / codex_remote_control` 任务,由目标设备 local-agent 本机执行 `codex remote-control start|stop --json` 并回写任务小结,同时写入 `task.authorized` 审计;未授权或设备离线写入 `task.denied`。独立 PC 后台的 `全局设备 / 电脑与 Codex 接入` 表格已接入 `启动远控 / 停止远控` 操作,Android APP 设备详情页已同步接入原生二次确认入口。
|
||||
- 2026-06-04 重新生成 0.136.0-alpha.2 协议快照后,manifest 识别 151 个 method,并新增 `itemTypes` 支持矩阵。当前本机 schema 已确认 `app/list`、`app/list/updated`、`configRequirements/read`、`mcpServerStatus/list` 和 `ThreadItem.contextCompaction`;官方 App Server 文档列出的 `collaborationMode/list`、`thread/turns/list`、`ThreadItem.collabToolCall` 在本机生成 schema 中仍未声明,所以 Boss 只把它们作为运行时兼容/官方文档跟进项,不把“线程间对话”写成无监管 P2P。
|
||||
- 当前 App Server 能力发现已新增治理摘要:local-agent 会在 heartbeat discovery 中拉取 `experimentalFeature/list / collaborationMode/list / permissionProfile/list / mcpServerStatus/list`,并把实验特性、协作模式、权限 Profile 与 MCP 服务状态写入设备 `codexAppServer.metadata`;Web 与原生 Android 设备详情页都会显示“治理”摘要。该链路只保留安全摘要,不保存 MCP resource URI、permission profile 文件规则、本地路径、token 或工具参数。
|
||||
- 当前 App Server 能力发现已新增账号与配置摘要:local-agent 会在 heartbeat discovery 中拉取 `account/read / account/rateLimits/read / config/read / configRequirements/read / externalAgentConfig/detect`,并把账号登录方式、套餐、额度使用率、App 配置计数、托管要求数量和外部 Agent 迁移候选数量写入设备 `codexAppServer.metadata`;Web 设备详情页会显示“账号 / 配置”摘要,原生 Android 设备详情页会显示“账号”摘要。该链路只读不写,不保存账号邮箱、完整 config、API key、本地路径或迁移描述。
|
||||
- 当前 App Server 能力发现已新增线程可见性摘要:local-agent 会在 heartbeat discovery 中拉取 `thread/list / thread/loaded/list`,并把线程总数、已加载线程数、活跃线程数、归档线程数、最新更新时间和非归档线程轻量目录写入设备 `codexAppServer.metadata.threadSummary`;Web 与原生 Android 设备详情页都会显示“线程”摘要。该链路不保存 cwd、本地路径、turn 内容、用户正文或内部 prompt。
|
||||
- 当前 App Server 能力发现已新增 turn 运行态摘要:local-agent 会在 heartbeat discovery 中对非归档可见线程拉取 `thread/turns/list`,请求固定 `itemsView=summary`,并把总轮次、运行中轮次、完成轮次、最新 turn 更新时间、每个线程的最近 turn 状态和最终 `agentMessage` 安全摘要写入设备 `codexAppServer.metadata.threadTurnSummary`;Web 与原生 Android 设备详情页都会显示“轮次”摘要。该链路不保存用户正文、reasoning 原文、命令输出、原始 items、内部 prompt 或系统提示词。
|
||||
- 当前 App Server discovery 还会把最终 `agentMessage` 合并进 heartbeat `projectCandidates.recentAssistantMessages`。服务端已有 `codexThreadRef` 匹配时会把 Codex Desktop 自己产生的新回复反向同步到 Boss APP 对应会话,并刷新 preview、lastMessageAt 和未读数;已有本地扫描候选的 folder/thread 映射优先保留,App Server 只补充最新回复摘要。
|
||||
- 当前 App Server 能力发现已新增线程操作能力摘要:local-agent 会把已验证进入当前协议快照的 archive / unarchive / fork / compact / rollback / rename / metadata / steer / interrupt / shell / unsubscribe 写入设备 `codexAppServer.metadata.threadActionSummary`;Web 与原生 Android 设备详情页都会显示“线程操作”。该字段只读,不在 heartbeat 中调用任何线程写 API。
|
||||
- 当前 App Server 能力发现已新增线程协作口径摘要:local-agent 会写入 `codexAppServer.metadata.threadCollaborationSummary`;Web 与原生 Android 设备详情页都会显示 Boss Broker 可用性、协作事件 handler 可用性、协作模式数量和“非原生私聊”状态。该字段用于提醒产品和运维:当前可做的是 Boss 受控线程协作,不是 Codex 原生线程互聊。
|
||||
- 当前 App Server 能力发现已新增协议漂移摘要:local-agent 会写入 `codexAppServer.metadata.protocolDriftSummary`;Web 与原生 Android 设备详情页都会显示“协议漂移:兼容/告警 · 失败探针 N 个 · 文档跟进 N 项 · Boss Broker 兜底”。该字段把运行时 discovery 失败 method、官方文档跟进项和当前兜底策略拆开展示,避免 Codex Server 更新后只靠原始日志判断协议是否漂移。
|
||||
- 当前 App Server 能力发现已新增插件治理能力摘要:local-agent 会把已验证进入当前协议快照的 install / uninstall / read / skill-read / share 写入设备 `codexAppServer.metadata.pluginGovernanceSummary`;设备详情页会显示“插件治理”。该字段只读,不在 heartbeat 中调用任何插件写 API。
|
||||
- 当前 App Server 能力发现已新增账号与配置治理能力摘要:local-agent 会把已验证进入当前协议快照的 login / logout / token refresh / add credits nudge / config write / MCP reload / Skill config write 写入设备 `codexAppServer.metadata.accountGovernanceSummary / configGovernanceSummary`;设备详情页会显示“账号治理 / 配置治理”。这些字段只读,不在 heartbeat 中调用任何账号或配置写 API。
|
||||
- 当前 App Server 能力发现已新增文件系统与命令会话治理能力摘要:local-agent 会把已验证进入当前协议快照的文件读写、目录、元数据、复制、删除、监听、命令 stdin、PTY resize、terminate 和输出流能力写入设备 `codexAppServer.metadata.fileSystemGovernanceSummary / commandSessionSummary`;设备详情页会显示“文件治理 / 命令会话”。这些字段只读,不在 heartbeat 中调用任何文件或命令控制 API。
|
||||
- 当前 App Server 能力发现已新增外部 Agent 迁移、Marketplace 和实验特性治理能力摘要:local-agent 会把已验证进入当前协议快照的 external-agent 导入、marketplace 添加 / 移除 / 升级、实验特性 enablement 写入设备 `codexAppServer.metadata.externalAgentGovernanceSummary / marketplaceGovernanceSummary / experimentalFeatureGovernanceSummary`;设备详情页会显示“迁移治理 / 市场治理 / 实验特性治理”。这些字段只读,不在 heartbeat 中调用任何迁移导入、marketplace 写入或实验特性启用 API。
|
||||
- 当前 App Server 能力发现已新增审查、Windows 沙箱和文件搜索事件能力摘要:local-agent 会把已验证进入当前协议快照的 review start、Windows sandbox readiness / setup start / setup completed、fuzzy file search updated / completed 写入设备 `codexAppServer.metadata.reviewGovernanceSummary / windowsSandboxGovernanceSummary / fuzzyFileSearchSummary`;设备详情页会显示“审查治理 / Windows 沙箱 / 文件搜索事件”。这些字段只读,不在 heartbeat 中调用任何审查启动、沙箱设置或文件搜索动作。
|
||||
- 当前 App Server 能力发现已新增 MCP、用户交互和 Guardian 治理能力摘要:local-agent 会把已验证进入当前协议快照的 MCP OAuth / resource / tool / elicitation、tool requestUserInput、Guardian denied action approval 和 permission request approval 写入设备 `codexAppServer.metadata.mcpGovernanceSummary / userInteractionGovernanceSummary / guardianGovernanceSummary`;设备详情页会显示“MCP 治理 / 用户交互 / Guardian 治理”。这些字段只读,不在 heartbeat 中调用任何 MCP、用户输入或 Guardian 放行动作。
|
||||
- 当前 App Server 能力发现已新增运行事件、扩展事件和线程生命周期事件能力摘要:local-agent 会把已验证进入当前协议快照的 process output / exited、raw response completed、skills changed、plugin installed、thread started / closed / archived / unarchived / name updated 写入设备 `codexAppServer.metadata.runtimeEventSummary / extensionEventSummary / threadLifecycleEventSummary`;设备详情页会显示“运行事件 / 扩展事件 / 线程生命周期”。这些字段只读,不在 heartbeat 中主动触发进程、插件、Skill 或线程生命周期动作。
|
||||
- 当前 App Server 能力发现已新增流式增量事件能力摘要:local-agent 会把已验证进入当前协议快照的 agent delta、plan delta、reasoning delta、MCP tool progress、command output、terminal interaction 和 file output 写入设备 `codexAppServer.metadata.streamDeltaEventSummary`;设备详情页会显示“流式增量”。同批已把执行中的 delta 事件接入 `executionProgress.streamEvents`,APP 进度卡只展示各类片段计数,不保存原始增量文本、命令输出、终端输入、推理正文或文件输出。
|
||||
- 当前 App Server 能力发现已支持共享 Skill 根目录下发:配置 `codexAppServerSkillExtraRoots` / `BOSS_CODEX_APP_SERVER_SKILL_EXTRA_ROOTS` 时,local-agent 会先调用 `skills/extraRoots/set`,再刷新 `skills/list`,并把 `skillExtraRootsSummary` 写入设备 `codexAppServer.metadata`;设备详情页会显示“共享 Skill 根”。该链路只保存数量、basename 和状态,不保存根目录绝对路径、Skill 文件路径或配置原文。
|
||||
- 当前 App Server 能力发现已新增 Hook 治理摘要:local-agent 会在 heartbeat discovery 中拉取 `hooks/list`,并把 hook 数、启用数、受管 / 可信 / 修改 / 未信任计数、warning / error 计数写入设备 `codexAppServer.metadata.hookSummary`;设备详情页会显示“Hook”。该链路不保存 hook key、command、sourcePath、statusMessage、hash、error message 或本地路径。
|
||||
- 当前量产 B+ 架构开发文档已新增:`docs/architecture/enterprise_ai_ops_architecture_cn.md`。该文档把 PPT 中的主 Agent / 业务 Agent / 老板端 / 经理端 / 员工端 / 治理层 / 系统层 / 设备层 / 执行层 / 接入层整理成后续产品架构约束,并明确数据库备份、业务回退、Codex 协议扩展和 Skill 治理方向;它是规划文档,不代表当前全部已落地
|
||||
- 当前 `claw-code` 已以最小 `ClawBackendAdapter` 形式接入执行底座,但默认关闭;只有显式配置 `BOSS_CLAW_*` 且可用性探测通过时,`master-agent` 当前对话中才会出现并允许选择 `claw-runtime`
|
||||
- 当前已新增最小 `Telegram Gateway`:Boss 当前可直接暴露 Telegram webhook,把 Telegram 私聊或受控群聊文本桥接进 `master-agent` 或按群 / Topic 路由到指定 Boss 项目,并在主 Agent 异步任务完成后自动回推 Telegram;配置入口已接到 Web `/me/telegram` 和原生 Android `我的 > Telegram 接入`
|
||||
- 如果历史上已经保存过 `backendOverride=claw-runtime`,但当前 `Claw Runtime` 不可用,运行时会自动回退到默认后端,并在 Web/Android 前台给出明确原因
|
||||
- 当前仓库已自带 `scripts/claw-runtime-smoke.mjs` 作为本地 smoke runtime;在没有真实 `claw-code` 可执行文件时,可先用 `BOSS_CLAW_COMMAND=node` 与 `BOSS_CLAW_ARGS=scripts/claw-runtime-smoke.mjs` 验证整条链
|
||||
- 当前 `oh-my-codex` 已以最小 `OmxTeamBackendAdapter` 形式接入执行底座,但默认关闭;当前已经接到 Web 群聊详情页 / 原生群资料页的编排后端选择卡,可在 `Boss Native` 与 `OMX Team` 间切换,OMX 不可用时会自动回退到默认后端并明确提示原因
|
||||
@@ -40,6 +72,15 @@
|
||||
- 当前 `conversation_reply / dispatch_execution` 的线程执行结果会先经过 `RemoteRuntimeAdapter` 标准化;如果线程返回的是固定模式的内部环境提示(如“当前会话环境只读 / cwd …”),会直接转成失败,不再把原文写回会话消息
|
||||
- 当前设备模型已支持同一台 Mac / Windows 同时接入 Codex `GUI + CLI` 双能力;Web / Android 设备详情页都会展示两种能力状态,并允许切换默认执行模式
|
||||
- 当前同项目 `GUI / CLI` 并行写入风险已接入项目/文件夹级冲突控制:默认阻断,用户只能对当前异常项目/文件夹选择 `禁止 / 允许本次 / 永久放行`
|
||||
- 当前已补上“Boss 统一电脑控制中枢”第二批本地 runtime:主 Agent 已能把聊天请求识别为 `discussion_only / project_development / browser_control / desktop_control`;`browser_control / desktop_control` 已能作为正式 `MasterAgentTask` 入队,并返回 `executionMode / riskLevel / requiresConfirmation` 元数据给前台;本机 `local-agent` 现已把 `browser-control-task-runner.mjs / computer-use-task-runner.mjs` 升级成外部 runtime 桥,并默认带上 `scripts/browser-control-smoke.mjs / scripts/cua-driver-computer-use-runtime.mjs` 作为 browser / desktop 起步执行器
|
||||
- 当前这条电脑控制链先只按 macOS 交付:`browser_control / desktop_control` 任务会写入 `controlPlatform=macos` 和 `computerUseProvider`,其中浏览器控制默认 `openai-computer-use`,桌面 GUI 控制默认 `codex-computer-use`;`local-agent` 会先调用 Codex Computer Use,失败后自动回退 `cua-driver-computer-use`。`local-agent` 下发 runtime stdin 时也会携带同一组字段,桌面 dialog guard 只保留 macOS adapter,Windows 分支不进入当前生产链路
|
||||
- 当前这两条控制链的 `control_summary` 已能回写结构化目标信息:browser 会保留 `targetUrl`,desktop 会保留 `targetApp`,Android 聊天窗口会在控制结果卡片里直接显示执行目标
|
||||
- 当前 `scripts/browser-control-smoke.mjs` 已提升到“最小真实浏览器探测”:如果目标 URL 可访问,会抓取页面 `<title>` 并回写结果;`scripts/codex-computer-use-runtime.mjs` 会通过 Codex App Server 发起 Codex Computer Use 执行;`scripts/cua-driver-computer-use-runtime.mjs` 作为 fallback 接入 `cua-driver` 的 macOS 窗口级控制能力,默认执行 `launch_app -> get_window_state`,并支持安全范围内的引号文本写入;涉及发送、提交、删除、支付等动作时默认返回确认卡,不直接执行高风险提交
|
||||
- 当前 boss-agent 已补 Mac OTA:`scripts/package-boss-agent-mac-runtime.sh` 会生成 `dist/boss-agent-mac-runtime-{version}.zip`,并同步发布 `public/downloads/boss-agent-mac-latest.zip/json`;本机 `local-agent` 默认每 5 分钟检查一次,可在 boss-agent 状态页手动“检查更新 / 下载并安装”。安装采用“下载校验 -> 写入暂存 wrapper -> 拉起 install.command”的安全路径,失败不会覆盖当前运行版本。正式分发脚本已支持 `BOSS_AGENT_NOTARIZE=1 + BOSS_AGENT_NOTARY_PROFILE` 的 Developer ID 公证路径,本地开发默认仍可 ad-hoc / Apple Development 签名。
|
||||
- 当前最新 boss-agent Mac 包版本为 `20260516221619`,已部署到 `https://boss.hyzq.net/api/v1/boss-agent/ota` 并在局域网 MacBook Air `macbook-air` 上完成真实 OTA 下载、sha256 校验、暂存、覆盖安装和 up-to-date 检查:安装后 `config.installed.json` 仍保持 `deviceId=macbook-air`、账号 `krisolo`、版本 `20260516221619`,`launchd` 状态为 running。
|
||||
- 当前安装器已做多电脑绑定保护:`install.command` 会保留所有 `config*.json` 并优先沿用当前 launchd active config;底层 `scripts/install-local-launchagent.sh` 在无显式参数时也会优先读取现有 LaunchAgent 的配置路径,再回退自定义设备配置,避免多台 Mac 重装/OTA 时误切到默认 `config.cloud.json`。
|
||||
- 当前 Cua runtime 已补上 launchd 友好的可执行文件发现:除 `PATH` 外会主动查找 `~/.local/bin/cua-driver` 和 `/Applications/CuaDriver.app/Contents/MacOS/cua-driver`;如果 `launch_app` 对已运行 App 返回 not found,会兜底走 `list_apps -> list_windows -> get_window_state` 复用现有窗口
|
||||
- 当前本机 `local-agent` 默认 heartbeat 已把 `browserAutomation / computerUse` 两项能力视为“已接通起步版 runtime”,因此 Boss 前台设备能力会直接显示这两条链路在线;`codexAppServer` 能力只有在显式打开 App Server runner 后才会上报在线,stdio 模式会校验本机 `codex` 命令可执行,ws/unix 模式会校验已配置 `codexAppServerUrl`;如果后续需要临时关闭,可在 `local-agent/config.cloud.json` 里单独下掉对应 connected 标记或 runtime 命令
|
||||
|
||||
本地已知运行方式:
|
||||
|
||||
@@ -90,19 +131,32 @@ cd /Users/kris/code/boss
|
||||
- `npm start`、服务器 `systemd` 与远端 `npm run build` 当前都显式设置了 `BOSS_RUNTIME_ROOT`,避免 `process.cwd()` 在 standalone / 服务器构建阶段误扫描整个仓库
|
||||
- `next.config.ts` 当前已把 `deployment / docs / design / local-agent / prompts / scripts / android` 等目录排除出 standalone tracing,服务器端构建不会再把非运行时资产卷进 `.next/standalone`
|
||||
- `data/boss-state.json` 的写入已经改成串行事务队列、原子替换和 `.bak` 备份恢复,`heartbeat` 与 APP 日志并发写入已复核通过
|
||||
- `data/boss-state.json` 当前额外具备自动历史快照:每次写入后按 `BOSS_STATE_AUTO_BACKUP_INTERVAL_MS` 节流写入 `data/backups/state-snapshot-*.json`,元数据标记 `actorAccount=system / reason=auto:writeState`,管理后台可直接作为回退点查看和恢复
|
||||
- `BossState` 当前新增 `schemaVersion / migratedAt` 元数据和 `migrateBossState` 迁移入口;读取旧的无版本状态时会补齐当前 schema,并规范化 `accountDeviceGrants / accountProjectGrants / accountSkillGrants / skillLifecycleRequests / permissionAuditLogs`
|
||||
- 这只是正式数据库迁移前置层,当前生产读写仍然是 `data/boss-state.json`,尚未完成 PostgreSQL / Redis / 其他 DB 落地
|
||||
- 当前登录成功后会写入 `boss_session` Cookie;`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 路由都要求有效会话
|
||||
- 当前 `boss_session` 默认保持 30 天,`Set-Cookie` 已验证为 `Max-Age=2592000`
|
||||
- 原生 Android 客户端当前会把登录返回的 `boss_session / restore token / account` 落到 `SharedPreferences`,并在 APP 启动时通过 `/api/auth/restore` 自动补回会话;已本地验证“登录 -> 取 restore token -> restore 接口恢复”链路
|
||||
- 当前多用户 / RBAC 第一阶段已落地:状态文件新增 `accountDeviceGrants / accountProjectGrants / accountSkillGrants / skillCatalog / skillLifecycleRequests / permissionAuditLogs`,非最高管理员访问 `devices / conversations / projects / messages / device skills / state` 时都会先走 `src/lib/boss-permissions.ts` 和 session-aware projections 过滤
|
||||
- 当前最高管理员授权管理接口已落地:`GET/POST /api/v1/admin/access` 可以查看脱敏账号、公司、设备、项目、Skill、授权、权限模板和审计日志,并支持公司管理、公司启用/停用、账号/设备归属、设备吊销、批量导入预览、批量导入子账号、重置子账号密码、离职回收、创建/更新子账号、启用/停用子账号、授予设备/项目/Skill 权限、套用权限模板、撤销授权;停用公司会禁用该租户普通子账号并撤销会话,停用 / 回收 / 重置账号也会撤销该账号当前活跃会话,吊销设备会清空设备 token、置离线并阻断 heartbeat / 任务认领 / Skill 同步 / 日志上报 / boss-agent OTA,普通账号访问返回 `403`
|
||||
- 当前旧 Web `/admin` 管理 UI 已下线:`src/components/admin/boss-admin-app.tsx` 和旧 data provider 已移除,`/admin` 现在只做兼容跳转到根路径 `/`。
|
||||
- 当前企业级后台独立化第一批已部署到云:`apps/boss-admin-web` 作为 Vue + Vite + Ant Design Vue 独立 PC 后台,静态产物位于 `/admin-web/index.html`;`admin.boss.hyzq.net` 根路径由 Caddy 内部 rewrite 到该静态入口,不再跳转到 `/enterprise-admin`。
|
||||
- 当前后台风险处理接口已落地:`POST /api/v1/admin/risks/actions` 仅 `highest_admin` 可用,支持对 `ops_fault` 指派负责人、设置 SLA、确认、关闭、创建或复用修复工单,对 `thread_context_alert` 指派负责人、设置 SLA、确认和关闭;`POST /api/v1/admin/risks/scan` 会扫描超时 SLA 并幂等写入 `adminNotifications`,会把 Computer Use 不可用、boss-agent OTA 失败等运行态异常补成可治理 `opsFaults`,也会扫描 MasterAgentTask SLA 并对安全阶段可恢复失败自动重排队;管理后台总览会展示开放风险通知和任务 SLA 面板;不支持的风险类型会明确返回 `RISK_ACTION_UNSUPPORTED`。
|
||||
- 当前权限审计查询第一版已落地:`GET /api/v1/audits/permission-logs` 仅 `highest_admin` 可读,支持按 `action / actorAccount / targetAccount / deviceId / projectId / skillId / cursor / limit` 查询 `permissionAuditLogs`,并实时返回短时间大量授权、Skill lifecycle 失败、过期授权仍存在、admin route 拒绝访问等 deterministic 风险摘要;后台 mutation 审计已支持 `ipAddress / userAgent / requestId / beforeJson / afterJson`,其中重置密码会记录安全化前后快照;Web `/me/ops/audit` 会向最高管理员展示最近权限审计和风险摘要
|
||||
- 当前 Skill 远程治理第一版可执行链路已落地:`GET/POST /api/v1/admin/skills/requests` 仅允许 `highest_admin` 创建和查看 `install / update / uninstall / rollback / version_lock` 请求;设备端通过 `/api/v1/devices/[deviceId]/skill-requests/claim` 和 `/complete` 认领回写,local-agent 默认每 5 秒执行本机 Skill 安装 / 更新 / 卸载 / 回滚 / 版本锁,并同步最新 Skill 清单。远程安装或带 `sourceUrl` 的更新必须命中本机 `skillLifecycleAllowedSources` 或 `skillLifecycleTrustedSources`;配置为空时不允许远程新来源安装,但保留既有本地 Skill 的更新 / 回滚 / 卸载 / 版本锁。携带 `checksum / expectedChecksum` 的请求会校验 `manifest.json` 或 `SKILL.md` 的 sha256,更新 / 卸载 / 回滚前会写入 `skillsDir/.boss-skill-backups` 并在失败时尽量恢复
|
||||
- 当前授权管理前台已接入:Web `/me/access` 与原生 Android `我的 > 用户与权限` 仅最高管理员可见,可创建子账号、授权设备/项目/Skill、套用 `只读观察员 / 项目开发者 / 设备操作者` 模板、查看同名 Skill 跨设备聚合并撤销单条授权
|
||||
- 当前权限继承规则:显式 `device.view` 可带来绑定该设备项目的只读可见性,但不会自动获得 `thread.chat / master_agent.ask / master_agent.takeover / computer.control / skill.use`;这些动作必须来自项目或 Skill 显式授权
|
||||
- 当前主 Agent 执行链已经使用授权快照:`boss-master-agent.ts` 会先按请求账号裁出可见设备、项目、线程状态、进展事件和 Skill,再生成执行提示词;排入 `MasterAgentTask` 时会记录本次授权范围,供后续审计和执行器收敛
|
||||
- 登录成功后的客户端跳转当前已做稳态兜底:会先确认 `/api/auth/session` 已可读,再 `replace` 到 `/conversations`,并补一次 `window.location.replace` 防止真机 WebView 偶发卡在登录提示页
|
||||
- `POST /api/auth/send-code` 当前已增加 60 秒冷却和 15 分钟窗口限流
|
||||
- `POST /api/auth/send-code` 当前还会先按用途校验账号状态:登录 / 忘记密码必须是已存在账号,注册必须是未注册账号
|
||||
- 当前账号连续登录失败 5 次后会锁定 10 分钟
|
||||
- 当前登录页已临时切到免验证模式;点击“登录”会直接创建最高管理员会话,不再校验账号密码或验证码
|
||||
- 当前登录页默认要求账号密码或验证码校验;临时开发兜底只有显式设置 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 时才会开启
|
||||
- 新注册和重置密码当前已切到 `scrypt` 哈希;历史 `sha256` 密码会在下一次密码登录时自动迁移
|
||||
- `launchd` 会保持 `com.hyzq.boss.local-agent` 常驻,所以本地 agent 被手动结束后会自动重启
|
||||
- `launchd` 默认加载 `local-agent/config.cloud.json`,控制面指向 `https://boss.hyzq.net`
|
||||
- `local-agent/config.example.json` 仍保留给本地 `127.0.0.1:3000` 回环开发
|
||||
- 本地 `launchd` 当前已把 `mac-studio` 作为 `17600003315` 的绑定 Codex 节点上报
|
||||
- 本地 `launchd` 当前已把 `mac-studio` 作为 `krisolo` 的绑定 Codex 节点上报
|
||||
- 本地 agent 当前会递归扫描 `~/.codex/skills`,并把本机 Skill 同步到云端设备维度
|
||||
- 根布局当前会挂载 APP 日志桥,路由切换、运行时错误、消息发送和 OTA 操作会通过 `/api/v1/app-logs` 实时同步到服务器;日志绑定已改成按当前登录会话解析设备
|
||||
- 根布局当前还会挂载原生运行时桥:维护 APP 内导航历史、拦截 Android 返回键、防止根页直接退回桌面,并在 OTA / 同签名覆盖安装后自动尝试恢复登录态
|
||||
@@ -123,6 +177,9 @@ cd /Users/kris/code/boss
|
||||
- 当前原生聊天页已把待审批推荐前移到主消息流:`ProjectDetailActivity` 会直接显示 `确认下发 / 拒绝` 操作,且刷新后仍能恢复最近一条待确认推荐
|
||||
- 当前 `approval_required` 群聊在已经存在一条 `pending_user_confirmation` 推荐时,会拒绝继续创建新的推荐并返回 `409`,前台会提示用户先确认或拒绝当前推荐
|
||||
- 当前普通单线程聊天也已补上真实执行链:`POST /api/v1/projects/[projectId]/messages` 不再只写用户消息,而是会追加 `conversation_reply` 任务;绑定设备上的 `local-agent` 认领后会继续恢复到真实 Codex 线程,再把线程原始回复回写到该聊天窗口
|
||||
- 当前 Boss APP 到 Codex 桌面版的记录同步以数据层镜像为主:普通单线程消息和托管模式消息都会把 APP 用户原文作为干净 `user_message` 写入目标 Codex 线程 rollout,并同步刷新 Codex thread 的 `updated_at / updated_at_ms`;托管链路不会把主 Agent 内部调度 prompt、系统提示词或权限字段镜像成桌面可见聊天记录
|
||||
- 当前 `local-agent` 已补 `Codex Desktop Refresh Bridge`:rollout 镜像完成后会优先 POST 到本机常驻 `http://127.0.0.1:4318/api/v1/codex-desktop/refresh`,由 `scripts/codex-desktop-refresh-bridge-daemon.mjs` 给 Codex 桌面版发安全刷新提示;daemon 不可用时回退到 `scripts/codex-desktop-refresh-hint.mjs` 命令式刷新。默认 `deeplink-reload` 模式会打开 `codex://threads/{threadId}` 目标线程深链,并在短延迟后发送一次应用刷新快捷键;它仍不模拟聊天输入、不点击、不发送。刷新桥默认会对短暂失败重试 2 次、每次间隔 120ms,并把 deep link 与尝试次数作为结果返回;失败只记 `local_agent.codex_desktop_refresh_failed`,不会回滚已经写入的线程消息。当前 bridge 还暴露 `GET /api/v1/codex-desktop/events` SSE 和 `GET /api/v1/codex-desktop/events/recent`,每次刷新 hint 都会广播不含消息正文、不含内部 prompt 的 `codex_desktop_refresh` 事件;`scripts/codex-desktop-event-consumer.mjs` 是后续 Codex Desktop 插件/IPC 的订阅样例,可用 `BOSS_CODEX_DESKTOP_EVENTS_ONCE=true` 做一次性 smoke
|
||||
- 当前 bridge 还暴露 `GET /api/v1/codex-desktop/capabilities`,内部复用 `scripts/codex-desktop-integration-probe.mjs` 探测当前 Codex Desktop:读取 `Info.plist`、确认 `codex` URL scheme、扫描 `app.asar` 中是否存在 `codex://threads/`,并明确返回 `packagePatch.supported=false`,避免后续误走修改签名 app 包体的路线
|
||||
- 当前 Web 群聊详情页也已补上待确认推荐的刷新恢复:服务端会在页面渲染时读取最近一条 `pending_user_confirmation` 的 dispatch plan,聊天输入区会继续显示“等待你确认主 Agent 推荐”,不再因刷新丢失确认入口
|
||||
- 当前 `AI 账号` 页面已分成三条显式接入链:`登录 OpenAI 平台账号(API Key)`、`接入阿里百炼备用账号` 和 `绑定 Master Codex Node`;OpenAI API 登录成功后会立即切成当前主控,阿里百炼账号会作为备用链路保存
|
||||
- 当前 `登录 OpenAI 平台账号` 已升级成浏览器辅助登录流:原生 Android 会先进入 `OpenAiOnboardingActivity`,自动打开 `OpenAI Platform` 登录页;用户登录后可直接跳到 `API Keys` 页面,回 APP 粘贴 key 完成接入
|
||||
@@ -133,13 +190,13 @@ cd /Users/kris/code/boss
|
||||
- 当前 `我的 > 主 Agent 提示词 / 记忆` 页面已接通:管理员全局主提示词只读展示、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 跨项目项目记忆都可以在 Web 端查看和编辑;当前对话设置按登录账号隔离,管理员全局主提示词不可覆盖
|
||||
- 当前 Web 端 `master-agent` 会话页右上角也已补齐微信式三点菜单,支持直接进入 `提示词 / 模型 / 推理强度 / 记忆 / 刷新`
|
||||
- 当前 `approval_required` 群聊在 Web 端已统一用单一状态快照驱动:如果存在新的待确认推荐,会自动折叠旧的拒绝态;如果上次推荐已拒绝,会明确展示“重新生成新的推荐”的恢复入口
|
||||
- 当前如果主控身份还是 `Master Codex Node`,但该节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API / 阿里百炼 Qwen` 备用账号,不再把失败日志直接原样回给用户
|
||||
- 当前如果主控身份是 `Master Codex Node`,主 Agent 会先按授权范围构建 Codex 设备池:设备必须在线,且 `Codex App Server / CLI / GUI` 至少一条模型通道在线,`codexAppServer.metadata.accountSummary.signedIn=false` 会被视为不可用;首选设备不可用时会自动切到下一台可用 Codex 设备,执行中失败也会先重排到下一台设备,全部 Codex 设备不可用时才尝试已配置的 API 备用链;如果 Codex 设备池和 API Key 都不可用,APP 会提示“当前没有可用的模型渠道”
|
||||
- 当前原生 Android 的聊天发送已收短客户端等待窗口;`master-agent` 单聊依赖服务端快速入队和消息流里的“主 Agent 思考中 / 回复超时 / 重试等待”状态,不再要求客户端长时间同步阻塞
|
||||
- 当前设备导入主链也已补上第一轮后端闭环:`heartbeat` 可上报真实项目候选,服务端会生成 `deviceImportDraft`;用户可提交勾选结果、生成导入决议,再把选中的线程真正落成聊天窗口
|
||||
- Web 与原生 Android 当前都已补上“新设备导入草稿 -> 勾选 -> 决议预览 -> 应用导入”的前台流程;已绑定生产设备继续保留 heartbeat 自动导入主链
|
||||
- 当前设备导入前台的状态表达已经统一为:`等待候选线程 / 等待勾选 / 建议生成中 / 建议已生成 / 已导入`,并会回显最终导入的线程名
|
||||
- 当前已导入设备也支持自动同步项目理解:绑定设备 heartbeat 发现活跃线程有新活动、或线程刚回写了新的执行结果时,系统会直接为这台设备上已导入的线程排隐藏的 `conversation_reply` 主 Agent 任务,回写最新的项目目标、当前进度、技术架构和下一步建议
|
||||
- 当前自动同步链路已经拆成两层:heartbeat / thread reply 默认只追加轻量 `threadProgressEvent`;只有在线程首次理解、文档信息过薄、距离上次全量刷新太久或主 Agent 真的要接手时,才补排隐藏的全量理解任务并更新 `ThreadStatusDocument`
|
||||
- 当前已导入设备的项目理解同步已经收窄到“显式接管 / 用户主动要求同步”边界:绑定设备 heartbeat 或线程回写默认只追加轻量 `threadProgressEvent`,不会在未接管状态下主动向 Codex 线程发起隐藏对话
|
||||
- 当前全量理解链路只在单线程接管有效、全局接管有效,或用户明确要求“同步/核对项目目标和版本记录”时排 `conversation_reply` 任务;关闭接管会同步清理仍在 queued/running 的项目理解同步任务,避免取消接管后继续主动打扰线程
|
||||
- 当前群资料页已补上“修复群成员”入口:当群里存在失效线程引用、`master-agent` 这类不可下发成员,或真实线程成员少于 2 个时,前台会明确提示并允许重新选择真实线程成员
|
||||
- 当前原生聊天页也已前移“修复群成员”入口:脏群会在消息流上方直接显示 `去修复` 按钮,并跳转到群资料页完成成员替换
|
||||
- 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `deviceImportDraft`,不再绕过勾选/审核阶段直接自动导入聊天窗口
|
||||
@@ -148,7 +205,8 @@ cd /Users/kris/code/boss
|
||||
- 当前设备导入 `review` 已补 owner/admin 鉴权,并已切成真实异步审核:`review` 会先排队 `device_import_resolution` master task,前台进入“主 Agent 审核中”并自动刷新;导入草稿在 `apply` 后再次 heartbeat 也不会从 `applied` 回退成 `resolved`
|
||||
- 原生会话页当前的刷新失败策略已改成按当前 tab 独立判错:`会话` 不会再因为 `设备 / OTA / 设置` 的旁路请求失败而整体提示“刷新失败”
|
||||
- 会话页、设备页、技能页和项目详情页当前都通过 `/api/v1/events` 的 SSE 自动刷新
|
||||
- 我的页当前保留 `账号与安全 / 设置 / 运维与修复 / AI 账号 / 技能 / 关于` 六个一级入口;`AI 账号` 支持查看 `主 GPT / 备用 GPT / API 容灾`,并明确主链路优先走已经在绑定电脑上登录 `ChatGPT Plus / Codex` 的 `Master Codex Node`
|
||||
- 我的页当前保留角色感知入口:`member` 只显示 `账号与安全 / 设置 / 技能 / 关于`,其中 Skill 列表继续由服务端按授权过滤;`admin / highest_admin` 额外显示 `运维与修复 / AI 账号 / 附件与存储 / Telegram 接入`;`用户与权限` 只给 `highest_admin`
|
||||
- `AI 账号` 支持查看 `主 GPT / 备用 GPT / API 容灾`,并明确主链路优先走已经在绑定电脑上登录 `ChatGPT Plus / Codex` 的 `Master Codex Node`
|
||||
- `AI 账号` 页当前已补上显式 `登录指引`:手机端不会直接弹出 ChatGPT OAuth;`主 GPT` 需要先在绑定电脑上的 Codex / ChatGPT Plus 会话里登录,再回手机端点“测试连接 / 校验连接”
|
||||
- `AI 账号` 页当前已升级成双入口:首页会显式展示 `登录 OpenAI 平台账号` 和 `绑定电脑上的 Codex 节点`
|
||||
- `登录 OpenAI 平台账号` 当前通过填写 `OpenAI API Key` 完成;校验成功后会立即设为当前主控
|
||||
@@ -157,6 +215,8 @@ cd /Users/kris/code/boss
|
||||
- 因此 `POST /api/v1/accounts/onboard/openai-api` 在公网环境下已经能返回明确中文网络错误,但在服务器出网恢复前,还不能完成真实 OpenAI 平台账号探针与调用
|
||||
- `POST /api/v1/accounts/[accountId]/validate` 当前对 `master_codex_node` 不再只看 `nodeId`,还会同时校验绑定设备是否在线;设备离线时返回 `degraded` 和清晰的人类可读提示
|
||||
- 主 Agent 当前真实对话链路已验证通过:`Boss Web -> /api/v1/projects/master-agent/messages -> master-agent task queue -> local-agent -> codex exec -> /complete -> 项目消息账本`
|
||||
- Telegram 当前真实对话链路已接通:`Telegram Bot webhook -> /api/v1/integrations/telegram/webhook -> master-agent -> /api/v1/master-agent/tasks/[taskId]/complete -> Telegram Bot sendMessage`
|
||||
- Telegram 配置保存当前也会自动做 webhook 同步:webhook 模式自动 `setWebhook`,polling/关闭时自动 `deleteWebhook`
|
||||
- 主 Agent 单聊当前已改成“快速入队 + 异步回流”:`POST /api/v1/projects/master-agent/messages` 会先返回 `masterReplyState + task`,真实回复随后再回写消息账本
|
||||
- 当前对话级 `agentControls` 已经生效:`master-agent` 会话支持 `modelOverride / reasoningEffortOverride`,并会优先作用到实际 OpenAI 回复和 Master Codex Node 执行 prompt
|
||||
- 当前对话级 `agentControls` 也已支持 `backendOverride`:`master-agent` 会话可在 `Claw Runtime` 可用时显式选择 `claw-runtime`,由 `ExecutionBackendSelector` 在当前对话里优先尝试对应后端;不可用时保存接口会直接拒绝,并返回人类可读原因
|
||||
@@ -170,18 +230,19 @@ cd /Users/kris/code/boss
|
||||
- `npm run aab:release` 当前会先准备本机 release keystore,再构建 signed release AAB 并发布到 `public/downloads/boss-android-latest.aab`
|
||||
- AAB 发布脚本当前还会额外保留带版本号的归档包:`public/downloads/boss-android-v{versionName}-{flavor}.aab`
|
||||
- AAB 归档元数据会写入 `public/downloads/boss-android-latest-aab.json`
|
||||
- 当前默认管理员账号:`17600003315`
|
||||
- 当前默认测试密码:`boss123456`
|
||||
- 登录页当前是临时免验证入口;Web 登录页和原生 Android 登录页都会直接创建会话
|
||||
- 当前默认管理员账号:`krisolo`
|
||||
- 当前默认测试密码由线上初始化配置管理,文档不再明文记录
|
||||
- Web 登录页和原生 Android 登录页默认都必须通过账号密码或验证码校验后才会创建会话
|
||||
- 当前已生成 Android debug APK:`android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
- 当前已生成 Android signed release APK:`android/app/build/outputs/apk/release/app-release.apk`
|
||||
- 当前 release 构建还会额外生成带版本号的 APK:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
|
||||
- 当前最新 release 构建版本:`2.5.11`(`versionCode=24`)
|
||||
- 当前 release keystore 位于本机 `android/keystores/boss-release.keystore`,签名参数位于 `android/signing/release-signing.properties`
|
||||
- 真机开发约束:用户已明确切换到当前连接的 OPPO `PHZ110`(ADB serial `U84XJRIB7D65ZH45`);除非用户再次要求切换设备,后续 Android 开发、ADB 安装、交互回归与问题复现统一使用这台 OPPO,不再回退到原 `PLB110`
|
||||
- Android 真机无线调试当前可恢复使用,但系统层面没有“永久保持无线调试开启”的官方稳定开关;重启、切网、ADB server 重启或重新切换 USB 调试后,都可能自动失效
|
||||
- 如果要尽量稳定,当前推荐做法是:同一局域网下先走 USB 启用,再执行 `adb tcpip 5555` 与 `adb connect <phone-ip>:5555`;同时固定同一 SSID、避免切热点/VPN、开启“保持唤醒”,并保留 USB 作为长时间调试兜底
|
||||
- `2.0.1` 已在本机连接的华为真机上复核通过,修复了 `Theme.SplashScreen` 导致的 `AppCompatActivity` 启动闪退
|
||||
- `2.1.0` 已把 Web 一级页和主要二级页全部补成原生活动页:`MainActivity / ProjectDetailActivity / ProjectGoalsActivity / ProjectVersionsActivity / ProjectForwardActivity / ThreadDetailActivity / DeviceDetailActivity / DeviceEnrollmentActivity / SkillInventoryActivity / SecurityActivity / SettingsActivity / AiAccountsActivity / OpsCenterActivity / AboutActivity`
|
||||
- `2.1.0` 已把 Web 一级页和主要二级页全部补成原生活动页:`MainActivity / ProjectDetailActivity / ProjectGoalsActivity / ProjectVersionsActivity / ProjectForwardActivity / ThreadDetailActivity / DeviceDetailActivity / DeviceEnrollmentActivity / SkillInventoryActivity / SecurityActivity / AccessManagementActivity / SettingsActivity / AiAccountsActivity / OpsCenterActivity / AboutActivity`
|
||||
- `2.1.0` 已完成签名包覆盖安装到本机连接的华为真机,并确认 `com.hyzq.boss` 可以成功拉起进程
|
||||
- `2.1.1` 已补上原生 OTA 下载安装引导、`REQUEST_INSTALL_PACKAGES` 权限声明,以及根页默认入口/返回逻辑收口
|
||||
- `2.2.0` 已把原生 UI 回退到微信式交互:会话首页改为简单聊天列表,项目详情页改为聊天优先,只保留 `项目目标 / 版本记录` 两个轻入口,设备页和我的页根面改为简单列表
|
||||
@@ -200,16 +261,38 @@ cd /Users/kris/code/boss
|
||||
- `2.5.5` 已给 `approval_required` 群聊补齐“确认 / 拒绝”两条审批动作;拒绝后会把群审批状态写成 `rejected`,并追加系统提示,不再继续下发到线程
|
||||
- `2.5.11` 对应这一轮的主链收口:Android 会话首页改为直接读取 `/api/v1/conversations`,会把这台 Mac 上已导入的 Codex 线程对话直接平铺出来;`master-agent` 对“操作真实线程”的请求会先生成推荐下发方案,确认后再把任务派到真实线程执行;线程无绑定或设备离线时,确认接口会给清晰失败原因,避免假成功状态
|
||||
- 当前附件分析任务已带受控 `task token` 下载链接和文本摘录:本地开发环境会跟随请求 origin 生成链接,生产环境默认走 `https://boss.hyzq.net`
|
||||
- `2.5.11` 当前补齐了消息删除闭环:`DELETE /api/v1/projects/[projectId]/messages?messageId=...` 会删除账本消息、刷新会话预览并推送实时事件;Android 长按消息的“删除”已接入该接口
|
||||
- `2.5.11` 当前补齐了原生 `我的 > 附件与存储` 入口:Android 可直接查看当前存储方式,切换服务器文件存储 / 阿里 OSS,并支持保存或测试并保存
|
||||
- `2.5.11` 当前后台通知已扩展到所有会话里的主 Agent 回复:只要 APP 不在前台,线程会话内的主 Agent 接管回复也会触发 Android 系统通知
|
||||
- `2.5.x` 当前已补上会话首页独立建群入口:可以不从单线程聊天内部出发,直接在会话首页右上角 `+` 建立新群聊;同时已把多个原生自定义 top bar 页面统一纳入状态栏安全区处理
|
||||
- 当前 `local-agent` 已能回写带 `dispatchExecutionId / targetProjectId / targetThreadId / rawThreadReply` 的任务完成载荷,群聊分发执行结果不再只停留在主 Agent 队列
|
||||
- 当前 `local-agent` 对 `conversation_reply` 任务会优先使用 `codex exec resume <targetCodexThreadRef>`,只有缺失真实线程引用时才退回 `--ephemeral`
|
||||
- 当前已绑定真实 `codexThreadRef` 的普通单线程聊天,会在 `local-agent` 执行 `codex exec resume` 前,先把 Boss 用户消息镜像写入对应 Codex Desktop rollout;这样 APP 发起的消息也能进入桌面版同一线程历史,并按 `sourceMessageId` 去重。rollout 定位优先使用 `state_5.sqlite`,状态库不可用或索引缺失时回退扫描 `~/.codex/sessions`;写入后会尽量刷新 `threads.updated_at / updated_at_ms / has_user_event`,再通过 `codex://threads/{threadId}` 深链提示桌面版打开目标线程
|
||||
- 当前 `local-agent` 已新增 `Codex App Server` provider:boss-agent 默认配置 `codexAppServerEnabled=true`,`conversation_reply / dispatch_execution` 会先通过 `codex app-server` 的 stdio JSON-RPC 恢复或创建线程,也可配置 `codexAppServerTransport=ws + codexAppServerUrl=ws://127.0.0.1:<port>` 或 `codexAppServerTransport=unix + codexAppServerUrl=unix:///absolute/path.sock` 连接同机长驻 App Server;长驻连接可通过 `codexAppServerAuthTokenFile` 或 `BOSS_CODEX_APP_SERVER_AUTH_TOKEN_FILE` 提供 bearer token。随后 runner 下发 `turn/start` 并收集流式 agent 回复;如果单个 JSON-RPC 请求返回 `-32001 / retry later`,runner 会先做指数退避重试;如果任务携带 `targetCodexTurnId`,会改用 `turn/steer` 干预活跃 turn;如果 App Server 在 turn 启动前失败,默认允许回退到 `codex exec resume`,如果 turn 已经启动则不再回退,避免同一轮用户消息被重复执行。桌面控制另有 `codexComputerUseEnabled=true`,默认先走 Codex Computer Use,再回退 CUA Driver。
|
||||
- 当前已新增 Boss 自有 Inter-Thread Broker 第一版:服务端入口 `POST /api/v1/projects/[projectId]/thread-collaboration` 会创建带源/目标 Codex 线程引用的协作任务;App Server runner 执行 `thread/read(source) -> thread/inject_items(target) -> turn/start(target)`,用于让一个线程的结论受控进入另一个线程,不依赖官方任意线程 P2P 互聊能力
|
||||
- 当前已新增 App Server 版 Boss 用户消息镜像:普通 `conversation_reply` 任务携带 `mirrorBossUserMessageToCodexDesktop=true` 时,App Server runner 会在 `thread/resume` 后、`turn/start` 前调用 `thread/inject_items`,把 Boss APP 用户原文写成目标 Codex 线程的 `role=user` Responses item;任务结果只回写 `threadHistorySync.threadId / injectedItemCount / source`,不回写消息 ID、用户原文、内部调度 prompt 或系统约束。CLI rollout 写入仍作为 App Server 不可用前的兼容兜底。
|
||||
- 当前已新增 Codex App Server 受控线程回滚:服务端入口 `POST /api/v1/projects/[projectId]/thread-rollback` 会创建 `intentCategory=thread_rollback` 任务;App Server runner 执行 `thread/rollback(target, numTurns)`,只回写“已回滚最近 N 轮”的用户可见摘要,不启动新 turn,不保存 App Server 返回的 thread/turn/items。该能力只回滚 Codex 线程历史,不自动还原本地文件变更。
|
||||
- 当前已新增 Codex App Server 受控线程压缩:服务端入口 `POST /api/v1/projects/[projectId]/thread-compact` 会创建 `intentCategory=thread_compact` 任务;App Server runner 执行 `thread/compact/start(target)`,只回写“已发起上下文压缩”的用户可见摘要,不启动普通 turn,不保存 contextCompaction item 原始字段。该能力只压缩 Codex 线程上下文,不代表代码修改、文件恢复或版本发布完成。
|
||||
- 当前已新增 Codex App Server 受控线程归档 / 恢复:服务端入口 `POST /api/v1/projects/[projectId]/thread-archive` 会创建 `intentCategory=thread_archive|thread_unarchive` 任务;App Server runner 直接执行 `thread/archive(target)` 或 `thread/unarchive(target)`,不先 resume 已归档线程,不启动普通 turn,不保存 App Server 返回的 thread 原始字段。该能力只改变 Codex 线程生命周期状态,不代表代码修改、文件恢复或版本发布完成。
|
||||
- 当前已新增 Codex App Server 受控线程改名:服务端入口复用 `POST /api/v1/projects/[projectId]/rename` 的 `mode=thread` 分支;本地 Boss 线程标题更新后会创建 `intentCategory=thread_rename` 任务,App Server runner 直接执行 `thread/name/set(target, name)`,不先 resume 线程,不启动普通 turn,不保存 App Server 线程原始字段。设备离线或冲突时,本地改名仍成功,响应只返回非致命同步错误。
|
||||
- 当前已新增 Codex App Server 受控线程目标同步:服务端入口复用 `POST /api/v1/projects/[projectId]/goals`;本地 Boss 项目目标更新后,如果该项目是已绑定 `codexThreadRef` 的单线程,会创建 `intentCategory=thread_goal_sync` 任务,App Server runner 直接执行 `thread/goal/set(target, objective, status, tokenBudget?)`,不启动普通 turn,不保存 App Server 原始 goal payload。设备离线或冲突时,本地项目目标仍成功,响应只返回非致命同步错误。
|
||||
- 当前已新增 Codex App Server 受控线程 Git 元数据同步:服务端入口 `POST /api/v1/projects/[projectId]/thread-metadata` 会创建 `intentCategory=thread_metadata_sync` 任务;App Server runner 直接执行 `thread/metadata/update(target, gitInfo)`,不启动普通 turn,不保存 App Server 原始 thread payload。当前只允许同步 `gitInfo.sha / branch / originUrl`,用于让 Boss 线程治理和 Codex 线程的分支/提交信息保持一致。
|
||||
- 当前已新增 Codex App Server 受控线程分叉:服务端入口 `POST /api/v1/projects/[projectId]/thread-fork` 会创建 `intentCategory=thread_fork` 任务;App Server runner 直接执行 `thread/fork(target)`,不启动普通 turn,不保存 App Server 返回的 path、cwd、turns 或 instructionSources。当前不允许远程覆盖 model、sandbox、instructions 或 config;新 Codex 线程进入 Boss 会话列表仍通过现有 thread discovery / 导入链路完成。
|
||||
- 当前 `local-agent` 对 `dispatch_execution` 任务会按 `orchestrationBackendId` 分流:默认走 `codex exec resume`;当任务显式选择 `omx-team` 且本机 `omxEnabled + omxCommand/omxArgs` 可用时,会改走 `OMX Team Runtime` JSON 协议执行并回写 `rawThreadReply / replyBody`
|
||||
- 当前 `local-agent` 会在 Codex 任务执行中和完成时回传 `executionProgress`:服务端把同一任务的进度卡从 queued / running 更新到 completed / failed,Android 原生聊天页会显示“进度 / 线程状态 / 实时状态 / 线程配置 / 线程协作 / 工具活动 / 思考摘要 / 账号状态 / 运行状态 / 安全提醒 / 审批状态 / 文件变更 / 分支详情 / 生成结果 / 后台智能体”。2026-05-31 起,Codex App Server 的 `turn/plan/updated`、`turn/diff/updated`、`item/started|completed`、`thread/started` 会直接映射为进度步骤、变更统计、生成产物和后台智能体;第二批已把 `item/*/requestApproval`、`item/autoApprovalReview/*`、`guardianWarning`、`serverRequest/resolved`、`item/fileChange/patchUpdated` 映射为审批、安全提醒和文件变更摘要;第三批已把 `thread/status/changed` 与 `thread/realtime/*` 安全映射为线程状态和实时状态摘要;第四批已把 `model/rerouted`、`thread/tokenUsage/updated`、`mcpServer/startupStatus/updated`、`remoteControl/status/changed` 安全映射为运行状态摘要;第五批已把 `thread/goal/*`、`thread/settings/updated` 和 `thread/compacted` 映射为线程配置摘要;第六批已把 `account/updated`、`account/rateLimits/updated`、`model/verification`、`warning`、`configWarning`、`deprecationNotice` 映射为账号状态、模型校验和安全提醒摘要;第七批已把 `ThreadItem.collabToolCall` 和 `ThreadItem.contextCompaction` 映射为线程协作和上下文压缩摘要;第八批已把 `mcpToolCall`、`dynamicToolCall`、`webSearch`、`imageView`、`enteredReviewMode`、`exitedReviewMode`、`commandExecution` 映射为工具活动摘要;第九批已把 `ThreadItem.plan` 和 `ThreadItem.reasoning.summary` 映射为计划步骤与思考摘要;第十批已把 `ThreadItem.imageGeneration` 映射为图像生成工具活动和图片产物;第十一批已把 `hook/started|completed` 映射为钩子生命周期工具活动;第十二批已把 `windowsSandbox/setupCompleted` 映射为 Windows 沙箱准备状态摘要;第十七批已把新版 `ThreadItem.collabToolCall.receiverThreadIds / agentsStates` 安全映射为线程协作目标数量和 agent 状态集合。所有进度均通过 `POST /api/v1/master-agent/tasks/[taskId]/progress` 实时刷新;字段白名单会剥离 cwd、turnId、配置文件路径、内部 prompt、collab 源/目标线程 ID、receiverThreadIds、agentsStates 私有消息、共享 Skill 根绝对路径、tool arguments/result、web URL token、命令正文/输出、raw reasoning content、reasoning item id、图像生成 revisedPrompt/result、hook sourcePath/statusMessage/entries、Windows sandbox sourcePath/samplePaths/本地绝对路径和未清洗密钥,complete 回写仍会携带最终进度兜底
|
||||
- 当前 `local-agent` heartbeat 已新增 Codex App Server capability discovery:按 TTL 拉取模型、provider 能力、Skill、Hook、Plugin、App 摘要,并附加只读线程操作、插件治理、账号治理、配置治理、文件治理、命令会话、外部 Agent 迁移、Marketplace、实验特性、审查、Windows 沙箱、文件搜索事件、MCP、用户交互、Guardian、运行事件、扩展事件、线程生命周期和流式增量能力 catalog,写入 `capabilities.codexAppServer.metadata`;Web 设备详情会展示 App Server 连接状态、模型数量、默认/快速/深度模型、扩展数量、Hook 治理摘要、线程操作摘要、插件治理摘要、账号治理摘要、配置治理摘要、文件治理摘要、命令会话摘要、迁移治理摘要、市场治理摘要、实验特性治理摘要、审查治理摘要、Windows 沙箱摘要、文件搜索事件摘要、MCP 治理摘要、用户交互摘要、Guardian 治理摘要、运行事件摘要、扩展事件摘要、线程生命周期摘要和流式增量摘要;原生 Android 设备详情当前已展示 App Server 连接态、模型、扩展、治理、账号、线程、轮次、线程操作、线程协作和协议漂移这些核心摘要
|
||||
- 当前 `MasterAgentTask` 已具备服务端租约和取消基础状态机:claim 会写入 `attemptCount / maxAttempts / leaseExpiresAt`,运行中任务租约过期后可被重新认领,超过重试上限会转 `timed_out`;`POST /api/v1/master-agent/tasks/[taskId]/cancel` 会把任务转 `canceled`,迟到的成功 complete 不会覆盖终态
|
||||
- 当前 App Server 执行中的任务取消已补真实中断链路:服务端新增 `GET /api/v1/master-agent/tasks/[taskId]/control-state` 给设备端按 token 轮询取消状态;`local-agent` 在 turn 启动后按 `masterAgentInterruptPollIntervalMs` 检查该接口,发现任务已取消会在同一个 App Server 连接上调用 `turn/interrupt`,并把 interrupted 作为干净取消结果处理,不再等长任务自然结束或把取消刷成失败日志
|
||||
- 当前 `local-agent` 对 `browser_control / desktop_control` 已从占位骨架升级成外部 runtime 桥:当本机配置了 `browserControlEnabled + browserControlCommand` 或 `computerUseEnabled + computerUseCommand` 时,会把标准化 JSON 请求透传给外部进程,并解析单行 JSON 结果;未启用时会 fail closed,返回明确的 runtime disabled 错误,不再假装执行成功
|
||||
- 远程电脑控制链路当前已有可复用压测基线:`npm run stress:remote-control` 可按参数压测 `local-agent -> MasterAgentTask -> browser_control / desktop_control runtime -> complete 回写` 全链路;`npm run stress:remote-control:ci` 固定 120 条链路任务和 360 条 runtime 并发任务,并用 p95 延迟预算判断是否退化。压测报告可通过 `--report-json=PATH` 落盘,便于后续接入真实 macOS AX / Windows UIA helper 后复用同一套稳定性判断。
|
||||
- 当前历史脏群如果不再包含真实线程成员,群聊消息不会再表现成“无响应”;服务端会在群内追加明确 `system_notice`,提示先重新添加线程成员
|
||||
- 当前设备导入决议已经升级成真正通过 `local-agent -> codex exec -> /complete` 回写的主 Agent 决议链;Web 和 Android 前台都会在 `pending_resolution` 阶段显示审核任务状态,并在任务完成后自动刷新出正式导入建议
|
||||
- 当前 `local-agent` 已改成先启动本地 `4317` 健康监听,再异步跑首次 heartbeat 和 task poll,避免控制面短时阻塞时本地健康探针不可用
|
||||
- 当前 heartbeat 上报 `browserAutomation / computerUse / codexAppServer` 能力时,不再只看静态 connected 布尔值;browser/computer 会参考 runtime 配置状态,Codex App Server 会参考 `codexAppServerEnabled`,stdio 模式校验本机 app-server 命令可执行性,ws/unix 模式校验 `codexAppServerUrl`
|
||||
- Codex 项目/线程扫描当前已搬到 worker 线程执行,避免 `.codex/logs_1.sqlite` 和 `state_5.sqlite` 的同步扫描阻塞主线程健康接口
|
||||
- 当前 `local-agent` 的任务完成回写已通过 `RemoteRuntimeAdapter` 标准化,`conversation_reply / dispatch_execution` 的完成载荷会先做统一归一化,再进入主 Agent 完成路由
|
||||
- 原生 Android 当前对 `master-agent` 聊天不再依赖长时间同步等待;发送后会先显示“主 Agent 思考中”,右上角改成微信式 `...` 菜单,菜单项包含 `模型 / 推理强度 / 会话信息 / 刷新`
|
||||
- 原生 Android 当前已新增 `TelegramIntegrationActivity`:可从 `我的 > Telegram 接入` 查看当前 Bot 状态、配置 Bot Token / Webhook Secret / Webhook URL、私聊白名单、群聊白名单、群聊触发策略和群 / Topic 到 Boss 项目的路由;群聊可配置为只接受 `@Bot` 或直接回复当前 Bot 的消息,并可直接测试连接或保存配置
|
||||
|
||||
## 2. 服务器状态
|
||||
|
||||
@@ -236,7 +319,8 @@ cd /Users/kris/code/boss
|
||||
- `boss-web` 当前通过 `npm start` 启动
|
||||
- 实际监听端口为 `3000`
|
||||
- `boss-web.service` 显式设置了 `BOSS_STATE_FILE=/opt/boss/data/boss-state.json`
|
||||
- `Caddy` 反代 `127.0.0.1:3000`
|
||||
- `Caddy` 反代 `127.0.0.1:3000`;`boss.hyzq.net` 服务客户 Web / App API,`admin.boss.hyzq.net` 作为平台级 To B 独立后台入口并把根路径内部 rewrite 到 `/admin-web/index.html`
|
||||
- 服务器上存在 `gptpluscontrol-boss-caddy-reconcile.timer`,会周期性用 `/home/ubuntu/build/gptpluscontrol/deploy/server/caddy.boss_hyzq_net.gptpluscontrol.conf` 重写 `/etc/caddy/Caddyfile` 和 `/opt/boss/deployment/Caddyfile`;以后改 Caddy 入口必须同步更新这份 canonical,否则会重新生成重复站点块并导致 Caddy reload 失败
|
||||
- `Postfix` 监听 `25 / 465 / 587`
|
||||
- `Dovecot` 监听 `993`
|
||||
- 当前部署脚本在远端重启服务后会自动执行一遍本机 health check
|
||||
@@ -256,10 +340,12 @@ cd /Users/kris/code/boss
|
||||
- 服务器本机 `dig +short boss.hyzq.net` 返回 `106.53.170.158`
|
||||
- 服务器本机访问 `http://boss.hyzq.net` 会被 `308` 跳转到 `https://boss.hyzq.net`
|
||||
- 服务器本机执行 `curl --resolve boss.hyzq.net:443:127.0.0.1 https://boss.hyzq.net -I` 返回 `307` 并跳转到 `/auth/login`
|
||||
- 当前 `admin.boss.hyzq.net` 用于平台级 To B 独立后台入口,站点根路径直接承载新 PC 后台;`/admin` 不再渲染旧 UI,只保留跳转到根路径的兼容入口
|
||||
|
||||
同时也确认了这些事实:
|
||||
|
||||
- 当前本机网络 `dig +short boss.hyzq.net` 仍返回 `198.18.1.188`
|
||||
- 当前本机网络 `dig +short admin.boss.hyzq.net` 已返回 `106.53.170.158`
|
||||
- 当前本机网络 `curl -I http://boss.hyzq.net` 返回 `308`
|
||||
- 当前本机网络 `curl -I https://boss.hyzq.net` 返回 `HTTP/2 307`,并跳转到 `/auth/login`
|
||||
- 当前本机网络 `curl https://boss.hyzq.net/api/health` 返回 `{"ok":true,"service":"boss-web",...}`
|
||||
@@ -282,13 +368,13 @@ cd /Users/kris/code/boss
|
||||
|
||||
## 4. 当前未完成或仅为 MVP 的部分
|
||||
|
||||
- 当前服务器默认仍是 `fixed`,验证码为 `000000`
|
||||
- 当前服务器默认仍是 `fixed`,但验证码登录必须先通过 `send-code` 生成账本记录;不能只靠固定码直接登录
|
||||
- 当前虽然已经补齐 OTA 版本中心、检查更新、执行升级和 APK 包下载链路,但仍是文件型状态驱动的 MVP,不是原生增量更新基础设施
|
||||
- 当前“OTA / 重装后不掉登录”覆盖原生 Android 客户端的 `SharedPreferences` 恢复与同签名覆盖安装;如果用户先卸载 APP 再全新安装,仍可能丢失本地原生存储
|
||||
- 数据存储仍是文件型,而不是数据库
|
||||
- 数据存储默认仍是文件型,但已经有 PostgreSQL store adapter、schema 和维护脚本;生产切换前需先执行备份、dry-run 迁移和回滚演练
|
||||
- 设备发现、项目扫描和额度采集仍是静态配置驱动的 MVP
|
||||
- APP 实时日志当前已能同步到主 Agent 会话,但还没有单独的日志检索、分页和告警升级规则
|
||||
- Skill 清单当前按设备同步和展示已经可用,但还没有“安装 / 卸载 Skill”这种远程管理能力
|
||||
- Skill 清单当前按设备同步和展示已经可用;远程治理已贯通最高管理员创建 lifecycle 请求、设备端认领、local-agent 执行安装 / 更新 / 卸载 / 回滚 / 版本锁、执行后同步 Skill 清单和完成回写。当前仍属于文件型状态与 Git 来源驱动的 MVP,生产使用前需要配置设备侧 source allowlist / trusted sources、校验和策略和失败告警。
|
||||
- 服务器侧主 Agent 实时回复依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;如果设备离线,只能保留任务或走 API 容灾账号
|
||||
- 设备导入主链的后端状态机已经跑通,并且已经分成两条:
|
||||
- 新接入设备继续走 `import draft -> 勾选 -> review -> apply`
|
||||
@@ -296,6 +382,10 @@ cd /Users/kris/code/boss
|
||||
- 本机 `mac-studio` 当前已经验证可通过 `local-agent` 直接从 `~/.codex/state_5.sqlite / logs_1.sqlite / session_index.jsonl / .codex-global-state.json` 扫描真实 Codex 线程,并通过 heartbeat 自动导入到会话列表
|
||||
- 线程发现当前会优先保留每个 Codex 文件夹下的主工作线程;如果同文件夹里存在 `worker / explorer` 子线程,会优先过滤这些子线程,避免把子代理线程误当成独立聊天窗口
|
||||
- `local-agent` 当前还会在 `codex exec resume` 前再次校验目标线程绑定;如果目标线程在 Codex 本地状态库里不存在、已归档、cwd 不匹配或是 `read-only` 会话,会直接 fail closed,不再把内部环境提示原文回写到聊天
|
||||
- 2026-06-06 起,Boss 已开始落地 B+ 可靠性外壳:目标架构是“Boss Cloud 控制面 + 本地 Boss Edge 执行面 + 可靠性外壳”。第一阶段不新增独立服务器进程,先让当前 `local-agent` 具备 Edge 行为:`MasterAgentTask` 保留旧 `status`,新增 `phase` 表达 `queued / claimed / executor_starting / turn_started / awaiting_reply / completing / completed / recoverable_failed / terminal_failed / timed_out / canceled / needs_user_action`;APP 进度卡优先按 phase 推导步骤状态,避免任务真实推进后仍显示卡在第一步。
|
||||
- 同批已新增本地 durable outbox:`local-agent` 对 `task.progress / task.complete / app.log` 先写本地 outbox,再发送云端;网络失败、云端 5xx/429 或进程重启后会在 heartbeat 前自动重放。outbox 写入在单进程内串行化,避免 progress、complete 和 heartbeat 重放并发覆盖。云端 complete 继续保持幂等,迟到 complete 不覆盖终态。
|
||||
- 同批已收紧自动重试边界:租约过期但尚未进入 Codex turn 的任务会进入 `recoverable_failed` 并安全重排;已经进入 `turn_started / awaiting_reply / completing` 的任务不会自动重复下发,避免同一 Codex 线程重复执行。Codex App Server 能力从单一 `connected` 提升为 `available / degraded / unavailable` 健康分级,调度不再只看布尔 connected。
|
||||
- 企业安全第一批已产品化:`GET/POST /api/v1/admin/backups` 在原有文件快照基础上新增业务投影、checksum 校验、恢复预览和 dry-run;恢复前仍自动创建 pre-restore 快照,创建、校验、预览、dry-run 和实际恢复动作都会写入审计日志。`GET /api/v1/admin/backoffice` 新增 `dataSafetySummary / taskRiskSummary`,平台后台和企业后台可展示备份健康、当前文件 MVP 阶段 RPO/RTO 说明、卡住任务和可恢复任务;`GET/POST /api/v1/master-agent/tasks/[taskId]/recovery` 支持查看任务恢复诊断并仅允许最高管理员重试 recoverable 的 turn 前任务;Android 进度卡新增 `phase` 人话解释和最后更新时间。
|
||||
- 如果历史上误把 `worker / explorer` 子线程当成开发线程继续复用,即使后来把数据库权限改回可写,这类线程也可能仍然带着“只读勘察 / 不改文件”的历史上下文;恢复开发时应优先切回主交接线程,或先对该子线程补发明确的解锁指令
|
||||
- 会话首页当前已经不再简单平铺所有线程;如果某个设备导入了大量同文件夹线程,首页会优先显示项目归档项,降低会话页噪音
|
||||
- 已绑定生产设备的自动导入链现在还会在 heartbeat 时清理已经不再出现在最新 `projectCandidates[]` 里的旧线程会话,避免旧导入结果长期残留
|
||||
@@ -303,7 +393,9 @@ cd /Users/kris/code/boss
|
||||
- 原生 Android 的二级深层页虽然仍保留 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等能力,但它们已经退出主 UI 正面;后续如再加入口,需继续遵守“一级微信式,复杂能力下沉”的规则
|
||||
- Android 本地 Gradle 验证当前必须串行执行;如果并发跑 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug`,会导致中间产物互踩并出现假失败
|
||||
- 聊天附件当前已经支持真实上传、消息落账本、受保护下载和原生打开;默认后端为服务器文件存储,可按用户切到阿里 OSS 私有桶
|
||||
- 认证虽然已有最小会话 Cookie,但还没有刷新令牌、跨端会话治理、CSRF 防护和更细的风控策略
|
||||
- 企业认证默认值已收紧:`POST /api/auth/login` 默认不再允许临时免验证登录,只有显式设置 `BOSS_AUTH_AUTO_LOGIN=1/true/yes` 才会开启开发兜底。
|
||||
- 状态存储现在通过 `src/lib/boss-state-store.ts` 抽象,默认继续使用 `data/boss-state.json`;只有显式设置 `BOSS_STATE_STORE=postgres` 才会进入 PostgreSQL 路径,真实连接 / 写入还必须同时配置 `BOSS_DATABASE_URL`。schema 见 `scripts/postgres-state-schema.sql`,生产切换前需先跑 `validate-schema`、文件备份、`migrate-file-to-postgres --dry-run`、PostgreSQL 备份导出和恢复演练。
|
||||
- 认证已补 CSRF 基础防护、restore token 轮换、账号锁定和子账号 MFA 开关;后续仍可继续补更完整的企业 IdP / SSO
|
||||
- 邮件对外正式投递仍缺少 DNS / 信誉相关的最终收口,例如 SPF、DKIM、DMARC、MX 与退信策略
|
||||
- 外部真实邮箱的 end-to-end 收件链路还没有在生产账号上完成最终验收
|
||||
|
||||
@@ -313,15 +405,22 @@ cd /Users/kris/code/boss
|
||||
|
||||
```bash
|
||||
curl -sS http://127.0.0.1:3000/api/health
|
||||
curl -sS -H 'Content-Type: application/json' -d '{"account":"17600003315","password":"boss123456","method":"password"}' http://127.0.0.1:3000/api/auth/login
|
||||
curl -sS -H 'Content-Type: application/json' -d '{"account":"krisolo","password":"<admin-password>","method":"password"}' http://127.0.0.1:3000/api/auth/login
|
||||
curl -sS http://127.0.0.1:3000/api/auth/session
|
||||
curl -sS http://127.0.0.1:3000/api/v1/conversations
|
||||
curl -sS http://127.0.0.1:3000/api/v1/projects/master-agent
|
||||
curl -sS http://127.0.0.1:3000/api/v1/devices/mac-studio/skills
|
||||
node scripts/boss-state-store-maintenance.mjs backup-file --dry-run
|
||||
node scripts/boss-state-store-maintenance.mjs validate-schema
|
||||
BOSS_STATE_STORE=postgres BOSS_DATABASE_URL="$BOSS_DATABASE_URL" node scripts/boss-state-store-maintenance.mjs migrate-file-to-postgres --dry-run
|
||||
BOSS_STATE_STORE=postgres BOSS_DATABASE_URL="$BOSS_DATABASE_URL" node scripts/boss-state-store-maintenance.mjs export-postgres-backup --output /tmp/boss-postgres-backup.json --dry-run
|
||||
BOSS_STATE_STORE=postgres BOSS_DATABASE_URL="$BOSS_DATABASE_URL" node scripts/boss-state-store-maintenance.mjs restore-postgres-backup --input data/boss-state.json --dry-run
|
||||
curl -I http://127.0.0.1:3000/api/v1/user/ota/package
|
||||
curl -sS http://127.0.0.1:4317/health
|
||||
curl -sS http://127.0.0.1:4317/api/v1/skills
|
||||
curl -sS -X POST http://127.0.0.1:4317/api/v1/heartbeat
|
||||
npm run stress:remote-control:ci
|
||||
npm run stress:remote-control -- --chain-tasks=120 --runtime-tasks=360 --runtime-concurrency=36 --timeout-ms=60000 --report-json=/tmp/boss-remote-control-stress.json
|
||||
```
|
||||
|
||||
服务器:
|
||||
|
||||
88
docs/architecture/dependency_security_audit_cn.md
Normal file
88
docs/architecture/dependency_security_audit_cn.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Boss 依赖漏洞治理记录
|
||||
|
||||
更新时间:`2026-04-27`
|
||||
|
||||
## 本次治理范围
|
||||
|
||||
- 处理 Web/npm 依赖:`package.json`、`package-lock.json`。
|
||||
- 处理应用源码中与漏洞依赖绑定的附件存储实现:`src/lib/boss-storage-aliyun-oss.ts`。
|
||||
- 处理构建追踪 warning:`src/lib/boss-mail.ts`。
|
||||
- 未改 Android 工程、`local-agent` 或部署脚本。
|
||||
- 修复策略:先运行 `npm audit --json` 定位来源;不使用 `npm audit fix --force`;对没有安全小版本升级路径的依赖链,改为移除依赖并用项目内最小实现替换。
|
||||
|
||||
## 漏洞统计
|
||||
|
||||
治理前 `npm audit --json`:
|
||||
|
||||
- total:`14`
|
||||
- high:`6`
|
||||
- moderate:`2`
|
||||
- low:`6`
|
||||
- critical:`0`
|
||||
|
||||
第一轮治理后 `npm audit --json`:
|
||||
|
||||
- total:`11`
|
||||
- high:`3`
|
||||
- moderate:`2`
|
||||
- low:`6`
|
||||
- critical:`0`
|
||||
|
||||
最终治理后 `npm audit`:
|
||||
|
||||
- `found 0 vulnerabilities`
|
||||
|
||||
## 已应用的安全修复与替换
|
||||
|
||||
- `npm audit fix` 自动更新传递依赖:
|
||||
- `@xmldom/xmldom`:`0.8.11 -> 0.8.13`
|
||||
- `brace-expansion`:`1.1.12 -> 1.1.14`
|
||||
- `lodash`:`4.17.23 -> 4.18.1`
|
||||
- 根级 `postcss`:`8.5.8 -> 8.5.12`
|
||||
- 手动把 Next patch 版本升级到安全版本:
|
||||
- `next`:`16.2.1 -> 16.2.4`
|
||||
- `eslint-config-next`:`16.2.1 -> 16.2.4`
|
||||
- 使用 npm `overrides` 将 Next 内部 `postcss` 收敛到安全版本:
|
||||
- `postcss`:`8.5.12`
|
||||
- 移除旧 OSS SDK 与代理链:
|
||||
- 移除 `ali-oss`
|
||||
- 移除 `@types/ali-oss`
|
||||
- 移除 `proxy-agent`
|
||||
- 将阿里云 OSS 附件存储改为项目内原生 REST 客户端:
|
||||
- 使用 Node `fetch` 发起 `PUT / GET / bucketInfo`。
|
||||
- 使用 `crypto.createHmac("sha1")` 生成 OSS V1 Authorization 与签名下载 URL。
|
||||
- 保持现有外部调用接口:上传附件、签名下载、读取对象、配置校验。
|
||||
- 将验证码邮件投递的 sendmail 启动器固定为 `/usr/bin/env` 字面量,避免 Turbopack 把动态 sendmail 路径追踪成大范围文件模式。
|
||||
|
||||
## 不采用的方案
|
||||
|
||||
- 未采用 `npm audit fix --force`:
|
||||
- npm 给出的部分修复路径包含 Next 降级,破坏当前 `Next.js 16 + React 19` 运行线。
|
||||
- 未采用 `proxy-agent@8.0.1` override:
|
||||
- 旧 `urllib@2` 通过 CommonJS lazy require 使用 `proxy-agent@5`,强制替换为 ESM 版本存在运行时破坏风险。
|
||||
- 未采用 `ali-oss@6.19.0-audit.1`:
|
||||
- 实测会把漏洞转移到 `urllib@3 -> undici@5` 链,`npm audit` 仍剩 `3` 条漏洞。
|
||||
- 未等待 Next 官方 patch:
|
||||
- 当前可以用 `overrides.postcss=8.5.12` 通过 lint/build 回归,风险可控。
|
||||
|
||||
## 已执行命令
|
||||
|
||||
```bash
|
||||
npm audit --json
|
||||
npm audit fix
|
||||
npm install next@16.2.4 eslint-config-next@16.2.4 --save-exact
|
||||
npm install
|
||||
npm audit
|
||||
npm ls ali-oss proxy-agent urllib undici postcss next --all
|
||||
npx tsx --test tests/boss-mail.test.ts tests/aliyun-oss-storage.test.ts
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
最终验证结果:
|
||||
|
||||
- `npm audit`:通过,`found 0 vulnerabilities`。
|
||||
- `npm ls ali-oss proxy-agent urllib undici postcss next --all`:通过,漏洞依赖链已不存在;`next` 使用 `postcss@8.5.12`。
|
||||
- `npx tsx --test tests/boss-mail.test.ts tests/aliyun-oss-storage.test.ts`:`5/5` 通过。
|
||||
- `npm run lint`:通过。
|
||||
- `npm run build`:通过;未再出现 `boss-mail.ts` Turbopack broad file pattern warning。
|
||||
463
docs/architecture/enterprise_ai_ops_architecture_cn.md
Normal file
463
docs/architecture/enterprise_ai_ops_architecture_cn.md
Normal file
@@ -0,0 +1,463 @@
|
||||
# Boss 企业 AI 运营中枢量产架构开发文档
|
||||
|
||||
更新时间:`2026-05-17`
|
||||
|
||||
## 1. 文档定位
|
||||
|
||||
这份文档把 `outputs/boss-product-intro-image2-full-raster.pptx` 里的产品架构、此前确认的量产 B+ 方案、以及 Codex App Server 最新开放协议思路统一成后续开发约束。
|
||||
|
||||
当前结论:Boss 不能只做一个“手机控制 Codex”的工具,而要升级成企业级 AI 运营中枢。Boss 负责组织、权限、任务、审计、数据安全、回退和跨设备协作;Codex、Computer Use、Skill、业务系统和第三方 Agent 都只是可替换执行能力。
|
||||
|
||||
本文件描述的是量产目标架构,不代表当前所有能力都已经落地。当前运行真相仍以 `docs/architecture/current_runtime_and_deploy_status_cn.md` 为准。
|
||||
|
||||
## 2. 来源材料
|
||||
|
||||
- 产品 PPT:`outputs/boss-product-intro-image2-full-raster.pptx`
|
||||
- PPT 抽图校对目录:`outputs/pptx-architecture-read/slides`
|
||||
- Codex App Server 官方文档:`https://developers.openai.com/codex/app-server`
|
||||
- 当前 Boss 运行文档:`docs/architecture/current_runtime_and_deploy_status_cn.md`
|
||||
- 当前 API 与服务清单:`docs/architecture/api_and_service_inventory_cn.md`
|
||||
|
||||
## 3. 产品总目标
|
||||
|
||||
Boss 的产品目标是把主 Agent、业务 Agent、组织角色、真实电脑、企业系统和 Skill 连接成可执行的企业管理系统。
|
||||
|
||||
PPT 中的核心判断需要进入产品开发主线:
|
||||
|
||||
- AI 已经能对话,但企业执行还没有被完整接管。
|
||||
- 真正的成本不在模型本身,而在重复沟通、人工汇总、跨系统搬运和不可追踪的执行过程。
|
||||
- Boss 的价值不是再做一个聊天机器人,而是让经营目标变成可拆解、可审批、可执行、可追踪、可复盘、可回退的闭环。
|
||||
- 企业级 AI 必须先可控,再谈自动化。
|
||||
|
||||
## 4. 采用方案 B:Boss 企业控制面 + 可插拔执行协议
|
||||
|
||||
量产版本默认采用方案 B。
|
||||
|
||||
方案 B 的定义:
|
||||
|
||||
- Boss 是企业级控制面和数据事实源。
|
||||
- Codex App Server、Codex MCP、Codex CLI、Computer Use、CUA Driver、Browser Automation、业务系统 API、Skill Runtime 都作为执行 provider 接入。
|
||||
- 所有 provider 的原始事件必须先归一化为 Boss 自有事件和消息模型,再进入 APP、Web 管理后台、审计日志和回退系统。
|
||||
- UI、权限、审计、备份、任务 SLA、风险处置和企业账号体系不能直接依赖某一个 provider 的私有字段。
|
||||
|
||||
选择方案 B 的原因:
|
||||
|
||||
- 企业客户最关心的是权限、审批、审计、稳定性、数据边界和可回退,不是某个单一执行引擎的能力展示。
|
||||
- Codex 协议未来会持续变化,Boss 必须通过适配层快速跟进,而不是把协议字段写死到业务模型里。
|
||||
- Boss 还需要支持我们自研的 Computer Use、未来的企业系统 API、Telegram/飞书/微信入口、Skill 分发和多租户后台,单独围绕 Codex 建模会限制长期扩展。
|
||||
|
||||
## 5. 方案 C 的优劣势
|
||||
|
||||
方案 C 指把 Codex App Server 作为更核心的数据和执行事实源,让 Boss 尽量贴近 Codex 原生 Thread、Turn、Item、Approval、Skill、Plugin 和 App Server event。
|
||||
|
||||
优势:
|
||||
|
||||
- 最接近 Codex 原生体验,线程、实时事件、审批、Skill、命令执行和文件变更可以更快跟随官方能力。
|
||||
- APP 与 Codex 桌面端的同线程实时同步理论上更顺,重复实现更少。
|
||||
- Codex 新增功能时,Boss 可以更快暴露给用户。
|
||||
|
||||
劣势:
|
||||
|
||||
- 业务核心会强绑定 Codex 协议,协议变更会直接冲击 Boss 的权限、审计、回退和消息账本。
|
||||
- 企业级多租户、子账号授权、跨公司隔离、平台总后台、Skill 分配和数据留存不能完全交给 Codex 原生模型。
|
||||
- 非 Codex 执行能力会变成二等能力,比如自研 Computer Use、业务系统 API、Telegram/飞书入口和后续企业 OA 集成。
|
||||
- 数据自动备份和业务级回退会受限于 Codex 本地会话存储语义,不能满足 To B 生产级治理。
|
||||
|
||||
最终策略:
|
||||
|
||||
- 不采用方案 C 作为总架构。
|
||||
- 在执行层内部吸收方案 C 的优点,优先新增 `CodexAppServerBackendAdapter`。
|
||||
- Boss 数据模型保持独立,Codex App Server 只作为强执行 provider 和实时事件来源。
|
||||
|
||||
## 6. 多层级协作关系
|
||||
|
||||
PPT 第 3、4、5、8、9 页确定的协作链路必须成为产品模型的骨架。
|
||||
|
||||
### 6.1 组织层级
|
||||
|
||||
| 层级 | 角色 | 主要职责 | 可见范围 |
|
||||
| --- | --- | --- | --- |
|
||||
| 平台最高管理员 | Boss 平台运营方 | 创建企业、开通老板账号、查看全局风险、处理服务异常、管理套餐和授权 | 全平台治理数据,不默认看企业业务内容明细 |
|
||||
| 企业老板端 | 企业超级管理员 | 看全局目标、成本、现金流、风险和最终结果;通过主 Agent 问询公司执行状态 | 本企业全局 |
|
||||
| 经理端 | 部门或项目负责人 | 接收目标、拆解任务、审批异常、追踪团队进度、协调资源 | 授权团队、部门、项目 |
|
||||
| 员工端 | 具体执行人员 | 处理具体任务、补充判断、上传材料、确认结果 | 自己任务和授权上下文 |
|
||||
| 系统端 | Boss 控制面 | 维护账号、设备、权限、SOP、审批、审计、日志、备份和回退 | 按租户和权限隔离 |
|
||||
|
||||
### 6.2 Agent 层级
|
||||
|
||||
| 层级 | Agent | 职责 |
|
||||
| --- | --- | --- |
|
||||
| 调度层 | 主 Agent | 理解目标、判断权限、拆解任务、分配资源、汇总结果、协调多线程/多设备/多业务 Agent |
|
||||
| 执行层 | 业务 Agent | 按 SOP 执行业务流程,如销售、客服、财务、HR、项目、行政、运维 |
|
||||
| 设备层 | 本地 Agent | 接入真实电脑、Codex、Computer Use、浏览器、文件、系统权限和本地 Skill |
|
||||
| Provider 层 | 执行 provider | Codex App Server、Codex CLI/MCP、CUA Driver、Browser Automation、业务系统 API、Skill Runtime |
|
||||
|
||||
### 6.3 主 Agent 与业务 Agent 分工
|
||||
|
||||
主 Agent 负责“为什么做、谁来做、能不能做”:
|
||||
|
||||
- 理解业务目标和约束条件。
|
||||
- 拆解任务、里程碑和依赖关系。
|
||||
- 判断账号、设备、项目、Skill 和数据访问权限。
|
||||
- 选择合适业务 Agent、设备 Agent 或执行 provider。
|
||||
- 在关键节点请求用户、经理或审批人确认。
|
||||
- 汇总执行结果、风险、阻塞和下一步动作。
|
||||
|
||||
业务 Agent 负责“按 SOP 把事情做完”:
|
||||
|
||||
- 销售 Agent:线索跟进、商机管理、合同执行、回款跟踪。
|
||||
- 客服 Agent:客户咨询、工单处理、服务跟进、满意度管理。
|
||||
- 财务 Agent:费用报销、预算管理、账务处理、财务分析。
|
||||
- HR Agent:招聘管理、入职管理、考勤管理、绩效管理。
|
||||
- 项目 Agent:项目计划、进度跟踪、风险管理、交付验收。
|
||||
- 行政 Agent:采购管理、资产管理、会议管理、用品管理。
|
||||
- 运维 Agent:巡检、告警、故障复盘和修复建议。
|
||||
|
||||
## 7. 标准执行闭环
|
||||
|
||||
所有企业动作都应尽量落到同一条闭环:
|
||||
|
||||
1. 用户提出经营目标或执行请求。
|
||||
2. 主 Agent 将自然语言目标拆成任务、里程碑、依赖和风险。
|
||||
3. 系统检查账号、设备、项目、Skill、SOP 和数据权限。
|
||||
4. 经理或授权人确认边界、资源、预算和高风险动作。
|
||||
5. 业务 Agent 或设备 Agent 按 SOP 执行。
|
||||
6. 员工只处理例外、补充材料、做判断和确认结果。
|
||||
7. 系统回写过程记录、权限记录、结果记录和异常记录。
|
||||
8. 主 Agent 形成复盘、版本记录、项目目标更新和下一步建议。
|
||||
|
||||
这条闭环必须沉淀四类记录:
|
||||
|
||||
- 权限记录:谁在何时对哪些内容拥有什么操作权限。
|
||||
- 过程记录:任务流转、操作步骤、审批意见和执行过程。
|
||||
- 结果记录:关键输出、指标达成、交付物和数据回写。
|
||||
- 异常记录:异常识别、处理过程、原因分析和改进措施。
|
||||
|
||||
## 8. 治理能力必须前置
|
||||
|
||||
PPT 第 8 页强调“AI 执行必须先可控,再谈自动化”。量产版本的功能优先级必须体现这一点。
|
||||
|
||||
| 治理能力 | 产品要求 |
|
||||
| --- | --- |
|
||||
| RBAC 角色权限 | 老板、经理、员工、子账号、设备、项目、Skill 和数据权限必须可裁剪 |
|
||||
| 审批与确认 | 高风险任务必须进入人工确认,不允许 AI 越权操作 |
|
||||
| 审计日志 | 记录任务来源、执行过程、结果回写、异常原因和审批链 |
|
||||
| 账号与设备治理 | 管理 AI 账号、真实电脑、本地 Agent、Skill 能力和授权状态 |
|
||||
| 数据边界 | 按企业、部门、项目、账号隔离上下文,越权数据不展示 |
|
||||
| SLA 与风险处置 | 异常任务、离线设备、执行失败、超时任务必须进入风险台 |
|
||||
|
||||
## 9. 量产数据安全与自动备份机制
|
||||
|
||||
当前文件型 `data/boss-state.json` 只能支撑 MVP,量产必须迁移到数据库和可回退的数据架构。
|
||||
|
||||
### 9.1 数据事实源
|
||||
|
||||
量产推荐:
|
||||
|
||||
- PostgreSQL 作为主业务库。
|
||||
- 对象存储保存附件、截图、执行产物、日志归档和备份包。
|
||||
- Redis 或队列系统只做缓存、锁和异步任务,不作为最终事实源。
|
||||
- 每一个重要状态变化都写入 append-only 事件账本。
|
||||
|
||||
核心原则:
|
||||
|
||||
- 普通业务表保存当前状态。
|
||||
- 事件账本保存“发生过什么”。
|
||||
- 审计表保存“谁允许了什么”。
|
||||
- 快照表保存“某个时刻可以回到哪里”。
|
||||
|
||||
### 9.2 自动备份
|
||||
|
||||
必须具备:
|
||||
|
||||
- PostgreSQL WAL 归档和定时全量备份。
|
||||
- 每日全量备份、小时级增量备份、关键变更前即时快照。
|
||||
- 跨区域或独立对象存储备份。
|
||||
- 备份加密、备份校验和定期恢复演练。
|
||||
- 按企业租户拆分可导出数据包,便于企业级交付和迁移。
|
||||
|
||||
建议目标:
|
||||
|
||||
- RPO:普通业务不超过 15 分钟,关键企业客户可配置到 5 分钟。
|
||||
- RTO:普通故障 1 小时内恢复,关键演示或生产客户 15 分钟内恢复核心链路。
|
||||
|
||||
### 9.3 业务级回退
|
||||
|
||||
量产回退不能只依赖数据库备份。必须提供业务级回退能力:
|
||||
|
||||
| 场景 | 回退方式 |
|
||||
| --- | --- |
|
||||
| 消息误删 | 软删除 + 会话级恢复 |
|
||||
| 项目目标或版本记录误改 | 版本化保存 + 一键恢复上一版 |
|
||||
| 权限误授权 | 授权变更事件可撤销,撤销后同步清理会话和任务上下文 |
|
||||
| Skill 安装或升级失败 | 安装前备份、版本锁、回滚到上一可用版本 |
|
||||
| 主 Agent 错误接管 | 关闭接管、取消 queued/running 主动任务、恢复原线程控制权 |
|
||||
| Codex 开发任务误操作 | 执行前 checkpoint、Git 分支隔离、diff 审批、必要时 revert |
|
||||
| Computer Use 错误点击 | 高风险动作前确认、动作录像/截图留档、支持人工中止 |
|
||||
| 企业配置误改 | 配置快照 + 审计原因 + 指定时间点恢复 |
|
||||
|
||||
## 10. Codex 协议扩展策略
|
||||
|
||||
Codex App Server 官方文档明确了它面向深度产品集成,包含 authentication、conversation history、approvals 和 streamed agent events。它当前基于 JSON-RPC 形态暴露 Thread、Turn、Item、Approval、Skill、Plugin、App、MCP、文件系统和模型列表等能力。
|
||||
|
||||
Boss 后续要把 Codex App Server 当作优先升级方向,但不能把业务模型绑死在它的字段上。
|
||||
|
||||
### 10.1 Provider 抽象
|
||||
|
||||
新增或强化统一执行 provider 接口:
|
||||
|
||||
```text
|
||||
Boss Task
|
||||
-> ExecutionBackendSelector
|
||||
-> CodexAppServerBackendAdapter
|
||||
-> CodexMcpBackendAdapter
|
||||
-> CodexCliExecBackendAdapter
|
||||
-> NativeComputerUseBackendAdapter
|
||||
-> BrowserAutomationBackendAdapter
|
||||
-> BusinessSystemApiBackendAdapter
|
||||
```
|
||||
|
||||
每个 provider 必须声明:
|
||||
|
||||
- `providerId`
|
||||
- `protocolVersion`
|
||||
- `capabilities`
|
||||
- `riskPolicy`
|
||||
- `approvalModes`
|
||||
- `streamingModes`
|
||||
- `rollbackSupport`
|
||||
- `healthCheck`
|
||||
- `fallbackProviderId`
|
||||
|
||||
### 10.2 事件归一化
|
||||
|
||||
Codex App Server 的原始事件不能直接进入前台 UI。必须先归一化为 Boss 事件:
|
||||
|
||||
| Codex 原始概念 | Boss 归一化概念 |
|
||||
| --- | --- |
|
||||
| Thread | ProjectConversation / CodexThreadRef |
|
||||
| Turn | ExecutionTurn / UserRequest |
|
||||
| Item | ExecutionItem / MessageSegment / ProgressStep |
|
||||
| Approval Request | ApprovalCard |
|
||||
| Command Execution | ExecutionStep |
|
||||
| File Change | ChangeSet |
|
||||
| Skill | SkillCapability |
|
||||
| Plugin/App | ExternalCapability |
|
||||
| Error | RiskEvent / TaskFailure |
|
||||
|
||||
### 10.3 版本兼容机制
|
||||
|
||||
每次 Codex 协议升级时必须走以下流程:
|
||||
|
||||
1. 拉取或生成当前 Codex App Server TypeScript / JSON Schema。
|
||||
2. 保存到 `docs/protocol-snapshots/codex-app-server/<version>/`。
|
||||
3. 自动生成 provider capability manifest。
|
||||
4. 跑协议兼容测试,确认 Thread、Turn、Item、Approval、Skill、Plugin 和 model/list 是否仍能映射。
|
||||
5. 新能力先挂 feature flag,不直接进入生产默认链路。
|
||||
6. 对关键企业租户先做灰度,再全量开启。
|
||||
|
||||
### 10.4 禁止写死的内容
|
||||
|
||||
以下内容不得写死进业务逻辑或 UI:
|
||||
|
||||
- Codex CLI 输出 envelope。
|
||||
- Codex Desktop 私有数据库字段。
|
||||
- 某一个 Codex 版本的 stderr/stdout 文案。
|
||||
- 某一个 App Server event 的完整原始字段。
|
||||
- 本地线程文件路径。
|
||||
- Codex 具体模型列表。
|
||||
- Skill 的本地绝对路径。
|
||||
|
||||
允许写死的是 Boss 自己的领域模型、权限模型、审计模型和回退模型。
|
||||
|
||||
## 11. 进度卡与实时协作
|
||||
|
||||
PPT 强调从目标到结果的可追踪闭环,Codex App Server 又提供 streamed agent events。Boss 的聊天窗口必须把“正在做什么”表达为结构化进度,而不是刷屏输出过程噪音。
|
||||
|
||||
建议统一进度卡结构:
|
||||
|
||||
- 进度:计划、执行中、完成、失败、等待审批。
|
||||
- 分支详情:Git 分支、diff 摘要、测试状态、生成产物。
|
||||
- 生成结果:文档、APK、图片、代码文件、报告。
|
||||
- 后台智能体:主 Agent、业务 Agent、Codex、explorer、Computer Use provider。
|
||||
- 风险:权限不足、设备离线、测试失败、审批等待、回退点。
|
||||
|
||||
执行过程中的低价值输出默认折叠,只显示最终结果和关键节点。用户需要查看细节时再展开过程日志。
|
||||
|
||||
## 12. Skill 治理与共享
|
||||
|
||||
Skill 是 Boss 企业扩展性的关键能力,但不能只做本机目录同步。
|
||||
|
||||
量产 Skill 治理需要支持:
|
||||
|
||||
- 平台级 Skill 市场。
|
||||
- 企业级私有 Skill 仓库。
|
||||
- 按公司、部门、账号、设备、项目授权 Skill。
|
||||
- Skill 安装、升级、回滚、版本锁。
|
||||
- Skill 依赖、来源、checksum、签名和安全等级。
|
||||
- Skill 使用审计和失败率统计。
|
||||
- 多电脑共享 Skill,但执行时仍按设备本地能力和权限裁剪。
|
||||
|
||||
主 Agent 在选择 Skill 时,只能看到当前用户、当前企业、当前设备和当前项目被授权的 Skill。
|
||||
|
||||
## 13. 当前落地进度与量产剩余清单
|
||||
|
||||
本节用于承接当前开发状态。这里的“已落地”只表示代码和本地回归已具备,不代表已经完成生产灰度、客户验收或长期稳定性验证。
|
||||
|
||||
### 13.1 已落地的量产底座
|
||||
|
||||
| 方向 | 当前状态 | 关键文件 / 接口 |
|
||||
| --- | --- | --- |
|
||||
| 多租户与 RBAC | 已具备最高管理员、企业管理员、成员账号、公司归属、设备 / 项目 / Skill 授权和审计日志 | `src/lib/boss-permissions.ts`、`GET/POST /api/v1/admin/access` |
|
||||
| 设备撤权 | 已支持 `revoke_device`:清空设备 token、置离线、写 `device.revoked` 审计,并阻断 heartbeat、任务认领、Skill 同步、日志上报、boss-agent OTA | `src/lib/boss-data.ts`、`src/app/api/device-heartbeat/route.ts`、`src/app/api/v1/admin/access/route.ts` |
|
||||
| 设备心跳安全 | 已禁止无 token 的已存在设备续命,禁止未准备 enrollment 的新设备自注册,吊销设备不会刷新 `lastSeenAt / status / projects / projectCandidates` | `POST /api/device-heartbeat` |
|
||||
| 主 Agent 任务租约 | 已支持 `attemptCount / maxAttempts / leaseExpiresAt`,运行中任务租约过期可重试认领,超过上限转 `timed_out` | `claimNextMasterAgentTask()`、`POST /api/v1/master-agent/tasks/claim` |
|
||||
| 主 Agent 任务取消 | 已支持取消 `queued / running / needs_user_action`,写 `canceledAt / canceledBy / cancelReason`,迟到 complete 不覆盖终态 | `POST /api/v1/master-agent/tasks/[taskId]/cancel` |
|
||||
| Codex 桌面同步 | 已支持 APP 用户消息镜像到 Codex Desktop rollout,并通过本机刷新桥提示桌面端感知更新 | `local-agent`、Codex Desktop Refresh Bridge |
|
||||
| Codex App Server 接入 | 已有第一批 provider runner,turn 启动前失败可回退 CLI,turn 启动后不重复执行 | `local-agent/codex-app-server-runner.mjs` |
|
||||
| 电脑控制 provider | macOS 链路优先 Codex Computer Use,失败后回退 CUA Driver;browser / desktop 任务统一走 `MasterAgentTask` 和进度卡 | `local-agent/computer-use-task-runner.mjs`、`scripts/codex-computer-use-runtime.mjs`、`scripts/cua-driver-computer-use-runtime.mjs` |
|
||||
| 状态快照与回退 | 文件状态已有自动快照、手动创建快照和恢复前 pre-restore 快照 | `src/lib/boss-state-backups.ts`、`GET/POST /api/v1/admin/backups` |
|
||||
| PostgreSQL 切换前置 | 已有 Postgres JSONB store、schema 校验、dry-run 迁移、Postgres 备份导出和恢复脚本 | `src/lib/boss-state-store.ts`、`scripts/boss-state-store-maintenance.mjs` |
|
||||
| boss-agent Mac OTA | 已支持 Mac agent 包检查、下载、校验和覆盖安装,并保留绑定配置 | `src/lib/boss-agent-ota.ts`、`local-agent/boss-agent-ota-runner.mjs` |
|
||||
| Skill 生命周期治理 | 已支持 install / update / uninstall / rollback / version_lock 请求、设备端 claim / complete、source allowlist、checksum、更新前备份和失败恢复 | `GET/POST /api/v1/admin/skills/requests`、`skill-requests/claim` |
|
||||
|
||||
### 13.2 量产 P0:上线前必须补齐
|
||||
|
||||
P0 的定义:不补齐会影响企业客户数据安全、权限边界、稳定演示或生产事故恢复。
|
||||
|
||||
1. 数据库正式切换:把 `BOSS_STATE_STORE=postgres` 从可选适配层推进到生产主路径,补正式 PostgreSQL 表拆分、迁移脚本、灰度开关和回滚剧本。
|
||||
2. 事件账本:新增 append-only event ledger,覆盖账号、设备、权限、任务、审批、Skill、备份、Computer Use 动作和主 Agent 接管。
|
||||
3. 备份恢复演练自动化:把文件快照和 Postgres 备份纳入后台“恢复演练”流程,记录演练时间、耗时、校验结果和负责人。
|
||||
4. 任务调度服务化:把 `MasterAgentTask` 从状态文件轮询升级为数据库任务队列或可靠队列,支持 lease、retry、cancel、dead-letter 和 worker 心跳。
|
||||
5. 企业 SSO / IdP:补 OIDC/SAML 之一,支持企业管理员配置登录策略、MFA 强制、离职回收和会话全量吊销。
|
||||
6. 设备绑定与正版授权:boss-agent 绑定二维码、授权到期、许可证校验、设备换绑、离线宽限期和吊销恢复流程需要闭环。
|
||||
7. 审计不可抵赖:关键操作需要稳定审计 ID、操作者、来源 IP、UA、前后快照、关联任务和导出能力。
|
||||
8. 高风险审批:Computer Use、代码提交、部署、权限变更、批量 Skill 下发必须进入审批卡,不允许只靠主 Agent 自行判断。
|
||||
9. 生产监控与告警:补服务端 metrics、错误率、任务积压、设备离线、API 失败、OTA 失败、备份失败和客户维度 SLA 告警。
|
||||
10. 客户数据隔离验收:对所有 Web / APP / API 投影视图做租户隔离回归,确保子账号看不到未授权设备、项目、线程、Skill、日志和附件。
|
||||
|
||||
### 13.3 量产 P1:首批企业客户试点必须补齐
|
||||
|
||||
P1 的定义:不影响最小上线,但会影响试点客户规模化复制、运营效率和长期留存。
|
||||
|
||||
1. 管理后台企业化:平台总后台和企业自管后台进一步拆清,补租户套餐、授权席位、设备额度、用量统计、客户健康分和客户成功视图。
|
||||
2. Skill 市场:支持平台 Skill、企业私有 Skill、版本签名、依赖扫描、灰度发布、回滚统计和失败率看板。
|
||||
3. 业务 Agent 目录:把销售、客服、财务、HR、项目、行政、运维 Agent 做成可配置目录,并绑定 SOP、权限和数据源。
|
||||
4. 进度卡增强:把 `execution_progress` 扩展为统一 task timeline,支持步骤、截图、文件变更、测试结果、审批记录和可展开过程日志。
|
||||
5. Codex 协议快照:建立 `docs/protocol-snapshots/codex-app-server/`,自动比较 protocol version、capabilities、model/list、approval、skill、plugin 变化。
|
||||
6. 多入口一致会话:Telegram 已有最小链路,后续补飞书、企业微信或微信生态入口,并统一权限、审计、通知和会话状态。
|
||||
7. 企业知识库与长期记忆:把项目目标、版本记录、任务结果、回退点、客户 SOP 和主 Agent 记忆纳入统一版本化记录。
|
||||
8. Computer Use 证据链:关键桌面动作需要截图 / 屏幕录制 / AX tree 摘要 / 操作序列留档,便于复盘和客户信任。
|
||||
|
||||
### 13.4 当前验证基线
|
||||
|
||||
截至本次文档更新,以下本地验证命令作为当前量产底座的最小回归基线:
|
||||
|
||||
```bash
|
||||
npx tsx --test tests/device-revocation-auth.test.ts tests/master-agent-task-reliability.test.ts tests/device-import-draft.test.ts tests/ai-account-validation.test.ts tests/device-import-candidate-id-regression.test.ts tests/master-agent-task-claim-route.test.ts tests/device-execution-conflict.test.ts tests/browser-desktop-control-summary-message.test.ts tests/skill-lifecycle-route.test.ts tests/state-store-maintenance-script.test.ts tests/boss-state-store.test.ts
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
本次验证结果:49 项核心测试通过,`npm run lint` 通过,`npm run build` 通过。
|
||||
|
||||
## 14. 90 天量产试点路径
|
||||
|
||||
PPT 第 10 页给出的 90 天路径进入后续 To B 交付方法论。
|
||||
|
||||
### 阶段 1:0-30 天
|
||||
|
||||
目标:选择 1-2 个高频流程,搭好企业账号、权限、设备和数据接入基础。
|
||||
|
||||
交付:
|
||||
|
||||
- 梳理老板、经理、员工权限。
|
||||
- 选择高频、规则清晰、跨系统搬运多的流程。
|
||||
- 接入关键系统和数据,如 CRM、ERP、财务、OA、知识库。
|
||||
- 接入真实电脑和本地 Agent。
|
||||
|
||||
### 阶段 2:31-60 天
|
||||
|
||||
目标:部署主 Agent 和 2-3 个业务 Agent,跑真实任务。
|
||||
|
||||
交付:
|
||||
|
||||
- 主 Agent 统筹,业务 Agent 执行具体流程。
|
||||
- 建立审批、审计、异常处理和权限边界。
|
||||
- 跑通真实任务并持续优化 SOP。
|
||||
- 形成可复制配置模板。
|
||||
|
||||
### 阶段 3:61-90 天
|
||||
|
||||
目标:接入经营看板,评估效率和复制条件。
|
||||
|
||||
交付:
|
||||
|
||||
- 实时展示任务进度、效率指标和业务价值。
|
||||
- 评估从发起到结果的整体响应时间。
|
||||
- 评估人工汇总减少、流程稳定性和复制条件。
|
||||
- 输出下一部门复制方案。
|
||||
|
||||
成功标准:
|
||||
|
||||
- 可持续执行。
|
||||
- 可审批。
|
||||
- 可追踪。
|
||||
- 可复制。
|
||||
|
||||
## 15. 产品开发优先级
|
||||
|
||||
第一优先级:稳定和治理。
|
||||
|
||||
- 数据库正式替换文件状态。
|
||||
- append-only 事件账本。
|
||||
- 自动备份和恢复演练。
|
||||
- 业务级回退。
|
||||
- 主 Agent 任务取消、接管关闭和主动任务清理。当前已有任务 cancel / timed_out 底座,仍需数据库队列化和 dead-letter。
|
||||
- 设备离线、执行失败、审批超时进入风险台。
|
||||
|
||||
第二优先级:Codex App Server 深度接入。
|
||||
|
||||
- App Server provider adapter。当前已有第一批 runner,仍需协议快照、能力清单和 streamed events 完整映射。
|
||||
- Thread / Turn / Item 映射。
|
||||
- Approval card 映射。
|
||||
- streamed events 进入进度卡。
|
||||
- skills/list、model/list、plugin/list 进入能力清单。
|
||||
- 协议快照和兼容测试。
|
||||
|
||||
第三优先级:企业协作网络。
|
||||
|
||||
- 老板端、经理端、员工端权限视图。
|
||||
- 业务 Agent 目录和 SOP。
|
||||
- 部门/项目维度执行闭环。
|
||||
- Skill 企业分配和跨设备同步。
|
||||
- 平台总后台风险与客户成功视图。
|
||||
|
||||
第四优先级:前瞻扩展。
|
||||
|
||||
- Telegram、飞书、微信、Web、APP 多入口一致会话。
|
||||
- 自研 Computer Use 与 Codex Computer Use 并存。
|
||||
- 多 provider 智能路由。
|
||||
- 企业知识库和长期记忆。
|
||||
- 自动复盘、流程优化和策略建议。
|
||||
|
||||
## 16. 非协商性原则
|
||||
|
||||
- 不允许把系统提示词、内部 prompt、设备 token、API key、工作目录调度说明写进用户可见消息。
|
||||
- 不允许主 Agent 在用户取消接管后继续主动向线程发起任务。
|
||||
- 不允许没有审计记录的高风险操作。
|
||||
- 不允许没有回退点的批量权限、Skill、数据或代码变更。
|
||||
- 不允许把 Codex 当前某个版本的协议字段直接作为 Boss 业务事实源。
|
||||
- 不允许把过程噪音当作未读消息。
|
||||
- 不允许让企业子账号看到未授权设备、项目、Skill 或线程上下文。
|
||||
|
||||
## 17. 下一步落地清单
|
||||
|
||||
1. 输出 PostgreSQL 正式 schema 设计:账号、企业、设备、项目、线程、消息、任务、审批、事件账本、审计、备份、Skill。
|
||||
2. 把 `MasterAgentTask` 调度从文件状态轮询迁到可靠队列或数据库任务表,并保留现有 lease / retry / cancel 语义。
|
||||
3. 新增 `docs/protocol-snapshots/codex-app-server/` 目录规范和兼容测试。
|
||||
4. 把 `execution_progress` 进度卡扩展成统一 task timeline。
|
||||
5. 把项目目标、版本记录、任务结果和回退点纳入同一套版本化记录。
|
||||
6. 为平台总后台增加恢复演练、租户风险、设备离线、主 Agent 失败和任务积压看板。
|
||||
7. 为 Skill 治理增加签名、依赖扫描、灰度发布和失败率统计。
|
||||
8. 为 boss-agent 增加企业正版授权、授权到期提醒、离线宽限和设备换绑流程。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user