Compare commits
346 Commits
codex/nati
...
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 | ||
|
|
e0c0ea1814 | ||
|
|
5bf745f45f | ||
|
|
164a7568a7 | ||
|
|
05dc9d8788 | ||
|
|
1b0f126d4f | ||
|
|
a084688e35 | ||
|
|
0781a56aad | ||
|
|
7131ee9eb1 | ||
|
|
68da424eb8 | ||
|
|
7593cc9cea | ||
|
|
b1a0516717 | ||
|
|
c4dbfc7398 | ||
|
|
0cba837ed3 | ||
|
|
d1e5a1ac5e | ||
|
|
07ecce3d0d | ||
|
|
0c01627d67 | ||
|
|
4c31dd7e98 | ||
|
|
4093c41949 | ||
|
|
b06b084438 | ||
|
|
8fc94f1849 | ||
|
|
1de9ae0492 | ||
|
|
4f59d59014 | ||
|
|
b5d6495017 | ||
|
|
9268f64506 | ||
|
|
ef3bf35463 | ||
|
|
0b0bc5152f | ||
|
|
a42e5b75dc | ||
|
|
45329159f5 | ||
|
|
e0e8d4f687 | ||
|
|
17ecd56b57 | ||
|
|
aaaf1926b4 | ||
|
|
f83ab50d6b | ||
|
|
233f61a649 | ||
|
|
b1fa3c9b26 | ||
|
|
4052822595 | ||
|
|
67d37c2c21 | ||
|
|
cc1afe8173 | ||
|
|
6153e94000 | ||
|
|
a43bb92f3c | ||
|
|
c5223c7c16 | ||
|
|
992f8dbba4 | ||
|
|
6d90123092 | ||
|
|
5782804df3 | ||
|
|
5789707072 | ||
|
|
3564aeaf2e | ||
|
|
9d7d2f4d17 | ||
|
|
2c47df702e | ||
|
|
43c733069c | ||
|
|
27ab594921 | ||
|
|
d04eca4703 | ||
|
|
9de4fb7d40 | ||
|
|
6f2206a438 | ||
|
|
d28afb2df1 | ||
|
|
b7492e4789 | ||
|
|
6956d1ac78 | ||
|
|
a46f11cf6c | ||
|
|
272698234d | ||
|
|
08a746c3bf | ||
|
|
0fcbf2d0a0 | ||
|
|
7206be05b6 | ||
|
|
c8156d5f40 | ||
|
|
447e9e0b62 | ||
|
|
a17c702edf | ||
|
|
f9a0d205df | ||
|
|
20b296ce4f | ||
|
|
ef17947635 | ||
|
|
b60da88e37 | ||
|
|
f046adc393 | ||
|
|
e00f7a55ea | ||
|
|
35913f9d1d | ||
|
|
7cc33d391b | ||
|
|
2a5962f767 | ||
|
|
52f7d08b9e | ||
|
|
71aa1a7143 | ||
|
|
6083079be9 | ||
|
|
0bae3a78ec | ||
|
|
28f692706b | ||
|
|
50d5327afd | ||
|
|
93c4574130 | ||
|
|
e649e2f9ac | ||
|
|
af0cc3fead | ||
|
|
276beb3486 | ||
|
|
2e4d6f693d | ||
|
|
2a34c19cc9 | ||
|
|
5a53b60f13 | ||
|
|
da78e82a90 | ||
|
|
40b78c5cae | ||
|
|
4ab0414d43 | ||
|
|
4d9b8e2976 | ||
|
|
010d8eda2d | ||
|
|
7d578aa12f | ||
|
|
f69eebd82d | ||
|
|
8e12fa1e8c | ||
|
|
f180a5e4a8 | ||
|
|
48a45f04c6 | ||
|
|
99acf26b1b | ||
|
|
4e78fb0a34 | ||
|
|
22406ed587 | ||
|
|
c4ce8d4b0a | ||
|
|
6bad739ab9 | ||
|
|
cf57b5058f | ||
|
|
432cf97541 | ||
|
|
01f438e3af | ||
|
|
908ad8858b | ||
|
|
9c53e583ba | ||
|
|
2f741c327f | ||
|
|
829005ba66 | ||
|
|
5ebb37cbfc | ||
|
|
425d8992ef | ||
|
|
d126c46479 | ||
|
|
9d19163b0d | ||
|
|
17dca04b6f | ||
|
|
062cab8e9a | ||
|
|
bf4b27b062 | ||
|
|
35bcf92d72 | ||
|
|
31004c512a | ||
|
|
05b9bee9e8 | ||
|
|
1a64fd9f29 | ||
|
|
5157a0ac07 | ||
|
|
c30b0a66ae | ||
|
|
da7d4e0a7d | ||
|
|
4d2d567bf9 | ||
|
|
da55071a99 | ||
|
|
459b301939 | ||
|
|
95f164e552 | ||
|
|
354c8b1f0b | ||
|
|
ad7dd94d95 | ||
|
|
42cf489450 | ||
|
|
24241d1f64 | ||
|
|
ec45bed59f | ||
|
|
60f5e2d7d6 | ||
|
|
8e2350e89d | ||
|
|
6c999fb951 | ||
|
|
39b576cc42 | ||
|
|
8daaea01fd | ||
|
|
bfb7c43447 | ||
|
|
519ecb56eb | ||
|
|
70e8a13368 | ||
|
|
8a62e72fd5 | ||
|
|
a3a4f3e980 | ||
|
|
384dd570de | ||
|
|
e348d6cc5d | ||
|
|
5e23e1d408 | ||
|
|
3a45e41b1b | ||
|
|
22442979fe | ||
|
|
3a03ec4cbd | ||
|
|
a4655439dd | ||
|
|
e52932e8ef | ||
|
|
87093677b8 | ||
|
|
a27c7da7d4 | ||
|
|
1c45a88205 | ||
|
|
60d69eb222 | ||
|
|
ba01ae5393 | ||
|
|
d316f0490e | ||
|
|
9000a9f185 | ||
|
|
4312b248a7 | ||
|
|
811d011178 | ||
|
|
d518878faa | ||
|
|
ee2fab7ceb | ||
|
|
c3ee76909d | ||
|
|
5c69eaa26d | ||
|
|
87ffe19f78 | ||
|
|
dcbff3cc7d | ||
|
|
be31503d22 | ||
|
|
02fcc56332 | ||
|
|
ec7081f6cc | ||
|
|
013d9566be | ||
|
|
e741952295 | ||
|
|
bc464905a5 | ||
|
|
e9ae37028e | ||
|
|
71d0979292 | ||
|
|
70494fc15b | ||
|
|
f417fe1955 | ||
|
|
4aed93e90c | ||
|
|
9d7f38412a | ||
|
|
0cb2171dd3 | ||
|
|
4336dc22a7 | ||
|
|
9c02ebb574 | ||
|
|
5b590f7cc1 | ||
|
|
7c6101f22b | ||
|
|
5eb1246f02 | ||
|
|
40861c63da | ||
|
|
03ac40f427 | ||
|
|
98dd0e3cd5 | ||
|
|
9c15c30a41 | ||
|
|
038c2bd088 | ||
|
|
745b47e812 | ||
|
|
3b2bf59b65 | ||
|
|
11724e9834 | ||
|
|
74ea7151ad | ||
|
|
1cd3a15a5d | ||
|
|
81f4245763 | ||
|
|
40d93c05d1 | ||
|
|
949dcf7845 | ||
|
|
1b55072d9b | ||
|
|
6402096639 | ||
|
|
ffefc62b35 | ||
|
|
3724b3b444 | ||
|
|
ef630f3572 | ||
|
|
062b46bd41 | ||
|
|
fe186ad8d5 | ||
|
|
e94e91a0f7 | ||
|
|
32960f8ecc | ||
|
|
c6e8d19ee5 | ||
|
|
e9ab62e94d | ||
|
|
e051a49f7a | ||
|
|
5fb75b50b4 | ||
|
|
88ab2d011a | ||
|
|
18dc7c6120 | ||
|
|
1e476a2097 | ||
|
|
9e4b64ba9e | ||
|
|
8273340f7f | ||
|
|
3307f79162 | ||
|
|
de23a6e921 | ||
|
|
aa75506364 | ||
|
|
c3900a11ec | ||
|
|
4262c8fb5c | ||
|
|
e4ff24a18f | ||
|
|
3cb4405b14 | ||
|
|
a5e8ba2b7e | ||
|
|
cc08ca28aa | ||
|
|
a3a7f43626 | ||
|
|
64ad401d0c | ||
|
|
d2291af32c | ||
|
|
7109f1d3db | ||
|
|
200fc18210 | ||
|
|
13c67425ab | ||
|
|
0783f4da14 | ||
|
|
42063db78f | ||
|
|
c90dea4b7c | ||
|
|
9613c3c154 | ||
|
|
227d270505 | ||
|
|
b606af66f6 | ||
|
|
a9e8bb9ddd | ||
|
|
f0735b31e5 | ||
|
|
afa7e79ad2 | ||
|
|
e27ea1e071 | ||
|
|
0a3390b132 | ||
|
|
4dbf4ac1de | ||
|
|
6559ad5bce | ||
|
|
ae571a76ff | ||
|
|
63ceef9871 | ||
|
|
8da592bddf | ||
|
|
9e0b5b223f | ||
|
|
ff56617fdb | ||
|
|
05e26afbf1 | ||
|
|
b794ba05fa | ||
|
|
ce8dcad41c | ||
|
|
17300c49ea | ||
|
|
efcefd8a62 | ||
|
|
785db90a7a | ||
|
|
8439428479 |
27
.env.server.example
Normal file
27
.env.server.example
Normal file
@@ -0,0 +1,27 @@
|
||||
BOSS_AUTH_VERIFICATION_MODE=fixed
|
||||
BOSS_AUTH_FIXED_CODE=000000
|
||||
BOSS_STATE_FILE=/opt/boss/data/boss-state.json
|
||||
|
||||
# 切到真实邮件验证码时,改成:
|
||||
# BOSS_AUTH_VERIFICATION_MODE=email
|
||||
# BOSS_AUTH_FIXED_CODE=
|
||||
|
||||
BOSS_MAIL_DOMAIN=boss.hyzq.net
|
||||
BOSS_MAIL_FROM_ADDRESS=verify@boss.hyzq.net
|
||||
BOSS_MAIL_FROM_NAME=Boss Verify
|
||||
BOSS_SENDMAIL_PATH=/usr/sbin/sendmail
|
||||
|
||||
# 可选:启用 ClawBackendAdapter(默认关闭)
|
||||
# BOSS_CLAW_ENABLED=true
|
||||
# BOSS_CLAW_COMMAND=node
|
||||
# BOSS_CLAW_ARGS=scripts/claw-runtime-smoke.mjs
|
||||
# BOSS_CLAW_WORKDIR=/opt/boss
|
||||
# BOSS_CLAW_TIMEOUT_MS=45000
|
||||
# BOSS_CLAW_DEFAULT_MODEL=gpt-5.4
|
||||
|
||||
# 可选:启用 OmxTeamBackendAdapter(默认关闭)
|
||||
# BOSS_OMX_ENABLED=true
|
||||
# BOSS_OMX_COMMAND=node
|
||||
# BOSS_OMX_ARGS=scripts/omx-team-smoke.mjs
|
||||
# BOSS_OMX_WORKDIR=/opt/boss
|
||||
# BOSS_OMX_TIMEOUT_MS=45000
|
||||
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
|
||||
|
||||
203
README.md
203
README.md
@@ -10,14 +10,17 @@
|
||||
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`
|
||||
|
||||
## 当前有效目录
|
||||
|
||||
- `src/app`:当前 Web 页面和 API 路由
|
||||
- `src/components`:共享 UI 和交互组件
|
||||
- `src/lib`:文件型状态模型和聚合投影视图
|
||||
- `src/lib/execution`:当前已落地的执行底座抽象层,包含 `ExecutionBackend / PromptAssembler / PermissionPolicy / RemoteRuntimeAdapter / OrchestrationBackend` 默认实现
|
||||
- `local-agent`:本地设备端心跳 + thread-context 上报服务
|
||||
- `deployment`:`Caddy`、`systemd`、`launchd` 配置
|
||||
- `scripts`:本地启动、安装、远端部署脚本
|
||||
@@ -33,7 +36,7 @@
|
||||
- `src/boss_control`:空占位目录,不参与当前运行
|
||||
- `src/boss_device_agent`:空占位目录,不参与当前运行
|
||||
|
||||
## 当前运行状态(2026-03-26)
|
||||
## 当前运行状态(2026-04-03)
|
||||
|
||||
本地:
|
||||
|
||||
@@ -43,16 +46,61 @@
|
||||
- `GET http://127.0.0.1:3000/api/v1/conversations` 正常
|
||||
- `GET http://127.0.0.1:3000/api/v1/projects/master-agent` 正常,主 Agent 项目页已能显示最近 APP 日志
|
||||
- `GET http://127.0.0.1:3000/api/v1/accounts` 正常,已返回主 GPT / 备用 GPT / API 容灾账号摘要
|
||||
- `POST http://127.0.0.1:3000/api/v1/accounts/master-codex-primary/validate` 正常,已验证会明确提示“主 GPT 不在手机里直接登录”,并校验绑定设备在线状态
|
||||
- `POST http://127.0.0.1:3102/api/v1/accounts/onboard/master-node` 正常,已验证会保存 Master Codex Node 绑定信息并返回显式登录指引
|
||||
- `POST http://127.0.0.1:3102/api/v1/accounts/onboard/openai-api` 正常,已验证会对 API Key 做真实 OpenAI 探针校验;无效 Key 会返回真实错误
|
||||
- `GET http://127.0.0.1:3000/api/v1/devices/mac-studio/skills` 正常,已返回本机同步 Skill 列表
|
||||
- `POST http://127.0.0.1:3000/api/auth/login` 正常,会写入 `boss_session` Cookie
|
||||
- `GET http://127.0.0.1:3000/api/auth/session` 正常
|
||||
- `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 包
|
||||
- `GET http://127.0.0.1:4317/health` 正常
|
||||
- 当前这台开发机的 `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`
|
||||
|
||||
服务器:
|
||||
@@ -90,13 +138,69 @@ Android APK:
|
||||
- 已生成 Android debug APK:`android/app/build/outputs/apk/debug/app-debug.apk`
|
||||
- 已生成 Android signed release APK:`android/app/build/outputs/apk/release/app-release.apk`
|
||||
- `npm run apk:release` 还会额外产出带版本号的文件:`android/app/build/outputs/apk/release/boss-android-v{versionName}-release.apk`
|
||||
- 当前最新 release 构建版本:`2.1.1`(`versionCode=8`)
|
||||
- 当前最新 release 构建版本:`2.5.11`(`versionCode=24`)
|
||||
- 当前 APK 已切到原生 Android 客户端:`MainActivity + BossApiClient + 原生 XML 布局`
|
||||
- 当前原生活动页已经覆盖:会话首页、项目详情、项目目标、版本记录、消息转发、线程详情、设备详情、添加设备、账号与安全、设置、AI 账号、技能、运维中心、关于
|
||||
- 真机开发约束:用户已明确切换到当前连接的 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]`
|
||||
- 当前会话搜索仍然保留线程可达性:如果命中单线程项目,会直接进入该线程;如果命中多线程项目里的某条线程,结果会显示 `项目 / 线程`,点击后先进入项目文件夹页并定位到对应线程,不会把首页重新打平成线程列表
|
||||
- 当前首页的 `置顶 / 已读 / 时间 / 预览 / 上下文环` 都已经按项目会话聚合:单线程项目直接作用在线程,多线程项目则作用在文件夹归档项,文件夹时间和预览取内部最新线程,上下文环取内部最需要关注的线程
|
||||
- 当前会话信息页已经支持按微信最新逻辑改线程名;群聊会作为独立新会话创建,默认自动命名,创建后可在群资料页改名
|
||||
- 原生顶部安全区当前已补齐状态栏 inset 处理,并把首页 / 会话信息 / 群资料 / 发起群聊 / 转发目标等页面的顶部操作区域收回到可点击安全区内
|
||||
- 当前消息转发已经切到微信式链路:长按消息可直接 `转发 / 多选 / 复制 / 删除`,多选后底部只保留 `转发`,统一进入原生会话选择页
|
||||
- 当前单条消息转发会在目标会话里显示为普通转发消息;多条消息会合并成一张“聊天记录”卡片,不再走旧的备注转发页
|
||||
- 当前群聊调度主链已补上第一轮业务闭环:群聊文字消息会先进入主 Agent 生成推荐下发方案,用户确认后创建真正的线程执行单,执行完成后会把线程原始结果回写到群聊,再追加一条主 Agent 汇总
|
||||
- 当前 `approval_required` 群聊已补齐两条审批动作:可以确认主 Agent 推荐,也可以明确拒绝;拒绝后会把群审批状态写成 `rejected`,并在群里追加系统提示,不会继续下发到线程
|
||||
- 当前原生聊天页已把待审批推荐前移到主消息流:`ProjectDetailActivity` 会直接显示 `确认下发 / 拒绝`,刷新后也能恢复最近一条待确认推荐
|
||||
- 当前 `approval_required` 群聊在已有待确认推荐时,会拒绝继续生成新的推荐,并提示用户先确认或拒绝当前推荐,避免审批消息叠加
|
||||
- 当前三条聊天主链都已接入真实等待链路:`主 Agent 单聊 / 普通线程单聊 / 群聊确认下发` 当前都会返回任务信息,原生 Android 会保持等待直到收到真实回写或明确超时提示
|
||||
- 当前 `我的 > AI 账号` 已补 `登录 OpenAI 平台账号`、`接入阿里百炼备用账号` 与 `绑定 Master Codex Node` 三条显式入口;OpenAI API 登录成功后会立即设为当前主控,阿里百炼账号会作为备用链路保存
|
||||
- 当前 `登录 OpenAI 平台账号` 已升级成浏览器辅助登录流:会先进入原生引导页,再自动打开 `OpenAI Platform` 登录页;用户登录后可直接跳到 `API Keys` 页面,回 APP 粘贴 key 完成接入
|
||||
- 当前 `AI 账号` 页顶部会显式展示“当前主控身份”,并提供 `校验主控 / 测试主 Agent 对话` 两个动作,切换主控后可直接验证聊天通路
|
||||
- 当前阿里百炼备用链已完成一次真实线上闭环验证:手动切到 `aliyun-qwen-backup` 后,`POST /api/v1/projects/master-agent/messages` 会返回 `queued`,并已实际回流 `阿里备用链正常。` 到 `master-agent` 会话
|
||||
- 当前 `我的 > AI 账号` 已把阿里百炼备用模型切成预设选择:Web 和原生 Android 都支持直接切换 `qwen3.5-plus / qwen3.5-flash`,只有在预设不适用时才需要填自定义模型
|
||||
- 当前 `我的 > 主 Agent 提示词 / 记忆` 页面已补:管理员全局主提示词只读展示、用户主提示词、当前对话附加提示词,以及用户通用记忆 / 跨项目项目记忆的新增、编辑、删除接口;当前对话设置按登录账号隔离,管理员全局主提示词不可覆盖
|
||||
- 当前 Web 端 `master-agent` 会话页右上角也已补齐微信式三点菜单,支持直接进入 `提示词 / 模型 / 推理强度 / 记忆 / 刷新`
|
||||
- 当前 `approval_required` 群聊在 Web 端已统一用单一状态快照驱动:如果有新的待确认推荐,会自动折叠旧的拒绝态;如果上次推荐已拒绝,会明确展示“重新生成新的推荐”的恢复入口
|
||||
- 当前 `OpenAiOnboardingActivity` 在登录成功后会直接给出 `测试主 Agent 对话` 入口,可一键跳到 `master-agent` 聊天页
|
||||
- 当前主控若还是 `Master Codex Node`,但节点离线或执行立即失败,主 Agent 会优先尝试已配置的 `OpenAI API / 阿里百炼 Qwen` 备用账号,避免聊天直接掉成失败日志
|
||||
- 当前原生 Android 的聊天发送已改成更短的客户端等待窗口;`master-agent` 单聊依赖服务端快速入队和消息流里的“思考中 / 超时 / 重试等待”状态,不再要求客户端长时间同步阻塞
|
||||
- 当前群资料页已经支持“修复群成员”:如果历史脏群里混入了 `master-agent` 或失效线程引用,前台会明确提示并允许重新选择真实线程成员,修复后会正式写回群成员账本
|
||||
- 当前原生聊天页也会直接提示“修复群成员”:当群里存在失效线程或不可下发成员时,`ProjectDetailActivity` 会在消息流上方直接给出 `去修复` 入口,并跳到群资料页完成修复
|
||||
- 当前 Web 群聊页也已补上待确认推荐的刷新恢复:群聊详情会在服务端读取最近一条 `pending_user_confirmation` 的 dispatch plan,并在刷新或重新进入页面后继续显示“等待你确认主 Agent 推荐”
|
||||
- 当前设备导入主链已补上真实审核闭环:设备 heartbeat 可上报真实项目候选,服务端会生成 `import draft`;用户提交勾选后会先排队 `device_import_resolution` 主 Agent 任务,前台进入“主 Agent 审核中”并自动刷新,任务完成后才写回正式导入建议,再把选中的线程真正落成聊天窗口
|
||||
- 当前新设备导入前台已经接通:Web `添加设备` 成功后会直接进入“导入项目”步骤;设备页详情里也可再次打开导入草稿。原生 Android 端同样已补 `DeviceImportDraftActivity`,可完成 `勾选 -> 预览决议 -> 应用导入`
|
||||
- 当前设备导入前台文案与状态卡已收口:会明确显示 `等待候选线程 / 等待勾选 / 建议已生成 / 已导入`,并在导入后回显真正落到会话首页的线程名
|
||||
- 当前已导入设备也支持自动同步项目理解:绑定设备 heartbeat 发现活跃线程有新活动、或线程本身刚回写新结果时,都会自动排隐藏的主 Agent 理解任务,把项目目标、当前进度和技术架构回写到项目理解和项目记忆
|
||||
- 当前主 Agent 对活跃线程的同步已经升级成“线程状态文档 + 最近进展事件 + 关键时刻深拉”:heartbeat / thread reply 平时优先追加轻量进展事件,只有在线程首次理解、状态变薄、长时间未全量刷新或主 Agent 真正接手时,才重新触发全量理解
|
||||
- 当前 Web 和 Android 都已经提供线程状态只读入口:Web 可直接打开 `/conversations/[projectId]/thread-status`,Android 可在单线程 `会话信息` 中进入 `ThreadStatusActivity`,查看当前目标、阶段、进度、架构、阻塞、建议下一步,以及最近 5 条进展事件
|
||||
- 当前 `dispatch_execution` 完成回写已补幂等:同一个执行单重复完成,不会再向群聊重复追加线程原始回复和主 Agent 汇总
|
||||
- 当前当 heartbeat 同时携带旧 `projects` 和新 `projectCandidates` 时,服务端会优先走 `import draft`,不再绕过勾选/应用阶段直接把旧项目目录导入为聊天窗口
|
||||
- 当前设备导入 `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` 已在本机连接的华为真机上完成签名包覆盖安装与启动复核,原生三栏入口和子活动页声明已全部接通
|
||||
- `2.1.1` 已补上原生 OTA 下载链路:关于页会直接请求受保护的 `/api/v1/user/ota/package`,下载完成后可拉起系统安装器
|
||||
- `2.2.0` 已把原生 UI 从控制台风格回退到微信式简单列表和聊天优先视图,并复核了设备页 / 我的页 / 深层高级入口
|
||||
- `2.2.1` 已继续补齐原生交互细节:聊天页发送后会先出现本地“发送中”气泡,关于页会展示 OTA 下载进度 / 重试 / 安装授权提示,根 tab 会记住用户上次停留位置并改成“再按一次返回进入后台”
|
||||
- `2.3.0` 已把会话模型切到“线程 = 聊天窗口”,补上文件夹名副信息、后台活跃数量动态图标、微信式会话信息页、线程改名、独立群聊创建、群资料页,以及 `主 Agent / 审计对话` 普通置顶会话化
|
||||
- `2.4.0` 已把消息转发切到微信式原生链路:聊天页支持长按消息操作、多选合并转发、统一目标会话选择页;单条消息转发显示为普通转发消息,多条消息转发显示为“聊天记录”卡片
|
||||
- `2.5.0` 已补齐聊天附件主链:原生聊天框左侧 `+` 会打开底部抽屉,支持图片 / 视频 / 文件发送;默认走服务器文件存储,`我的 > 附件与存储` 可切到阿里 OSS 私有桶;附件消息已支持下载 / 打开、手动分析、自动分析状态,以及带 task token 的主 Agent 附件分析链接
|
||||
- `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` 群聊也已补齐“确认 / 拒绝”两条审批动作
|
||||
|
||||
## 本地启动
|
||||
|
||||
@@ -125,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` 仅保留为跳转到根域的兼容入口
|
||||
|
||||
## 设备端本地服务
|
||||
|
||||
@@ -149,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 额度和项目列表
|
||||
@@ -157,7 +279,21 @@ device-agent 当前职责:
|
||||
- 递归扫描本机 `~/.codex/skills`,并同步到云端 `/api/v1/devices/[deviceId]/skills`
|
||||
- 轮询云端 `/api/v1/master-agent/tasks/claim`,并用当前电脑已登录的 `codex` 账号执行主 Agent 任务
|
||||
- 将主 Agent 执行结果回写到云端 `/api/v1/master-agent/tasks/[taskId]/complete`
|
||||
- 提供本地 `/health`、`/api/v1/device`、`/api/v1/skills`、`/api/v1/heartbeat`
|
||||
- 对普通单线程会话,认领到的 `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 协议执行
|
||||
- `local-agent` 当前的任务完成回写已通过 `RemoteRuntimeAdapter` 标准化,`conversation_reply / dispatch_execution` 的完成结果都会先归一到统一远程执行结果结构,再进入主 Agent 完成路由
|
||||
- `RemoteRuntimeAdapter` 当前还会拦截固定模式的线程内部环境提示(如“当前会话环境只读 / cwd 我可以在命令里指向 …”),并改写成系统失败提示,不再把这类脏文本直接回写到单聊或群聊
|
||||
- 当前设备模型已支持同一台 Mac / Windows 同时接入 Codex `GUI + CLI` 双能力;设备详情页会同时展示两种能力状态,并允许切换默认执行模式
|
||||
- 当前同项目 `GUI / CLI` 并行写入风险已接入项目/文件夹级冲突控制:默认阻断,用户可仅对当前异常项目/文件夹选择 `禁止 / 允许本次 / 永久放行`
|
||||
- `local-agent` 当前会先启动本地 `4317` 健康监听,再异步执行首次 heartbeat 和 task poll,避免控制面短暂阻塞时本地健康检查一起挂死
|
||||
- Codex 项目/线程扫描当前已搬到 worker 线程执行,避免 `.codex/logs_1.sqlite` 和 `state_5.sqlite` 的同步扫描阻塞主线程 HTTP 响应
|
||||
- 如果某个历史群聊里已经没有真实线程成员,当前不会再表现成“发了没反应”,而是会在群里追加一条 `system_notice`,提示用户先重新整理群成员
|
||||
- 设备导入审核当前已经升级成 `local-agent -> codex exec -> complete` 的真实任务链;Web 和 Android 前台都会在 `pending_resolution` 阶段显示“主 Agent 审核中”并自动刷新,审核失败时保留当前勾选以便重新生成
|
||||
- 提供本地 `/boss-agent`、`/api/v1/boss-agent/status`、`/api/v1/boss-agent/ota/check`、`/api/v1/boss-agent/ota/apply`、`/health`、`/api/v1/device`、`/api/v1/skills`、`/api/v1/heartbeat`
|
||||
|
||||
当前常驻默认值:
|
||||
|
||||
@@ -174,12 +310,16 @@ 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`
|
||||
- Android 原生会话页:`android/app/src/main/java/com/hyzq/boss/ProjectDetailActivity.java`
|
||||
- Android 原生设备页:`android/app/src/main/java/com/hyzq/boss/DeviceDetailActivity.java`
|
||||
- Android 原生我的页:`android/app/src/main/java/com/hyzq/boss/AiAccountsActivity.java`、`android/app/src/main/java/com/hyzq/boss/OpsCenterActivity.java`、`android/app/src/main/java/com/hyzq/boss/SettingsActivity.java`
|
||||
- Android 微信式 surface contract:`android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java`
|
||||
- Android 聊天页布局:`android/app/src/main/res/layout/activity_project_chat.xml`
|
||||
- 服务器环境示例:`.env.server.example`
|
||||
|
||||
当前 `scripts/deploy-server.sh`:
|
||||
@@ -232,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`
|
||||
@@ -244,32 +385,58 @@ npm run aab:release
|
||||
- Web 端根布局当前仍保留 `NativeAppBridge`,用于浏览器态与历史桥接兼容;当前正式 APK 已改为原生 Activity + 原生 API 客户端,不再依赖 WebView
|
||||
- APP 日志桥已经改成会话感知:只会按当前登录账号解析绑定设备,不再在未登录页默认按全局管理员设备写日志
|
||||
- APP 外壳已经从“桌面预览卡片”切回真机态:移动端不再渲染假的 `9:41 / 5G` 状态栏,底部 `会话 / 设备 / 我的` 导航固定在视口底部,背景改为全屏 cover,不再出现圆角矩形外壳
|
||||
- 原生 Android 当前也和这套产品口径对齐:根页采用微信式简单列表,项目聊天页改成消息流优先,`设备 / 我的` 页不再展示控制台式统计卡片
|
||||
- 原生聊天页当前会即时渲染本地发送中消息,并且只有在用户接近底部或本次发送是主动触发时才自动滚到底
|
||||
- 登录成功后的进入首页链路已做稳态处理:会先确认 `/api/auth/session` 可读,再执行 `replace(/conversations)`,并附带一次原生级兜底跳转,避免真机 WebView 偶发停留在“正在进入会话首页”
|
||||
- `/api/v1/events` 已作为 SSE 出口使用,会话页、设备页、技能页和项目详情页会按事件自动刷新,不再只靠手动刷新
|
||||
- 我的页新增 `技能` 入口,`/me/skills` 会按设备分组展示 Skill,并支持一键复制调用语句
|
||||
- 我的页新增 `主 Agent 提示词 / 记忆` 入口,`/me/master-agent` 会展示管理员全局主提示词、用户主提示词、当前对话附加提示词、组合预览,以及当前用户的通用记忆和跨项目项目记忆
|
||||
- 我的页新增 `AI 账号` 入口,`/me/ai-accounts` 会展示主 GPT / 备用 GPT / API 容灾,并明确主链路优先走已登录 `ChatGPT Plus / Codex` 的 `Master Codex Node`
|
||||
- `AI 账号` 页面当前已补上显式 `登录指引`:手机端不会直接弹出 ChatGPT OAuth;主 GPT 的登录动作必须在绑定电脑上的 Codex / ChatGPT Plus 会话里完成,再回手机端点“测试连接 / 校验连接”
|
||||
- `AI 账号` 页面当前已升级成双入口:首页会显式展示 `登录 OpenAI 平台账号` 和 `绑定电脑上的 Codex 节点`
|
||||
- `登录 OpenAI 平台账号` 当前通过填写 `OpenAI API Key` 完成;校验成功后会立即创建/更新 `openai_api` 主账号,并设为当前主控
|
||||
- `绑定电脑上的 Codex 节点` 当前会创建/更新 `master_codex_node` 主账号,并可直接设为当前主控;同时会返回“登录发生在绑定设备上”的明确中文指引
|
||||
- 当前公网服务器对 `api.openai.com` 仍存在出网阻塞;`OpenAI API Key` 登录入口已经实现,但在服务器恢复出网前,公网校验会返回明确的中文网络错误,建议先切回 `Master Codex Node`
|
||||
- `POST /api/v1/accounts/[accountId]/validate` 当前不再只看 `nodeId`;对 `master_codex_node` 会同时校验绑定设备是否在线,并在设备离线时返回明确的降级说明
|
||||
- API 容灾当前不走服务器预置 Key,而是由用户在 APP 的 `我的 > AI 账号` 中自行配置 `OpenAI API` 账号
|
||||
- 设备页当前只展示已接入生产链路的设备,历史演示脏数据已经从正式设备视图、运维视图和审计视图中剔除
|
||||
- 登录页当前已临时切到免验证模式,点击“登录”会直接进入会话首页
|
||||
- 认证现在已经有最小会话链路:登录后会写入 `boss_session` Cookie,默认保持 30 天,`会话 / 设备 / 我的 / 线程` 页面以及主要 `/api/v1/*` 接口都要求有效会话
|
||||
- 本机 `local-agent` 现在会直接从 `~/.codex/state_5.sqlite / logs_1.sqlite / session_index.jsonl / .codex-global-state.json` 动态发现真实 Codex 线程,并在 heartbeat 里上报 `projectCandidates`
|
||||
- 线程发现当前会优先保留每个 Codex 文件夹下的“主工作线程”;如果同一文件夹里同时存在 `worker / explorer` 这类子线程,会优先过滤掉这些子线程,避免会话首页被子代理线程冲成异常多条
|
||||
- 如果某条历史线程在 Codex 本地状态库里是 `read-only`,`local-agent` 当前会在候选发现和 `codex exec resume` 前都直接拒绝这类线程,避免把只读线程误当成可开发线程继续复用
|
||||
- 如果某个项目下已经存在历史 `worker / explorer` 子线程,即使数据库权限后来被改成可写,也不能默认把它们当成主开发线程复用;这类线程往往还带着“只读勘察 / 不改文件”的历史上下文,恢复开发时应优先回到该项目的主交接线程,或先显式补发“解除只读勘察限制”的新用户指令
|
||||
- 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;因此会话页会自动出现这台设备当前真实运行的 Codex 线程窗口
|
||||
- 对已经绑定的生产设备,服务端现在会在 heartbeat 时自动选中建议导入项、生成导入决议并直接应用;如果某个项目下存在多个线程,会话首页会先显示项目归档项,而不是把所有线程平铺在首页
|
||||
- 对已经绑定的生产设备,如果某些自动导入线程已经不再出现在最新 `projectCandidates[]` 中,服务端会在下一次 heartbeat 自动清理这些过时会话,避免旧线程长期滞留首页
|
||||
- 认证现在已经有最小会话链路:登录后会写入 `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` 密码会在下一次密码登录时自动迁移
|
||||
- 当前默认最高管理员账号:`17600003315`
|
||||
- 当前默认测试密码:`boss123456`
|
||||
- 当前本机 Codex 节点 `mac-studio` 已绑定到 `17600003315`
|
||||
- 主 Agent 对话当前真实执行链路是:`Boss Web -> master-agent task queue -> local-agent -> codex exec -> complete task -> project ledger`
|
||||
- 主 Agent 同步等待窗口已调到 55 秒;如果本机 Codex 节点执行较慢,项目页也会通过 SSE 在任务完成后自动刷新出真实回复
|
||||
- 原生 Android 当前把 `ProjectForwardActivity / ThreadDetailActivity / OpsCenterActivity` 等复杂能力下沉到二级或更深层入口,不再把线程预算 / 转发 / 运维说明堆在主聊天页和一级我的页
|
||||
- 原生 OTA 当前除了整包下载和系统安装器拉起,还会在关于页保留本地下载状态;离开关于页再回来时,仍能看到进行中 / 失败 / 待授权 / 可安装状态
|
||||
- Android 本地 Gradle 验证当前必须串行执行,避免并发 `testDebugUnitTest / compileDebugJavaWithJavac / assembleDebug` 相互踩坏中间产物
|
||||
- 当前默认最高管理员账号:`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 提示
|
||||
- `master-agent` 单聊当前已支持当前对话级别的 `模型 / 推理强度` 覆盖,服务端会优先把该会话的 `agentControls` 用到实际 OpenAI 回复和 Master Codex Node 执行 prompt 中
|
||||
- 原生 Android 当前在 `master-agent` 聊天页右上角提供微信式 `...` 菜单,菜单项包含 `模型 / 推理强度 / 会话信息 / 刷新`
|
||||
- 服务器已经部署 `Postfix + Dovecot`,邮箱别名 `verify@boss.hyzq.net` / `no-reply@boss.hyzq.net` 当前会投递到本机 `bossmail` 邮箱
|
||||
- 应用内 `POST /api/auth/send-code` 已经支持 email 模式,并可通过 `/opt/boss/.env.server` 切换;本轮已临时切到 email 模式验证成功,随后恢复默认 fixed
|
||||
- 应用内 `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 / 大文件默认手动触发
|
||||
- 当前采用“极轻云 + 本地设备端”的路线,云端只承载 Web、轻 API 和状态文件
|
||||
- 服务器侧主 Agent 对话能否返回真实大模型回复,依赖被绑定设备的 `local-agent` 在线并能执行 `codex exec`;服务器本身不直接持有主 GPT 会话
|
||||
- 原生 Android 当前不再依赖长时间同步等待 `master-agent` 完整回复;消息发送后会立即进入“主 Agent 思考中”状态,并通过后台轮询刷新真实回复
|
||||
|
||||
@@ -13,6 +13,9 @@ android {
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
}
|
||||
testOptions {
|
||||
unitTests.includeAndroidResources = true
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
@@ -33,8 +36,8 @@ android {
|
||||
applicationId "com.hyzq.boss"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 8
|
||||
versionName "2.1.1"
|
||||
versionCode 24
|
||||
versionName "2.5.11"
|
||||
buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\""
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -55,7 +58,9 @@ dependencies {
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
|
||||
implementation "androidx.viewpager2:viewpager2:1.1.0"
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
testImplementation "org.robolectric:robolectric:4.14.1"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -18,6 +28,7 @@
|
||||
android:label="@string/title_activity_main"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:screenOrientation="portrait"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
@@ -27,19 +38,37 @@
|
||||
|
||||
</activity>
|
||||
|
||||
<activity android:name=".ProjectDetailActivity" android:exported="false" />
|
||||
<activity android:name=".ProjectGoalsActivity" android:exported="false" />
|
||||
<activity android:name=".ProjectVersionsActivity" android:exported="false" />
|
||||
<activity android:name=".ProjectForwardActivity" android:exported="false" />
|
||||
<activity android:name=".ThreadDetailActivity" android:exported="false" />
|
||||
<activity android:name=".DeviceDetailActivity" android:exported="false" />
|
||||
<activity android:name=".DeviceEnrollmentActivity" android:exported="false" />
|
||||
<activity android:name=".SkillInventoryActivity" android:exported="false" />
|
||||
<activity android:name=".SecurityActivity" android:exported="false" />
|
||||
<activity android:name=".SettingsActivity" android:exported="false" />
|
||||
<activity android:name=".AiAccountsActivity" android:exported="false" />
|
||||
<activity android:name=".OpsCenterActivity" android:exported="false" />
|
||||
<activity android:name=".AboutActivity" android:exported="false" />
|
||||
<activity
|
||||
android:name=".ProjectDetailActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<activity android:name=".ConversationFolderActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".ProjectGoalsActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".ProjectVersionsActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".ProjectForwardActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".ForwardTargetActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".ThreadDetailActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".ConversationInfoActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".ThreadStatusActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".GroupInfoActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".GroupCreateActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".DeviceDetailActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".DeviceEnrollmentActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".DeviceImportDraftActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".SkillInventoryActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".SecurityActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".AccessManagementActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".SettingsActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".StorageSettingsActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".AiAccountsActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".TelegramIntegrationActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".OpenAiOnboardingActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".MasterAgentPromptActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".MasterAgentTakeoverActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".MasterAgentMemoryActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".OpsCenterActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
<activity android:name=".AboutActivity" android:exported="false" android:screenOrientation="portrait" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
||||
@@ -9,18 +9,52 @@ import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.provider.Settings;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class AboutActivity extends BossScreenActivity {
|
||||
private static final long OTA_PROGRESS_POLL_INTERVAL_MS = 1_000L;
|
||||
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
private static final String OTA_UI_PREFS = "boss_native_client";
|
||||
private static final String KEY_ACTIVE_DOWNLOAD_ID = "ota_active_download_id";
|
||||
private static final String KEY_COMPLETED_DOWNLOAD_ID = "ota_completed_download_id";
|
||||
private static final String KEY_LAST_DOWNLOAD_FILE_NAME = "ota_last_download_file_name";
|
||||
private static final String KEY_LAST_DOWNLOAD_VERSION = "ota_last_download_version";
|
||||
private static final String KEY_LAST_DOWNLOAD_STATUS = "ota_last_download_status";
|
||||
|
||||
private long activeDownloadId = -1L;
|
||||
private long completedDownloadId = -1L;
|
||||
private @Nullable JSONObject otaPayload;
|
||||
private @Nullable LinearLayout otaDownloadStateSection;
|
||||
private @Nullable Uri downloadedApkUri;
|
||||
private @Nullable String lastDownloadFileName;
|
||||
private @Nullable String lastDownloadVersion;
|
||||
private int lastDownloadStatus = -1;
|
||||
private long lastDownloadedBytes = 0L;
|
||||
private long lastTotalBytes = -1L;
|
||||
private @Nullable BossRealtimeClient realtimeClient;
|
||||
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
|
||||
private final Handler otaProgressHandler = new Handler(Looper.getMainLooper());
|
||||
private final Runnable otaProgressPoller = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
refreshDownloadStateSection();
|
||||
if (activeDownloadId > 0) {
|
||||
otaProgressHandler.postDelayed(this, OTA_PROGRESS_POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private final BroadcastReceiver otaDownloadReceiver = new BroadcastReceiver() {
|
||||
@Override
|
||||
@@ -32,7 +66,6 @@ public class AboutActivity extends BossScreenActivity {
|
||||
if (downloadId <= 0 || downloadId != activeDownloadId) {
|
||||
return;
|
||||
}
|
||||
activeDownloadId = -1L;
|
||||
handleCompletedDownload(downloadId);
|
||||
}
|
||||
};
|
||||
@@ -40,18 +73,30 @@ public class AboutActivity extends BossScreenActivity {
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("关于 / OTA", "原生版本中心");
|
||||
configureScreen("关于", "版本与 OTA 更新");
|
||||
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();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
stopRealtimeUpdates();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
stopRealtimeUpdates();
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
try {
|
||||
unregisterReceiver(otaDownloadReceiver);
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
@@ -67,14 +112,12 @@ public class AboutActivity extends BossScreenActivity {
|
||||
try {
|
||||
BossApiClient.ApiResponse settings = apiClient.getSettings();
|
||||
BossApiClient.ApiResponse ota = apiClient.getOtaStatus();
|
||||
BossApiClient.ApiResponse session = apiClient.getSession();
|
||||
if (!settings.ok() || !ota.ok() || !session.ok()) {
|
||||
if (!settings.ok() || !ota.ok()) {
|
||||
throw new IllegalStateException("PROFILE_OR_OTA_LOAD_FAILED");
|
||||
}
|
||||
runOnUiThread(() -> renderAbout(
|
||||
settings.json.optJSONObject("user"),
|
||||
ota.json,
|
||||
session.json.optJSONObject("session")
|
||||
ota.json
|
||||
));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
@@ -85,69 +128,233 @@ public class AboutActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void renderAbout(@Nullable JSONObject user, JSONObject ota, @Nullable JSONObject session) {
|
||||
replaceContent();
|
||||
otaPayload = ota;
|
||||
if (user != null) {
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"当前版本",
|
||||
user.optString("version", "-")
|
||||
+ "\n当前账号:" + user.optString("account", "-")
|
||||
+ "\n绑定 Codex:" + user.optString("boundCodexNodeLabel", "未绑定"),
|
||||
session == null ? "-" : "会话到期 " + session.optString("expiresAt", "-")
|
||||
));
|
||||
private void updateRealtimeSubscription() {
|
||||
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
|
||||
realtimeClient.start();
|
||||
return;
|
||||
}
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
JSONObject availableRelease = ota.optJSONObject("availableRelease");
|
||||
String otaBody = availableRelease == null
|
||||
? "当前已经是最新版本。"
|
||||
: availableRelease.optString("version", "未知版本")
|
||||
+ "\n" + availableRelease.optString("summary", "暂无摘要")
|
||||
+ "\n文件:" + availableRelease.optString("packageFileName", "-");
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"OTA 状态",
|
||||
otaBody,
|
||||
"当前版本 " + ota.optString("currentVersion", "-")
|
||||
));
|
||||
private void stopRealtimeUpdates() {
|
||||
if (realtimeClient != null) {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
LinearLayout actionCard = BossUi.buildCard(this, "OTA 操作", "可在原生页直接检查更新、登记 OTA 并下载 APK。", "当前接口:/api/v1/user/ota");
|
||||
Button check = BossUi.buildPrimaryButton(this, "检查更新");
|
||||
check.setOnClickListener(v -> performOtaAction("check"));
|
||||
actionCard.addView(check);
|
||||
Button apply = BossUi.buildSecondaryButton(this, "登记应用 OTA");
|
||||
apply.setOnClickListener(v -> performOtaAction("apply"));
|
||||
actionCard.addView(apply);
|
||||
Button download = BossUi.buildSecondaryButton(this, "应用内下载 APK");
|
||||
download.setOnClickListener(v -> downloadLatestApk());
|
||||
actionCard.addView(download);
|
||||
appendContent(actionCard);
|
||||
void handleRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (event == null || event.eventName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!"ota.updated".equals(event.eventName)) {
|
||||
return;
|
||||
}
|
||||
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
|
||||
if (eventFingerprint.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(this::reload);
|
||||
}
|
||||
|
||||
JSONArray logs = ota.optJSONArray("logs");
|
||||
if (logs != null) {
|
||||
for (int i = 0; i < logs.length(); i++) {
|
||||
JSONObject log = logs.optJSONObject(i);
|
||||
if (log == null) continue;
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
log.optString("version", "OTA"),
|
||||
log.optString("summary", ""),
|
||||
log.optString("status", "-") + " · " + log.optString("createdAt", "-")
|
||||
));
|
||||
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
||||
pruneRecentRealtimeEvents(now);
|
||||
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
|
||||
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
|
||||
return true;
|
||||
}
|
||||
recentRealtimeEventTimestamps.put(eventFingerprint, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void pruneRecentRealtimeEvents(long now) {
|
||||
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, Long> entry = iterator.next();
|
||||
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void renderAbout(@Nullable JSONObject user, JSONObject ota) {
|
||||
replaceContent();
|
||||
otaPayload = ota;
|
||||
invalidateStaleDownloadedApk(ota.optJSONObject("availableRelease"));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"当前版本",
|
||||
resolveInstalledVersionLabel(user, ota, BuildConfig.VERSION_NAME),
|
||||
"已安装版本",
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
JSONObject availableRelease = ota.optJSONObject("availableRelease");
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"OTA 状态",
|
||||
buildOtaStatusSubtitle(ota),
|
||||
buildOtaStatusMeta(ota),
|
||||
availableRelease == null ? null : "OTA",
|
||||
null
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
"OTA 更新内容",
|
||||
buildOtaContentBody(ota),
|
||||
availableRelease == null ? "没有可下载的新版本时,可直接点按钮检查更新。" : "下载完成后会自动拉起系统安装器。"
|
||||
));
|
||||
|
||||
android.widget.Button otaButton = BossUi.buildPrimaryButton(this, resolvePrimaryOtaActionLabel(availableRelease));
|
||||
otaButton.setEnabled(activeDownloadId <= 0);
|
||||
otaButton.setOnClickListener(v -> performPrimaryOtaAction(availableRelease));
|
||||
appendContent(otaButton);
|
||||
|
||||
appendContent(BossUi.buildMenuRow(this, "重新检查更新", "拉取最新 OTA 状态", null, v -> performOtaAction("check")));
|
||||
if (downloadedApkUri != null || completedDownloadId > 0) {
|
||||
appendContent(BossUi.buildMenuRow(
|
||||
this,
|
||||
"同步已应用状态",
|
||||
"安装完成后点这里,把服务端 OTA 状态更新为已应用",
|
||||
null,
|
||||
v -> performOtaAction("apply")
|
||||
));
|
||||
}
|
||||
otaDownloadStateSection = new LinearLayout(this);
|
||||
otaDownloadStateSection.setOrientation(LinearLayout.VERTICAL);
|
||||
appendContent(otaDownloadStateSection);
|
||||
refreshDownloadStateSection();
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private static String buildOtaStatusSubtitle(JSONObject ota) {
|
||||
JSONObject availableRelease = ota.optJSONObject("availableRelease");
|
||||
if (availableRelease == null) {
|
||||
return "当前已经是最新版本。";
|
||||
}
|
||||
return "发现新版本 " + availableRelease.optString("version", "未知版本");
|
||||
}
|
||||
|
||||
private static String resolveInstalledVersionLabel(
|
||||
@Nullable JSONObject user,
|
||||
JSONObject ota,
|
||||
@Nullable String packageVersionName
|
||||
) {
|
||||
if (packageVersionName != null && !packageVersionName.isEmpty()) {
|
||||
return packageVersionName;
|
||||
}
|
||||
if (user != null) {
|
||||
String userVersion = user.optString("version", "");
|
||||
if (!userVersion.isEmpty()) {
|
||||
return userVersion;
|
||||
}
|
||||
}
|
||||
return ota.optString("currentVersion", "-");
|
||||
}
|
||||
|
||||
private static String buildOtaStatusMeta(JSONObject ota) {
|
||||
JSONObject availableRelease = ota.optJSONObject("availableRelease");
|
||||
if (availableRelease == null) {
|
||||
return "当前版本 " + ota.optString("currentVersion", "-");
|
||||
}
|
||||
String summaryLine = firstSummaryLine(availableRelease.optJSONArray("summary"));
|
||||
return availableRelease.optString("packageFileName", "boss-android-latest.apk")
|
||||
+ (summaryLine.isEmpty() ? "" : " · " + summaryLine);
|
||||
}
|
||||
|
||||
private static String buildOtaContentBody(JSONObject ota) {
|
||||
JSONObject availableRelease = ota.optJSONObject("availableRelease");
|
||||
if (availableRelease != null) {
|
||||
JSONArray lines = availableRelease.optJSONArray("summary");
|
||||
if (lines != null && lines.length() > 0) {
|
||||
StringBuilder builder = new StringBuilder("版本 ").append(availableRelease.optString("version", "-"));
|
||||
for (int i = 0; i < lines.length(); i++) {
|
||||
String line = lines.optString(i);
|
||||
if (line == null || line.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
builder.append("\n").append(i + 1).append(". ").append(line);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
String note = availableRelease.optString("note", "");
|
||||
if (!note.isEmpty()) {
|
||||
return "版本 " + availableRelease.optString("version", "-") + "\n" + note;
|
||||
}
|
||||
}
|
||||
|
||||
JSONArray logs = ota.optJSONArray("logs");
|
||||
if (logs != null && logs.length() > 0) {
|
||||
JSONObject latest = logs.optJSONObject(0);
|
||||
if (latest != null) {
|
||||
String note = latest.optString("note", "");
|
||||
if (!note.isEmpty()) {
|
||||
return latest.optString("version", "当前版本") + "\n" + note;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "当前没有待更新内容,点击下方按钮可重新检查更新。";
|
||||
}
|
||||
|
||||
private static String firstSummaryLine(@Nullable JSONArray lines) {
|
||||
if (lines == null || lines.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
for (int i = 0; i < lines.length(); i++) {
|
||||
String line = lines.optString(i);
|
||||
if (line != null && !line.isEmpty()) {
|
||||
return line;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String resolvePrimaryOtaActionLabel(@Nullable JSONObject availableRelease) {
|
||||
if (activeDownloadId > 0) {
|
||||
return "下载中…";
|
||||
}
|
||||
if (downloadedApkUri != null) {
|
||||
return "安装更新";
|
||||
}
|
||||
if (availableRelease != null) {
|
||||
return "立即 OTA";
|
||||
}
|
||||
return "检查更新";
|
||||
}
|
||||
|
||||
private void performPrimaryOtaAction(@Nullable JSONObject availableRelease) {
|
||||
if (activeDownloadId > 0) {
|
||||
return;
|
||||
}
|
||||
if (downloadedApkUri != null) {
|
||||
installDownloadedApk();
|
||||
return;
|
||||
}
|
||||
if (availableRelease != null) {
|
||||
downloadLatestApk();
|
||||
return;
|
||||
}
|
||||
performOtaAction("check");
|
||||
}
|
||||
|
||||
private void performOtaAction(String action) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = "check".equals(action) ? apiClient.checkOta() : apiClient.applyOta();
|
||||
BossApiClient.ApiResponse response = "apply".equals(action)
|
||||
? apiClient.applyOta()
|
||||
: apiClient.checkOta();
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("check".equals(action) ? "已完成版本检查" : "已登记 OTA 应用");
|
||||
if ("apply".equals(action)) {
|
||||
clearLocalOtaDownloadState();
|
||||
}
|
||||
showMessage("apply".equals(action) ? "已同步 OTA 应用状态" : "已完成版本检查");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
@@ -159,6 +366,19 @@ public class AboutActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void clearLocalOtaDownloadState() {
|
||||
activeDownloadId = -1L;
|
||||
completedDownloadId = -1L;
|
||||
downloadedApkUri = null;
|
||||
lastDownloadFileName = null;
|
||||
lastDownloadVersion = null;
|
||||
lastDownloadStatus = -1;
|
||||
lastDownloadedBytes = 0L;
|
||||
lastTotalBytes = -1L;
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
persistDownloadUiState();
|
||||
}
|
||||
|
||||
private void downloadLatestApk() {
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
@@ -187,6 +407,9 @@ public class AboutActivity extends BossScreenActivity {
|
||||
String fileName = availableRelease == null
|
||||
? "boss-android-latest.apk"
|
||||
: availableRelease.optString("packageFileName", "boss-android-latest.apk");
|
||||
String releaseVersion = availableRelease == null
|
||||
? null
|
||||
: availableRelease.optString("version", null);
|
||||
|
||||
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(apiClient.getProtectedOtaPackageUrl()));
|
||||
request.setTitle(fileName);
|
||||
@@ -198,7 +421,18 @@ public class AboutActivity extends BossScreenActivity {
|
||||
request.addRequestHeader("x-boss-native-app", "1");
|
||||
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName);
|
||||
|
||||
downloadedApkUri = null;
|
||||
lastDownloadFileName = fileName;
|
||||
lastDownloadVersion = releaseVersion;
|
||||
lastDownloadStatus = DownloadManager.STATUS_PENDING;
|
||||
lastDownloadedBytes = 0L;
|
||||
lastTotalBytes = -1L;
|
||||
completedDownloadId = -1L;
|
||||
activeDownloadId = manager.enqueue(request);
|
||||
persistDownloadUiState();
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
otaProgressHandler.post(otaProgressPoller);
|
||||
refreshDownloadStateSection();
|
||||
showMessage("已开始下载,完成后会自动拉起安装。");
|
||||
}
|
||||
|
||||
@@ -213,10 +447,23 @@ public class AboutActivity extends BossScreenActivity {
|
||||
try (android.database.Cursor cursor = manager.query(query)) {
|
||||
if (cursor == null || !cursor.moveToFirst()) {
|
||||
showMessage("下载完成,但无法读取文件信息");
|
||||
activeDownloadId = -1L;
|
||||
completedDownloadId = -1L;
|
||||
lastDownloadStatus = DownloadManager.STATUS_FAILED;
|
||||
persistDownloadUiState();
|
||||
refreshDownloadStateSection();
|
||||
return;
|
||||
}
|
||||
int status = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
|
||||
if (status != DownloadManager.STATUS_SUCCESSFUL) {
|
||||
lastDownloadStatus = cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS));
|
||||
lastDownloadedBytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR));
|
||||
lastTotalBytes = cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES));
|
||||
if (lastDownloadStatus != DownloadManager.STATUS_SUCCESSFUL) {
|
||||
activeDownloadId = -1L;
|
||||
completedDownloadId = -1L;
|
||||
downloadedApkUri = null;
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
persistDownloadUiState();
|
||||
refreshDownloadStateSection();
|
||||
showMessage("下载未成功完成");
|
||||
return;
|
||||
}
|
||||
@@ -224,15 +471,26 @@ public class AboutActivity extends BossScreenActivity {
|
||||
|
||||
Uri apkUri = manager.getUriForDownloadedFile(downloadId);
|
||||
if (apkUri == null) {
|
||||
activeDownloadId = -1L;
|
||||
completedDownloadId = -1L;
|
||||
lastDownloadStatus = DownloadManager.STATUS_FAILED;
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
persistDownloadUiState();
|
||||
refreshDownloadStateSection();
|
||||
showMessage("下载完成,但找不到安装包");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getPackageManager().canRequestPackageInstalls()) {
|
||||
activeDownloadId = -1L;
|
||||
completedDownloadId = downloadId;
|
||||
downloadedApkUri = apkUri;
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
persistDownloadUiState();
|
||||
refreshDownloadStateSection();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -241,4 +499,271 @@ public class AboutActivity extends BossScreenActivity {
|
||||
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(installIntent);
|
||||
}
|
||||
|
||||
private void refreshDownloadStateSection() {
|
||||
if (otaDownloadStateSection == null) {
|
||||
return;
|
||||
}
|
||||
otaDownloadStateSection.removeAllViews();
|
||||
OtaDownloadStateMapper.UiState uiState = resolveDownloadUiState();
|
||||
if (uiState == null) {
|
||||
return;
|
||||
}
|
||||
otaDownloadStateSection.addView(BossUi.buildListRow(
|
||||
this,
|
||||
uiState.title,
|
||||
uiState.subtitle,
|
||||
uiState.meta,
|
||||
uiState.badge,
|
||||
null
|
||||
));
|
||||
if (uiState.actionKind != OtaDownloadStateMapper.ActionKind.NONE) {
|
||||
otaDownloadStateSection.addView(BossUi.buildMenuRow(
|
||||
this,
|
||||
uiState.actionLabel,
|
||||
uiState.subtitle,
|
||||
null,
|
||||
v -> performDownloadStateAction(uiState.actionKind)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private OtaDownloadStateMapper.UiState resolveDownloadUiState() {
|
||||
JSONObject availableRelease = otaPayload == null ? null : otaPayload.optJSONObject("availableRelease");
|
||||
invalidateStaleDownloadedApk(availableRelease);
|
||||
String fileName = resolveDownloadFileName();
|
||||
if (activeDownloadId > 0) {
|
||||
DownloadProgressSnapshot snapshot = queryDownloadProgress(activeDownloadId);
|
||||
if (snapshot != null) {
|
||||
lastDownloadStatus = snapshot.status;
|
||||
lastDownloadedBytes = snapshot.bytesDownloaded;
|
||||
lastTotalBytes = snapshot.totalBytes;
|
||||
boolean hasKnownTotal = snapshot.totalBytes > 0;
|
||||
int percent = hasKnownTotal
|
||||
? (int) Math.round((snapshot.bytesDownloaded * 100.0d) / snapshot.totalBytes)
|
||||
: 0;
|
||||
if (snapshot.status == DownloadManager.STATUS_RUNNING
|
||||
|| snapshot.status == DownloadManager.STATUS_PENDING
|
||||
|| snapshot.status == DownloadManager.STATUS_PAUSED) {
|
||||
return OtaDownloadStateMapper.active(fileName, percent, hasKnownTotal, snapshot.bytesDownloaded, snapshot.totalBytes);
|
||||
}
|
||||
if (snapshot.status == DownloadManager.STATUS_FAILED) {
|
||||
activeDownloadId = -1L;
|
||||
completedDownloadId = -1L;
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
persistDownloadUiState();
|
||||
return OtaDownloadStateMapper.failed(fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastDownloadStatus == DownloadManager.STATUS_FAILED) {
|
||||
return OtaDownloadStateMapper.failed(fileName);
|
||||
}
|
||||
if (downloadedApkUri != null) {
|
||||
if (!canInstallDownloadedPackages()) {
|
||||
return OtaDownloadStateMapper.waitingInstallPermission(fileName);
|
||||
}
|
||||
return OtaDownloadStateMapper.readyToInstall(fileName);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void performDownloadStateAction(OtaDownloadStateMapper.ActionKind actionKind) {
|
||||
switch (actionKind) {
|
||||
case RETRY_DOWNLOAD:
|
||||
downloadLatestApk();
|
||||
break;
|
||||
case OPEN_INSTALL_PERMISSION:
|
||||
openUnknownAppSourcesSettings();
|
||||
break;
|
||||
case INSTALL_APK:
|
||||
installDownloadedApk();
|
||||
break;
|
||||
case NONE:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void installDownloadedApk() {
|
||||
if (downloadedApkUri == null) {
|
||||
showMessage("当前没有可安装的更新包");
|
||||
return;
|
||||
}
|
||||
if (!canInstallDownloadedPackages()) {
|
||||
showMessage("请先开启安装未知来源应用权限");
|
||||
openUnknownAppSourcesSettings();
|
||||
return;
|
||||
}
|
||||
Intent installIntent = new Intent(Intent.ACTION_VIEW);
|
||||
installIntent.setDataAndType(downloadedApkUri, "application/vnd.android.package-archive");
|
||||
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(installIntent);
|
||||
}
|
||||
|
||||
private String resolveDownloadFileName() {
|
||||
if (lastDownloadFileName != null && !lastDownloadFileName.isEmpty()) {
|
||||
return lastDownloadFileName;
|
||||
}
|
||||
JSONObject availableRelease = otaPayload == null ? null : otaPayload.optJSONObject("availableRelease");
|
||||
if (availableRelease != null) {
|
||||
return availableRelease.optString("packageFileName", "boss-android-latest.apk");
|
||||
}
|
||||
return "boss-android-latest.apk";
|
||||
}
|
||||
|
||||
private boolean canInstallDownloadedPackages() {
|
||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.O
|
||||
|| getPackageManager().canRequestPackageInstalls();
|
||||
}
|
||||
|
||||
private void openUnknownAppSourcesSettings() {
|
||||
Intent intent = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
||||
? new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()))
|
||||
: new Intent(Settings.ACTION_SECURITY_SETTINGS);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void restoreDownloadUiState() {
|
||||
android.content.SharedPreferences prefs = getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE);
|
||||
activeDownloadId = prefs.getLong(KEY_ACTIVE_DOWNLOAD_ID, -1L);
|
||||
completedDownloadId = prefs.getLong(KEY_COMPLETED_DOWNLOAD_ID, -1L);
|
||||
lastDownloadFileName = prefs.getString(KEY_LAST_DOWNLOAD_FILE_NAME, null);
|
||||
lastDownloadVersion = prefs.getString(KEY_LAST_DOWNLOAD_VERSION, null);
|
||||
lastDownloadStatus = prefs.getInt(KEY_LAST_DOWNLOAD_STATUS, -1);
|
||||
if (completedDownloadId > 0) {
|
||||
DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
|
||||
if (manager != null) {
|
||||
downloadedApkUri = manager.getUriForDownloadedFile(completedDownloadId);
|
||||
}
|
||||
}
|
||||
if (activeDownloadId > 0) {
|
||||
otaProgressHandler.removeCallbacks(otaProgressPoller);
|
||||
otaProgressHandler.post(otaProgressPoller);
|
||||
}
|
||||
}
|
||||
|
||||
private void persistDownloadUiState() {
|
||||
getSharedPreferences(OTA_UI_PREFS, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putLong(KEY_ACTIVE_DOWNLOAD_ID, activeDownloadId)
|
||||
.putLong(KEY_COMPLETED_DOWNLOAD_ID, completedDownloadId)
|
||||
.putString(KEY_LAST_DOWNLOAD_FILE_NAME, lastDownloadFileName)
|
||||
.putString(KEY_LAST_DOWNLOAD_VERSION, lastDownloadVersion)
|
||||
.putInt(KEY_LAST_DOWNLOAD_STATUS, lastDownloadStatus)
|
||||
.apply();
|
||||
}
|
||||
|
||||
private void invalidateStaleDownloadedApk(@Nullable JSONObject availableRelease) {
|
||||
long[] downloadIds = collectStaleDownloadIdsForRemoval(
|
||||
availableRelease,
|
||||
lastDownloadFileName,
|
||||
lastDownloadVersion,
|
||||
downloadedApkUri != null || completedDownloadId > 0 || activeDownloadId > 0,
|
||||
activeDownloadId,
|
||||
completedDownloadId
|
||||
);
|
||||
if (downloadIds.length == 0) {
|
||||
return;
|
||||
}
|
||||
removeStaleDownloadTasks(downloadIds);
|
||||
clearLocalOtaDownloadState();
|
||||
}
|
||||
|
||||
private void removeStaleDownloadTasks(long[] downloadIds) {
|
||||
if (downloadIds.length == 0) {
|
||||
return;
|
||||
}
|
||||
DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
|
||||
if (manager == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
manager.remove(downloadIds);
|
||||
} catch (RuntimeException ignored) {
|
||||
// Keep UI state recoverable even if DownloadManager cleanup fails.
|
||||
}
|
||||
}
|
||||
|
||||
static long[] collectStaleDownloadIdsForRemoval(
|
||||
@Nullable JSONObject availableRelease,
|
||||
@Nullable String downloadedFileName,
|
||||
@Nullable String downloadedVersion,
|
||||
boolean hasLocalDownload,
|
||||
long activeId,
|
||||
long completedId
|
||||
) {
|
||||
if (!hasLocalDownload) {
|
||||
return new long[0];
|
||||
}
|
||||
if (isDownloadedReleaseCurrent(availableRelease, downloadedFileName, downloadedVersion)) {
|
||||
return new long[0];
|
||||
}
|
||||
return collectDownloadIdsForRemoval(activeId, completedId);
|
||||
}
|
||||
|
||||
private static long[] collectDownloadIdsForRemoval(long activeId, long completedId) {
|
||||
if (activeId > 0 && completedId > 0) {
|
||||
if (activeId == completedId) {
|
||||
return new long[]{activeId};
|
||||
}
|
||||
return new long[]{activeId, completedId};
|
||||
}
|
||||
if (activeId > 0) {
|
||||
return new long[]{activeId};
|
||||
}
|
||||
if (completedId > 0) {
|
||||
return new long[]{completedId};
|
||||
}
|
||||
return new long[0];
|
||||
}
|
||||
|
||||
private static boolean isDownloadedReleaseCurrent(
|
||||
@Nullable JSONObject availableRelease,
|
||||
@Nullable String downloadedFileName,
|
||||
@Nullable String downloadedVersion
|
||||
) {
|
||||
if (availableRelease == null) {
|
||||
return false;
|
||||
}
|
||||
String releaseFileName = availableRelease.optString("packageFileName", "");
|
||||
String releaseVersion = availableRelease.optString("version", "");
|
||||
if (releaseFileName.isEmpty() || releaseVersion.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return releaseFileName.equals(downloadedFileName) && releaseVersion.equals(downloadedVersion);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private DownloadProgressSnapshot queryDownloadProgress(long downloadId) {
|
||||
DownloadManager manager = (DownloadManager) getSystemService(DOWNLOAD_SERVICE);
|
||||
if (manager == null) {
|
||||
return null;
|
||||
}
|
||||
DownloadManager.Query query = new DownloadManager.Query().setFilterById(downloadId);
|
||||
try (android.database.Cursor cursor = manager.query(query)) {
|
||||
if (cursor == null || !cursor.moveToFirst()) {
|
||||
return null;
|
||||
}
|
||||
return new DownloadProgressSnapshot(
|
||||
cursor.getInt(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)),
|
||||
cursor.getLong(cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DownloadProgressSnapshot {
|
||||
private final int status;
|
||||
private final long bytesDownloaded;
|
||||
private final long totalBytes;
|
||||
|
||||
private DownloadProgressSnapshot(int status, long bytesDownloaded, long totalBytes) {
|
||||
this.status = status;
|
||||
this.bytesDownloaded = bytesDownloaded;
|
||||
this.totalBytes = totalBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -0,0 +1,41 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public final class AttachmentComposerState {
|
||||
private AttachmentComposerState() {}
|
||||
|
||||
public static boolean requiresConfirmation(@Nullable String sourceType) {
|
||||
return ProjectChatUiState.requiresAttachmentConfirmation(sourceType);
|
||||
}
|
||||
|
||||
public static final class PendingAttachment {
|
||||
public final String sourceType;
|
||||
public final String fileName;
|
||||
public final String mimeType;
|
||||
public final long fileSizeBytes;
|
||||
@Nullable public final Uri uri;
|
||||
|
||||
public PendingAttachment(
|
||||
String sourceType,
|
||||
String fileName,
|
||||
String mimeType,
|
||||
long fileSizeBytes,
|
||||
@Nullable Uri uri
|
||||
) {
|
||||
this.sourceType = sourceType == null ? "file" : sourceType;
|
||||
this.fileName = fileName == null || fileName.trim().isEmpty() ? "attachment" : fileName;
|
||||
this.mimeType = mimeType == null || mimeType.trim().isEmpty()
|
||||
? "application/octet-stream"
|
||||
: mimeType;
|
||||
this.fileSizeBytes = Math.max(fileSizeBytes, 0L);
|
||||
this.uri = uri;
|
||||
}
|
||||
|
||||
public boolean requiresConfirmation() {
|
||||
return AttachmentComposerState.requiresConfirmation(sourceType);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
314
android/app/src/main/java/com/hyzq/boss/BossMarkdown.java
Normal file
314
android/app/src/main/java/com/hyzq/boss/BossMarkdown.java
Normal file
@@ -0,0 +1,314 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
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;
|
||||
import android.text.TextUtils;
|
||||
import android.text.style.BackgroundColorSpan;
|
||||
import android.text.style.BulletSpan;
|
||||
import android.text.style.ForegroundColorSpan;
|
||||
import android.text.style.LeadingMarginSpan;
|
||||
import android.text.style.QuoteSpan;
|
||||
import android.text.style.RelativeSizeSpan;
|
||||
import android.text.style.StyleSpan;
|
||||
import android.text.style.TypefaceSpan;
|
||||
import android.util.LruCache;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
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);
|
||||
|
||||
private BossMarkdown() {}
|
||||
|
||||
public static CharSequence render(Context context, String markdown, boolean outgoing) {
|
||||
if (TextUtils.isEmpty(markdown) || TextUtils.isEmpty(markdown.trim())) {
|
||||
return "(空消息)";
|
||||
}
|
||||
String cacheKey = buildCacheKey(context, markdown, outgoing);
|
||||
CharSequence cached = RENDER_CACHE.get(cacheKey);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
Palette palette = Palette.resolve(context, outgoing);
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder();
|
||||
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<>();
|
||||
|
||||
for (String line : lines) {
|
||||
String trimmed = line.trim();
|
||||
if (trimmed.startsWith("```")) {
|
||||
if (inCodeFence) {
|
||||
appendCodeBlock(builder, joinCodeLines(codeLines), palette);
|
||||
codeLines.clear();
|
||||
}
|
||||
inCodeFence = !inCodeFence;
|
||||
continue;
|
||||
}
|
||||
if (inCodeFence) {
|
||||
codeLines.add(line);
|
||||
continue;
|
||||
}
|
||||
if (trimmed.isEmpty()) {
|
||||
appendBlankLine(builder);
|
||||
continue;
|
||||
}
|
||||
|
||||
Matcher headingMatcher = HEADING_PATTERN.matcher(line);
|
||||
if (headingMatcher.matches()) {
|
||||
int level = headingMatcher.group(1).length();
|
||||
appendHeading(builder, headingMatcher.group(2), level, palette);
|
||||
continue;
|
||||
}
|
||||
|
||||
Matcher bulletMatcher = BULLET_PATTERN.matcher(line);
|
||||
if (bulletMatcher.matches()) {
|
||||
appendBullet(builder, bulletMatcher.group(1), palette);
|
||||
continue;
|
||||
}
|
||||
|
||||
Matcher orderedMatcher = ORDERED_PATTERN.matcher(line);
|
||||
if (orderedMatcher.matches()) {
|
||||
appendOrdered(builder, orderedMatcher.group(1), orderedMatcher.group(2), palette);
|
||||
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;
|
||||
}
|
||||
|
||||
appendParagraph(builder, line, palette);
|
||||
}
|
||||
|
||||
if (inCodeFence && !codeLines.isEmpty()) {
|
||||
appendCodeBlock(builder, joinCodeLines(codeLines), palette);
|
||||
}
|
||||
|
||||
trimTrailingNewline(builder);
|
||||
CharSequence rendered = SpannedString.valueOf(builder);
|
||||
RENDER_CACHE.put(cacheKey, rendered);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
private static String buildCacheKey(Context context, String markdown, boolean outgoing) {
|
||||
int uiMode = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
|
||||
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();
|
||||
appendInlineStyled(builder, text, palette);
|
||||
builder.setSpan(new StyleSpan(Typeface.BOLD), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
float size = level == 1 ? 1.18f : level == 2 ? 1.1f : 1.04f;
|
||||
builder.setSpan(new RelativeSizeSpan(size), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.append('\n');
|
||||
}
|
||||
|
||||
private static void appendParagraph(SpannableStringBuilder builder, String text, Palette palette) {
|
||||
ensureBlockSeparation(builder, false);
|
||||
appendInlineStyled(builder, text, palette);
|
||||
builder.append('\n');
|
||||
}
|
||||
|
||||
private static void appendBullet(SpannableStringBuilder builder, String text, Palette palette) {
|
||||
ensureBlockSeparation(builder, false);
|
||||
int start = builder.length();
|
||||
builder.append("• ");
|
||||
appendInlineStyled(builder, text, palette);
|
||||
builder.setSpan(new BulletSpan(BossUi.dp(palette.context, 6), palette.quoteColor), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.setSpan(new LeadingMarginSpan.Standard(BossUi.dp(palette.context, 14)), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.append('\n');
|
||||
}
|
||||
|
||||
private static void appendOrdered(SpannableStringBuilder builder, String number, String text, Palette palette) {
|
||||
ensureBlockSeparation(builder, false);
|
||||
int start = builder.length();
|
||||
builder.append(number).append(". ");
|
||||
appendInlineStyled(builder, text, palette);
|
||||
builder.setSpan(new LeadingMarginSpan.Standard(BossUi.dp(palette.context, 14)), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.append('\n');
|
||||
}
|
||||
|
||||
private static void appendQuote(SpannableStringBuilder builder, String text, Palette palette) {
|
||||
ensureBlockSeparation(builder, false);
|
||||
int start = builder.length();
|
||||
appendInlineStyled(builder, TextUtils.isEmpty(text) ? "引用" : text, palette);
|
||||
QuoteSpan quoteSpan = Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
|
||||
? new QuoteSpan(palette.quoteColor, BossUi.dp(palette.context, 3), BossUi.dp(palette.context, 8))
|
||||
: new QuoteSpan(palette.quoteColor);
|
||||
builder.setSpan(quoteSpan, start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.append('\n');
|
||||
}
|
||||
|
||||
private static void appendLabelSection(
|
||||
SpannableStringBuilder builder,
|
||||
String label,
|
||||
String content,
|
||||
Palette palette
|
||||
) {
|
||||
ensureBlockSeparation(builder, true);
|
||||
int labelStart = builder.length();
|
||||
builder.append(label.trim());
|
||||
builder.setSpan(new StyleSpan(Typeface.BOLD), labelStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.setSpan(new RelativeSizeSpan(1.03f), labelStart, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.append('\n');
|
||||
appendInlineStyled(builder, content.trim(), palette);
|
||||
builder.append('\n');
|
||||
}
|
||||
|
||||
private static void appendCodeBlock(SpannableStringBuilder builder, String text, Palette palette) {
|
||||
if (TextUtils.isEmpty(text)) {
|
||||
return;
|
||||
}
|
||||
ensureBlockSeparation(builder, true);
|
||||
int start = builder.length();
|
||||
builder.append(text);
|
||||
int end = builder.length();
|
||||
builder.setSpan(new TypefaceSpan("monospace"), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.setSpan(new BackgroundColorSpan(palette.codeBackgroundColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.setSpan(new ForegroundColorSpan(palette.codeTextColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.setSpan(new LeadingMarginSpan.Standard(BossUi.dp(palette.context, 10), BossUi.dp(palette.context, 10)),
|
||||
start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.append('\n');
|
||||
}
|
||||
|
||||
private static void appendInlineStyled(SpannableStringBuilder builder, String text, Palette palette) {
|
||||
Matcher matcher = INLINE_TOKEN_PATTERN.matcher(text);
|
||||
int cursor = 0;
|
||||
while (matcher.find()) {
|
||||
if (matcher.start() > cursor) {
|
||||
builder.append(text, cursor, matcher.start());
|
||||
}
|
||||
if (matcher.group(2) != null) {
|
||||
int start = builder.length();
|
||||
builder.append(matcher.group(2));
|
||||
builder.setSpan(new StyleSpan(Typeface.BOLD), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
} else if (matcher.group(4) != null) {
|
||||
int start = builder.length();
|
||||
builder.append(matcher.group(4));
|
||||
builder.setSpan(new TypefaceSpan("monospace"), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.setSpan(new BackgroundColorSpan(palette.codeBackgroundColor), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
builder.setSpan(new ForegroundColorSpan(palette.codeTextColor), start, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
}
|
||||
cursor = matcher.end();
|
||||
}
|
||||
if (cursor < text.length()) {
|
||||
builder.append(text.substring(cursor));
|
||||
}
|
||||
}
|
||||
|
||||
private static void ensureBlockSeparation(SpannableStringBuilder builder, boolean relaxed) {
|
||||
if (builder.length() == 0) {
|
||||
return;
|
||||
}
|
||||
if (builder.charAt(builder.length() - 1) != '\n') {
|
||||
builder.append('\n');
|
||||
return;
|
||||
}
|
||||
if (!relaxed) {
|
||||
return;
|
||||
}
|
||||
if (builder.length() < 2 || builder.charAt(builder.length() - 2) != '\n') {
|
||||
builder.append('\n');
|
||||
}
|
||||
}
|
||||
|
||||
private static void appendBlankLine(SpannableStringBuilder builder) {
|
||||
if (builder.length() == 0) {
|
||||
return;
|
||||
}
|
||||
if (builder.charAt(builder.length() - 1) != '\n') {
|
||||
builder.append('\n');
|
||||
}
|
||||
if (builder.length() < 2 || builder.charAt(builder.length() - 2) != '\n') {
|
||||
builder.append('\n');
|
||||
}
|
||||
}
|
||||
|
||||
private static void trimTrailingNewline(SpannableStringBuilder builder) {
|
||||
while (builder.length() > 0 && builder.charAt(builder.length() - 1) == '\n') {
|
||||
builder.delete(builder.length() - 1, builder.length());
|
||||
}
|
||||
}
|
||||
|
||||
private static String joinCodeLines(List<String> codeLines) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int index = 0; index < codeLines.size(); index += 1) {
|
||||
if (index > 0) {
|
||||
builder.append('\n');
|
||||
}
|
||||
builder.append(codeLines.get(index));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static final class Palette {
|
||||
final Context context;
|
||||
final int quoteColor;
|
||||
final int codeBackgroundColor;
|
||||
final int codeTextColor;
|
||||
|
||||
private Palette(Context context, int quoteColor, int codeBackgroundColor, int codeTextColor) {
|
||||
this.context = context;
|
||||
this.quoteColor = quoteColor;
|
||||
this.codeBackgroundColor = codeBackgroundColor;
|
||||
this.codeTextColor = codeTextColor;
|
||||
}
|
||||
|
||||
static Palette resolve(Context context, boolean outgoing) {
|
||||
if (outgoing) {
|
||||
return new Palette(
|
||||
context,
|
||||
Color.parseColor("#B7E6C6"),
|
||||
Color.parseColor("#33FFFFFF"),
|
||||
context.getColor(R.color.boss_surface)
|
||||
);
|
||||
}
|
||||
return new Palette(
|
||||
context,
|
||||
Color.parseColor("#91A39A"),
|
||||
Color.parseColor("#FFF0F1F3"),
|
||||
context.getColor(R.color.boss_text_primary)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
291
android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java
Normal file
291
android/app/src/main/java/com/hyzq/boss/BossRealtimeClient.java
Normal file
@@ -0,0 +1,291 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
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;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
final class BossRealtimeClient {
|
||||
private static final String TAG = "BossRealtime";
|
||||
private static final String HEARTBEAT_EVENT_NAME = "heartbeat";
|
||||
private static final String REALTIME_STREAM_HTTP_401 = "REALTIME_STREAM_HTTP_401";
|
||||
private static final int STREAM_CONNECT_TIMEOUT_MS = 12_000;
|
||||
private static final int STREAM_READ_TIMEOUT_MS = 30_000;
|
||||
private static final long INITIAL_BACKOFF_MS = 800L;
|
||||
private static final long MAX_BACKOFF_MS = 5000L;
|
||||
|
||||
interface Listener {
|
||||
void onRealtimeEvent(BossRealtimeEvent event);
|
||||
|
||||
default void onRealtimeConnectionChanged(boolean connected) {}
|
||||
}
|
||||
|
||||
private final BossApiClient apiClient;
|
||||
private final Listener listener;
|
||||
private volatile boolean running;
|
||||
private volatile boolean connected;
|
||||
private @Nullable Thread workerThread;
|
||||
private @Nullable HttpURLConnection activeConnection;
|
||||
|
||||
BossRealtimeClient(BossApiClient apiClient, Listener listener) {
|
||||
this.apiClient = apiClient;
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
synchronized void start() {
|
||||
if (running) {
|
||||
return;
|
||||
}
|
||||
running = true;
|
||||
workerThread = new Thread(this::runLoop, "boss-realtime");
|
||||
workerThread.start();
|
||||
}
|
||||
|
||||
synchronized void stop() {
|
||||
running = false;
|
||||
setConnected(false);
|
||||
if (activeConnection != null) {
|
||||
activeConnection.disconnect();
|
||||
activeConnection = null;
|
||||
}
|
||||
if (workerThread != null) {
|
||||
workerThread.interrupt();
|
||||
workerThread = null;
|
||||
}
|
||||
}
|
||||
|
||||
boolean isConnected() {
|
||||
return connected;
|
||||
}
|
||||
|
||||
private void setConnected(boolean nextConnected) {
|
||||
if (connected == nextConnected) {
|
||||
return;
|
||||
}
|
||||
connected = nextConnected;
|
||||
listener.onRealtimeConnectionChanged(nextConnected);
|
||||
}
|
||||
|
||||
private void runLoop() {
|
||||
long backoffMs = INITIAL_BACKOFF_MS;
|
||||
while (running) {
|
||||
try {
|
||||
Log.i(TAG, "Realtime stream connecting");
|
||||
openAndConsumeStream();
|
||||
backoffMs = INITIAL_BACKOFF_MS;
|
||||
} catch (Exception error) {
|
||||
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();
|
||||
if (restored.ok()) {
|
||||
Log.i(TAG, "Realtime stream session restored");
|
||||
backoffMs = INITIAL_BACKOFF_MS;
|
||||
continue;
|
||||
}
|
||||
Log.w(
|
||||
TAG,
|
||||
"Realtime stream restore failed: " + restored.statusCode + " " + restored.message()
|
||||
);
|
||||
} catch (Exception restoreError) {
|
||||
Log.w(TAG, "Realtime stream restore threw", restoreError);
|
||||
}
|
||||
}
|
||||
Log.w(TAG, "Realtime stream disconnected; retrying in " + backoffMs + "ms", error);
|
||||
try {
|
||||
Thread.sleep(backoffMs);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return;
|
||||
}
|
||||
backoffMs = Math.min(backoffMs + 1200L, MAX_BACKOFF_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void openAndConsumeStream() throws IOException {
|
||||
HttpURLConnection connection = apiClient.openConnection("/api/v1/events");
|
||||
activeConnection = connection;
|
||||
try {
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setConnectTimeout(STREAM_CONNECT_TIMEOUT_MS);
|
||||
connection.setReadTimeout(STREAM_READ_TIMEOUT_MS);
|
||||
connection.setUseCaches(false);
|
||||
connection.setDoInput(true);
|
||||
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);
|
||||
}
|
||||
|
||||
int statusCode = connection.getResponseCode();
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
throw new IOException("REALTIME_STREAM_HTTP_" + statusCode);
|
||||
}
|
||||
setConnected(true);
|
||||
Log.i(TAG, "Realtime stream connected");
|
||||
|
||||
try (InputStream inputStream = connection.getInputStream();
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
|
||||
StringBuilder block = new StringBuilder();
|
||||
String line;
|
||||
while (running && (line = reader.readLine()) != null) {
|
||||
if (line.isEmpty()) {
|
||||
dispatchEventBlock(block.toString());
|
||||
block.setLength(0);
|
||||
continue;
|
||||
}
|
||||
block.append(line).append('\n');
|
||||
}
|
||||
if (block.length() > 0) {
|
||||
dispatchEventBlock(block.toString());
|
||||
}
|
||||
if (running) {
|
||||
Log.w(TAG, "Realtime stream ended; reopening");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setConnected(false);
|
||||
activeConnection = null;
|
||||
connection.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldAttemptSessionRestore(Exception error) {
|
||||
return error instanceof IOException
|
||||
&& REALTIME_STREAM_HTTP_401.equals(error.getMessage())
|
||||
&& 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()) {
|
||||
return;
|
||||
}
|
||||
listener.onRealtimeEvent(event);
|
||||
}
|
||||
|
||||
static @Nullable BossRealtimeEvent parseEventBlock(String rawBlock) {
|
||||
if (rawBlock == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = rawBlock.trim();
|
||||
if (trimmed.isEmpty() || trimmed.startsWith(":")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String eventName = "";
|
||||
StringBuilder dataBuilder = new StringBuilder();
|
||||
for (String line : rawBlock.split("\n")) {
|
||||
if (line.startsWith("event:")) {
|
||||
eventName = line.substring("event:".length()).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
if (dataBuilder.length() > 0) {
|
||||
dataBuilder.append('\n');
|
||||
}
|
||||
dataBuilder.append(line.substring("data:".length()).trim());
|
||||
}
|
||||
}
|
||||
if (eventName.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (HEARTBEAT_EVENT_NAME.equals(eventName)) {
|
||||
return null;
|
||||
}
|
||||
JSONObject payload = new JSONObject();
|
||||
if (dataBuilder.length() > 0) {
|
||||
try {
|
||||
payload = new JSONObject(dataBuilder.toString());
|
||||
} catch (JSONException ignored) {
|
||||
payload = new JSONObject();
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return new BossRealtimeEvent(eventName, payload);
|
||||
}
|
||||
|
||||
static String buildEventFingerprint(@Nullable BossRealtimeEvent event) {
|
||||
if (event == null || event.eventName.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
return event.eventName + "|" + canonicalizeJson(event.payload);
|
||||
}
|
||||
|
||||
private static String canonicalizeJson(@Nullable Object value) {
|
||||
if (value == null || value == JSONObject.NULL) {
|
||||
return "null";
|
||||
}
|
||||
if (value instanceof JSONObject) {
|
||||
JSONObject object = (JSONObject) value;
|
||||
ArrayList<String> keys = new ArrayList<>();
|
||||
Iterator<String> iterator = object.keys();
|
||||
while (iterator.hasNext()) {
|
||||
String key = iterator.next();
|
||||
if (!"at".equals(key)) {
|
||||
keys.add(key);
|
||||
}
|
||||
}
|
||||
Collections.sort(keys);
|
||||
StringBuilder builder = new StringBuilder("{");
|
||||
for (int index = 0; index < keys.size(); index += 1) {
|
||||
if (index > 0) {
|
||||
builder.append(',');
|
||||
}
|
||||
String key = keys.get(index);
|
||||
builder.append(JSONObject.quote(key));
|
||||
builder.append(':');
|
||||
builder.append(canonicalizeJson(object.opt(key)));
|
||||
}
|
||||
builder.append('}');
|
||||
return builder.toString();
|
||||
}
|
||||
if (value instanceof JSONArray) {
|
||||
JSONArray array = (JSONArray) value;
|
||||
StringBuilder builder = new StringBuilder("[");
|
||||
for (int index = 0; index < array.length(); index += 1) {
|
||||
if (index > 0) {
|
||||
builder.append(',');
|
||||
}
|
||||
builder.append(canonicalizeJson(array.opt(index)));
|
||||
}
|
||||
builder.append(']');
|
||||
return builder.toString();
|
||||
}
|
||||
if (value instanceof String) {
|
||||
return JSONObject.quote((String) value);
|
||||
}
|
||||
if (value instanceof Number || value instanceof Boolean) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
return JSONObject.quote(String.valueOf(value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
public final class BossRealtimeEvent {
|
||||
public final String eventName;
|
||||
public final JSONObject payload;
|
||||
|
||||
public BossRealtimeEvent(String eventName, @Nullable JSONObject payload) {
|
||||
this.eventName = eventName == null ? "" : eventName.trim();
|
||||
this.payload = payload == null ? new JSONObject() : payload;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
import android.view.View;
|
||||
import android.widget.ImageButton;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
@@ -17,9 +18,10 @@ public abstract class BossScreenActivity extends AppCompatActivity {
|
||||
protected final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
|
||||
protected BossApiClient apiClient;
|
||||
protected Button backButton;
|
||||
protected Button refreshButton;
|
||||
protected Button headerActionButton;
|
||||
protected ImageButton backButton;
|
||||
protected ImageButton refreshButton;
|
||||
protected ImageButton headerActionButton;
|
||||
protected View topBarView;
|
||||
protected TextView titleView;
|
||||
protected TextView subtitleView;
|
||||
protected SwipeRefreshLayout refreshLayout;
|
||||
@@ -28,22 +30,32 @@ public abstract class BossScreenActivity extends AppCompatActivity {
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_screen);
|
||||
setContentView(getLayoutResId());
|
||||
apiClient = new BossApiClient(this);
|
||||
|
||||
backButton = findViewById(R.id.screen_back_button);
|
||||
refreshButton = findViewById(R.id.screen_refresh_button);
|
||||
headerActionButton = findViewById(R.id.screen_header_action);
|
||||
topBarView = findViewById(R.id.screen_top_bar);
|
||||
titleView = findViewById(R.id.screen_title);
|
||||
subtitleView = findViewById(R.id.screen_subtitle);
|
||||
refreshLayout = findViewById(R.id.screen_refresh_layout);
|
||||
contentLayout = findViewById(R.id.screen_content);
|
||||
|
||||
BossWindowInsets.applyStatusBarInset(topBarView);
|
||||
|
||||
backButton.setOnClickListener(v -> finish());
|
||||
BossUi.bindTopIconButton(this, backButton, BossUi.TopActionIcon.BACK, "返回");
|
||||
BossUi.bindTopIconButton(this, refreshButton, BossUi.TopActionIcon.REFRESH, "刷新");
|
||||
BossUi.bindTopIconButton(this, headerActionButton, BossUi.TopActionIcon.MORE, "更多");
|
||||
refreshButton.setOnClickListener(v -> reload());
|
||||
refreshLayout.setOnRefreshListener(this::reload);
|
||||
}
|
||||
|
||||
protected int getLayoutResId() {
|
||||
return R.layout.activity_screen;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
executor.shutdownNow();
|
||||
@@ -57,7 +69,7 @@ public abstract class BossScreenActivity extends AppCompatActivity {
|
||||
|
||||
protected void setHeaderAction(String label, android.view.View.OnClickListener listener) {
|
||||
headerActionButton.setVisibility(android.view.View.VISIBLE);
|
||||
headerActionButton.setText(label);
|
||||
BossUi.bindTopIconButton(this, headerActionButton, BossUi.topActionIconForLabel(label), label);
|
||||
headerActionButton.setOnClickListener(listener);
|
||||
}
|
||||
|
||||
@@ -69,7 +81,8 @@ public abstract class BossScreenActivity extends AppCompatActivity {
|
||||
protected void setRefreshing(boolean refreshing) {
|
||||
refreshLayout.setRefreshing(refreshing);
|
||||
refreshButton.setEnabled(!refreshing);
|
||||
refreshButton.setText(refreshing ? "同步中" : "刷新");
|
||||
refreshButton.setAlpha(refreshing ? 0.45f : 1f);
|
||||
refreshButton.setContentDescription(refreshing ? "同步中" : "刷新");
|
||||
}
|
||||
|
||||
protected void replaceContent(android.view.View... views) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.view.View;
|
||||
|
||||
import androidx.core.graphics.Insets;
|
||||
import androidx.core.view.ViewCompat;
|
||||
import androidx.core.view.WindowInsetsCompat;
|
||||
|
||||
public final class BossWindowInsets {
|
||||
private BossWindowInsets() {}
|
||||
|
||||
public static void applyStatusBarInset(View view) {
|
||||
if (view == null) {
|
||||
return;
|
||||
}
|
||||
final int initialLeft = view.getPaddingLeft();
|
||||
final int initialTop = view.getPaddingTop();
|
||||
final int initialRight = view.getPaddingRight();
|
||||
final int initialBottom = view.getPaddingBottom();
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view, (target, insets) -> {
|
||||
Insets statusInsets = insets.getInsets(
|
||||
WindowInsetsCompat.Type.statusBars() | WindowInsetsCompat.Type.displayCutout()
|
||||
);
|
||||
target.setPadding(
|
||||
initialLeft,
|
||||
initialTop + statusInsets.top,
|
||||
initialRight,
|
||||
initialBottom
|
||||
);
|
||||
return insets;
|
||||
});
|
||||
|
||||
if (ViewCompat.isAttachedToWindow(view)) {
|
||||
ViewCompat.requestApplyInsets(view);
|
||||
return;
|
||||
}
|
||||
|
||||
view.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
|
||||
@Override
|
||||
public void onViewAttachedToWindow(View v) {
|
||||
v.removeOnAttachStateChangeListener(this);
|
||||
ViewCompat.requestApplyInsets(v);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewDetachedFromWindow(View v) {
|
||||
// no-op
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static void applyKeyboardAvoidingInset(View view) {
|
||||
if (view == null) {
|
||||
return;
|
||||
}
|
||||
final int initialLeft = view.getPaddingLeft();
|
||||
final int initialTop = view.getPaddingTop();
|
||||
final int initialRight = view.getPaddingRight();
|
||||
final int initialBottom = view.getPaddingBottom();
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view, (target, insets) -> {
|
||||
Insets navigationInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars());
|
||||
Insets imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime());
|
||||
int bottomInset = Math.max(navigationInsets.bottom, imeInsets.bottom);
|
||||
target.setPadding(
|
||||
initialLeft,
|
||||
initialTop,
|
||||
initialRight,
|
||||
initialBottom + bottomInset
|
||||
);
|
||||
return insets;
|
||||
});
|
||||
|
||||
if (ViewCompat.isAttachedToWindow(view)) {
|
||||
ViewCompat.requestApplyInsets(view);
|
||||
return;
|
||||
}
|
||||
|
||||
view.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
|
||||
@Override
|
||||
public void onViewAttachedToWindow(View v) {
|
||||
v.removeOnAttachStateChangeListener(this);
|
||||
ViewCompat.requestApplyInsets(v);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewDetachedFromWindow(View v) {
|
||||
// no-op
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public class ConversationFolderActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_FOLDER_KEY = "folder_key";
|
||||
public static final String EXTRA_FOLDER_NAME = "folder_name";
|
||||
public static final String EXTRA_TARGET_PROJECT_ID = "target_project_id";
|
||||
public static final String EXTRA_TARGET_PROJECT_IDS = "target_project_ids";
|
||||
public static final String EXTRA_TARGET_PROJECT_LABEL = "target_project_label";
|
||||
private static final long REALTIME_REFRESH_DEBOUNCE_MS = 300L;
|
||||
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
|
||||
private String folderKey;
|
||||
private String folderName;
|
||||
private String folderDeviceId;
|
||||
private String targetProjectId;
|
||||
private ArrayList<String> targetProjectIds;
|
||||
private String targetProjectLabel;
|
||||
private @Nullable BossRealtimeClient realtimeClient;
|
||||
private @Nullable JSONObject currentFolderPayload;
|
||||
private final Handler uiHandler = new Handler(Looper.getMainLooper());
|
||||
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
|
||||
private final Set<String> trackedProjectIds = new LinkedHashSet<>();
|
||||
private boolean realtimeReloadScheduled;
|
||||
private final Runnable realtimeReloadRunnable = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
realtimeReloadScheduled = false;
|
||||
reload();
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
folderKey = getIntent().getStringExtra(EXTRA_FOLDER_KEY);
|
||||
folderName = getIntent().getStringExtra(EXTRA_FOLDER_NAME);
|
||||
folderDeviceId = parseFolderDeviceId(folderKey);
|
||||
targetProjectId = getIntent().getStringExtra(EXTRA_TARGET_PROJECT_ID);
|
||||
targetProjectIds = new ArrayList<>();
|
||||
String[] extraTargetProjectIds = getIntent().getStringArrayExtra(EXTRA_TARGET_PROJECT_IDS);
|
||||
if (extraTargetProjectIds != null) {
|
||||
for (String item : extraTargetProjectIds) {
|
||||
if (item != null) {
|
||||
String trimmed = item.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
targetProjectIds.add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
targetProjectLabel = getIntent().getStringExtra(EXTRA_TARGET_PROJECT_LABEL);
|
||||
configureScreen(folderName == null || folderName.isEmpty() ? "项目线程" : folderName, "0 个线程");
|
||||
refreshButton.setVisibility(android.view.View.GONE);
|
||||
setHeaderAction("...", v -> showMoreMenu());
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
cancelRealtimeReloadSchedule();
|
||||
stopRealtimeUpdates();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
cancelRealtimeReloadSchedule();
|
||||
stopRealtimeUpdates();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (folderKey == null || folderKey.isEmpty()) {
|
||||
replaceContent(BossUi.buildEmptyCard(this, "缺少项目标识。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getConversationFolder(folderKey);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> renderFolder(response.json.optJSONObject("folder")));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "项目线程加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateRealtimeSubscription() {
|
||||
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
|
||||
realtimeClient.start();
|
||||
return;
|
||||
}
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
private void stopRealtimeUpdates() {
|
||||
if (realtimeClient != null) {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
void handleRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (event == null || event.eventName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!shouldReloadForRealtimeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
|
||||
if (eventFingerprint.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
|
||||
return;
|
||||
}
|
||||
if (tryApplyFolderRealtimePatch(event)) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(this::scheduleRealtimeReload);
|
||||
}
|
||||
|
||||
private boolean tryApplyFolderRealtimePatch(BossRealtimeEvent event) {
|
||||
if (event == null || currentFolderPayload == null) {
|
||||
return false;
|
||||
}
|
||||
if (!"conversation.updated".equals(event.eventName)
|
||||
&& !"project.messages.updated".equals(event.eventName)) {
|
||||
return false;
|
||||
}
|
||||
String affectedProjectId = event.payload.optString("projectId", "").trim();
|
||||
if (affectedProjectId.isEmpty() || !trackedProjectIds.contains(affectedProjectId)) {
|
||||
return false;
|
||||
}
|
||||
JSONObject threadConversationItem = event.payload.optJSONObject("threadConversationItem");
|
||||
if (threadConversationItem == null) {
|
||||
return false;
|
||||
}
|
||||
String patchedFolderKey = threadConversationItem.optString("folderKey", "").trim();
|
||||
if (folderKey == null || folderKey.isEmpty() || !folderKey.equals(patchedFolderKey)) {
|
||||
return false;
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
if (currentFolderPayload == null) {
|
||||
scheduleRealtimeReload();
|
||||
return;
|
||||
}
|
||||
JSONObject mergedFolder = replaceThreadConversationItem(
|
||||
currentFolderPayload,
|
||||
affectedProjectId,
|
||||
threadConversationItem
|
||||
);
|
||||
currentFolderPayload = mergedFolder;
|
||||
renderFolder(mergedFolder);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private void scheduleRealtimeReload() {
|
||||
if (realtimeReloadScheduled) {
|
||||
return;
|
||||
}
|
||||
realtimeReloadScheduled = true;
|
||||
uiHandler.postDelayed(realtimeReloadRunnable, REALTIME_REFRESH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private void cancelRealtimeReloadSchedule() {
|
||||
uiHandler.removeCallbacks(realtimeReloadRunnable);
|
||||
realtimeReloadScheduled = false;
|
||||
}
|
||||
|
||||
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (!"conversation.updated".equals(event.eventName)
|
||||
&& !"project.messages.updated".equals(event.eventName)) {
|
||||
return false;
|
||||
}
|
||||
String payloadProjectId = event.payload.optString("projectId", "").trim();
|
||||
if (!payloadProjectId.isEmpty()) {
|
||||
return trackedProjectIds.contains(payloadProjectId)
|
||||
|| (!targetProjectIds.isEmpty() && targetProjectIds.contains(payloadProjectId))
|
||||
|| (targetProjectId != null && targetProjectId.equals(payloadProjectId));
|
||||
}
|
||||
String payloadDeviceId = event.payload.optString("deviceId", "").trim();
|
||||
if (payloadDeviceId.isEmpty() || folderDeviceId == null || folderDeviceId.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return payloadDeviceId.equals(folderDeviceId);
|
||||
}
|
||||
|
||||
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
||||
pruneRecentRealtimeEvents(now);
|
||||
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
|
||||
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
|
||||
return true;
|
||||
}
|
||||
recentRealtimeEventTimestamps.put(eventFingerprint, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void pruneRecentRealtimeEvents(long now) {
|
||||
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, Long> entry = iterator.next();
|
||||
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void renderFolder(@Nullable JSONObject folder) {
|
||||
replaceContent();
|
||||
if (folder == null) {
|
||||
currentFolderPayload = null;
|
||||
trackedProjectIds.clear();
|
||||
appendContent(BossUi.buildEmptyCard(this, "未找到项目线程。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
currentFolderPayload = copyJson(folder);
|
||||
String resolvedFolderName = folder.optString("folderLabel", folderName == null ? "项目线程" : folderName);
|
||||
folderDeviceId = folder.optString("deviceId", folderDeviceId == null ? "" : folderDeviceId).trim();
|
||||
int threadCount = folder.optInt("threadCount", 0);
|
||||
configureScreen(resolvedFolderName, threadCount + " 个线程");
|
||||
|
||||
JSONArray threads = folder.optJSONArray("threads");
|
||||
updateTrackedProjectIds(threads);
|
||||
if (threads == null || threads.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前项目下没有线程。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
ArrayList<Integer> targetIndices = resolveTargetThreadIndices(threads);
|
||||
for (int i = 0; i < targetIndices.size(); i++) {
|
||||
renderThreadAtIndex(threads, targetIndices.get(i), true);
|
||||
}
|
||||
for (int i = 0; i < threads.length(); i++) {
|
||||
if (!targetIndices.contains(i)) {
|
||||
renderThreadAtIndex(threads, i, false);
|
||||
}
|
||||
}
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void updateTrackedProjectIds(@Nullable JSONArray threads) {
|
||||
trackedProjectIds.clear();
|
||||
if (threads == null) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < threads.length(); i++) {
|
||||
JSONObject item = threads.optJSONObject(i);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
String projectId = item.optString("projectId", "").trim();
|
||||
if (!projectId.isEmpty()) {
|
||||
trackedProjectIds.add(projectId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private JSONObject replaceThreadConversationItem(JSONObject folder, String affectedProjectId, JSONObject threadConversationItem) {
|
||||
JSONObject mergedFolder = copyJson(folder);
|
||||
JSONArray existingThreads = mergedFolder.optJSONArray("threads");
|
||||
JSONArray mergedThreads = new JSONArray();
|
||||
boolean replaced = false;
|
||||
if (existingThreads != null) {
|
||||
for (int i = 0; i < existingThreads.length(); i++) {
|
||||
JSONObject item = existingThreads.optJSONObject(i);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if (affectedProjectId.equals(item.optString("projectId", "").trim())) {
|
||||
mergedThreads.put(copyJson(threadConversationItem));
|
||||
replaced = true;
|
||||
continue;
|
||||
}
|
||||
mergedThreads.put(copyJson(item));
|
||||
}
|
||||
}
|
||||
if (!replaced) {
|
||||
mergedThreads.put(copyJson(threadConversationItem));
|
||||
}
|
||||
try {
|
||||
mergedFolder.put("threads", mergedThreads);
|
||||
mergedFolder.put("threadCount", mergedThreads.length());
|
||||
} catch (org.json.JSONException ignored) {
|
||||
}
|
||||
return mergedFolder;
|
||||
}
|
||||
|
||||
private JSONObject copyJson(@Nullable JSONObject source) {
|
||||
if (source == null) {
|
||||
return new JSONObject();
|
||||
}
|
||||
try {
|
||||
return new JSONObject(source.toString());
|
||||
} catch (org.json.JSONException ignored) {
|
||||
return new JSONObject();
|
||||
}
|
||||
}
|
||||
|
||||
private String parseFolderDeviceId(@Nullable String candidateFolderKey) {
|
||||
if (candidateFolderKey == null) {
|
||||
return "";
|
||||
}
|
||||
int separatorIndex = candidateFolderKey.indexOf(':');
|
||||
if (separatorIndex <= 0) {
|
||||
return "";
|
||||
}
|
||||
return candidateFolderKey.substring(0, separatorIndex).trim();
|
||||
}
|
||||
|
||||
private void renderThreadAtIndex(JSONArray threads, int index, boolean highlighted) {
|
||||
JSONObject item = threads.optJSONObject(index);
|
||||
if (item == null) return;
|
||||
String projectId = item.optString("projectId", "");
|
||||
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
|
||||
appendContent(BossUi.buildConversationRow(
|
||||
this,
|
||||
row,
|
||||
false,
|
||||
false,
|
||||
highlighted,
|
||||
v -> {
|
||||
if (projectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
return;
|
||||
}
|
||||
openProject(projectId, row.threadTitle);
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
private ArrayList<Integer> resolveTargetThreadIndices(JSONArray threads) {
|
||||
ArrayList<Integer> targetIndices = new ArrayList<>();
|
||||
if (threads == null || threads.length() == 0) {
|
||||
return targetIndices;
|
||||
}
|
||||
|
||||
if (!targetProjectIds.isEmpty()) {
|
||||
for (int i = 0; i < threads.length(); i++) {
|
||||
JSONObject item = threads.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
String projectId = item.optString("projectId", "");
|
||||
if (targetProjectIds.contains(projectId)) {
|
||||
targetIndices.add(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetIndices.isEmpty() && targetProjectId != null && !targetProjectId.isEmpty()) {
|
||||
for (int i = 0; i < threads.length(); i++) {
|
||||
JSONObject item = threads.optJSONObject(i);
|
||||
if (item != null && targetProjectId.equals(item.optString("projectId", ""))) {
|
||||
targetIndices.add(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetIndices.isEmpty() && targetProjectLabel != null && !targetProjectLabel.isEmpty()) {
|
||||
for (int i = 0; i < threads.length(); i++) {
|
||||
JSONObject item = threads.optJSONObject(i);
|
||||
if (item != null && targetProjectLabel.equals(item.optString("threadTitle", ""))) {
|
||||
targetIndices.add(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetIndices;
|
||||
}
|
||||
|
||||
private void openProject(String projectId, String projectName) {
|
||||
Intent intent = new Intent(this, ProjectDetailActivity.class);
|
||||
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, projectId);
|
||||
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, projectName);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void showMoreMenu() {
|
||||
new androidx.appcompat.app.AlertDialog.Builder(this)
|
||||
.setItems(new CharSequence[]{"刷新"}, (dialog, which) -> reload())
|
||||
.show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.widget.SwitchCompat;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
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;
|
||||
private String projectName;
|
||||
private String projectFolderName;
|
||||
private int participantCount;
|
||||
private boolean takeoverEnabled;
|
||||
private boolean takeoverInheritedFromGlobal;
|
||||
private @Nullable BossRealtimeClient realtimeClient;
|
||||
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
|
||||
|
||||
@Override
|
||||
protected int getLayoutResId() {
|
||||
return R.layout.activity_conversation_info;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
|
||||
configureScreen("会话信息", projectName == null ? "单线程会话" : projectName);
|
||||
refreshButton.setVisibility(android.view.View.GONE);
|
||||
setHeaderAction("...", v -> showMoreMenu());
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
stopRealtimeUpdates();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
stopRealtimeUpdates();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
LoadedConversation loadedConversation = loadConversation();
|
||||
BossApiClient.ApiResponse detailResponse = loadedConversation.detailResponse;
|
||||
BossApiClient.ApiResponse participantsResponse = loadedConversation.participantsResponse;
|
||||
JSONObject threadStatusPayload = null;
|
||||
try {
|
||||
BossApiClient.ApiResponse threadStatusResponse = loadedConversation.threadStatusResponse;
|
||||
if (threadStatusResponse.ok()) {
|
||||
threadStatusPayload = threadStatusResponse.json;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
threadStatusPayload = null;
|
||||
}
|
||||
JSONObject finalThreadStatusPayload = threadStatusPayload;
|
||||
runOnUiThread(() -> renderConversation(detailResponse.json, participantsResponse.json, finalThreadStatusPayload));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "会话信息加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateRealtimeSubscription() {
|
||||
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
|
||||
realtimeClient.start();
|
||||
return;
|
||||
}
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
private void stopRealtimeUpdates() {
|
||||
if (realtimeClient != null) {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
void handleRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!shouldReloadForRealtimeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
|
||||
if (eventFingerprint.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(this::reload);
|
||||
}
|
||||
|
||||
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
|
||||
String payloadProjectId = event.payload.optString("projectId", "").trim();
|
||||
if (payloadProjectId.isEmpty() || !payloadProjectId.equals(projectId)) {
|
||||
return false;
|
||||
}
|
||||
return "conversation.updated".equals(event.eventName)
|
||||
|| "project.messages.updated".equals(event.eventName);
|
||||
}
|
||||
|
||||
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
||||
pruneRecentRealtimeEvents(now);
|
||||
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
|
||||
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
|
||||
return true;
|
||||
}
|
||||
recentRealtimeEventTimestamps.put(eventFingerprint, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void pruneRecentRealtimeEvents(long now) {
|
||||
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, Long> entry = iterator.next();
|
||||
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void renderConversation(JSONObject detail, JSONObject participantsPayload, @Nullable JSONObject threadStatusPayload) {
|
||||
replaceContent();
|
||||
JSONObject project = detail.optJSONObject("project");
|
||||
JSONArray participants = participantsPayload.optJSONArray("participants");
|
||||
|
||||
if (project == null) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "会话不存在。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
projectName = project.optString("name", projectName == null ? "会话信息" : projectName);
|
||||
JSONObject agentControls = detail.optJSONObject("agentControls");
|
||||
JSONObject threadMeta = project.optJSONObject("threadMeta");
|
||||
projectFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
|
||||
participantCount = participants == null ? 0 : participants.length();
|
||||
takeoverEnabled = agentControls != null && agentControls.optBoolean("effectiveTakeoverEnabled", false);
|
||||
takeoverInheritedFromGlobal = agentControls != null && agentControls.optBoolean("takeoverInheritedFromGlobal", false);
|
||||
configureScreen("会话信息", buildSubtitle(threadMeta, participantCount));
|
||||
|
||||
appendTakeoverControl();
|
||||
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"发起群聊",
|
||||
"选择其他线程加入新群",
|
||||
"原会话保留",
|
||||
null,
|
||||
v -> openGroupCreate()
|
||||
));
|
||||
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"线程详情",
|
||||
"查看当前线程聊天与项目",
|
||||
resolveThreadId(project, threadMeta),
|
||||
null,
|
||||
v -> openProject(projectId, projectName)
|
||||
));
|
||||
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"线程状态",
|
||||
"状态文档和最近进展事件",
|
||||
projectFolderName.isEmpty() ? null : projectFolderName,
|
||||
null,
|
||||
v -> openThreadStatus()
|
||||
));
|
||||
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"参与线程",
|
||||
participantCount <= 0 ? "暂无参与线程" : "共 " + participantCount + " 个",
|
||||
projectFolderName.isEmpty() ? null : projectFolderName,
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
if (participants == null || participants.length() == 0) {
|
||||
appendConversationInfoItem(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"暂无参与线程",
|
||||
"下拉刷新后重试",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
} else {
|
||||
for (int i = 0; i < participants.length(); i++) {
|
||||
JSONObject participant = participants.optJSONObject(i);
|
||||
if (participant == null) continue;
|
||||
appendConversationInfoItem(buildParticipantRow(participant));
|
||||
}
|
||||
}
|
||||
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void appendTakeoverControl() {
|
||||
SwitchCompat takeoverSwitch = new SwitchCompat(this);
|
||||
takeoverSwitch.setShowText(false);
|
||||
takeoverSwitch.setText(null);
|
||||
takeoverSwitch.setChecked(takeoverEnabled);
|
||||
takeoverSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> saveTakeoverSetting(isChecked));
|
||||
appendConversationInfoItem(BossUi.buildWechatSwitchRow(
|
||||
this,
|
||||
"主 Agent 协同接管",
|
||||
takeoverInheritedFromGlobal
|
||||
? "跟随全局默认开启"
|
||||
: "为此线程单独开启",
|
||||
takeoverSwitch
|
||||
));
|
||||
}
|
||||
|
||||
private void appendConversationInfoItem(android.view.View view) {
|
||||
android.view.ViewGroup.LayoutParams currentParams = view.getLayoutParams();
|
||||
LinearLayout.LayoutParams params;
|
||||
if (currentParams instanceof LinearLayout.LayoutParams) {
|
||||
params = (LinearLayout.LayoutParams) currentParams;
|
||||
} else {
|
||||
params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
}
|
||||
params.bottomMargin = BossUi.dp(this, 8);
|
||||
view.setLayoutParams(params);
|
||||
appendContent(view);
|
||||
}
|
||||
|
||||
private LinearLayout buildParticipantRow(JSONObject participant) {
|
||||
boolean sourceProject = participant.optBoolean("isSourceProject", false);
|
||||
String participantProjectId = participant.optString("projectId", "");
|
||||
String title = participant.optString("threadDisplayName", "未命名线程");
|
||||
String subtitle = participant.optString("folderName", "");
|
||||
String meta = participant.optString("deviceId", "");
|
||||
if (!participant.optString("threadId", "").isEmpty()) {
|
||||
meta = meta.isEmpty() ? participant.optString("threadId", "") : meta + " · " + participant.optString("threadId", "");
|
||||
}
|
||||
if (sourceProject) {
|
||||
subtitle = subtitle.isEmpty() ? "来源线程" : "来源线程 · " + subtitle;
|
||||
}
|
||||
return BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
title,
|
||||
subtitle,
|
||||
meta,
|
||||
sourceProject ? "来源" : null,
|
||||
v -> openProject(participantProjectId, title)
|
||||
);
|
||||
}
|
||||
|
||||
private void openProject(String targetProjectId, String targetProjectName) {
|
||||
if (targetProjectId == null || targetProjectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
return;
|
||||
}
|
||||
Intent intent = new Intent(this, ProjectDetailActivity.class);
|
||||
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, targetProjectId);
|
||||
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, targetProjectName);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openGroupCreate() {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
return;
|
||||
}
|
||||
Intent intent = new Intent(this, GroupCreateActivity.class);
|
||||
intent.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_ID, projectId);
|
||||
intent.putExtra(GroupCreateActivity.EXTRA_SOURCE_PROJECT_NAME, projectName);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openThreadStatus() {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
return;
|
||||
}
|
||||
Intent intent = new Intent(this, ThreadStatusActivity.class);
|
||||
intent.putExtra(ThreadStatusActivity.EXTRA_PROJECT_ID, projectId);
|
||||
intent.putExtra(ThreadStatusActivity.EXTRA_PROJECT_NAME, projectName);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openRenameDialog() {
|
||||
final EditText input = BossUi.buildInput(this, "线程名", false);
|
||||
input.setText(projectName == null ? "" : projectName);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("重命名会话")
|
||||
.setView(input)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("保存", (dialog, which) -> saveConversationName(input.getText().toString().trim()))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void showMoreMenu() {
|
||||
new AlertDialog.Builder(this)
|
||||
.setItems(new CharSequence[]{"改名", "刷新"}, (dialog, which) -> {
|
||||
if (which == 0) {
|
||||
openRenameDialog();
|
||||
return;
|
||||
}
|
||||
reload();
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private void saveConversationName(String name) {
|
||||
if (name.isEmpty()) {
|
||||
showMessage("线程名不能为空");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.renameConversation(projectId, name, false);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
Intent result = new Intent();
|
||||
result.putExtra(EXTRA_PROJECT_NAME, name);
|
||||
setResult(RESULT_OK, result);
|
||||
showMessage("线程名已更新");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void saveTakeoverSetting(boolean enabled) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = saveTakeoverSettingsWithRetry(
|
||||
projectId,
|
||||
enabled,
|
||||
null
|
||||
);
|
||||
if (!response.ok()) {
|
||||
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();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private LoadedConversation loadConversation() throws Exception {
|
||||
BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(projectId);
|
||||
if (isUnauthorized(detailResponse)) {
|
||||
BossApiClient.ApiResponse loginResponse = apiClient.autoLogin();
|
||||
if (!loginResponse.ok()) {
|
||||
throw new IllegalStateException(loginResponse.message());
|
||||
}
|
||||
detailResponse = apiClient.getProjectDetail(projectId);
|
||||
}
|
||||
if (!detailResponse.ok()) {
|
||||
throw new IllegalStateException(detailResponse.message());
|
||||
}
|
||||
|
||||
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId);
|
||||
if (!participantsResponse.ok()) {
|
||||
throw new IllegalStateException(participantsResponse.message());
|
||||
}
|
||||
|
||||
BossApiClient.ApiResponse threadStatusResponse = apiClient.getThreadStatus(projectId);
|
||||
return new LoadedConversation(detailResponse, participantsResponse, threadStatusResponse);
|
||||
}
|
||||
|
||||
private BossApiClient.ApiResponse saveTakeoverSettingsWithRetry(
|
||||
String targetProjectId,
|
||||
boolean takeoverEnabled,
|
||||
@Nullable Boolean globalTakeoverEnabled
|
||||
) throws Exception {
|
||||
BossApiClient.ApiResponse response = apiClient.updateProjectTakeoverSettings(
|
||||
targetProjectId,
|
||||
takeoverEnabled,
|
||||
globalTakeoverEnabled
|
||||
);
|
||||
if (!isUnauthorized(response)) {
|
||||
return response;
|
||||
}
|
||||
BossApiClient.ApiResponse loginResponse = apiClient.autoLogin();
|
||||
if (!loginResponse.ok()) {
|
||||
return response;
|
||||
}
|
||||
return apiClient.updateProjectTakeoverSettings(
|
||||
targetProjectId,
|
||||
takeoverEnabled,
|
||||
globalTakeoverEnabled
|
||||
);
|
||||
}
|
||||
|
||||
private boolean isUnauthorized(@Nullable BossApiClient.ApiResponse response) {
|
||||
return response != null && response.statusCode == 401 && "UNAUTHORIZED".equals(response.message());
|
||||
}
|
||||
|
||||
private String buildSubtitle(@Nullable JSONObject threadMeta, int count) {
|
||||
String folder = threadMeta == null ? "" : threadMeta.optString("folderName", "");
|
||||
String suffix = count <= 0 ? "暂无参与线程" : count + " 个参与线程";
|
||||
if (folder.isEmpty()) {
|
||||
return suffix;
|
||||
}
|
||||
return folder + " · " + suffix;
|
||||
}
|
||||
|
||||
private String resolveThreadId(JSONObject project, @Nullable JSONObject threadMeta) {
|
||||
if (threadMeta != null) {
|
||||
String threadId = threadMeta.optString("threadId", "");
|
||||
if (!threadId.isEmpty()) {
|
||||
return threadId;
|
||||
}
|
||||
}
|
||||
return project.optString("id", "");
|
||||
}
|
||||
|
||||
private static final class LoadedConversation {
|
||||
private final BossApiClient.ApiResponse detailResponse;
|
||||
private final BossApiClient.ApiResponse participantsResponse;
|
||||
private final BossApiClient.ApiResponse threadStatusResponse;
|
||||
|
||||
private LoadedConversation(
|
||||
BossApiClient.ApiResponse detailResponse,
|
||||
BossApiClient.ApiResponse participantsResponse,
|
||||
BossApiClient.ApiResponse threadStatusResponse
|
||||
) {
|
||||
this.detailResponse = detailResponse;
|
||||
this.participantsResponse = participantsResponse;
|
||||
this.threadStatusResponse = threadStatusResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
@@ -11,23 +10,48 @@ import androidx.appcompat.app.AlertDialog;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class DeviceDetailActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_DEVICE_ID = "device_id";
|
||||
public static final String EXTRA_DEVICE_NAME = "device_name";
|
||||
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
|
||||
private String deviceId;
|
||||
private String deviceName;
|
||||
private @Nullable BossRealtimeClient realtimeClient;
|
||||
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
deviceId = getIntent().getStringExtra(EXTRA_DEVICE_ID);
|
||||
deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME);
|
||||
configureScreen(deviceName == null ? "设备详情" : deviceName, "原生设备详情");
|
||||
configureScreen(deviceName == null ? "设备详情" : deviceName, "设备状态、GUI/CLI 能力与默认执行模式");
|
||||
setHeaderAction("编辑", v -> openEditDialog());
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
stopRealtimeUpdates();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
stopRealtimeUpdates();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
@@ -45,11 +69,72 @@ public class DeviceDetailActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void updateRealtimeSubscription() {
|
||||
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
|
||||
realtimeClient.start();
|
||||
return;
|
||||
}
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
private void stopRealtimeUpdates() {
|
||||
if (realtimeClient != null) {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
void handleRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (event == null || event.eventName.isEmpty() || deviceId == null || deviceId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!shouldReloadForRealtimeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
|
||||
if (eventFingerprint.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(this::reload);
|
||||
}
|
||||
|
||||
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
|
||||
String payloadDeviceId = event.payload.optString("deviceId", "").trim();
|
||||
if (payloadDeviceId.isEmpty() || !payloadDeviceId.equals(deviceId)) {
|
||||
return false;
|
||||
}
|
||||
return "devices.updated".equals(event.eventName)
|
||||
|| "devices.skills.updated".equals(event.eventName)
|
||||
|| "project.context_risk.updated".equals(event.eventName);
|
||||
}
|
||||
|
||||
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
||||
pruneRecentRealtimeEvents(now);
|
||||
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
|
||||
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
|
||||
return true;
|
||||
}
|
||||
recentRealtimeEventTimestamps.put(eventFingerprint, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void pruneRecentRealtimeEvents(long now) {
|
||||
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, Long> entry = iterator.next();
|
||||
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void renderDevice(JSONObject payload) {
|
||||
JSONObject workspace = payload.optJSONObject("workspace");
|
||||
JSONObject device = workspace == null ? null : workspace.optJSONObject("selectedDevice");
|
||||
JSONArray relatedThreads = workspace == null ? null : workspace.optJSONArray("relatedThreads");
|
||||
JSONObject enrollment = workspace == null ? null : workspace.optJSONObject("activeEnrollment");
|
||||
JSONObject primaryPolicy = resolvePrimaryProjectExecutionPolicy(workspace);
|
||||
|
||||
replaceContent();
|
||||
if (device == null) {
|
||||
@@ -59,53 +144,199 @@ public class DeviceDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
deviceName = device.optString("name", deviceId);
|
||||
configureScreen(deviceName, device.optString("endpoint", "设备详情"));
|
||||
appendContent(BossUi.buildCard(
|
||||
configureScreen(deviceName, "设备状态、GUI/CLI 能力与默认执行模式");
|
||||
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(device);
|
||||
appendContent(BossUi.buildDeviceCard(
|
||||
this,
|
||||
device.optString("name", "设备"),
|
||||
device.optString("note", "暂无备注"),
|
||||
"状态 " + device.optString("status", "unknown")
|
||||
+ " · 账号 " + device.optString("account", "-")
|
||||
+ " · 5h " + device.optInt("quota5h", 0)
|
||||
+ " · 7d " + device.optInt("quota7d", 0)
|
||||
WechatSurfaceMapper.toDeviceRow(device),
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
Button skillsButton = BossUi.buildPrimaryButton(this, "查看技能");
|
||||
skillsButton.setOnClickListener(v -> openSkills());
|
||||
appendContent(skillsButton);
|
||||
|
||||
if (relatedThreads != null && relatedThreads.length() > 0) {
|
||||
for (int i = 0; i < relatedThreads.length(); i++) {
|
||||
JSONObject thread = relatedThreads.optJSONObject(i);
|
||||
if (thread == null) continue;
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
thread.optString("title", "线程"),
|
||||
thread.optString("summary", ""),
|
||||
thread.optString("workerId", "-")
|
||||
+ " · " + thread.optInt("contextBudgetRemainingPct", 0) + "%"
|
||||
+ " · " + thread.optString("contextBudgetLevel", "safe"),
|
||||
v -> openThread(thread.optString("threadId"))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (enrollment != null) {
|
||||
appendContent(BossUi.buildCard(
|
||||
if (summary.meta != null && !summary.meta.isEmpty()) {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"当前绑定草稿",
|
||||
"pairingCode " + enrollment.optString("pairingCode", "-")
|
||||
+ "\ntoken " + enrollment.optString("token", "-"),
|
||||
enrollment.optString("status", "ready")
|
||||
+ " · 到期 " + enrollment.optString("expiresAt", "-")
|
||||
"设备说明",
|
||||
summary.meta,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
WechatSurfaceMapper.deviceCapabilityTitle("gui"),
|
||||
WechatSurfaceMapper.deviceCapabilityStatusLabel(device, "gui"),
|
||||
WechatSurfaceMapper.deviceCapabilityDetailLabel(device, "gui"),
|
||||
null,
|
||||
null
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
WechatSurfaceMapper.deviceCapabilityTitle("cli"),
|
||||
WechatSurfaceMapper.deviceCapabilityStatusLabel(device, "cli"),
|
||||
WechatSurfaceMapper.deviceCapabilityDetailLabel(device, "cli"),
|
||||
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,
|
||||
"默认执行模式",
|
||||
WechatSurfaceMapper.devicePreferredExecutionModeSummary(device),
|
||||
"切换",
|
||||
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,
|
||||
"异常项目 / 文件夹冲突",
|
||||
primaryPolicy.optString("projectId", "未知项目"),
|
||||
primaryPolicy.optString("folderKey", ""),
|
||||
null,
|
||||
null
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"当前冲突态",
|
||||
WechatSurfaceMapper.projectConflictStateLabel(primaryPolicy.optString("conflictState", "")),
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"当前策略",
|
||||
WechatSurfaceMapper.projectConflictAllowPolicyLabel(primaryPolicy.optString("allowPolicy", "")),
|
||||
"仅作用于当前异常项目 / 文件夹",
|
||||
null,
|
||||
null
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"冲突策略",
|
||||
"禁止 / 允许本次 / 永久放行",
|
||||
"切换",
|
||||
null,
|
||||
v -> showConflictDecisionDialog(payload)
|
||||
));
|
||||
}
|
||||
appendContent(BossUi.buildMenuRow(this, "导入项目", "勾选这台设备上要暴露到会话首页的项目和线程", null, v -> openImportDraft()));
|
||||
appendContent(BossUi.buildMenuRow(this, "查看技能", "查看当前设备同步的 Skill 清单", null, v -> openSkills()));
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void openThread(String threadId) {
|
||||
Intent intent = new Intent(this, ThreadDetailActivity.class);
|
||||
intent.putExtra(ThreadDetailActivity.EXTRA_THREAD_ID, threadId);
|
||||
private void openImportDraft() {
|
||||
Intent intent = new Intent(this, DeviceImportDraftActivity.class);
|
||||
intent.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_ID, deviceId);
|
||||
intent.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_NAME, deviceName);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
@@ -116,6 +347,63 @@ public class DeviceDetailActivity extends BossScreenActivity {
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void showPreferredExecutionModeDialog(JSONObject device) {
|
||||
String currentMode = device == null ? "cli" : device.optString("preferredExecutionMode", "cli");
|
||||
String[] modeLabels = new String[] {
|
||||
WechatSurfaceMapper.deviceExecutionModeChoiceLabel("gui"),
|
||||
WechatSurfaceMapper.deviceExecutionModeChoiceLabel("cli")
|
||||
};
|
||||
String[] modeValues = new String[] {"gui", "cli"};
|
||||
int checkedIndex = "gui".equals(currentMode) ? 0 : 1;
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("默认执行模式")
|
||||
.setSingleChoiceItems(modeLabels, checkedIndex, (dialog, which) -> {
|
||||
dialog.dismiss();
|
||||
savePreferredExecutionMode(modeValues[which]);
|
||||
})
|
||||
.setNegativeButton("取消", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void showConflictDecisionDialog(JSONObject payload) {
|
||||
JSONObject workspace = payload == null ? null : payload.optJSONObject("workspace");
|
||||
JSONObject primaryPolicy = resolvePrimaryProjectExecutionPolicy(workspace);
|
||||
if (primaryPolicy == null) {
|
||||
showMessage("当前没有可处理的异常项目 / 文件夹。");
|
||||
return;
|
||||
}
|
||||
String[] labels = new String[] {"禁止", "允许本次", "永久放行"};
|
||||
String[] values = new String[] {"forbid", "allow_once", "allow_always"};
|
||||
int checkedIndex = resolveConflictDecisionCheckedIndex(primaryPolicy.optString("allowPolicy", ""));
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("冲突策略")
|
||||
.setSingleChoiceItems(labels, checkedIndex, (dialog, which) -> {
|
||||
dialog.dismiss();
|
||||
saveConflictDecision(
|
||||
primaryPolicy.optString("projectId", ""),
|
||||
primaryPolicy.optString("folderKey", ""),
|
||||
values[which]
|
||||
);
|
||||
})
|
||||
.setNegativeButton("取消", null)
|
||||
.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 {
|
||||
@@ -131,6 +419,80 @@ 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(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.updateDevicePreferredExecutionMode(
|
||||
deviceId,
|
||||
preferredExecutionMode
|
||||
);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("默认执行模式已更新");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void saveConflictDecision(String projectId, @Nullable String folderKey, String decision) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.updateProjectConflictDecision(
|
||||
deviceId,
|
||||
projectId,
|
||||
folderKey,
|
||||
decision
|
||||
);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("冲突策略已更新");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void showEditForm(JSONObject device) {
|
||||
LinearLayout form = new LinearLayout(this);
|
||||
form.setOrientation(LinearLayout.VERTICAL);
|
||||
@@ -208,4 +570,21 @@ public class DeviceDetailActivity extends BossScreenActivity {
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private @Nullable JSONObject resolvePrimaryProjectExecutionPolicy(@Nullable JSONObject workspace) {
|
||||
if (workspace == null) return null;
|
||||
JSONArray policies = workspace.optJSONArray("projectExecutionPolicies");
|
||||
if (policies == null || policies.length() == 0) return null;
|
||||
return policies.optJSONObject(0);
|
||||
}
|
||||
|
||||
private int resolveConflictDecisionCheckedIndex(String allowPolicy) {
|
||||
if ("allow_once".equals(allowPolicy)) {
|
||||
return 1;
|
||||
}
|
||||
if ("allow_always".equals(allowPolicy)) {
|
||||
return 2;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.widget.EditText;
|
||||
|
||||
@@ -19,7 +20,7 @@ public class DeviceEnrollmentActivity extends BossScreenActivity {
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("添加设备", "通过 pairing code 或 token 把新设备接入");
|
||||
configureScreen("添加设备", "填写设备信息后生成配对草稿");
|
||||
hideHeaderAction();
|
||||
buildForm();
|
||||
}
|
||||
@@ -38,23 +39,24 @@ public class DeviceEnrollmentActivity extends BossScreenActivity {
|
||||
noteInput = BossUi.buildInput(this, "备注", true);
|
||||
projectsInput = BossUi.buildInput(this, "项目列表,逗号分隔", true);
|
||||
|
||||
android.widget.Button submitButton = BossUi.buildPrimaryButton(this, "生成绑定草稿");
|
||||
submitButton.setOnClickListener(v -> submitEnrollment());
|
||||
|
||||
replaceContent(
|
||||
BossUi.buildCard(
|
||||
BossUi.buildSoftPanel(
|
||||
this,
|
||||
"绑定新设备",
|
||||
"支持通过 pairing code、临时 token 或登录引导把 Mac、Windows、云端节点接入。",
|
||||
"当前原生页会直接调用 /api/v1/devices/enrollments"
|
||||
"接入新设备",
|
||||
"支持通过 pairing code 或 token 接入 Mac、Windows、云端节点。",
|
||||
"生成后把配对码交给设备端即可完成绑定。"
|
||||
),
|
||||
nameInput,
|
||||
avatarInput,
|
||||
accountInput,
|
||||
endpointInput,
|
||||
noteInput,
|
||||
projectsInput,
|
||||
BossUi.buildPrimaryButton(this, "生成绑定草稿")
|
||||
BossUi.buildFormCell(this, "设备名称", "例如 Mac Studio 或 Windows GPU", nameInput),
|
||||
BossUi.buildFormCell(this, "头像字符", "会显示在设备卡片左侧", avatarInput),
|
||||
BossUi.buildFormCell(this, "所属账号", "默认使用当前登录账号", accountInput),
|
||||
BossUi.buildFormCell(this, "设备地址", "例如 mac://kris.local", endpointInput),
|
||||
BossUi.buildFormCell(this, "设备备注", "可填写位置、用途或节点说明", noteInput),
|
||||
BossUi.buildFormCell(this, "项目列表", "多个项目用逗号分隔", projectsInput),
|
||||
submitButton
|
||||
);
|
||||
((android.widget.Button) contentLayout.getChildAt(contentLayout.getChildCount() - 1))
|
||||
.setOnClickListener(v -> submitEnrollment());
|
||||
}
|
||||
|
||||
private void submitEnrollment() {
|
||||
@@ -79,16 +81,20 @@ public class DeviceEnrollmentActivity extends BossScreenActivity {
|
||||
runOnUiThread(() -> {
|
||||
JSONObject enrollment = response.json.optJSONObject("enrollment");
|
||||
JSONObject device = response.json.optJSONObject("device");
|
||||
android.widget.Button importButton = BossUi.buildSecondaryButton(this, "继续导入线程");
|
||||
importButton.setOnClickListener(v -> openImportDraft(device));
|
||||
replaceContent(
|
||||
BossUi.buildCard(
|
||||
BossUi.buildSoftPanel(
|
||||
this,
|
||||
"绑定草稿已生成",
|
||||
"设备 " + (device == null ? "-" : device.optString("name", "-"))
|
||||
+ "\npairingCode " + (enrollment == null ? "-" : enrollment.optString("pairingCode", "-"))
|
||||
+ "\ntoken " + (enrollment == null ? "-" : enrollment.optString("token", "-")),
|
||||
enrollment == null ? "ready" : enrollment.optString("status", "ready")
|
||||
+ " · 到期 " + enrollment.optString("expiresAt", "-")
|
||||
)
|
||||
(enrollment == null ? "ready" : enrollment.optString("status", "ready"))
|
||||
+ " · 到期 " + (enrollment == null ? "-" : enrollment.optString("expiresAt", "-"))
|
||||
+ "\n下一步:打开导入草稿,勾选线程后生成导入建议。"
|
||||
),
|
||||
importButton
|
||||
);
|
||||
setRefreshing(false);
|
||||
});
|
||||
@@ -100,4 +106,20 @@ public class DeviceEnrollmentActivity extends BossScreenActivity {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void openImportDraft(@Nullable JSONObject device) {
|
||||
if (device == null) {
|
||||
showMessage("设备草稿未生成完成");
|
||||
return;
|
||||
}
|
||||
String deviceId = device.optString("id", "");
|
||||
if (deviceId.isEmpty()) {
|
||||
showMessage("缺少 deviceId");
|
||||
return;
|
||||
}
|
||||
Intent intent = new Intent(this, DeviceImportDraftActivity.class);
|
||||
intent.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_ID, deviceId);
|
||||
intent.putExtra(DeviceImportDraftActivity.EXTRA_DEVICE_NAME, device.optString("name", nameInput.getText().toString().trim()));
|
||||
startActivity(intent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public class DeviceImportDraftActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_DEVICE_ID = "device_id";
|
||||
public static final String EXTRA_DEVICE_NAME = "device_name";
|
||||
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
|
||||
private String deviceId;
|
||||
private String deviceName;
|
||||
private @Nullable JSONObject currentDraft;
|
||||
private @Nullable JSONObject currentResolution;
|
||||
private @Nullable JSONObject currentReviewTask;
|
||||
private final LinkedHashSet<String> selectedCandidateIds = new LinkedHashSet<>();
|
||||
private final Runnable reviewPollRunnable = this::reload;
|
||||
private @Nullable BossRealtimeClient realtimeClient;
|
||||
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
deviceId = getIntent().getStringExtra(EXTRA_DEVICE_ID);
|
||||
deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME);
|
||||
configureScreen("导入项目", deviceName == null ? "选择要导入的 Codex 项目与线程" : deviceName);
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
stopRealtimeUpdates();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (deviceId == null || deviceId.isEmpty()) {
|
||||
replaceContent(BossUi.buildEmptyCard(this, "缺少 deviceId。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getDeviceImportDraft(deviceId);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> applyPayload(
|
||||
response.json.optJSONObject("draft"),
|
||||
response.json.optJSONObject("resolution"),
|
||||
response.json.optJSONObject("reviewTask")
|
||||
));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "导入草稿加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateRealtimeSubscription() {
|
||||
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
|
||||
realtimeClient.start();
|
||||
return;
|
||||
}
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
private void stopRealtimeUpdates() {
|
||||
if (realtimeClient != null) {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
void handleRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (event == null || event.eventName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!shouldReloadForRealtimeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
|
||||
if (eventFingerprint.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(this::reload);
|
||||
}
|
||||
|
||||
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (!"devices.updated".equals(event.eventName)) {
|
||||
return false;
|
||||
}
|
||||
String payloadDeviceId = event.payload.optString("deviceId", "").trim();
|
||||
if (payloadDeviceId.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
if (deviceId == null || deviceId.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
return payloadDeviceId.equals(deviceId);
|
||||
}
|
||||
|
||||
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
||||
pruneRecentRealtimeEvents(now);
|
||||
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
|
||||
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
|
||||
return true;
|
||||
}
|
||||
recentRealtimeEventTimestamps.put(eventFingerprint, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void pruneRecentRealtimeEvents(long now) {
|
||||
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, Long> entry = iterator.next();
|
||||
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void applyPayload(
|
||||
@Nullable JSONObject draft,
|
||||
@Nullable JSONObject resolution,
|
||||
@Nullable JSONObject reviewTask
|
||||
) {
|
||||
currentDraft = draft;
|
||||
currentResolution = resolution;
|
||||
currentReviewTask = reviewTask;
|
||||
selectedCandidateIds.clear();
|
||||
JSONArray selected = draft == null ? null : draft.optJSONArray("selectedCandidateIds");
|
||||
if (selected != null) {
|
||||
for (int i = 0; i < selected.length(); i++) {
|
||||
String candidateId = selected.optString(i, "");
|
||||
if (!candidateId.isEmpty()) {
|
||||
selectedCandidateIds.add(candidateId);
|
||||
}
|
||||
}
|
||||
}
|
||||
renderCurrentState();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
contentLayout.removeCallbacks(reviewPollRunnable);
|
||||
stopRealtimeUpdates();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
private boolean isReviewPending(@Nullable JSONObject draft, @Nullable JSONObject resolution, @Nullable JSONObject reviewTask) {
|
||||
if (draft == null || resolution != null) {
|
||||
return false;
|
||||
}
|
||||
if (!"pending_resolution".equals(draft.optString("status", ""))) {
|
||||
return false;
|
||||
}
|
||||
if (reviewTask == null) {
|
||||
return false;
|
||||
}
|
||||
String taskStatus = reviewTask.optString("status", "");
|
||||
return "queued".equals(taskStatus) || "running".equals(taskStatus);
|
||||
}
|
||||
|
||||
private void renderCurrentState() {
|
||||
JSONObject draft = currentDraft;
|
||||
JSONObject resolution = currentResolution;
|
||||
JSONObject reviewTask = currentReviewTask;
|
||||
contentLayout.removeCallbacks(reviewPollRunnable);
|
||||
replaceContent();
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
"导入 Codex 项目",
|
||||
(deviceName == null ? "当前设备" : deviceName) + "\n先勾选线程,再生成导入建议,最后应用导入。",
|
||||
draft == null
|
||||
? "等待设备完成首次 heartbeat"
|
||||
: "状态 " + resolveStatusTitle(draft)
|
||||
));
|
||||
|
||||
if (draft == null) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "设备完成配对并上报项目候选后,这里会出现可导入项目。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
JSONArray candidates = draft.optJSONArray("candidates");
|
||||
if (candidates == null || candidates.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "设备已在线,但当前还没有发现可导入线程。可以稍后刷新重试。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
int recommendedCount = 0;
|
||||
for (int i = 0; i < candidates.length(); i++) {
|
||||
JSONObject candidate = candidates.optJSONObject(i);
|
||||
if (candidate != null && candidate.optBoolean("suggestedImport", false)) {
|
||||
recommendedCount += 1;
|
||||
}
|
||||
}
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
resolveStatusTitle(draft),
|
||||
resolveStatusBody(draft, resolution, reviewTask),
|
||||
"候选 " + candidates.length()
|
||||
+ " · 已选 " + selectedCandidateIds.size()
|
||||
+ " · 推荐 " + recommendedCount
|
||||
));
|
||||
|
||||
Map<String, JSONArray> grouped = new LinkedHashMap<>();
|
||||
for (int i = 0; i < candidates.length(); i++) {
|
||||
JSONObject candidate = candidates.optJSONObject(i);
|
||||
if (candidate == null) continue;
|
||||
String groupKey = candidate.optString("codexFolderRef", candidate.optString("folderRef", candidate.optString("folderName", "未命名项目")));
|
||||
JSONArray bucket = grouped.get(groupKey);
|
||||
if (bucket == null) {
|
||||
bucket = new JSONArray();
|
||||
grouped.put(groupKey, bucket);
|
||||
}
|
||||
bucket.put(candidate);
|
||||
}
|
||||
|
||||
for (JSONArray items : grouped.values()) {
|
||||
JSONObject first = items.optJSONObject(0);
|
||||
if (first == null) continue;
|
||||
String folderName = first.optString("folderName", "未命名项目");
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
folderName,
|
||||
items.length() + " 个线程",
|
||||
"勾选后会进入主 Agent 导入建议",
|
||||
null,
|
||||
null
|
||||
));
|
||||
for (int i = 0; i < items.length(); i++) {
|
||||
JSONObject candidate = items.optJSONObject(i);
|
||||
if (candidate == null) continue;
|
||||
String candidateId = candidate.optString("candidateId", "");
|
||||
boolean selectedState = selectedCandidateIds.contains(candidateId);
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
candidate.optString("threadDisplayName", "未命名线程"),
|
||||
"最近活跃:" + candidate.optString("lastActiveAt", "-"),
|
||||
null,
|
||||
selectedState
|
||||
? (candidate.optBoolean("suggestedImport", false) ? "已选 · 推荐导入" : "已选")
|
||||
: (candidate.optBoolean("suggestedImport", false) ? "推荐导入" : null),
|
||||
v -> toggleSelection(candidateId)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (resolution != null) {
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"导入建议",
|
||||
resolution.optString("summary", "已生成导入建议。"),
|
||||
"应用后会把选中的线程映射成正式聊天窗口。"
|
||||
));
|
||||
JSONArray items = resolution.optJSONArray("items");
|
||||
if (items != null) {
|
||||
for (int i = 0; i < items.length(); i++) {
|
||||
JSONObject item = items.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
item.optString("threadDisplayName", "未命名线程"),
|
||||
item.optString("folderName", ""),
|
||||
item.optString("action", "") + " · " + item.optString("reason", ""),
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reviewTask != null) {
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"审核任务",
|
||||
"状态:" + reviewTask.optString("status", "unknown"),
|
||||
isReviewPending(draft, resolution, reviewTask)
|
||||
? "主 Agent 正在生成导入建议,页面会自动刷新。"
|
||||
: "如果任务失败,可以直接重新生成导入建议。"
|
||||
));
|
||||
}
|
||||
|
||||
JSONArray appliedProjectNames = draft.optJSONArray("appliedProjectNames");
|
||||
if (appliedProjectNames != null && appliedProjectNames.length() > 0) {
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"应用结果",
|
||||
"已导入 " + appliedProjectNames.length() + " 个线程:" + joinNames(appliedProjectNames) + "。",
|
||||
"这些线程现在会出现在会话首页。"
|
||||
));
|
||||
}
|
||||
|
||||
Button reviewButton = BossUi.buildMiniActionButton(this, "生成导入建议", true);
|
||||
reviewButton.setEnabled(!selectedCandidateIds.isEmpty() && !isReviewPending(draft, resolution, reviewTask));
|
||||
if (isReviewPending(draft, resolution, reviewTask)) {
|
||||
reviewButton.setText("主 Agent 审核中");
|
||||
} else if (reviewTask != null && "failed".equals(reviewTask.optString("status", ""))) {
|
||||
reviewButton.setText("重新生成导入建议");
|
||||
} else if ("resolved".equals(draft.optString("status", "")) || "applied".equals(draft.optString("status", ""))) {
|
||||
reviewButton.setText("重新生成导入建议");
|
||||
}
|
||||
reviewButton.setOnClickListener(v -> reviewSelection());
|
||||
Button clearButton = BossUi.buildMiniActionButton(this, "清空勾选", false);
|
||||
clearButton.setEnabled(!selectedCandidateIds.isEmpty());
|
||||
clearButton.setOnClickListener(v -> clearSelection());
|
||||
Button applyButton = BossUi.buildMiniActionButton(
|
||||
this,
|
||||
"applied".equals(draft.optString("status", "")) ? "已导入" : "应用导入",
|
||||
false
|
||||
);
|
||||
applyButton.setEnabled(resolution != null && "resolved".equals(draft.optString("status", "")));
|
||||
applyButton.setOnClickListener(v -> applyResolution());
|
||||
appendContent(BossUi.buildInlineActionRow(this, reviewButton, clearButton, applyButton));
|
||||
setRefreshing(false);
|
||||
if (isReviewPending(draft, resolution, reviewTask)) {
|
||||
contentLayout.postDelayed(reviewPollRunnable, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveStatusTitle(@Nullable JSONObject draft) {
|
||||
if (draft == null) {
|
||||
return "等待导入草稿";
|
||||
}
|
||||
String status = draft.optString("status", "");
|
||||
if ("pending_candidates".equals(status)) {
|
||||
return "等待候选线程";
|
||||
}
|
||||
if ("pending_selection".equals(status)) {
|
||||
return "等待勾选";
|
||||
}
|
||||
if ("pending_resolution".equals(status)) {
|
||||
return "建议生成中";
|
||||
}
|
||||
if ("resolved".equals(status)) {
|
||||
return "建议已生成";
|
||||
}
|
||||
if ("applied".equals(status)) {
|
||||
return "已导入";
|
||||
}
|
||||
return "导入草稿";
|
||||
}
|
||||
|
||||
private String resolveStatusBody(@Nullable JSONObject draft, @Nullable JSONObject resolution, @Nullable JSONObject reviewTask) {
|
||||
if (draft == null) {
|
||||
return "先让设备完成首次 heartbeat 并上报候选线程,导入草稿就会出现在这里。";
|
||||
}
|
||||
String status = draft.optString("status", "");
|
||||
if ("pending_candidates".equals(status)) {
|
||||
return "设备已经就绪,等 heartbeat 带回线程候选后,就可以开始勾选。";
|
||||
}
|
||||
if ("pending_selection".equals(status)) {
|
||||
return "先勾选想导入的线程,再生成导入建议。";
|
||||
}
|
||||
if ("pending_resolution".equals(status)) {
|
||||
if (isReviewPending(draft, resolution, reviewTask)) {
|
||||
return "勾选已保存,主 Agent 正在整理导入建议,页面会自动刷新。";
|
||||
}
|
||||
if (reviewTask != null && "failed".equals(reviewTask.optString("status", ""))) {
|
||||
return "主 Agent 这次没能生成导入建议。可以稍后重新生成,当前勾选会保留。";
|
||||
}
|
||||
return "勾选已保存,接下来会生成导入建议。";
|
||||
}
|
||||
if ("resolved".equals(status)) {
|
||||
return resolution == null ? "可以先看建议,再点应用导入。" : resolution.optString("summary", "可以先看建议,再点应用导入。");
|
||||
}
|
||||
if ("applied".equals(status)) {
|
||||
JSONArray appliedProjectNames = draft.optJSONArray("appliedProjectNames");
|
||||
if (appliedProjectNames != null && appliedProjectNames.length() > 0) {
|
||||
return "已导入 " + appliedProjectNames.length() + " 个线程:" + joinNames(appliedProjectNames) + "。";
|
||||
}
|
||||
return "导入已完成,线程已经落到会话首页。";
|
||||
}
|
||||
return "先勾选线程,再生成导入建议,最后应用导入。";
|
||||
}
|
||||
|
||||
private String joinNames(JSONArray values) {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (int i = 0; i < values.length(); i++) {
|
||||
String value = values.optString(i, "");
|
||||
if (!value.isEmpty()) {
|
||||
names.add(value);
|
||||
}
|
||||
}
|
||||
return String.join("、", names);
|
||||
}
|
||||
|
||||
private void toggleSelection(String candidateId) {
|
||||
if (candidateId == null || candidateId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (selectedCandidateIds.contains(candidateId)) {
|
||||
selectedCandidateIds.remove(candidateId);
|
||||
} else {
|
||||
selectedCandidateIds.add(candidateId);
|
||||
}
|
||||
renderCurrentState();
|
||||
}
|
||||
|
||||
private void reviewSelection() {
|
||||
if (deviceId == null || deviceId.isEmpty()) {
|
||||
showMessage("缺少 deviceId");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
JSONObject selectedDraft = null;
|
||||
try {
|
||||
JSONArray selected = new JSONArray();
|
||||
for (String candidateId : selectedCandidateIds) {
|
||||
selected.put(candidateId);
|
||||
}
|
||||
BossApiClient.ApiResponse selectResponse = apiClient.selectDeviceImportCandidates(deviceId, selected);
|
||||
if (!selectResponse.ok()) {
|
||||
throw new IllegalStateException(selectResponse.message());
|
||||
}
|
||||
selectedDraft = selectResponse.json.optJSONObject("draft");
|
||||
BossApiClient.ApiResponse reviewResponse = apiClient.reviewDeviceImportDraft(deviceId);
|
||||
if (!reviewResponse.ok()) {
|
||||
throw new IllegalStateException(reviewResponse.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
boolean hasResolution = reviewResponse.json.optJSONObject("resolution") != null;
|
||||
showMessage(hasResolution ? "已生成导入建议" : "已提交给主 Agent 审核");
|
||||
applyPayload(
|
||||
reviewResponse.json.optJSONObject("draft"),
|
||||
reviewResponse.json.optJSONObject("resolution"),
|
||||
reviewResponse.json.optJSONObject("reviewTask") != null
|
||||
? reviewResponse.json.optJSONObject("reviewTask")
|
||||
: reviewResponse.json.optJSONObject("task")
|
||||
);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
final JSONObject fallbackDraft = selectedDraft;
|
||||
runOnUiThread(() -> {
|
||||
if (fallbackDraft != null) {
|
||||
applyPayload(fallbackDraft, null, null);
|
||||
} else {
|
||||
setRefreshing(false);
|
||||
}
|
||||
showMessage("导入建议生成失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void clearSelection() {
|
||||
if (deviceId == null || deviceId.isEmpty()) {
|
||||
showMessage("缺少 deviceId");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.selectDeviceImportCandidates(deviceId, new JSONArray());
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage("已清空当前勾选");
|
||||
applyPayload(response.json.optJSONObject("draft"), null, null);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("清空勾选失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void applyResolution() {
|
||||
if (deviceId == null || deviceId.isEmpty()) {
|
||||
showMessage("缺少 deviceId");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.applyDeviceImportDraft(deviceId);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage("已应用导入");
|
||||
applyPayload(
|
||||
response.json.optJSONObject("draft"),
|
||||
response.json.optJSONObject("resolution"),
|
||||
null
|
||||
);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("导入应用失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
248
android/app/src/main/java/com/hyzq/boss/ForwardPayloads.java
Normal file
248
android/app/src/main/java/com/hyzq/boss/ForwardPayloads.java
Normal file
@@ -0,0 +1,248 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class ForwardPayloads {
|
||||
private ForwardPayloads() {}
|
||||
|
||||
public static JSONObject build(
|
||||
String mode,
|
||||
@Nullable String sourceMessageId,
|
||||
@Nullable List<String> sourceMessageIds
|
||||
) throws JSONException {
|
||||
MutableJsonObject payload = new MutableJsonObject();
|
||||
String normalizedMode = isEmpty(mode) ? "single" : mode;
|
||||
payload.put("mode", normalizedMode);
|
||||
|
||||
if (normalizedMode.startsWith("single")) {
|
||||
String resolvedSourceMessageId = sourceMessageId;
|
||||
if (isEmpty(resolvedSourceMessageId) && sourceMessageIds != null && sourceMessageIds.size() == 1) {
|
||||
resolvedSourceMessageId = sourceMessageIds.get(0);
|
||||
}
|
||||
if (isEmpty(resolvedSourceMessageId)) {
|
||||
throw new JSONException("sourceMessageId required");
|
||||
}
|
||||
payload.put("sourceMessageId", resolvedSourceMessageId);
|
||||
return payload;
|
||||
}
|
||||
|
||||
MutableJsonArray orderedIds = new MutableJsonArray();
|
||||
if (sourceMessageIds != null) {
|
||||
for (String messageId : sourceMessageIds) {
|
||||
if (!isEmpty(messageId)) {
|
||||
orderedIds.put(messageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (orderedIds.length() == 0) {
|
||||
throw new JSONException("sourceMessageIds required");
|
||||
}
|
||||
payload.put("sourceMessageIds", orderedIds);
|
||||
return payload;
|
||||
}
|
||||
|
||||
public static String toRequestBody(String targetProjectId, @Nullable JSONObject payload) throws JSONException {
|
||||
MutableJsonObject requestPayload = new MutableJsonObject();
|
||||
requestPayload.put("targetProjectId", targetProjectId);
|
||||
if (payload == null) {
|
||||
return requestPayload.toString();
|
||||
}
|
||||
|
||||
String mode = payload.optString("mode", "");
|
||||
if (!isEmpty(mode)) {
|
||||
requestPayload.put("mode", mode);
|
||||
}
|
||||
|
||||
String sourceMessageId = payload.optString("sourceMessageId", "");
|
||||
if (!isEmpty(sourceMessageId)) {
|
||||
requestPayload.put("sourceMessageId", sourceMessageId);
|
||||
}
|
||||
|
||||
JSONArray sourceMessageIds = payload.optJSONArray("sourceMessageIds");
|
||||
if (sourceMessageIds != null && sourceMessageIds.length() > 0) {
|
||||
MutableJsonArray orderedIds = new MutableJsonArray();
|
||||
for (int i = 0; i < sourceMessageIds.length(); i++) {
|
||||
String messageId = sourceMessageIds.optString(i);
|
||||
if (!isEmpty(messageId)) {
|
||||
orderedIds.put(messageId);
|
||||
}
|
||||
}
|
||||
if (orderedIds.length() > 0) {
|
||||
requestPayload.put("sourceMessageIds", orderedIds);
|
||||
}
|
||||
}
|
||||
|
||||
return requestPayload.toString();
|
||||
}
|
||||
|
||||
public static boolean isApprovalRequired(@Nullable JSONObject responseJson) {
|
||||
return responseJson != null && responseJson.optBoolean("approvalRequired", false);
|
||||
}
|
||||
|
||||
private static boolean isEmpty(@Nullable String value) {
|
||||
return value == null || value.length() == 0;
|
||||
}
|
||||
|
||||
private static final class MutableJsonObject extends JSONObject {
|
||||
private final Map<String, Object> values = new LinkedHashMap<>();
|
||||
|
||||
@Override
|
||||
public JSONObject put(String key, boolean value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject put(String key, int value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject put(String key, long value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject put(String key, Object value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(String key) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof String ? (String) value : "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(String key, String fallback) {
|
||||
String value = optString(key);
|
||||
return value.isEmpty() ? fallback : value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONArray optJSONArray(String key) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof JSONArray ? (JSONArray) value : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean optBoolean(String key, boolean fallback) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof Boolean ? (Boolean) value : fallback;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuilder builder = new StringBuilder("{");
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, Object> entry : values.entrySet()) {
|
||||
if (!first) {
|
||||
builder.append(",");
|
||||
}
|
||||
first = false;
|
||||
builder.append("\"").append(escape(entry.getKey())).append("\":");
|
||||
builder.append(stringify(entry.getValue()));
|
||||
}
|
||||
builder.append("}");
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class MutableJsonArray extends JSONArray {
|
||||
private final ArrayList<Object> values = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public JSONArray put(boolean value) {
|
||||
values.add(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONArray put(int value) {
|
||||
values.add(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONArray put(long value) {
|
||||
values.add(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONArray put(Object value) {
|
||||
values.add(value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int length() {
|
||||
return values.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public JSONObject optJSONObject(int index) {
|
||||
if (index < 0 || index >= values.size()) {
|
||||
return null;
|
||||
}
|
||||
Object value = values.get(index);
|
||||
return value instanceof JSONObject ? (JSONObject) value : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(int index) {
|
||||
if (index < 0 || index >= values.size()) {
|
||||
return "";
|
||||
}
|
||||
Object value = values.get(index);
|
||||
return value instanceof String ? (String) value : "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuilder builder = new StringBuilder("[");
|
||||
for (int i = 0; i < values.size(); i++) {
|
||||
if (i > 0) {
|
||||
builder.append(",");
|
||||
}
|
||||
builder.append(stringify(values.get(i)));
|
||||
}
|
||||
builder.append("]");
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
private static String stringify(@Nullable Object value) {
|
||||
if (value == null) {
|
||||
return "null";
|
||||
}
|
||||
if (value instanceof String) {
|
||||
return "\"" + escape((String) value) + "\"";
|
||||
}
|
||||
if (value instanceof Number || value instanceof Boolean) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
return value
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ForwardTargetActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_SOURCE_PROJECT_ID = "source_project_id";
|
||||
public static final String EXTRA_FORWARD_MODE = "forward_mode";
|
||||
public static final String EXTRA_SOURCE_MESSAGE_ID = "source_message_id";
|
||||
public static final String EXTRA_SOURCE_MESSAGE_IDS = "source_message_ids";
|
||||
|
||||
private String sourceProjectId;
|
||||
private String forwardMode;
|
||||
@Nullable
|
||||
private String sourceMessageId;
|
||||
private final ArrayList<String> sourceMessageIds = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
protected int getLayoutResId() {
|
||||
return R.layout.activity_forward_target;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
Intent intent = getIntent();
|
||||
sourceProjectId = intent.getStringExtra(EXTRA_SOURCE_PROJECT_ID);
|
||||
forwardMode = intent.getStringExtra(EXTRA_FORWARD_MODE);
|
||||
sourceMessageId = intent.getStringExtra(EXTRA_SOURCE_MESSAGE_ID);
|
||||
String[] messageIds = intent.getStringArrayExtra(EXTRA_SOURCE_MESSAGE_IDS);
|
||||
if (messageIds != null) {
|
||||
for (String messageId : messageIds) {
|
||||
if (!TextUtils.isEmpty(messageId)) {
|
||||
sourceMessageIds.add(messageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
configureScreen("选择转发目标", buildSourceMeta());
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (isEmpty(sourceProjectId)) {
|
||||
showMessage("缺少源会话");
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getConversations();
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
JSONArray conversations = response.json.optJSONArray("conversations");
|
||||
List<JSONObject> targets = collectSelectableTargets(conversations, sourceProjectId);
|
||||
runOnUiThread(() -> renderTargets(targets));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "转发目标加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static List<JSONObject> collectSelectableTargets(JSONArray conversations, String sourceProjectId) {
|
||||
ArrayList<JSONObject> result = new ArrayList<>();
|
||||
if (conversations == null) {
|
||||
return result;
|
||||
}
|
||||
for (int i = 0; i < conversations.length(); i++) {
|
||||
JSONObject item = conversations.optJSONObject(i);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
if (!isEmpty(sourceProjectId) && sourceProjectId.equals(item.optString("projectId", ""))) {
|
||||
continue;
|
||||
}
|
||||
result.add(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static JSONObject buildForwardPayload(String mode, @Nullable String sourceMessageId, List<String> sourceMessageIds)
|
||||
throws JSONException {
|
||||
return ForwardPayloads.build(mode, sourceMessageId, sourceMessageIds);
|
||||
}
|
||||
|
||||
static String resolveForwardResultMessage(JSONObject responseJson) {
|
||||
return ForwardPayloads.isApprovalRequired(responseJson) ? "已提交主 Agent 审批" : "转发成功";
|
||||
}
|
||||
|
||||
private void renderTargets(List<JSONObject> targets) {
|
||||
replaceContent(
|
||||
BossUi.buildCard(
|
||||
this,
|
||||
"正在选择转发目标",
|
||||
buildSourceBody(),
|
||||
buildSourceMeta()
|
||||
)
|
||||
);
|
||||
|
||||
if (targets.isEmpty()) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前没有可转发的目标会话。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
for (JSONObject target : targets) {
|
||||
appendContent(BossUi.buildConversationRow(
|
||||
this,
|
||||
WechatSurfaceMapper.toConversationRow(target),
|
||||
v -> forwardToTarget(target)
|
||||
));
|
||||
}
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private String buildSourceBody() {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append("源会话:").append(isEmpty(sourceProjectId) ? "-" : sourceProjectId);
|
||||
builder.append("\n转发模式:").append(isEmpty(forwardMode) ? "single" : forwardMode);
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String buildSourceMeta() {
|
||||
int messageCount = sourceMessageIds.size();
|
||||
if (!isEmpty(sourceMessageId)) {
|
||||
return "source_message_id 已就绪";
|
||||
}
|
||||
if (messageCount > 0) {
|
||||
return "source_message_ids " + messageCount + " 条";
|
||||
}
|
||||
return "等待聊天页入口补充消息选择";
|
||||
}
|
||||
|
||||
private void forwardToTarget(JSONObject target) {
|
||||
if (target == null) {
|
||||
showMessage("目标会话无效");
|
||||
return;
|
||||
}
|
||||
String targetProjectId = target.optString("projectId", "");
|
||||
if (isEmpty(targetProjectId)) {
|
||||
showMessage("目标会话无效");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
JSONObject payload = buildForwardPayload(
|
||||
forwardMode,
|
||||
sourceMessageId,
|
||||
sourceMessageIds
|
||||
);
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.forwardProjectMessage(sourceProjectId, targetProjectId, payload);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage(resolveForwardResultMessage(response.json));
|
||||
setResult(RESULT_OK);
|
||||
finish();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("转发失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (JSONException error) {
|
||||
showMessage("缺少源消息,暂无法转发");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isEmpty(@Nullable String value) {
|
||||
return value == null || value.length() == 0;
|
||||
}
|
||||
}
|
||||
455
android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java
Normal file
455
android/app/src/main/java/com/hyzq/boss/GroupCreateActivity.java
Normal file
@@ -0,0 +1,455 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class GroupCreateActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_SOURCE_PROJECT_ID = "source_project_id";
|
||||
public static final String EXTRA_SOURCE_PROJECT_NAME = "source_project_name";
|
||||
|
||||
private final List<CandidateConversation> candidates = new ArrayList<>();
|
||||
private final Set<String> selectedProjectIds = new LinkedHashSet<>();
|
||||
private final Set<String> lastCandidateProjectIds = new LinkedHashSet<>();
|
||||
private static final Set<String> AUTO_JOIN_GROUP_TITLES = new HashSet<>(Arrays.asList(
|
||||
"主agent",
|
||||
"硬件审计协作",
|
||||
"boss移动控制台"
|
||||
));
|
||||
|
||||
private String sourceProjectId;
|
||||
private String sourceProjectName;
|
||||
private String sourceFolderName;
|
||||
private LinearLayout candidateListLayout;
|
||||
private Button createButton;
|
||||
private boolean creatingGroupChat;
|
||||
private JSONObject cachedParticipantsPayload;
|
||||
private JSONObject cachedConversationsPayload;
|
||||
|
||||
@Override
|
||||
protected int getLayoutResId() {
|
||||
return R.layout.activity_group_create;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
sourceProjectId = getIntent().getStringExtra(EXTRA_SOURCE_PROJECT_ID);
|
||||
sourceProjectName = getIntent().getStringExtra(EXTRA_SOURCE_PROJECT_NAME);
|
||||
configureScreen(
|
||||
"发起群聊",
|
||||
hasSourceProject() ? (sourceProjectName == null ? "从当前会话出发" : sourceProjectName) : "从会话列表直接建群"
|
||||
);
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse conversationsResponse = apiClient.getConversations();
|
||||
if (!conversationsResponse.ok()) throw new IllegalStateException(conversationsResponse.message());
|
||||
if (!hasSourceProject()) {
|
||||
runOnUiThread(() -> renderCreatePage(null, conversationsResponse.json, true));
|
||||
return;
|
||||
}
|
||||
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(sourceProjectId);
|
||||
if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message());
|
||||
runOnUiThread(() -> renderCreatePage(participantsResponse.json, conversationsResponse.json, true));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "群聊创建页加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderCreatePage(JSONObject participantsPayload, JSONObject conversationsPayload, boolean rebuildCandidates) {
|
||||
cachedParticipantsPayload = participantsPayload;
|
||||
cachedConversationsPayload = conversationsPayload;
|
||||
replaceContent();
|
||||
|
||||
JSONObject threadMeta = participantsPayload == null ? null : participantsPayload.optJSONObject("threadMeta");
|
||||
JSONArray participants = participantsPayload == null ? null : participantsPayload.optJSONArray("participants");
|
||||
sourceFolderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
|
||||
if (hasSourceProject()) {
|
||||
sourceProjectName = threadMeta == null
|
||||
? sourceProjectName
|
||||
: threadMeta.optString("threadDisplayName", sourceProjectName == null ? "当前会话" : sourceProjectName);
|
||||
}
|
||||
appendContent(buildHeaderView(
|
||||
hasSourceProject(),
|
||||
sourceProjectId,
|
||||
sourceProjectName,
|
||||
threadMeta,
|
||||
participants
|
||||
));
|
||||
|
||||
if (rebuildCandidates) {
|
||||
List<JSONObject> selectableConversations = collectSelectableConversationItems(conversationsPayload, sourceProjectId);
|
||||
List<CandidateConversation> nextCandidates = new ArrayList<>(selectableConversations.size());
|
||||
Set<String> nextCandidateProjectIds = new LinkedHashSet<>();
|
||||
for (JSONObject item : selectableConversations) {
|
||||
CandidateConversation candidate = new CandidateConversation(
|
||||
item.optString("projectId", ""),
|
||||
item
|
||||
);
|
||||
nextCandidates.add(candidate);
|
||||
nextCandidateProjectIds.add(candidate.projectId);
|
||||
}
|
||||
Set<String> currentSelectedProjectIds = new LinkedHashSet<>(selectedProjectIds);
|
||||
candidates.clear();
|
||||
candidates.addAll(nextCandidates);
|
||||
selectedProjectIds.clear();
|
||||
selectedProjectIds.addAll(reconcileSelectedProjectIds(
|
||||
currentSelectedProjectIds,
|
||||
lastCandidateProjectIds,
|
||||
nextCandidateProjectIds,
|
||||
hasSourceProject()
|
||||
));
|
||||
lastCandidateProjectIds.clear();
|
||||
lastCandidateProjectIds.addAll(nextCandidateProjectIds);
|
||||
}
|
||||
|
||||
appendContent(buildSectionLabel("选择其他线程"));
|
||||
appendContent(BossUi.buildHintPill(
|
||||
this,
|
||||
buildSelectionHintText(candidates.size(), selectedProjectIds.size(), hasSourceProject())
|
||||
));
|
||||
|
||||
candidateListLayout = new LinearLayout(this);
|
||||
candidateListLayout.setOrientation(LinearLayout.VERTICAL);
|
||||
for (CandidateConversation candidate : candidates) {
|
||||
candidateListLayout.addView(buildCandidateRow(candidate));
|
||||
}
|
||||
if (candidates.isEmpty()) {
|
||||
candidateListLayout.addView(BossUi.buildEmptyCard(this, "当前没有可选择的其他线程。"));
|
||||
}
|
||||
appendContent(candidateListLayout);
|
||||
|
||||
createButton = BossUi.buildPrimaryButton(this, "创建群聊");
|
||||
createButton.setOnClickListener(v -> createGroupChat());
|
||||
Button cancelButton = BossUi.buildSecondaryButton(this, "取消");
|
||||
cancelButton.setOnClickListener(v -> finish());
|
||||
appendContent(BossUi.buildInlineActionRow(this, cancelButton, createButton));
|
||||
|
||||
setRefreshing(false);
|
||||
updateCreateButtonState();
|
||||
}
|
||||
|
||||
private View buildHeaderView(
|
||||
boolean hasSourceProject,
|
||||
@Nullable String sourceProjectId,
|
||||
@Nullable String sourceProjectName,
|
||||
@Nullable JSONObject threadMeta,
|
||||
@Nullable JSONArray participants
|
||||
) {
|
||||
if (hasSourceProject) {
|
||||
return BossUi.buildSimpleProfileHeader(
|
||||
this,
|
||||
TextUtils.isEmpty(sourceProjectName) ? "当前会话" : sourceProjectName,
|
||||
"从当前会话发起群聊",
|
||||
buildSourceHeaderDetail(sourceProjectId, threadMeta, participants)
|
||||
);
|
||||
}
|
||||
return BossUi.buildSimpleProfileHeader(
|
||||
this,
|
||||
"发起新群聊",
|
||||
"从会话列表直接建群",
|
||||
"至少选择 2 个线程后创建新群"
|
||||
);
|
||||
}
|
||||
|
||||
private TextView buildSectionLabel(String text) {
|
||||
TextView label = new TextView(this);
|
||||
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
);
|
||||
params.leftMargin = BossUi.dp(this, 16);
|
||||
params.rightMargin = BossUi.dp(this, 16);
|
||||
params.bottomMargin = BossUi.dp(this, 6);
|
||||
label.setLayoutParams(params);
|
||||
label.setText(text);
|
||||
label.setTextSize(13);
|
||||
label.setTextColor(getColor(R.color.boss_text_muted));
|
||||
return label;
|
||||
}
|
||||
|
||||
static List<JSONObject> collectSelectableConversationItems(@Nullable JSONObject conversationsPayload, String sourceProjectId) {
|
||||
List<JSONObject> result = new ArrayList<>();
|
||||
JSONArray conversations = conversationsPayload == null ? null : conversationsPayload.optJSONArray("conversations");
|
||||
if (conversations == null) {
|
||||
return result;
|
||||
}
|
||||
for (int i = 0; i < conversations.length(); i++) {
|
||||
JSONObject item = conversations.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
if (isEligibleForManualGroupSelection(item, sourceProjectId)) {
|
||||
result.add(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static boolean isEligibleForManualGroupSelection(@Nullable JSONObject item, @Nullable String sourceProjectId) {
|
||||
if (item == null) {
|
||||
return false;
|
||||
}
|
||||
String projectId = item.optString("projectId", "");
|
||||
if (projectId.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (sourceProjectId != null && !sourceProjectId.isEmpty() && sourceProjectId.equals(projectId)) {
|
||||
return false;
|
||||
}
|
||||
if (item.optBoolean("isGroup", false)) {
|
||||
return false;
|
||||
}
|
||||
if (!"single_device".equals(item.optString("conversationType", "single_device"))) {
|
||||
return false;
|
||||
}
|
||||
return !AUTO_JOIN_GROUP_TITLES.contains(normalizeConversationTitle(item));
|
||||
}
|
||||
|
||||
private static String normalizeConversationTitle(JSONObject item) {
|
||||
String title = item.optString("projectTitle", item.optString("threadTitle", ""));
|
||||
return title == null ? "" : title.replaceAll("\\s+", "").toLowerCase();
|
||||
}
|
||||
|
||||
static WechatSurfaceMapper.ConversationRow toCandidateConversationRow(JSONObject item, boolean selected) {
|
||||
return new WechatSurfaceMapper.ConversationRow(
|
||||
item.optString("projectTitle", item.optString("threadTitle", "未命名会话")),
|
||||
item.optString("folderLabel", ""),
|
||||
item.optString("lastMessagePreview", item.optString("preview", "")),
|
||||
item.optString("latestReplyLabel", ""),
|
||||
0,
|
||||
selected ? "已选" : "",
|
||||
0,
|
||||
false,
|
||||
"",
|
||||
"",
|
||||
new WechatSurfaceMapper.GroupAvatarMember[0]
|
||||
);
|
||||
}
|
||||
|
||||
static String buildSourceHeaderDetail(
|
||||
@Nullable String sourceProjectId,
|
||||
@Nullable JSONObject threadMeta,
|
||||
@Nullable JSONArray participants
|
||||
) {
|
||||
return buildSourceBody(sourceProjectId, threadMeta, participants);
|
||||
}
|
||||
|
||||
static String buildSelectionHintText(
|
||||
int candidateCount,
|
||||
int selectedCount,
|
||||
boolean hasSourceProject
|
||||
) {
|
||||
if (candidateCount <= 0) {
|
||||
return "当前没有可加入的其他线程";
|
||||
}
|
||||
if (selectedCount <= 0) {
|
||||
return hasSourceProject ? "至少选择 1 个其他线程" : "至少选择 2 个线程";
|
||||
}
|
||||
return "已选 " + selectedCount + " 个线程";
|
||||
}
|
||||
|
||||
private LinearLayout buildCandidateRow(CandidateConversation candidate) {
|
||||
LinearLayout row = BossUi.buildConversationRow(
|
||||
this,
|
||||
toCandidateConversationRow(candidate.sourceItem, selectedProjectIds.contains(candidate.projectId)),
|
||||
v -> toggleSelection(candidate.projectId)
|
||||
);
|
||||
LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) row.getLayoutParams();
|
||||
params.bottomMargin = BossUi.dp(this, 8);
|
||||
row.setLayoutParams(params);
|
||||
row.setPadding(BossUi.dp(this, 12), BossUi.dp(this, 12), BossUi.dp(this, 12), BossUi.dp(this, 12));
|
||||
return row;
|
||||
}
|
||||
|
||||
private void toggleSelection(String projectId) {
|
||||
if (selectedProjectIds.contains(projectId)) {
|
||||
selectedProjectIds.remove(projectId);
|
||||
} else {
|
||||
selectedProjectIds.add(projectId);
|
||||
}
|
||||
refreshCandidateRows();
|
||||
updateCreateButtonState();
|
||||
}
|
||||
|
||||
private void refreshCandidateRows() {
|
||||
if (cachedParticipantsPayload == null || cachedConversationsPayload == null) {
|
||||
return;
|
||||
}
|
||||
renderCreatePage(cachedParticipantsPayload, cachedConversationsPayload, false);
|
||||
}
|
||||
|
||||
private void updateCreateButtonState() {
|
||||
if (createButton != null) {
|
||||
boolean refreshing = refreshLayout != null && refreshLayout.isRefreshing();
|
||||
createButton.setEnabled(canCreateGroupChat(refreshing, creatingGroupChat, selectedProjectIds, hasSourceProject()));
|
||||
createButton.setText(creatingGroupChat ? "创建中..." : "创建群聊");
|
||||
}
|
||||
}
|
||||
|
||||
private void createGroupChat() {
|
||||
boolean refreshing = refreshLayout != null && refreshLayout.isRefreshing();
|
||||
if (refreshing || creatingGroupChat) {
|
||||
return;
|
||||
}
|
||||
if (selectedProjectIds.isEmpty()) {
|
||||
showMessage("请至少选择一个其他线程");
|
||||
return;
|
||||
}
|
||||
List<String> memberProjectIdsSnapshot = new ArrayList<>(selectedProjectIds);
|
||||
creatingGroupChat = true;
|
||||
setRefreshing(true);
|
||||
updateCreateButtonState();
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
JSONArray memberProjectIds = new JSONArray();
|
||||
for (String projectId : memberProjectIdsSnapshot) {
|
||||
memberProjectIds.put(projectId);
|
||||
}
|
||||
payload.put("memberProjectIds", memberProjectIds);
|
||||
BossApiClient.ApiResponse response = hasSourceProject()
|
||||
? apiClient.createGroupChat(sourceProjectId, payload)
|
||||
: apiClient.createStandaloneGroupChat(payload);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
JSONObject project = response.json.optJSONObject("project");
|
||||
if (project == null) throw new IllegalStateException("GROUP_CHAT_PROJECT_MISSING");
|
||||
String createdProjectId = project.optString("id", "");
|
||||
if (createdProjectId.isEmpty()) {
|
||||
throw new IllegalStateException("GROUP_CHAT_PROJECT_ID_MISSING");
|
||||
}
|
||||
String createdProjectName = project.optString("name", sourceProjectName == null ? "群聊" : sourceProjectName);
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
creatingGroupChat = false;
|
||||
updateCreateButtonState();
|
||||
showMessage("群聊已创建");
|
||||
Intent intent = new Intent(this, ProjectDetailActivity.class);
|
||||
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, createdProjectId);
|
||||
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, createdProjectName);
|
||||
startActivity(intent);
|
||||
finish();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
creatingGroupChat = false;
|
||||
setRefreshing(false);
|
||||
showMessage("创建失败:" + error.getMessage());
|
||||
updateCreateButtonState();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static boolean canCreateGroupChat(
|
||||
boolean refreshing,
|
||||
boolean creatingGroupChat,
|
||||
@Nullable Set<String> selectedProjectIds,
|
||||
boolean hasSourceProject
|
||||
) {
|
||||
return !refreshing
|
||||
&& !creatingGroupChat
|
||||
&& selectedProjectIds != null
|
||||
&& selectedProjectIds.size() >= (hasSourceProject ? 1 : 2);
|
||||
}
|
||||
|
||||
static Set<String> reconcileSelectedProjectIds(
|
||||
@Nullable Set<String> currentSelectedProjectIds,
|
||||
@Nullable Set<String> previousCandidateProjectIds,
|
||||
@Nullable Set<String> nextCandidateProjectIds
|
||||
) {
|
||||
return reconcileSelectedProjectIds(
|
||||
currentSelectedProjectIds,
|
||||
previousCandidateProjectIds,
|
||||
nextCandidateProjectIds,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
static Set<String> reconcileSelectedProjectIds(
|
||||
@Nullable Set<String> currentSelectedProjectIds,
|
||||
@Nullable Set<String> previousCandidateProjectIds,
|
||||
@Nullable Set<String> nextCandidateProjectIds,
|
||||
boolean defaultSelectAll
|
||||
) {
|
||||
Set<String> reconciled = new LinkedHashSet<>();
|
||||
if (nextCandidateProjectIds == null || nextCandidateProjectIds.isEmpty()) {
|
||||
return reconciled;
|
||||
}
|
||||
if (previousCandidateProjectIds == null
|
||||
|| previousCandidateProjectIds.isEmpty()
|
||||
|| !previousCandidateProjectIds.equals(nextCandidateProjectIds)) {
|
||||
if (defaultSelectAll) {
|
||||
reconciled.addAll(nextCandidateProjectIds);
|
||||
}
|
||||
return reconciled;
|
||||
}
|
||||
if (currentSelectedProjectIds == null || currentSelectedProjectIds.isEmpty()) {
|
||||
return reconciled;
|
||||
}
|
||||
for (String projectId : currentSelectedProjectIds) {
|
||||
if (nextCandidateProjectIds.contains(projectId)) {
|
||||
reconciled.add(projectId);
|
||||
}
|
||||
}
|
||||
return reconciled;
|
||||
}
|
||||
|
||||
private boolean hasSourceProject() {
|
||||
return sourceProjectId != null && !sourceProjectId.isEmpty();
|
||||
}
|
||||
|
||||
private static String buildSourceBody(
|
||||
@Nullable String sourceProjectId,
|
||||
@Nullable JSONObject threadMeta,
|
||||
@Nullable JSONArray participants
|
||||
) {
|
||||
String threadId = threadMeta == null ? sourceProjectId : threadMeta.optString("threadId", sourceProjectId);
|
||||
String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
|
||||
String resolvedFolderName = folderName.isEmpty() ? "未命名文件夹" : folderName;
|
||||
String resolvedThreadId = threadId == null || threadId.isEmpty() ? "未命名线程" : threadId;
|
||||
return resolvedThreadId
|
||||
+ " · "
|
||||
+ resolvedFolderName
|
||||
+ " · "
|
||||
+ (participants == null ? 0 : participants.length())
|
||||
+ " 个参与线程";
|
||||
}
|
||||
|
||||
private static final class CandidateConversation {
|
||||
private final String projectId;
|
||||
private final JSONObject sourceItem;
|
||||
|
||||
private CandidateConversation(
|
||||
String projectId,
|
||||
JSONObject sourceItem
|
||||
) {
|
||||
this.projectId = projectId;
|
||||
this.sourceItem = sourceItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
676
android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java
Normal file
676
android/app/src/main/java/com/hyzq/boss/GroupInfoActivity.java
Normal file
@@ -0,0 +1,676 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class GroupInfoActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
|
||||
private String projectId;
|
||||
private String projectName;
|
||||
private @Nullable BossRealtimeClient realtimeClient;
|
||||
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
|
||||
|
||||
@Override
|
||||
protected int getLayoutResId() {
|
||||
return R.layout.activity_group_info;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
|
||||
configureScreen("群资料", projectName == null ? "协作群聊" : projectName);
|
||||
refreshButton.setVisibility(android.view.View.GONE);
|
||||
setHeaderAction("...", v -> showMoreMenu());
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
stopRealtimeUpdates();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
stopRealtimeUpdates();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse detailResponse = apiClient.getProjectDetail(projectId);
|
||||
if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message());
|
||||
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId);
|
||||
if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message());
|
||||
BossApiClient.ApiResponse orchestrationResponse = apiClient.getProjectOrchestrationBackend(projectId);
|
||||
JSONObject orchestrationBackend = orchestrationResponse.ok()
|
||||
? orchestrationResponse.json
|
||||
: buildFallbackOrchestrationBackendPayload(orchestrationResponse.message());
|
||||
runOnUiThread(() -> renderGroup(detailResponse.json, participantsResponse.json, orchestrationBackend));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "群资料加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateRealtimeSubscription() {
|
||||
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
|
||||
realtimeClient.start();
|
||||
return;
|
||||
}
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
private void stopRealtimeUpdates() {
|
||||
if (realtimeClient != null) {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
void handleRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!shouldReloadForRealtimeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
|
||||
if (eventFingerprint.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(this::reload);
|
||||
}
|
||||
|
||||
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
|
||||
String payloadProjectId = event.payload.optString("projectId", "").trim();
|
||||
if (payloadProjectId.isEmpty() || !payloadProjectId.equals(projectId)) {
|
||||
return false;
|
||||
}
|
||||
return "conversation.updated".equals(event.eventName)
|
||||
|| "project.messages.updated".equals(event.eventName);
|
||||
}
|
||||
|
||||
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
||||
pruneRecentRealtimeEvents(now);
|
||||
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
|
||||
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
|
||||
return true;
|
||||
}
|
||||
recentRealtimeEventTimestamps.put(eventFingerprint, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void pruneRecentRealtimeEvents(long now) {
|
||||
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, Long> entry = iterator.next();
|
||||
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void renderGroup(JSONObject detail, JSONObject participantsPayload) {
|
||||
renderGroup(detail, participantsPayload, null);
|
||||
}
|
||||
|
||||
private void renderGroup(JSONObject detail, JSONObject participantsPayload, @Nullable JSONObject orchestrationBackendPayload) {
|
||||
replaceContent();
|
||||
JSONObject project = detail.optJSONObject("project");
|
||||
JSONArray participants = participantsPayload.optJSONArray("participants");
|
||||
|
||||
if (project == null) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "群聊不存在。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
projectName = project.optString("name", projectName == null ? "群聊" : projectName);
|
||||
JSONObject threadMeta = project.optJSONObject("threadMeta");
|
||||
String folderName = threadMeta == null ? "" : threadMeta.optString("folderName", "");
|
||||
int participantCount = participants == null ? 0 : participants.length();
|
||||
boolean repairRequired = participantsPayload.optBoolean("repairRequired", false);
|
||||
String repairReason = participantsPayload.optString("repairReason", "");
|
||||
int validParticipantCount = participantsPayload.optInt("validParticipantCount", participantCount);
|
||||
int invalidParticipantCount = participantsPayload.optInt("invalidParticipantCount", 0);
|
||||
configureScreen("群资料", buildSubtitle(folderName, participantCount));
|
||||
|
||||
appendContent(BossUi.buildSimpleProfileHeader(
|
||||
this,
|
||||
projectName,
|
||||
"协作群聊",
|
||||
buildHeaderDetail(project, threadMeta, folderName, participantCount)
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"线程详情",
|
||||
"查看当前群聊对应项目",
|
||||
resolveThreadId(project, threadMeta),
|
||||
null,
|
||||
v -> openProject(projectId, projectName)
|
||||
));
|
||||
if (orchestrationBackendPayload != null) {
|
||||
appendContent(buildOrchestrationBackendRow(orchestrationBackendPayload));
|
||||
}
|
||||
appendContent(buildDispatchReminderRow(project));
|
||||
|
||||
if (repairRequired) {
|
||||
String meta = invalidParticipantCount > 0
|
||||
? "存在 " + invalidParticipantCount + " 个失效成员"
|
||||
: "当前仅有 " + validParticipantCount + " 个真实线程成员";
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"修复群成员",
|
||||
TextUtils.isEmpty(repairReason) ? "重新选择要加入群聊的真实线程" : repairReason,
|
||||
meta,
|
||||
"推荐",
|
||||
v -> openRepairMembersDialog(participantsPayload)
|
||||
));
|
||||
}
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"群成员",
|
||||
participantCount <= 0 ? "暂无成员" : "共 " + participantCount + " 个",
|
||||
folderName.isEmpty() ? null : folderName,
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
if (participants == null || participants.length() == 0) {
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"暂无群成员",
|
||||
"下拉刷新后重试",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
} else {
|
||||
for (int i = 0; i < participants.length(); i++) {
|
||||
JSONObject participant = participants.optJSONObject(i);
|
||||
if (participant == null) continue;
|
||||
appendContent(buildMemberRow(participant));
|
||||
}
|
||||
}
|
||||
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private LinearLayout buildDispatchReminderRow(JSONObject project) {
|
||||
boolean enabled = project.optBoolean("lightDispatchReminderEnabled", false);
|
||||
return BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"推荐下发默认轻提醒",
|
||||
enabled ? "已开启" : "已关闭",
|
||||
enabled ? "后续推荐会保留轻状态卡,不再弹重确认提醒。" : "当前仍会显式提醒你确认主 Agent 推荐。",
|
||||
enabled ? "开启" : "关闭",
|
||||
v -> openDispatchReminderDialog(enabled)
|
||||
);
|
||||
}
|
||||
|
||||
private LinearLayout buildMemberRow(JSONObject participant) {
|
||||
boolean sourceProject = participant.optBoolean("isSourceProject", false);
|
||||
boolean canOpenProject = participant.optBoolean("canOpenProject", true);
|
||||
String status = participant.optString("status", "active");
|
||||
String statusLabel = participant.optString("statusLabel", "");
|
||||
String participantProjectId = participant.optString("projectId", "");
|
||||
String title = participant.optString("threadDisplayName", "未命名线程");
|
||||
String subtitle = participant.optString("folderName", "");
|
||||
String meta = participant.optString("deviceId", "");
|
||||
String threadId = participant.optString("threadId", "");
|
||||
if (!threadId.isEmpty()) {
|
||||
meta = meta.isEmpty() ? threadId : meta + " · " + threadId;
|
||||
}
|
||||
if (sourceProject) {
|
||||
subtitle = subtitle.isEmpty() ? "当前群聊" : "当前群聊 · " + subtitle;
|
||||
}
|
||||
if (!statusLabel.isEmpty() && !"active".equals(status)) {
|
||||
subtitle = subtitle.isEmpty() ? statusLabel : subtitle + " · " + statusLabel;
|
||||
}
|
||||
return BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
title,
|
||||
subtitle,
|
||||
meta,
|
||||
sourceProject ? "当前" : (!"active".equals(status) ? "失效" : null),
|
||||
canOpenProject ? v -> openProject(participantProjectId, title) : null
|
||||
);
|
||||
}
|
||||
|
||||
private void openRepairMembersDialog(JSONObject participantsPayload) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse conversationsResponse = apiClient.getConversations();
|
||||
if (!conversationsResponse.ok()) throw new IllegalStateException(conversationsResponse.message());
|
||||
runOnUiThread(() -> showRepairMembersPicker(participantsPayload, conversationsResponse.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("加载可选线程失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void showRepairMembersPicker(JSONObject participantsPayload, JSONObject conversationsPayload) {
|
||||
JSONArray conversations = conversationsPayload.optJSONArray("conversations");
|
||||
JSONArray participants = participantsPayload.optJSONArray("participants");
|
||||
java.util.Set<String> selectedProjectIds = new java.util.LinkedHashSet<>();
|
||||
if (participants != null) {
|
||||
for (int i = 0; i < participants.length(); i++) {
|
||||
JSONObject participant = participants.optJSONObject(i);
|
||||
if (participant == null) continue;
|
||||
if (!"active".equals(participant.optString("status", "active"))) continue;
|
||||
String participantProjectId = participant.optString("projectId", "").trim();
|
||||
if (!participantProjectId.isEmpty()) {
|
||||
selectedProjectIds.add(participantProjectId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
java.util.ArrayList<String> labels = new java.util.ArrayList<>();
|
||||
java.util.ArrayList<String> projectIds = new java.util.ArrayList<>();
|
||||
java.util.ArrayList<Boolean> checkedValues = new java.util.ArrayList<>();
|
||||
if (conversations != null) {
|
||||
for (int i = 0; i < conversations.length(); i++) {
|
||||
JSONObject conversation = conversations.optJSONObject(i);
|
||||
if (conversation == null) continue;
|
||||
if (!"single_device".equals(conversation.optString("conversationType", ""))) continue;
|
||||
String candidateProjectId = conversation.optString("projectId", "").trim();
|
||||
if (candidateProjectId.isEmpty() || candidateProjectId.equals(projectId)) continue;
|
||||
String title = conversation.optString("threadTitle", conversation.optString("projectTitle", "未命名线程"));
|
||||
String folderLabel = conversation.optString("folderLabel", "");
|
||||
labels.add(TextUtils.isEmpty(folderLabel) ? title : title + " · " + folderLabel);
|
||||
projectIds.add(candidateProjectId);
|
||||
checkedValues.add(selectedProjectIds.contains(candidateProjectId));
|
||||
}
|
||||
}
|
||||
|
||||
if (labels.isEmpty()) {
|
||||
setRefreshing(false);
|
||||
showMessage("当前没有可加入群聊的真实线程");
|
||||
return;
|
||||
}
|
||||
|
||||
CharSequence[] items = labels.toArray(new CharSequence[0]);
|
||||
boolean[] checked = new boolean[checkedValues.size()];
|
||||
for (int i = 0; i < checked.length; i++) {
|
||||
checked[i] = checkedValues.get(i);
|
||||
}
|
||||
|
||||
setRefreshing(false);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("修复群成员")
|
||||
.setMessage("请选择要加入这个群聊的真实线程。")
|
||||
.setMultiChoiceItems(items, checked, (dialog, which, isChecked) -> checked[which] = isChecked)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("应用", (dialog, which) -> applyGroupRepair(projectIds, checked))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void applyGroupRepair(java.util.List<String> candidateProjectIds, boolean[] checked) {
|
||||
JSONArray memberProjectIds = new JSONArray();
|
||||
for (int i = 0; i < checked.length && i < candidateProjectIds.size(); i++) {
|
||||
if (checked[i]) {
|
||||
memberProjectIds.put(candidateProjectIds.get(i));
|
||||
}
|
||||
}
|
||||
if (memberProjectIds.length() < 2) {
|
||||
showMessage("群聊至少需要 2 个真实线程成员");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.replaceConversationParticipants(projectId, memberProjectIds);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("群成员已更新");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("修复失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void openProject(String targetProjectId, String targetProjectName) {
|
||||
if (targetProjectId == null || targetProjectId.isEmpty()) {
|
||||
showMessage("缺少 projectId");
|
||||
return;
|
||||
}
|
||||
Intent intent = new Intent(this, ProjectDetailActivity.class);
|
||||
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, targetProjectId);
|
||||
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, targetProjectName);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openRenameDialog() {
|
||||
final EditText input = BossUi.buildInput(this, "群名", false);
|
||||
input.setText(projectName == null ? "" : projectName);
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("重命名群聊")
|
||||
.setView(input)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("保存", (dialog, which) -> saveGroupName(input.getText().toString().trim()))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void saveGroupName(String name) {
|
||||
if (name.isEmpty()) {
|
||||
showMessage("群名不能为空");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.renameConversation(projectId, name, true);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
Intent result = new Intent();
|
||||
result.putExtra(EXTRA_PROJECT_NAME, name);
|
||||
setResult(RESULT_OK, result);
|
||||
showMessage("群名已更新");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private LinearLayout buildOrchestrationBackendRow(JSONObject backendPayload) {
|
||||
String requestedBackendId = backendPayload.optString("requestedBackendId", "boss-native-orchestrator");
|
||||
String currentBackendId = backendPayload.optString("currentBackendId", requestedBackendId);
|
||||
JSONObject omxAvailability = backendPayload.optJSONObject("omxAvailability");
|
||||
String currentLabel = resolveBackendLabel(backendPayload, currentBackendId);
|
||||
String requestedLabel = resolveBackendLabel(backendPayload, requestedBackendId);
|
||||
String subtitle = "当前:" + currentLabel;
|
||||
if (!TextUtils.equals(currentBackendId, requestedBackendId)) {
|
||||
subtitle += " · 请求:" + requestedLabel;
|
||||
}
|
||||
boolean omxSelectable = omxAvailability != null && omxAvailability.optBoolean("selectable", false);
|
||||
boolean fallbackActive = !TextUtils.equals(currentBackendId, requestedBackendId);
|
||||
if (omxAvailability != null) {
|
||||
subtitle += omxSelectable ? " · OMX 可用" : " · OMX 受限";
|
||||
}
|
||||
String meta = omxAvailability == null
|
||||
? "等待后端状态"
|
||||
: buildOrchestrationBackendAvailabilitySummary(omxAvailability, fallbackActive);
|
||||
String badge = fallbackActive ? "回退" : (omxSelectable ? "当前" : "受限");
|
||||
return BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"编排后端",
|
||||
subtitle,
|
||||
meta,
|
||||
badge,
|
||||
v -> openOrchestrationBackendDialog(backendPayload)
|
||||
);
|
||||
}
|
||||
|
||||
private void openOrchestrationBackendDialog(JSONObject backendPayload) {
|
||||
JSONArray availableChoices = backendPayload.optJSONArray("availableChoices");
|
||||
if (availableChoices == null || availableChoices.length() == 0) {
|
||||
showMessage("编排后端状态暂不可用");
|
||||
return;
|
||||
}
|
||||
|
||||
CharSequence[] items = new CharSequence[availableChoices.length()];
|
||||
final String[] backendIds = new String[availableChoices.length()];
|
||||
final boolean[] selectable = new boolean[availableChoices.length()];
|
||||
final String omxReason = backendPayload.optJSONObject("omxAvailability") == null
|
||||
? "OMX Team Runtime 当前不可用。"
|
||||
: backendPayload.optJSONObject("omxAvailability").optString("reasonLabel", "OMX Team Runtime 当前不可用。");
|
||||
final boolean omxSelectable = backendPayload.optJSONObject("omxAvailability") != null
|
||||
&& backendPayload.optJSONObject("omxAvailability").optBoolean("selectable", false);
|
||||
for (int i = 0; i < availableChoices.length(); i++) {
|
||||
JSONObject choice = availableChoices.optJSONObject(i);
|
||||
if (choice == null) {
|
||||
items[i] = "未命名后端";
|
||||
backendIds[i] = "";
|
||||
selectable[i] = false;
|
||||
continue;
|
||||
}
|
||||
backendIds[i] = choice.optString("backendId", "");
|
||||
selectable[i] = choice.optBoolean("selectable", false);
|
||||
String label = resolveBackendLabel(backendPayload, backendIds[i]);
|
||||
items[i] = label + (selectable[i] ? "" : "(不可用)");
|
||||
}
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("选择编排后端")
|
||||
.setMessage(omxSelectable
|
||||
? "Boss Native Orchestrator 永远可用;OMX Team Runtime 当前可直接切换。"
|
||||
: "Boss Native Orchestrator 永远可用;OMX Team Runtime 当前不可用,切换时会自动回退到 Boss Native Orchestrator。")
|
||||
.setItems(items, (dialog, which) -> {
|
||||
String selectedBackendId = backendIds[which];
|
||||
if (TextUtils.isEmpty(selectedBackendId)) {
|
||||
showMessage("编排后端选择无效");
|
||||
return;
|
||||
}
|
||||
if (!selectable[which] && TextUtils.equals(selectedBackendId, "omx-team")) {
|
||||
showMessage(omxReason);
|
||||
return;
|
||||
}
|
||||
saveOrchestrationBackend(selectedBackendId);
|
||||
})
|
||||
.setNegativeButton("取消", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void saveOrchestrationBackend(String requestedBackendId) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.updateProjectOrchestrationBackend(projectId, requestedBackendId);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("编排后端已更新为 " + resolveBackendLabelForId(requestedBackendId));
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void openDispatchReminderDialog(boolean enabled) {
|
||||
CharSequence[] items = enabled
|
||||
? new CharSequence[]{"关闭默认轻提醒"}
|
||||
: new CharSequence[]{"开启默认轻提醒"};
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("推荐下发默认轻提醒")
|
||||
.setMessage(enabled
|
||||
? "关闭后,这个群会恢复成每次都显式确认的提醒方式。"
|
||||
: "开启后,这个群后续仍会显示一张轻状态卡,但不再出现重提醒。")
|
||||
.setItems(items, (dialog, which) -> saveDispatchReminderPreference(!enabled))
|
||||
.setNegativeButton("取消", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void saveDispatchReminderPreference(boolean enabled) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.updateProjectDispatchReminder(projectId, enabled);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage(enabled ? "已开启默认轻提醒" : "已关闭默认轻提醒");
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void showMoreMenu() {
|
||||
new AlertDialog.Builder(this)
|
||||
.setItems(new CharSequence[]{"改名", "刷新"}, (dialog, which) -> {
|
||||
if (which == 0) {
|
||||
openRenameDialog();
|
||||
return;
|
||||
}
|
||||
reload();
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private String resolveBackendLabel(JSONObject backendPayload, String backendId) {
|
||||
JSONArray availableChoices = backendPayload.optJSONArray("availableChoices");
|
||||
if (availableChoices != null) {
|
||||
for (int i = 0; i < availableChoices.length(); i++) {
|
||||
JSONObject choice = availableChoices.optJSONObject(i);
|
||||
if (choice == null) continue;
|
||||
if (TextUtils.equals(choice.optString("backendId", ""), backendId)) {
|
||||
return choice.optString("label", resolveBackendLabelForId(backendId));
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolveBackendLabelForId(backendId);
|
||||
}
|
||||
|
||||
private String resolveBackendLabelForId(String backendId) {
|
||||
if (TextUtils.equals(backendId, "omx-team")) {
|
||||
return "OMX Team Runtime";
|
||||
}
|
||||
return "Boss Native Orchestrator";
|
||||
}
|
||||
|
||||
private String normalizeOrchestrationReasonLabel(String value) {
|
||||
String trimmed = value == null ? "" : value.trim();
|
||||
if (trimmed.endsWith("。") || trimmed.endsWith(".")) {
|
||||
return trimmed.substring(0, trimmed.length() - 1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private String buildOrchestrationBackendAvailabilitySummary(JSONObject omxAvailability, boolean fallbackActive) {
|
||||
if (omxAvailability.optBoolean("selectable", false)) {
|
||||
return "OMX Team Runtime 当前可用,当前可切换到该后端。";
|
||||
}
|
||||
String reasonLabel = normalizeOrchestrationReasonLabel(
|
||||
omxAvailability.optString("reasonLabel", "OMX Team Runtime 当前不可用。")
|
||||
);
|
||||
return fallbackActive
|
||||
? reasonLabel + ",当前已自动回退到 Boss Native Orchestrator。"
|
||||
: reasonLabel + ",切换后会自动回退到 Boss Native Orchestrator。";
|
||||
}
|
||||
|
||||
private JSONObject buildFallbackOrchestrationBackendPayload(String reason) {
|
||||
try {
|
||||
JSONArray availableChoices = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("backendId", "boss-native-orchestrator")
|
||||
.put("label", "Boss Native Orchestrator")
|
||||
.put("selectable", true)
|
||||
.put("current", true))
|
||||
.put(new JSONObject()
|
||||
.put("backendId", "omx-team")
|
||||
.put("label", "OMX Team Runtime")
|
||||
.put("selectable", false)
|
||||
.put("current", false));
|
||||
return new JSONObject()
|
||||
.put("currentBackendId", "boss-native-orchestrator")
|
||||
.put("requestedBackendId", "boss-native-orchestrator")
|
||||
.put("availableChoices", availableChoices)
|
||||
.put("omxAvailability", new JSONObject()
|
||||
.put("selectable", false)
|
||||
.put("reason", "disabled")
|
||||
.put("reasonLabel", TextUtils.isEmpty(reason) ? "OMX Team Runtime 当前不可用。" : reason));
|
||||
} catch (Exception error) {
|
||||
return new JSONObject();
|
||||
}
|
||||
}
|
||||
|
||||
private String buildSubtitle(String folderName, int count) {
|
||||
String memberLabel = count <= 0 ? "暂无成员" : count + " 个成员";
|
||||
if (folderName.isEmpty()) {
|
||||
return memberLabel;
|
||||
}
|
||||
return folderName + " · " + memberLabel;
|
||||
}
|
||||
|
||||
private String buildHeaderDetail(JSONObject project, @Nullable JSONObject threadMeta, String folderName, int count) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
String threadId = resolveThreadId(project, threadMeta);
|
||||
if (!threadId.isEmpty()) {
|
||||
builder.append(threadId);
|
||||
}
|
||||
if (!folderName.isEmpty()) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(folderName);
|
||||
}
|
||||
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", "");
|
||||
if (!threadId.isEmpty()) {
|
||||
return threadId;
|
||||
}
|
||||
}
|
||||
return project.optString("id", "");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,409 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.Spinner;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class MasterAgentMemoryActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
|
||||
private static final String[] MEMORY_SCOPE_VALUES = {"global", "project"};
|
||||
private static final String[] MEMORY_SCOPE_LABELS = {"我的通用记忆", "项目记忆"};
|
||||
private static final String[] MEMORY_TYPE_VALUES = {
|
||||
"user_preference",
|
||||
"project_progress",
|
||||
"decision",
|
||||
"risk",
|
||||
"blocking_issue",
|
||||
"research_note",
|
||||
"workflow_rule"
|
||||
};
|
||||
private static final String[] MEMORY_TYPE_LABELS = {
|
||||
"用户偏好",
|
||||
"项目进度",
|
||||
"决策",
|
||||
"风险",
|
||||
"阻塞",
|
||||
"调研结论",
|
||||
"工作规则"
|
||||
};
|
||||
|
||||
private String projectId;
|
||||
private String projectName;
|
||||
private boolean contentLoaded;
|
||||
private @Nullable JSONObject globalMemoriesPayload;
|
||||
private @Nullable JSONObject projectMemoriesPayload;
|
||||
private @Nullable JSONArray globalMemoryItems;
|
||||
private @Nullable JSONArray projectMemoryItems;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
|
||||
configureScreen("记忆", projectName == null ? "主 Agent 记忆" : projectName);
|
||||
setHeaderAction("新增", v -> openMemoryEditor(null));
|
||||
updateSaveAvailability();
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
replaceContent(BossUi.buildEmptyCard(this, "缺少 projectId。"));
|
||||
setRefreshing(false);
|
||||
contentLoaded = false;
|
||||
updateSaveAvailability();
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getMasterAgentMemories(projectId);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> renderMemories(response.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
contentLoaded = false;
|
||||
updateSaveAvailability();
|
||||
replaceContent(BossUi.buildEmptyCard(this, "记忆加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderMemories(JSONObject payload) {
|
||||
JSONObject memories = payload.optJSONObject("memories");
|
||||
globalMemoriesPayload = memories == null ? null : memories.optJSONObject("global");
|
||||
projectMemoriesPayload = memories == null ? null : memories.optJSONObject("project");
|
||||
globalMemoryItems = extractMemoryItems(memories, "global");
|
||||
projectMemoryItems = extractMemoryItems(memories, "project");
|
||||
|
||||
replaceContent();
|
||||
appendContent(BossUi.buildSimpleProfileHeader(
|
||||
this,
|
||||
projectName == null ? "主 Agent" : projectName,
|
||||
"自动沉淀 / 手动维护",
|
||||
"项目记忆会绑定到真实项目,通用记忆属于当前用户。"
|
||||
));
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
"记忆说明",
|
||||
"主 Agent 会自动沉淀长期有用的信息。你也可以在这里手动新增、编辑或删除。",
|
||||
"底层是结构化存储,项目记忆会显示真实 projectId。"
|
||||
));
|
||||
|
||||
renderSection(
|
||||
"我的通用记忆",
|
||||
globalMemoryItems,
|
||||
"当前没有通用记忆。"
|
||||
);
|
||||
renderSection(
|
||||
"项目记忆",
|
||||
projectMemoryItems,
|
||||
"当前还没有项目记忆。"
|
||||
);
|
||||
|
||||
contentLoaded = true;
|
||||
updateSaveAvailability();
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void renderSection(String title, @Nullable JSONArray items, String emptyText) {
|
||||
int count = items == null ? 0 : items.length();
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
title,
|
||||
count <= 0 ? emptyText : "共 " + count + " 条",
|
||||
null,
|
||||
null,
|
||||
null
|
||||
));
|
||||
if (items == null || items.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, emptyText));
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < items.length(); i++) {
|
||||
JSONObject memory = items.optJSONObject(i);
|
||||
if (memory == null) continue;
|
||||
appendContent(buildMemoryRow(memory));
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable JSONArray extractMemoryItems(@Nullable JSONObject memories, String key) {
|
||||
if (memories == null || !memories.has(key)) {
|
||||
return null;
|
||||
}
|
||||
Object value = memories.opt(key);
|
||||
if (value instanceof JSONArray) {
|
||||
return (JSONArray) value;
|
||||
}
|
||||
if (value instanceof JSONObject) {
|
||||
return ((JSONObject) value).optJSONArray("items");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private LinearLayout buildMemoryRow(JSONObject memory) {
|
||||
String scope = memory.optString("scope", "global");
|
||||
String type = memory.optString("memoryType", "user_preference");
|
||||
String title = memory.optString("title", "未命名记忆");
|
||||
String content = memory.optString("content", "");
|
||||
String tags = joinTags(memory.optJSONArray("tags"));
|
||||
String meta = memory.optString("updatedAt", memory.optString("createdAt", ""));
|
||||
if (!TextUtils.isEmpty(tags)) {
|
||||
meta = TextUtils.isEmpty(meta) ? tags : meta + " · " + tags;
|
||||
}
|
||||
String badge = "project".equals(scope) ? "项目" : "全局";
|
||||
String subtitle = memoryTypeLabel(type) + (TextUtils.isEmpty(content) ? "" : " · " + content);
|
||||
return BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
title,
|
||||
subtitle,
|
||||
meta,
|
||||
badge,
|
||||
v -> openMemoryEditor(memory)
|
||||
);
|
||||
}
|
||||
|
||||
private void openMemoryEditor(@Nullable JSONObject memory) {
|
||||
LinearLayout form = new LinearLayout(this);
|
||||
form.setOrientation(LinearLayout.VERTICAL);
|
||||
|
||||
final Spinner scopeSpinner = new Spinner(this);
|
||||
ArrayAdapter<String> scopeAdapter = new ArrayAdapter<>(
|
||||
this,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
MEMORY_SCOPE_LABELS
|
||||
);
|
||||
scopeSpinner.setAdapter(scopeAdapter);
|
||||
|
||||
final Spinner typeSpinner = new Spinner(this);
|
||||
ArrayAdapter<String> typeAdapter = new ArrayAdapter<>(
|
||||
this,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
MEMORY_TYPE_LABELS
|
||||
);
|
||||
typeSpinner.setAdapter(typeAdapter);
|
||||
|
||||
final EditText titleInput = BossUi.buildInput(this, "记忆标题", false);
|
||||
final EditText contentInput = BossUi.buildInput(this, "记忆内容", true);
|
||||
final EditText projectIdInput = BossUi.buildInput(this, "例如:wenshenapp", false);
|
||||
final EditText tagsInput = BossUi.buildInput(this, "标签,逗号分隔", false);
|
||||
contentInput.setMinLines(6);
|
||||
|
||||
if (memory != null) {
|
||||
titleInput.setText(memory.optString("title", ""));
|
||||
contentInput.setText(memory.optString("content", ""));
|
||||
projectIdInput.setText(memory.optString("projectId", ""));
|
||||
tagsInput.setText(joinTags(memory.optJSONArray("tags")));
|
||||
scopeSpinner.setSelection("project".equals(memory.optString("scope", "global")) ? 1 : 0);
|
||||
typeSpinner.setSelection(memoryTypeIndex(memory.optString("memoryType", "user_preference")));
|
||||
} else {
|
||||
scopeSpinner.setSelection(0);
|
||||
typeSpinner.setSelection(0);
|
||||
projectIdInput.setText(projectId == null || "master-agent".equals(projectId) ? "" : projectId);
|
||||
}
|
||||
|
||||
form.addView(BossUi.buildFormCell(this, "作用域", "决定是用户通用记忆还是当前项目记忆。", scopeSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "projectId", "项目记忆需要绑定到真实项目;通用记忆可以留空。", projectIdInput));
|
||||
form.addView(BossUi.buildFormCell(this, "标题", "一句话说明这条记忆。", titleInput));
|
||||
form.addView(BossUi.buildFormCell(this, "内容", "主 Agent 读取时会使用这段内容。", contentInput));
|
||||
form.addView(BossUi.buildFormCell(this, "类型", "帮助主 Agent 决定优先级与使用场景。", typeSpinner));
|
||||
form.addView(BossUi.buildFormCell(this, "标签", "以逗号分隔,便于后续检索和归档。", tagsInput));
|
||||
|
||||
AlertDialog.Builder builder = new AlertDialog.Builder(this)
|
||||
.setTitle(memory == null ? "新增记忆" : "编辑记忆")
|
||||
.setView(form)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("保存", (dialog, which) -> saveMemory(
|
||||
memory,
|
||||
MEMORY_SCOPE_VALUES[scopeSpinner.getSelectedItemPosition()],
|
||||
projectIdInput.getText() == null ? "" : projectIdInput.getText().toString(),
|
||||
titleInput.getText() == null ? "" : titleInput.getText().toString(),
|
||||
contentInput.getText() == null ? "" : contentInput.getText().toString(),
|
||||
MEMORY_TYPE_VALUES[typeSpinner.getSelectedItemPosition()],
|
||||
tagsInput.getText() == null ? "" : tagsInput.getText().toString()
|
||||
));
|
||||
|
||||
if (memory != null) {
|
||||
builder.setNeutralButton("删除", (dialog, which) -> confirmDeleteMemory(memory));
|
||||
}
|
||||
|
||||
builder.show();
|
||||
}
|
||||
|
||||
private void confirmDeleteMemory(JSONObject memory) {
|
||||
final String memoryId = memory.optString("memoryId", "");
|
||||
if (memoryId.isEmpty()) {
|
||||
showMessage("缺少 memoryId");
|
||||
return;
|
||||
}
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("删除记忆")
|
||||
.setMessage("确定删除这条记忆吗?")
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("删除", (dialog, which) -> deleteMemory(memoryId))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void saveMemory(
|
||||
@Nullable JSONObject existingMemory,
|
||||
String scope,
|
||||
String targetProjectId,
|
||||
String title,
|
||||
String content,
|
||||
String memoryType,
|
||||
String tagsText
|
||||
) {
|
||||
if (!contentLoaded && existingMemory == null) {
|
||||
showMessage("记忆尚未加载完成,请先刷新成功后再保存。");
|
||||
return;
|
||||
}
|
||||
final String normalizedTitle = title == null ? "" : title.trim();
|
||||
final String normalizedContent = content == null ? "" : content.trim();
|
||||
if (normalizedTitle.isEmpty()) {
|
||||
showMessage("记忆标题不能为空");
|
||||
return;
|
||||
}
|
||||
if (normalizedContent.isEmpty()) {
|
||||
showMessage("记忆内容不能为空");
|
||||
return;
|
||||
}
|
||||
final JSONArray tags = parseTags(tagsText);
|
||||
final boolean projectScope = "project".equals(scope);
|
||||
final String normalizedProjectId = targetProjectId == null ? "" : targetProjectId.trim();
|
||||
if (projectScope && normalizedProjectId.isEmpty()) {
|
||||
showMessage("项目记忆必须填写真实 projectId");
|
||||
return;
|
||||
}
|
||||
final String memoryId = existingMemory == null ? "" : existingMemory.optString("memoryId", "");
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("scope", scope);
|
||||
if (projectScope) {
|
||||
payload.put("projectId", normalizedProjectId);
|
||||
}
|
||||
payload.put("title", normalizedTitle);
|
||||
payload.put("content", normalizedContent);
|
||||
payload.put("memoryType", memoryType);
|
||||
payload.put("tags", tags);
|
||||
if (existingMemory != null && existingMemory.has("sourceMessageId")) {
|
||||
payload.put("sourceMessageId", existingMemory.optString("sourceMessageId", ""));
|
||||
}
|
||||
|
||||
BossApiClient.ApiResponse response = memoryId.isEmpty()
|
||||
? apiClient.createMasterAgentMemory(projectId, payload)
|
||||
: apiClient.updateMasterAgentMemory(projectId, memoryId, payload);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage("记忆已保存");
|
||||
setResult(RESULT_OK);
|
||||
finish();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("记忆保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void deleteMemory(String memoryId) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.deleteMasterAgentMemory(projectId, memoryId);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage("记忆已删除");
|
||||
setResult(RESULT_OK);
|
||||
finish();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("记忆删除失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private int memoryTypeIndex(String memoryType) {
|
||||
for (int i = 0; i < MEMORY_TYPE_VALUES.length; i++) {
|
||||
if (MEMORY_TYPE_VALUES[i].equals(memoryType)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private JSONArray parseTags(String rawTags) {
|
||||
JSONArray tags = new JSONArray();
|
||||
if (rawTags == null) {
|
||||
return tags;
|
||||
}
|
||||
String[] parts = rawTags.split("[,,]");
|
||||
for (String part : parts) {
|
||||
String tag = part == null ? "" : part.trim();
|
||||
if (!tag.isEmpty()) {
|
||||
tags.put(tag);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
private String joinTags(@Nullable JSONArray tags) {
|
||||
if (tags == null || tags.length() == 0) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < tags.length(); i++) {
|
||||
String tag = tags.optString(i, "").trim();
|
||||
if (tag.isEmpty()) continue;
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(tag);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String memoryTypeLabel(String memoryType) {
|
||||
for (int i = 0; i < MEMORY_TYPE_VALUES.length; i++) {
|
||||
if (MEMORY_TYPE_VALUES[i].equals(memoryType)) {
|
||||
return MEMORY_TYPE_LABELS[i];
|
||||
}
|
||||
}
|
||||
return memoryType;
|
||||
}
|
||||
|
||||
private void updateSaveAvailability() {
|
||||
if (headerActionButton != null) {
|
||||
headerActionButton.setEnabled(contentLoaded);
|
||||
headerActionButton.setAlpha(contentLoaded ? 1f : 0.45f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.text.Editable;
|
||||
import android.text.TextWatcher;
|
||||
import android.text.TextUtils;
|
||||
import android.widget.AdapterView;
|
||||
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 org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class MasterAgentPromptActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
|
||||
private String projectId;
|
||||
private String projectName;
|
||||
private boolean contentLoaded;
|
||||
private @Nullable JSONObject promptPolicy;
|
||||
private @Nullable JSONObject userPrompt;
|
||||
private @Nullable JSONObject projectControls;
|
||||
private @Nullable String adminPromptText;
|
||||
private @Nullable String userPromptText;
|
||||
private @Nullable String projectPromptOverrideText;
|
||||
private @Nullable String backendOverrideText;
|
||||
private boolean clawSelectable;
|
||||
private @Nullable String clawReasonLabel;
|
||||
private final List<String> backendOverrideValues = new ArrayList<>();
|
||||
private EditText userPromptInput;
|
||||
private EditText projectPromptInput;
|
||||
private Spinner backendSpinner;
|
||||
private TextView previewTextView;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
|
||||
configureScreen("提示词", projectName == null ? "主 Agent 提示词分层" : projectName);
|
||||
setHeaderAction("保存", v -> savePromptProfile());
|
||||
updateSaveAvailability();
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
replaceContent(BossUi.buildEmptyCard(this, "缺少 projectId。"));
|
||||
setRefreshing(false);
|
||||
contentLoaded = false;
|
||||
updateSaveAvailability();
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getMasterAgentPromptProfile(projectId);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> renderPromptProfile(response.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
contentLoaded = false;
|
||||
updateSaveAvailability();
|
||||
replaceContent(BossUi.buildEmptyCard(this, "提示词加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderPromptProfile(JSONObject payload) {
|
||||
promptPolicy = payload.optJSONObject("promptPolicy");
|
||||
userPrompt = payload.optJSONObject("userPrompt");
|
||||
projectControls = payload.optJSONObject("projectControls");
|
||||
JSONObject clawAvailability = payload.optJSONObject("clawAvailability");
|
||||
adminPromptText = promptPolicy == null ? null : promptPolicy.optString("globalPrompt", "");
|
||||
userPromptText = userPrompt == null ? "" : userPrompt.optString("content", "");
|
||||
projectPromptOverrideText = payload.optString(
|
||||
"projectPromptOverride",
|
||||
projectControls == null ? "" : projectControls.optString("promptOverride", "")
|
||||
);
|
||||
backendOverrideText = projectControls == null ? "" : projectControls.optString("backendOverride", "");
|
||||
clawSelectable = clawAvailability != null && clawAvailability.optBoolean("selectable", false);
|
||||
clawReasonLabel = clawAvailability == null ? "" : clawAvailability.optString("reasonLabel", "");
|
||||
|
||||
replaceContent();
|
||||
appendContent(BossUi.buildSimpleProfileHeader(
|
||||
this,
|
||||
projectName == null ? "主 Agent" : projectName,
|
||||
"管理员全局主提示词 + 用户私有主提示词 + 当前对话提示词",
|
||||
"管理员提示词不可覆盖,用户可编辑自己的主提示词和当前对话覆盖。"
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
"管理员全局主提示词",
|
||||
TextUtils.isEmpty(adminPromptText) ? "暂无全局主提示词。" : adminPromptText,
|
||||
"只读 · 由管理员 Web 后台配置 · 不可覆盖"
|
||||
));
|
||||
|
||||
userPromptInput = BossUi.buildInput(this, "编辑当前用户的主 Agent 提示词", true);
|
||||
userPromptInput.setText(TextUtils.isEmpty(userPromptText) ? "" : userPromptText);
|
||||
userPromptInput.setMinLines(8);
|
||||
userPromptInput.setText(userPromptText == null ? "" : userPromptText);
|
||||
appendContent(BossUi.buildFormCell(
|
||||
this,
|
||||
"用户私有主提示词",
|
||||
"仅影响当前登录用户的主 Agent 对话。",
|
||||
userPromptInput
|
||||
));
|
||||
|
||||
projectPromptInput = BossUi.buildInput(this, "编辑当前对话附加提示词", true);
|
||||
projectPromptInput.setMinLines(8);
|
||||
projectPromptInput.setText(projectPromptOverrideText == null ? "" : projectPromptOverrideText);
|
||||
appendContent(BossUi.buildFormCell(
|
||||
this,
|
||||
"当前对话提示词",
|
||||
"只对当前 master-agent 会话生效。",
|
||||
projectPromptInput
|
||||
));
|
||||
|
||||
backendOverrideValues.clear();
|
||||
List<String> backendLabels = new ArrayList<>();
|
||||
backendOverrideValues.add("");
|
||||
backendLabels.add("默认");
|
||||
if (clawSelectable) {
|
||||
backendOverrideValues.add("claw-runtime");
|
||||
backendLabels.add("Claw Runtime");
|
||||
}
|
||||
|
||||
backendSpinner = new Spinner(this);
|
||||
backendSpinner.setAdapter(new ArrayAdapter<>(this, android.R.layout.simple_spinner_dropdown_item, backendLabels));
|
||||
backendSpinner.setSelection(indexOfBackendOverride(backendOverrideText));
|
||||
backendSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
||||
@Override
|
||||
public void onItemSelected(AdapterView<?> parent, android.view.View view, int position, long id) {
|
||||
refreshPreview();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNothingSelected(AdapterView<?> parent) {
|
||||
refreshPreview();
|
||||
}
|
||||
});
|
||||
appendContent(BossUi.buildFormCell(
|
||||
this,
|
||||
"执行后端",
|
||||
"默认沿用 Boss 当前主链;需要时可显式切到 Claw Runtime。",
|
||||
backendSpinner
|
||||
));
|
||||
|
||||
if (!clawSelectable) {
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
"Claw Runtime 当前不可用",
|
||||
TextUtils.isEmpty(clawReasonLabel) ? "当前环境未满足 Claw Runtime 的启动条件。" : clawReasonLabel,
|
||||
TextUtils.equals(backendOverrideText, "claw-runtime")
|
||||
? "当前对话之前保存过 Claw Runtime,运行时会自动回退到默认后端。"
|
||||
: "恢复可用后,执行后端下拉框会重新出现 Claw Runtime。"
|
||||
));
|
||||
}
|
||||
|
||||
previewTextView = new TextView(this);
|
||||
previewTextView.setText(buildPreviewText());
|
||||
previewTextView.setTextSize(14);
|
||||
previewTextView.setLineSpacing(0f, 1.2f);
|
||||
previewTextView.setTextColor(getColor(R.color.boss_text_primary));
|
||||
previewTextView.setPadding(0, BossUi.dp(this, 8), 0, 0);
|
||||
|
||||
LinearLayout previewPanel = new LinearLayout(this);
|
||||
previewPanel.setOrientation(LinearLayout.VERTICAL);
|
||||
previewPanel.addView(previewTextView);
|
||||
appendContent(BossUi.buildFormCell(
|
||||
this,
|
||||
"合成预览",
|
||||
"主 Agent 实际执行时会先遵守管理员全局主提示词,再追加你的私有提示词和当前对话提示词。",
|
||||
previewPanel
|
||||
));
|
||||
|
||||
TextWatcher previewWatcher = new TextWatcher() {
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
|
||||
|
||||
@Override
|
||||
public void onTextChanged(CharSequence s, int start, int before, int count) {
|
||||
refreshPreview();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterTextChanged(Editable s) {}
|
||||
};
|
||||
userPromptInput.addTextChangedListener(previewWatcher);
|
||||
projectPromptInput.addTextChangedListener(previewWatcher);
|
||||
refreshPreview();
|
||||
|
||||
contentLoaded = true;
|
||||
updateSaveAvailability();
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void refreshPreview() {
|
||||
if (previewTextView != null) {
|
||||
previewTextView.setText(buildPreviewText());
|
||||
}
|
||||
}
|
||||
|
||||
private String buildPreviewText() {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
if (!TextUtils.isEmpty(adminPromptText)) {
|
||||
builder.append("【管理员全局主提示词】\n").append(adminPromptText).append("\n\n");
|
||||
}
|
||||
String userText = userPromptInput == null ? userPromptText : userPromptInput.getText().toString();
|
||||
if (!TextUtils.isEmpty(userText)) {
|
||||
builder.append("【用户私有主提示词】\n").append(userText).append("\n\n");
|
||||
}
|
||||
String projectText = projectPromptInput == null ? projectPromptOverrideText : projectPromptInput.getText().toString();
|
||||
if (!TextUtils.isEmpty(projectText)) {
|
||||
builder.append("【当前对话提示词】\n").append(projectText).append("\n\n");
|
||||
}
|
||||
String backendValue = backendSpinner == null
|
||||
? (backendOverrideText == null ? "" : backendOverrideText)
|
||||
: backendOverrideValues.get(backendSpinner.getSelectedItemPosition());
|
||||
if (!TextUtils.isEmpty(backendValue)) {
|
||||
builder.append("【执行后端】\n").append(backendValue).append("\n\n");
|
||||
} else if (TextUtils.equals(backendOverrideText, "claw-runtime") && !clawSelectable) {
|
||||
builder.append("【执行后端】\n默认(Claw Runtime 当前不可用,运行时会自动回退)\n\n");
|
||||
}
|
||||
if (builder.length() == 0) {
|
||||
return "当前没有任何提示词内容。";
|
||||
}
|
||||
return builder.toString().trim();
|
||||
}
|
||||
|
||||
private void savePromptProfile() {
|
||||
if (!contentLoaded) {
|
||||
showMessage("提示词尚未加载完成,请先刷新成功后再保存。");
|
||||
return;
|
||||
}
|
||||
final String userContent = userPromptInput == null ? "" : userPromptInput.getText().toString();
|
||||
final String promptOverride = projectPromptInput == null ? "" : projectPromptInput.getText().toString();
|
||||
final String backendOverride = backendSpinner == null
|
||||
? ""
|
||||
: backendOverrideValues.get(backendSpinner.getSelectedItemPosition());
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("userPromptContent", userContent);
|
||||
payload.put("promptOverride", promptOverride);
|
||||
payload.put("backendOverride", TextUtils.isEmpty(backendOverride) ? JSONObject.NULL : backendOverride);
|
||||
BossApiClient.ApiResponse response = apiClient.updateMasterAgentPromptProfile(projectId, payload);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage("提示词已保存");
|
||||
setResult(RESULT_OK);
|
||||
finish();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("提示词保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateSaveAvailability() {
|
||||
if (headerActionButton != null) {
|
||||
headerActionButton.setEnabled(contentLoaded);
|
||||
headerActionButton.setAlpha(contentLoaded ? 1f : 0.45f);
|
||||
}
|
||||
}
|
||||
|
||||
private int indexOfBackendOverride(@Nullable String value) {
|
||||
if (TextUtils.isEmpty(value)) {
|
||||
return 0;
|
||||
}
|
||||
for (int index = 0; index < backendOverrideValues.size(); index += 1) {
|
||||
if (value.equals(backendOverrideValues.get(index))) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.widget.SwitchCompat;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class MasterAgentTakeoverActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
|
||||
private String projectId;
|
||||
private String projectName;
|
||||
private boolean contentLoaded;
|
||||
private boolean globalTakeoverEnabled;
|
||||
private SwitchCompat globalTakeoverSwitch;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
|
||||
configureScreen("全局接管", projectName == null ? "主 Agent 协同推进" : projectName);
|
||||
setHeaderAction("保存", v -> saveTakeoverSettings());
|
||||
updateSaveAvailability();
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
replaceContent(BossUi.buildEmptyCard(this, "缺少 projectId。"));
|
||||
setRefreshing(false);
|
||||
contentLoaded = false;
|
||||
updateSaveAvailability();
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = loadTakeoverControls();
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> renderTakeoverSettings(response.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
contentLoaded = false;
|
||||
updateSaveAvailability();
|
||||
replaceContent(BossUi.buildEmptyCard(this, "全局接管加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderTakeoverSettings(JSONObject payload) {
|
||||
JSONObject controls = payload.optJSONObject("controls");
|
||||
globalTakeoverEnabled = controls != null && controls.optBoolean("globalTakeoverEnabled", false);
|
||||
|
||||
replaceContent();
|
||||
appendContent(BossUi.buildSimpleProfileHeader(
|
||||
this,
|
||||
projectName == null ? "主 Agent" : projectName,
|
||||
"全局主 Agent 协同推进",
|
||||
"为线程会话默认开启协同推进,不会抢走你继续直接控制线程开发的能力。"
|
||||
));
|
||||
|
||||
globalTakeoverSwitch = new SwitchCompat(this);
|
||||
globalTakeoverSwitch.setText("开启");
|
||||
globalTakeoverSwitch.setChecked(globalTakeoverEnabled);
|
||||
appendContent(BossUi.buildFormCell(
|
||||
this,
|
||||
"全局主 Agent 协同接管",
|
||||
"开启后,线程会话默认跟随全局协同推进;线程会话仍可单独覆盖。",
|
||||
globalTakeoverSwitch
|
||||
));
|
||||
appendContent(BossUi.buildSoftPanel(
|
||||
this,
|
||||
"说明",
|
||||
"主 Agent 会理解项目状态、给建议、补调度方案,但不会因为介入就抢走你继续直接控制线程开发的能力。",
|
||||
"线程级开关优先于这里的全局默认。"
|
||||
));
|
||||
|
||||
contentLoaded = true;
|
||||
updateSaveAvailability();
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void saveTakeoverSettings() {
|
||||
if (!contentLoaded) {
|
||||
showMessage("全局接管尚未加载完成,请先刷新成功后再保存。");
|
||||
return;
|
||||
}
|
||||
final boolean enabled = globalTakeoverSwitch != null && globalTakeoverSwitch.isChecked();
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = saveTakeoverControls(
|
||||
projectId,
|
||||
null,
|
||||
enabled
|
||||
);
|
||||
if (!response.ok()) {
|
||||
throw new IllegalStateException(response.message());
|
||||
}
|
||||
runOnUiThread(() -> {
|
||||
showMessage(enabled ? "已开启全局主 Agent 协同接管" : "已关闭全局主 Agent 协同接管");
|
||||
setResult(RESULT_OK);
|
||||
finish();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private BossApiClient.ApiResponse loadTakeoverControls() throws Exception {
|
||||
BossApiClient.ApiResponse response = apiClient.getProjectAgentControls(projectId);
|
||||
if (response.ok() || !isUnauthorized(response)) {
|
||||
return response;
|
||||
}
|
||||
BossApiClient.ApiResponse loginResponse = apiClient.autoLogin();
|
||||
if (!loginResponse.ok()) {
|
||||
return response;
|
||||
}
|
||||
return apiClient.getProjectAgentControls(projectId);
|
||||
}
|
||||
|
||||
private BossApiClient.ApiResponse saveTakeoverControls(
|
||||
String projectId,
|
||||
@Nullable Boolean takeoverEnabled,
|
||||
@Nullable Boolean globalTakeoverEnabled
|
||||
) throws Exception {
|
||||
BossApiClient.ApiResponse response = apiClient.updateProjectTakeoverSettings(
|
||||
projectId,
|
||||
takeoverEnabled,
|
||||
globalTakeoverEnabled
|
||||
);
|
||||
if (response.ok() || !isUnauthorized(response)) {
|
||||
return response;
|
||||
}
|
||||
BossApiClient.ApiResponse loginResponse = apiClient.autoLogin();
|
||||
if (!loginResponse.ok()) {
|
||||
return response;
|
||||
}
|
||||
return apiClient.updateProjectTakeoverSettings(projectId, takeoverEnabled, globalTakeoverEnabled);
|
||||
}
|
||||
|
||||
private boolean isUnauthorized(BossApiClient.ApiResponse response) {
|
||||
return response != null
|
||||
&& response.statusCode == 401
|
||||
&& "UNAUTHORIZED".equals(response.message());
|
||||
}
|
||||
|
||||
private void updateSaveAvailability() {
|
||||
if (headerActionButton != null) {
|
||||
headerActionButton.setEnabled(contentLoaded);
|
||||
headerActionButton.setAlpha(contentLoaded ? 1f : 0.45f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.text.InputType;
|
||||
import android.view.View;
|
||||
import android.widget.EditText;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class OpenAiOnboardingActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_AUTO_OPEN_LOGIN = "extra_auto_open_login";
|
||||
private static final String STATE_AUTO_OPENED = "state_auto_opened";
|
||||
private static final String OPENAI_LOGIN_URL = "https://platform.openai.com/login";
|
||||
private static final String OPENAI_KEYS_URL = "https://platform.openai.com/api-keys";
|
||||
|
||||
private EditText labelInput;
|
||||
private EditText displayNameInput;
|
||||
private EditText accountIdentifierInput;
|
||||
private EditText modelInput;
|
||||
private EditText apiKeyInput;
|
||||
private boolean autoOpened;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("登录 OpenAI 平台账号", "先登录 OpenAI,再回这里接入 API Key");
|
||||
hideHeaderAction();
|
||||
refreshButton.setVisibility(View.GONE);
|
||||
refreshLayout.setEnabled(false);
|
||||
if (savedInstanceState != null) {
|
||||
autoOpened = savedInstanceState.getBoolean(STATE_AUTO_OPENED, false);
|
||||
}
|
||||
buildForm();
|
||||
reload();
|
||||
if (getIntent().getBooleanExtra(EXTRA_AUTO_OPEN_LOGIN, false) && !autoOpened) {
|
||||
autoOpened = true;
|
||||
openExternalUrl(OPENAI_LOGIN_URL, "已打开 OpenAI 登录页,登录后回到这里继续。");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onSaveInstanceState(Bundle outState) {
|
||||
super.onSaveInstanceState(outState);
|
||||
outState.putBoolean(STATE_AUTO_OPENED, autoOpened);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
replaceContent();
|
||||
appendContent(BossUi.buildSimpleProfileHeader(
|
||||
this,
|
||||
"OpenAI 平台账号",
|
||||
"像 Codex 一样,先去浏览器登录,再回 APP 完成接入。",
|
||||
"OpenAI 目前不会把可直接调用 API 的凭据通过第三方 OAuth 直接交给 APP,所以最后一步仍然需要 API Key。"
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"第一步:打开 OpenAI 登录页",
|
||||
"先在浏览器完成 OpenAI Platform 登录。",
|
||||
"返回 APP 后继续下一步。",
|
||||
null,
|
||||
v -> openExternalUrl(OPENAI_LOGIN_URL, "已打开 OpenAI 登录页。")
|
||||
));
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"第二步:打开 API Keys 页面",
|
||||
"登录后创建或复制新的 API Key。",
|
||||
"建议创建专门给 Boss 使用的 Key。",
|
||||
null,
|
||||
v -> openExternalUrl(OPENAI_KEYS_URL, "已打开 API Keys 页面。")
|
||||
));
|
||||
|
||||
appendContent(BossUi.buildFormCell(this, "标签", "建议使用 主 GPT", labelInput));
|
||||
appendContent(BossUi.buildFormCell(this, "显示名称", "会展示在账号列表和当前主控里", displayNameInput));
|
||||
appendContent(BossUi.buildFormCell(this, "账号标识", "可填邮箱、账号名或自定义备注", accountIdentifierInput));
|
||||
appendContent(BossUi.buildFormCell(this, "模型", "例如 gpt-5.4", modelInput));
|
||||
appendContent(BossUi.buildFormCell(this, "API Key", "从 OpenAI Platform 复制后粘贴到这里", apiKeyInput));
|
||||
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"从剪贴板粘贴 API Key",
|
||||
"如果你刚从浏览器复制了 key,可以直接粘贴到输入框。",
|
||||
null,
|
||||
null,
|
||||
v -> pasteApiKeyFromClipboard()
|
||||
));
|
||||
|
||||
android.widget.Button guideButton = BossUi.buildSecondaryButton(this, "主 GPT 登录说明");
|
||||
guideButton.setOnClickListener(v ->
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("为什么还要 API Key?")
|
||||
.setMessage("浏览器登录解决的是账号身份校验,但主 Agent 真正调用 OpenAI 模型仍然需要 API Key。当前这条链会先帮你打开 OpenAI 登录和 API Keys 页面,再回 APP 完成接入。")
|
||||
.setPositiveButton("知道了", null)
|
||||
.show()
|
||||
);
|
||||
appendContent(guideButton);
|
||||
|
||||
android.widget.Button submitButton = BossUi.buildPrimaryButton(this, "验证并设为当前主控");
|
||||
submitButton.setOnClickListener(v -> submit());
|
||||
appendContent(submitButton);
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void buildForm() {
|
||||
labelInput = BossUi.buildInput(this, "标签,例如 主 GPT", false);
|
||||
labelInput.setText("主 GPT");
|
||||
displayNameInput = BossUi.buildInput(this, "显示名称", false);
|
||||
displayNameInput.setText("OpenAI 平台账号");
|
||||
accountIdentifierInput = BossUi.buildInput(this, "账号标识 / 备注", false);
|
||||
modelInput = BossUi.buildInput(this, "模型,例如 gpt-5.4", false);
|
||||
modelInput.setText("gpt-5.4");
|
||||
apiKeyInput = BossUi.buildInput(this, "OpenAI API Key", false);
|
||||
apiKeyInput.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||
}
|
||||
|
||||
private void pasteApiKeyFromClipboard() {
|
||||
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
|
||||
if (clipboard == null || !clipboard.hasPrimaryClip()) {
|
||||
showMessage("剪贴板里还没有可用的 API Key。");
|
||||
return;
|
||||
}
|
||||
ClipData clip = clipboard.getPrimaryClip();
|
||||
if (clip == null || clip.getItemCount() == 0) {
|
||||
showMessage("剪贴板里还没有可用的 API Key。");
|
||||
return;
|
||||
}
|
||||
CharSequence text = clip.getItemAt(0).coerceToText(this);
|
||||
if (text == null || text.toString().trim().isEmpty()) {
|
||||
showMessage("剪贴板里还没有可用的 API Key。");
|
||||
return;
|
||||
}
|
||||
apiKeyInput.setText(text.toString().trim());
|
||||
showMessage("已从剪贴板粘贴 API Key。");
|
||||
}
|
||||
|
||||
private void openExternalUrl(String url, String successMessage) {
|
||||
try {
|
||||
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
|
||||
startActivity(intent);
|
||||
showMessage(successMessage);
|
||||
} catch (Exception error) {
|
||||
showMessage("打开浏览器失败:" + error.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void submit() {
|
||||
String label = labelInput.getText().toString().trim();
|
||||
String displayName = displayNameInput.getText().toString().trim();
|
||||
String accountIdentifier = accountIdentifierInput.getText().toString().trim();
|
||||
String model = modelInput.getText().toString().trim();
|
||||
String apiKey = apiKeyInput.getText().toString().trim();
|
||||
if (label.isEmpty() || displayName.isEmpty() || apiKey.isEmpty()) {
|
||||
showMessage("标签、显示名称和 API Key 不能为空");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("label", label);
|
||||
payload.put("displayName", displayName);
|
||||
payload.put("accountIdentifier", accountIdentifier);
|
||||
payload.put("model", model);
|
||||
payload.put("apiKey", apiKey);
|
||||
payload.put("enabled", true);
|
||||
payload.put("setActive", true);
|
||||
payload.put("provider", "openai_api");
|
||||
payload.put("role", "primary");
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.onboardOpenAiApiAccount(payload);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
setResult(RESULT_OK);
|
||||
setRefreshing(false);
|
||||
showPostLoginActions(response.json);
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
String detail = error.getMessage();
|
||||
showMessage(detail == null || detail.trim().isEmpty()
|
||||
? "OpenAI 平台账号登录失败,请稍后重试。"
|
||||
: "OpenAI 平台账号登录失败:" + detail);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void showPostLoginActions(JSONObject responseJson) {
|
||||
JSONObject activeIdentity = responseJson == null ? null : responseJson.optJSONObject("activeIdentity");
|
||||
StringBuilder message = new StringBuilder();
|
||||
if (activeIdentity != null) {
|
||||
String statusLabel = activeIdentity.optString("statusLabel", "");
|
||||
String note = activeIdentity.optString("note", "");
|
||||
message.append("当前主控:")
|
||||
.append(activeIdentity.optString("label", "OpenAI 平台账号"))
|
||||
.append(" · ")
|
||||
.append(activeIdentity.optString("displayName", ""))
|
||||
.append('\n')
|
||||
.append("状态:")
|
||||
.append(statusLabel.isEmpty() ? "可用" : statusLabel);
|
||||
if (!note.isEmpty()) {
|
||||
message.append('\n').append(note);
|
||||
}
|
||||
} else {
|
||||
message.append("OpenAI 平台账号已登录,并设为当前主控。");
|
||||
}
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("OpenAI 平台账号已登录")
|
||||
.setMessage(message.toString() + "\n\n你现在可以直接测试主 Agent 对话,确认当前主控链路是否可用。")
|
||||
.setPositiveButton("测试主 Agent 对话", (dialog, which) -> {
|
||||
openMasterAgentConversation();
|
||||
finish();
|
||||
})
|
||||
.setNegativeButton("返回账号页", (dialog, which) -> finish())
|
||||
.setOnDismissListener(dialog -> {
|
||||
if (!isFinishing()) {
|
||||
finish();
|
||||
}
|
||||
})
|
||||
.show();
|
||||
}
|
||||
|
||||
private void openMasterAgentConversation() {
|
||||
Intent intent = new Intent(this, ProjectDetailActivity.class);
|
||||
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent");
|
||||
intent.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
|
||||
startActivity(intent);
|
||||
}
|
||||
}
|
||||
@@ -9,88 +9,139 @@ import androidx.annotation.Nullable;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class OpsCenterActivity extends BossScreenActivity {
|
||||
private enum Tab {
|
||||
OPS,
|
||||
AUDIT
|
||||
}
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class OpsCenterActivity extends BossScreenActivity {
|
||||
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
|
||||
private Tab activeTab = Tab.OPS;
|
||||
private LinearLayout contentRoot;
|
||||
private @Nullable BossRealtimeClient realtimeClient;
|
||||
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("运维中心", "运维对话 / 审计对话");
|
||||
configureScreen("运维与修复", "运维会话、修复回放与 standby 切换");
|
||||
setHeaderAction("刷新", v -> reload());
|
||||
contentRoot = new LinearLayout(this);
|
||||
contentRoot.setOrientation(LinearLayout.VERTICAL);
|
||||
replaceContent(contentRoot);
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
stopRealtimeUpdates();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
stopRealtimeUpdates();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse ops = apiClient.getOpsSummary();
|
||||
BossApiClient.ApiResponse audit = apiClient.getAuditSummary();
|
||||
if (!ops.ok() || !audit.ok()) {
|
||||
throw new IllegalStateException("OPS_OR_AUDIT_LOAD_FAILED");
|
||||
if (!ops.ok()) {
|
||||
throw new IllegalStateException("OPS_LOAD_FAILED");
|
||||
}
|
||||
runOnUiThread(() -> render(ops.json, audit.json));
|
||||
runOnUiThread(() -> render(ops.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "运维中心加载失败:" + error.getMessage()));
|
||||
replaceContent(BossUi.buildEmptyCard(this, "运维与修复加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void render(JSONObject ops, JSONObject audit) {
|
||||
contentRoot.removeAllViews();
|
||||
contentRoot.addView(buildTabBar());
|
||||
if (activeTab == Tab.OPS) {
|
||||
renderOpsTab(ops);
|
||||
} else {
|
||||
renderAuditTab(audit);
|
||||
private void updateRealtimeSubscription() {
|
||||
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
|
||||
realtimeClient.start();
|
||||
return;
|
||||
}
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
private void stopRealtimeUpdates() {
|
||||
if (realtimeClient != null) {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
void handleRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (event == null || event.eventName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!shouldReloadForRealtimeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
|
||||
if (eventFingerprint.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(this::reload);
|
||||
}
|
||||
|
||||
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
|
||||
return "app.logs.updated".equals(event.eventName)
|
||||
|| "project.context_risk.updated".equals(event.eventName);
|
||||
}
|
||||
|
||||
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
||||
pruneRecentRealtimeEvents(now);
|
||||
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
|
||||
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
|
||||
return true;
|
||||
}
|
||||
recentRealtimeEventTimestamps.put(eventFingerprint, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void pruneRecentRealtimeEvents(long now) {
|
||||
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, Long> entry = iterator.next();
|
||||
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void render(JSONObject ops) {
|
||||
replaceContent(contentRoot);
|
||||
contentRoot.removeAllViews();
|
||||
renderOpsTab(ops);
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private LinearLayout buildTabBar() {
|
||||
LinearLayout bar = new LinearLayout(this);
|
||||
bar.setOrientation(LinearLayout.HORIZONTAL);
|
||||
bar.addView(buildTabButton("运维对话", activeTab == Tab.OPS, v -> {
|
||||
activeTab = Tab.OPS;
|
||||
reload();
|
||||
}));
|
||||
bar.addView(buildTabButton("审计对话", activeTab == Tab.AUDIT, v -> {
|
||||
activeTab = Tab.AUDIT;
|
||||
reload();
|
||||
}));
|
||||
return bar;
|
||||
}
|
||||
|
||||
private Button buildTabButton(String label, boolean active, android.view.View.OnClickListener listener) {
|
||||
Button button = BossUi.buildPrimaryButton(this, label);
|
||||
button.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f));
|
||||
button.setBackgroundResource(active ? R.drawable.bg_primary_button : R.drawable.bg_secondary_button);
|
||||
button.setTextColor(getColor(active ? R.color.boss_surface : R.color.boss_green));
|
||||
button.setOnClickListener(listener);
|
||||
return button;
|
||||
}
|
||||
|
||||
private void renderOpsTab(JSONObject ops) {
|
||||
contentRoot.addView(BossUi.buildCard(
|
||||
contentRoot.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"当前巡检模式",
|
||||
"巡检状态",
|
||||
ops.optString("mode", "idle").equals("active")
|
||||
? "active:当前存在风险线程或未关闭运维工单。"
|
||||
: "idle:当前没有高风险工单,保持低频巡检。",
|
||||
"来源:/api/v1/ops/summary"
|
||||
"这里只保留修复与验证的轻量入口。",
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
JSONArray faults = ops.optJSONArray("faults");
|
||||
@@ -106,7 +157,9 @@ public class OpsCenterActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
private LinearLayout buildFaultCard(JSONObject fault, @Nullable JSONArray tickets) {
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
LinearLayout card = new LinearLayout(this);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
card.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
fault.optString("faultKey", "故障"),
|
||||
fault.optString("summary", "暂无摘要"),
|
||||
@@ -114,13 +167,9 @@ public class OpsCenterActivity extends BossScreenActivity {
|
||||
+ " · " + fault.optString("status", "-")
|
||||
+ " · " + fault.optString("nodeId", "-")
|
||||
+ " · " + fault.optString("serviceName", "-")
|
||||
);
|
||||
|
||||
card.addView(BossUi.buildCard(
|
||||
this,
|
||||
"建议动作",
|
||||
fault.optString("suggestedNextAction", "暂无"),
|
||||
"trace " + fault.optString("traceId", "-")
|
||||
+ " · 建议 " + fault.optString("suggestedNextAction", "暂无"),
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
if (tickets != null) {
|
||||
@@ -135,122 +184,38 @@ public class OpsCenterActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
private LinearLayout buildTicketCard(JSONObject ticket) {
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
LinearLayout card = new LinearLayout(this);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
card.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
ticket.optString("title", "修复工单"),
|
||||
ticket.optString("actionSummary", "暂无动作摘要"),
|
||||
ticket.optString("approvalStatus", "-")
|
||||
+ " · " + ticket.optString("executionStatus", "-")
|
||||
+ " · " + ticket.optString("targetNodeId", "-")
|
||||
);
|
||||
+ " · " + ticket.optString("updatedAt", "-"),
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
if (ticket.optJSONObject("verification") != null) {
|
||||
JSONObject verification = ticket.optJSONObject("verification");
|
||||
card.addView(BossUi.buildCard(
|
||||
card.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"验证结果",
|
||||
verification.optString("summary", "暂无"),
|
||||
verification.optString("status", "-")
|
||||
+ " · " + verification.optString("verifiedAt", "-")
|
||||
+ " · " + verification.optString("verifiedAt", "-"),
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
Button approve = BossUi.buildPrimaryButton(this, "批准修复");
|
||||
Button approve = BossUi.buildMiniActionButton(this, "批准修复", true);
|
||||
approve.setOnClickListener(v -> approveTicket(ticket.optString("ticketId")));
|
||||
card.addView(approve);
|
||||
|
||||
Button verify = BossUi.buildSecondaryButton(this, "验证修复");
|
||||
Button verify = BossUi.buildMiniActionButton(this, "验证修复", false);
|
||||
verify.setOnClickListener(v -> verifyTicket(ticket.optString("ticketId")));
|
||||
card.addView(verify);
|
||||
return card;
|
||||
}
|
||||
|
||||
private void renderAuditTab(JSONObject audit) {
|
||||
contentRoot.addView(BossUi.buildCard(
|
||||
this,
|
||||
"审计概要",
|
||||
"待处理请求 " + (audit.optJSONArray("pendingRequests") == null ? 0 : audit.optJSONArray("pendingRequests").length())
|
||||
+ "\n最新结果 " + (audit.optJSONArray("latestResults") == null ? 0 : audit.optJSONArray("latestResults").length()),
|
||||
"来源:/api/v1/audits/summary"
|
||||
));
|
||||
|
||||
JSONArray pendingRequests = audit.optJSONArray("pendingRequests");
|
||||
if (pendingRequests == null || pendingRequests.length() == 0) {
|
||||
contentRoot.addView(BossUi.buildEmptyCard(this, "当前没有待处理的审计请求。"));
|
||||
} else {
|
||||
for (int i = 0; i < pendingRequests.length(); i++) {
|
||||
JSONObject request = pendingRequests.optJSONObject(i);
|
||||
if (request == null) continue;
|
||||
contentRoot.addView(buildAuditRequestCard(request));
|
||||
}
|
||||
}
|
||||
|
||||
JSONArray latestResults = audit.optJSONArray("latestResults");
|
||||
if (latestResults != null && latestResults.length() > 0) {
|
||||
contentRoot.addView(BossUi.buildCard(this, "审计结果", "最近完成的审计会展示在这里。", "可回看 decision / findings"));
|
||||
for (int i = 0; i < latestResults.length(); i++) {
|
||||
JSONObject result = latestResults.optJSONObject(i);
|
||||
if (result == null) continue;
|
||||
contentRoot.addView(buildAuditResultCard(result));
|
||||
}
|
||||
}
|
||||
|
||||
JSONArray capabilities = audit.optJSONArray("capabilities");
|
||||
if (capabilities != null && capabilities.length() > 0) {
|
||||
contentRoot.addView(BossUi.buildCard(this, "能力注册表", "展示当前设备上的可用能力。", "与审计请求的 capabilityRequirements 对应"));
|
||||
for (int i = 0; i < capabilities.length(); i++) {
|
||||
JSONObject capability = capabilities.optJSONObject(i);
|
||||
if (capability == null) continue;
|
||||
contentRoot.addView(BossUi.buildCard(
|
||||
this,
|
||||
capability.optString("displayName", "能力"),
|
||||
capability.optString("capabilityType", "-")
|
||||
+ "\n提供者:" + capability.optString("providerId", "-")
|
||||
+ "\n模式:" + capability.optString("leaseMode", "-")
|
||||
+ "\n动作:" + joinArray(capability.optJSONArray("supportedActions")),
|
||||
capability.optString("status", "-")
|
||||
+ " · " + capability.optString("healthStatus", "-")
|
||||
+ " · " + capability.optString("nodeId", "-")
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private LinearLayout buildAuditRequestCard(JSONObject request) {
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
this,
|
||||
request.optString("projectName", "审计请求"),
|
||||
request.optString("objective", "暂无目标"),
|
||||
request.optString("auditType", "-")
|
||||
+ " · priority " + request.optInt("priority", 0)
|
||||
+ " · " + request.optString("trigger", "-")
|
||||
);
|
||||
card.addView(BossUi.buildCard(
|
||||
this,
|
||||
"审计条件",
|
||||
"要求:" + joinStringArray(request.optJSONArray("acceptanceCriteria"))
|
||||
+ "\n风险:" + joinStringArray(request.optJSONArray("riskFocus"))
|
||||
+ "\n证据:" + joinStringArray(request.optJSONArray("evidenceRefs")),
|
||||
"时限 " + request.optInt("timeBudgetSeconds", 0) + " 秒"
|
||||
));
|
||||
return card;
|
||||
}
|
||||
|
||||
private LinearLayout buildAuditResultCard(JSONObject result) {
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
this,
|
||||
result.optString("decision", "result"),
|
||||
result.optString("summary", "暂无摘要"),
|
||||
result.optString("status", "-")
|
||||
+ " · confidence " + result.optDouble("confidence", 0.0)
|
||||
+ " · " + result.optString("completedAt", "-")
|
||||
);
|
||||
card.addView(BossUi.buildCard(
|
||||
this,
|
||||
"审计发现",
|
||||
joinStringArray(result.optJSONArray("findings")),
|
||||
"需要动作:" + joinStringArray(result.optJSONArray("requiredActions"))
|
||||
));
|
||||
card.addView(BossUi.buildInlineActionRow(this, approve, verify));
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -311,16 +276,4 @@ public class OpsCenterActivity extends BossScreenActivity {
|
||||
}
|
||||
return builder.length() == 0 ? "-" : builder.toString();
|
||||
}
|
||||
|
||||
private String joinStringArray(@Nullable JSONArray values) {
|
||||
if (values == null || values.length() == 0) return "-";
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < values.length(); i++) {
|
||||
String value = values.optString(i);
|
||||
if (value == null || value.isEmpty()) continue;
|
||||
if (builder.length() > 0) builder.append(";");
|
||||
builder.append(value);
|
||||
}
|
||||
return builder.length() == 0 ? "-" : builder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
public final class OtaDownloadStateMapper {
|
||||
public enum ActionKind {
|
||||
NONE,
|
||||
RETRY_DOWNLOAD,
|
||||
OPEN_INSTALL_PERMISSION,
|
||||
INSTALL_APK
|
||||
}
|
||||
|
||||
public static final class UiState {
|
||||
public final String title;
|
||||
public final String subtitle;
|
||||
public final String meta;
|
||||
public final String badge;
|
||||
public final String actionLabel;
|
||||
public final ActionKind actionKind;
|
||||
|
||||
public UiState(
|
||||
String title,
|
||||
String subtitle,
|
||||
String meta,
|
||||
String badge,
|
||||
String actionLabel,
|
||||
ActionKind actionKind
|
||||
) {
|
||||
this.title = title;
|
||||
this.subtitle = subtitle;
|
||||
this.meta = meta;
|
||||
this.badge = badge;
|
||||
this.actionLabel = actionLabel;
|
||||
this.actionKind = actionKind;
|
||||
}
|
||||
}
|
||||
|
||||
private OtaDownloadStateMapper() {}
|
||||
|
||||
public static String toProgressLabel(int percent, boolean hasKnownTotal) {
|
||||
if (!hasKnownTotal) {
|
||||
return "正在准备下载";
|
||||
}
|
||||
int safePercent = Math.max(0, Math.min(100, percent));
|
||||
return "已下载 " + safePercent + "%";
|
||||
}
|
||||
|
||||
public static UiState active(String fileName, int percent, boolean hasKnownTotal, long bytesDownloaded, long totalBytes) {
|
||||
return new UiState(
|
||||
"安装包下载中",
|
||||
toProgressLabel(percent, hasKnownTotal),
|
||||
buildMeta(fileName, bytesDownloaded, totalBytes),
|
||||
"NOW",
|
||||
null,
|
||||
ActionKind.NONE
|
||||
);
|
||||
}
|
||||
|
||||
public static UiState failed(String fileName) {
|
||||
return new UiState(
|
||||
"安装包下载失败",
|
||||
"下载未成功完成,可以直接重试",
|
||||
fileName,
|
||||
"FAIL",
|
||||
"重试下载",
|
||||
ActionKind.RETRY_DOWNLOAD
|
||||
);
|
||||
}
|
||||
|
||||
public static UiState waitingInstallPermission(String fileName) {
|
||||
return new UiState(
|
||||
"等待安装授权",
|
||||
"请先允许 Boss 安装未知来源应用",
|
||||
fileName,
|
||||
"STEP",
|
||||
"前往授权",
|
||||
ActionKind.OPEN_INSTALL_PERMISSION
|
||||
);
|
||||
}
|
||||
|
||||
public static UiState readyToInstall(String fileName) {
|
||||
return new UiState(
|
||||
"安装包已就绪",
|
||||
"下载完成,可继续拉起系统安装",
|
||||
fileName,
|
||||
"DONE",
|
||||
"继续安装",
|
||||
ActionKind.INSTALL_APK
|
||||
);
|
||||
}
|
||||
|
||||
private static String buildMeta(String fileName, long bytesDownloaded, long totalBytes) {
|
||||
if (bytesDownloaded <= 0 && totalBytes <= 0) {
|
||||
return fileName;
|
||||
}
|
||||
StringBuilder builder = new StringBuilder(fileName);
|
||||
builder.append(" · ").append(formatBytes(bytesDownloaded));
|
||||
if (totalBytes > 0) {
|
||||
builder.append(" / ").append(formatBytes(totalBytes));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static String formatBytes(long bytes) {
|
||||
if (bytes <= 0) {
|
||||
return "0 B";
|
||||
}
|
||||
if (bytes < 1024) {
|
||||
return bytes + " B";
|
||||
}
|
||||
if (bytes < 1024L * 1024L) {
|
||||
return String.format(java.util.Locale.US, "%.1f KB", bytes / 1024.0d);
|
||||
}
|
||||
return String.format(java.util.Locale.US, "%.1f MB", bytes / (1024.0d * 1024.0d));
|
||||
}
|
||||
}
|
||||
769
android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java
Normal file
769
android/app/src/main/java/com/hyzq/boss/ProjectChatUiState.java
Normal file
@@ -0,0 +1,769 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
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;
|
||||
|
||||
private SelectionState(Set<String> selectedMessageIds) {
|
||||
LinkedHashSet<String> normalizedIds = new LinkedHashSet<>(selectedMessageIds);
|
||||
this.multiSelecting = !normalizedIds.isEmpty();
|
||||
this.selectedMessageIds = Collections.unmodifiableSet(normalizedIds);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class ChromeState {
|
||||
public final boolean multiSelecting;
|
||||
public final boolean showComposer;
|
||||
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;
|
||||
public final String subtitle;
|
||||
|
||||
private ChromeState(
|
||||
boolean multiSelecting,
|
||||
boolean showComposer,
|
||||
boolean showMultiSelectBar,
|
||||
boolean showRefresh,
|
||||
boolean showHeaderAction,
|
||||
boolean copyEnabled,
|
||||
boolean forwardEnabled,
|
||||
String backLabel,
|
||||
String title,
|
||||
String subtitle
|
||||
) {
|
||||
this.multiSelecting = multiSelecting;
|
||||
this.showComposer = showComposer;
|
||||
this.showMultiSelectBar = showMultiSelectBar;
|
||||
this.showRefresh = showRefresh;
|
||||
this.showHeaderAction = showHeaderAction;
|
||||
this.copyEnabled = copyEnabled;
|
||||
this.forwardEnabled = forwardEnabled;
|
||||
this.backLabel = backLabel;
|
||||
this.title = title;
|
||||
this.subtitle = subtitle;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class ReplyWaitSpec {
|
||||
public final boolean shouldWait;
|
||||
public final String baselineMessageId;
|
||||
|
||||
private ReplyWaitSpec(boolean shouldWait, @Nullable String baselineMessageId) {
|
||||
this.shouldWait = shouldWait && !isBlank(baselineMessageId);
|
||||
this.baselineMessageId = this.shouldWait ? baselineMessageId.trim() : "";
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean canSend(String text, boolean sending) {
|
||||
return !sending && text != null && !text.trim().isEmpty();
|
||||
}
|
||||
|
||||
public static boolean requiresAttachmentConfirmation(@Nullable String sourceType) {
|
||||
return "image".equals(sourceType) || "video".equals(sourceType);
|
||||
}
|
||||
|
||||
public static boolean shouldAutoScroll(boolean nearBottom, boolean forced) {
|
||||
return nearBottom || forced;
|
||||
}
|
||||
|
||||
public static List<MessageDisplayItem> buildMessageDisplayItems(@Nullable JSONArray messages) {
|
||||
ArrayList<MessageDisplayItem> items = new ArrayList<>();
|
||||
if (messages == null || messages.length() == 0) {
|
||||
return items;
|
||||
}
|
||||
ArrayList<JSONObject> pendingProcessMessages = new ArrayList<>();
|
||||
for (int i = 0; i < messages.length(); i++) {
|
||||
JSONObject message = messages.optJSONObject(i);
|
||||
if (message == null) {
|
||||
continue;
|
||||
}
|
||||
if (isThreadProcessMessage(message)) {
|
||||
pendingProcessMessages.add(message);
|
||||
continue;
|
||||
}
|
||||
flushProcessGroup(items, pendingProcessMessages);
|
||||
items.add(MessageDisplayItem.message(message));
|
||||
}
|
||||
flushProcessGroup(items, pendingProcessMessages);
|
||||
return items;
|
||||
}
|
||||
|
||||
public static boolean hasThreadProcessFoldCandidates(@Nullable JSONArray messages, int startIndex) {
|
||||
if (messages == null || messages.length() == 0) {
|
||||
return false;
|
||||
}
|
||||
int firstIndex = Math.max(0, startIndex);
|
||||
for (int i = firstIndex; i < messages.length(); i++) {
|
||||
JSONObject message = messages.optJSONObject(i);
|
||||
if (message != null && isThreadProcessMessage(message)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static String processGroupPreview(@Nullable MessageDisplayItem item) {
|
||||
if (item == null || item.processMessages.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
JSONObject latestMessage = item.processMessages.get(item.processMessages.size() - 1);
|
||||
return truncate(latestMessage.optString("body", ""), 52);
|
||||
}
|
||||
|
||||
public static String processGroupDetail(@Nullable MessageDisplayItem item) {
|
||||
if (item == null || item.processMessages.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (int i = 0; i < item.processMessages.size(); i++) {
|
||||
JSONObject message = item.processMessages.get(i);
|
||||
String body = compactBody(message.optString("body", ""));
|
||||
if (body.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append("\n\n");
|
||||
}
|
||||
builder.append(i + 1).append(". ").append(body);
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private static void flushProcessGroup(List<MessageDisplayItem> items, List<JSONObject> pendingProcessMessages) {
|
||||
if (pendingProcessMessages.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
items.add(MessageDisplayItem.processGroup(pendingProcessMessages));
|
||||
pendingProcessMessages.clear();
|
||||
}
|
||||
|
||||
public static String threadExecutionConflictTitle(@Nullable JSONObject conflict) {
|
||||
if (conflict == null) {
|
||||
return "当前线程命中冲突保护";
|
||||
}
|
||||
if ("preferred_gui_mode".equals(conflict.optString("reason", ""))) {
|
||||
return "当前项目默认先走 GUI";
|
||||
}
|
||||
return "当前项目已命中并发保护";
|
||||
}
|
||||
|
||||
public static String threadExecutionConflictSummary(@Nullable JSONObject conflict) {
|
||||
if (conflict == null) {
|
||||
return "当前线程命中了 GUI / CLI 冲突保护,请先确认是否继续。";
|
||||
}
|
||||
String projectName = conflict.optString("projectName", "当前项目");
|
||||
String deviceName = conflict.optString("deviceName", "当前设备");
|
||||
if ("preferred_gui_mode".equals(conflict.optString("reason", ""))) {
|
||||
return deviceName + " 现在默认优先 GUI。要让主 Agent 继续通过 CLI 推进 " + projectName + ",需要你先对这个项目放行;这个选择只对这个项目生效。";
|
||||
}
|
||||
return projectName + " 最近检测到 GUI / CLI 同时活动,当前先按禁止处理。这个提示只影响这个项目;你可以临时放行,或者把这个项目永久放行。";
|
||||
}
|
||||
|
||||
public static String labelForThreadExecutionConflictDecision(@Nullable String decision) {
|
||||
if ("allow_once".equals(decision)) {
|
||||
return "允许本次";
|
||||
}
|
||||
if ("allow_always".equals(decision)) {
|
||||
return "永久放行";
|
||||
}
|
||||
return "禁止";
|
||||
}
|
||||
|
||||
public static String summarizeThreadExecutionConflictDecisionResult(@Nullable String decision) {
|
||||
if ("allow_once".equals(decision)) {
|
||||
return "已允许本次,继续发送中…";
|
||||
}
|
||||
if ("allow_always".equals(decision)) {
|
||||
return "已对当前项目永久放行,继续发送中…";
|
||||
}
|
||||
return "已保持禁止,这次消息没有发出。";
|
||||
}
|
||||
|
||||
public static SelectionState emptySelection() {
|
||||
return new SelectionState(new LinkedHashSet<>());
|
||||
}
|
||||
|
||||
public static SelectionState selectOnly(String messageId) {
|
||||
return toggleSelection(emptySelection(), messageId);
|
||||
}
|
||||
|
||||
public static SelectionState toggleSelection(@Nullable SelectionState current, String messageId) {
|
||||
if (messageId == null || messageId.trim().isEmpty()) {
|
||||
throw new IllegalArgumentException("messageId must not be blank");
|
||||
}
|
||||
SelectionState state = current == null ? emptySelection() : current;
|
||||
LinkedHashSet<String> selectedMessageIds = new LinkedHashSet<>(state.selectedMessageIds);
|
||||
if (selectedMessageIds.contains(messageId)) {
|
||||
selectedMessageIds.remove(messageId);
|
||||
return new SelectionState(selectedMessageIds);
|
||||
}
|
||||
selectedMessageIds.add(messageId);
|
||||
return new SelectionState(selectedMessageIds);
|
||||
}
|
||||
|
||||
public static boolean canForwardSelection(@Nullable SelectionState state) {
|
||||
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
|
||||
) {
|
||||
if (current == null || current.selectedMessageIds.isEmpty() || availableMessageIds == null || availableMessageIds.isEmpty()) {
|
||||
return emptySelection();
|
||||
}
|
||||
LinkedHashSet<String> available = new LinkedHashSet<>(availableMessageIds);
|
||||
LinkedHashSet<String> selected = new LinkedHashSet<>();
|
||||
for (String selectedMessageId : current.selectedMessageIds) {
|
||||
if (available.contains(selectedMessageId)) {
|
||||
selected.add(selectedMessageId);
|
||||
}
|
||||
}
|
||||
return new SelectionState(selected);
|
||||
}
|
||||
|
||||
public static ChromeState resolveChromeState(
|
||||
@Nullable SelectionState selectionState,
|
||||
boolean conversationInfoReady,
|
||||
@Nullable String defaultTitle,
|
||||
@Nullable String defaultSubtitle
|
||||
) {
|
||||
boolean multiSelecting = selectionState != null && selectionState.multiSelecting;
|
||||
if (multiSelecting) {
|
||||
int selectedCount = selectionState.selectedMessageIds.size();
|
||||
return new ChromeState(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
canCopySelection(selectionState),
|
||||
canForwardSelection(selectionState),
|
||||
"取消",
|
||||
"已选 " + selectedCount + " 条",
|
||||
"选择要转发的消息"
|
||||
);
|
||||
}
|
||||
return new ChromeState(
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
!conversationInfoReady,
|
||||
conversationInfoReady,
|
||||
false,
|
||||
false,
|
||||
"返回",
|
||||
isBlank(defaultTitle) ? "项目详情" : defaultTitle,
|
||||
isBlank(defaultSubtitle) ? "原生页面" : defaultSubtitle
|
||||
);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String labelForForwardKind(@Nullable String kind) {
|
||||
if ("forward_single".equals(kind)) {
|
||||
return "转发";
|
||||
}
|
||||
if ("forward_bundle".equals(kind)) {
|
||||
return "聊天记录";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String summarizeForwardBundle(@Nullable String lastBody, int itemCount) {
|
||||
if (itemCount > 0 && !isBlank(lastBody)) {
|
||||
return itemCount + " 条消息 · 最后一条:" + truncate(lastBody, 28);
|
||||
}
|
||||
if (itemCount > 0) {
|
||||
return itemCount + " 条消息";
|
||||
}
|
||||
return truncate(lastBody, 28);
|
||||
}
|
||||
|
||||
public static String labelForAttachmentAnalysisState(@Nullable String analysisState) {
|
||||
if ("queued_auto".equals(analysisState)) {
|
||||
return "自动分析排队中";
|
||||
}
|
||||
if ("ready_manual".equals(analysisState)) {
|
||||
return "待分析";
|
||||
}
|
||||
if ("processing".equals(analysisState)) {
|
||||
return "AI 分析中";
|
||||
}
|
||||
if ("completed".equals(analysisState)) {
|
||||
return "已分析";
|
||||
}
|
||||
if ("failed".equals(analysisState)) {
|
||||
return "分析失败";
|
||||
}
|
||||
return "已发送";
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static String actionLabelForAttachmentAnalysisState(@Nullable String analysisState) {
|
||||
if ("ready_manual".equals(analysisState)) {
|
||||
return "让 AI 分析";
|
||||
}
|
||||
if ("failed".equals(analysisState)) {
|
||||
return "重试分析";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String labelForAttachmentKind(@Nullable String attachmentKind) {
|
||||
if ("image".equals(attachmentKind)) {
|
||||
return "图片";
|
||||
}
|
||||
if ("video".equals(attachmentKind)) {
|
||||
return "视频";
|
||||
}
|
||||
if ("pdf".equals(attachmentKind)) {
|
||||
return "PDF";
|
||||
}
|
||||
if ("office".equals(attachmentKind)) {
|
||||
return "文档";
|
||||
}
|
||||
if ("text".equals(attachmentKind)) {
|
||||
return "文本";
|
||||
}
|
||||
return "文件";
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static JSONObject latestPendingDispatchPlan(@Nullable JSONArray plans) {
|
||||
if (plans == null || plans.length() == 0) {
|
||||
return null;
|
||||
}
|
||||
for (int i = 0; i < plans.length(); i++) {
|
||||
JSONObject plan = plans.optJSONObject(i);
|
||||
if (plan == null) {
|
||||
continue;
|
||||
}
|
||||
if ("pending_user_confirmation".equals(plan.optString("status", ""))) {
|
||||
return plan;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static JSONObject latestRejectedDispatchPlan(@Nullable JSONArray plans) {
|
||||
if (plans == null || plans.length() == 0) {
|
||||
return null;
|
||||
}
|
||||
for (int i = 0; i < plans.length(); i++) {
|
||||
JSONObject plan = plans.optJSONObject(i);
|
||||
if (plan == null) {
|
||||
continue;
|
||||
}
|
||||
if ("rejected".equals(plan.optString("status", ""))) {
|
||||
return plan;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static List<String> dispatchPlanApprovedTargetIds(@Nullable JSONObject plan) {
|
||||
ArrayList<String> approved = new ArrayList<>();
|
||||
if (plan == null) {
|
||||
return approved;
|
||||
}
|
||||
JSONArray targets = plan.optJSONArray("targets");
|
||||
if (targets == null) {
|
||||
return approved;
|
||||
}
|
||||
for (int i = 0; i < targets.length(); i++) {
|
||||
JSONObject target = targets.optJSONObject(i);
|
||||
if (target == null) {
|
||||
continue;
|
||||
}
|
||||
String projectId = target.optString("projectId", "").trim();
|
||||
if (!projectId.isEmpty()) {
|
||||
approved.add(projectId);
|
||||
}
|
||||
}
|
||||
return approved;
|
||||
}
|
||||
|
||||
public static String summarizeDispatchPlan(@Nullable JSONObject plan) {
|
||||
if (plan == null) {
|
||||
return "主 Agent 暂未生成推荐线程。";
|
||||
}
|
||||
String summary = plan.optString("summary", "").trim();
|
||||
List<String> targetTitles = new ArrayList<>();
|
||||
JSONArray targets = plan.optJSONArray("targets");
|
||||
if (targets != null) {
|
||||
for (int i = 0; i < targets.length(); i++) {
|
||||
JSONObject target = targets.optJSONObject(i);
|
||||
if (target == null) {
|
||||
continue;
|
||||
}
|
||||
String title = target.optString("threadDisplayName", "").trim();
|
||||
if (!title.isEmpty()) {
|
||||
targetTitles.add(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append(isBlank(summary) ? "主 Agent 已生成推荐线程。" : summary);
|
||||
if (!targetTitles.isEmpty()) {
|
||||
builder.append("\n推荐线程:");
|
||||
builder.append(String.join("、", targetTitles));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
public static String summarizeDispatchPlanCompact(@Nullable JSONObject plan) {
|
||||
if (plan == null) {
|
||||
return "主 Agent 暂未生成推荐线程。";
|
||||
}
|
||||
List<String> targetTitles = dispatchPlanTargetTitles(plan);
|
||||
String summary = plan.optString("summary", "").trim();
|
||||
if (targetTitles.isEmpty()) {
|
||||
return isBlank(summary) ? "主 Agent 已生成推荐线程。" : truncate(summary, 32);
|
||||
}
|
||||
if (isBlank(summary)) {
|
||||
return "推荐给:" + String.join("、", targetTitles);
|
||||
}
|
||||
return "推荐给:" + String.join("、", targetTitles) + "\n" + truncate(summary, 32);
|
||||
}
|
||||
|
||||
public static String summarizeDispatchPlanLight(@Nullable JSONObject plan) {
|
||||
int targetCount = dispatchPlanTargetTitles(plan).size();
|
||||
if (targetCount <= 0) {
|
||||
return "主 Agent 已推荐线程";
|
||||
}
|
||||
return "主 Agent 已推荐 " + targetCount + " 个线程";
|
||||
}
|
||||
|
||||
private static List<String> dispatchPlanTargetTitles(@Nullable JSONObject plan) {
|
||||
List<String> targetTitles = new ArrayList<>();
|
||||
if (plan == null) {
|
||||
return targetTitles;
|
||||
}
|
||||
JSONArray targets = plan.optJSONArray("targets");
|
||||
if (targets != null) {
|
||||
for (int i = 0; i < targets.length(); i++) {
|
||||
JSONObject target = targets.optJSONObject(i);
|
||||
if (target == null) {
|
||||
continue;
|
||||
}
|
||||
String title = target.optString("threadDisplayName", "").trim();
|
||||
if (!title.isEmpty()) {
|
||||
targetTitles.add(title);
|
||||
}
|
||||
}
|
||||
}
|
||||
return targetTitles;
|
||||
}
|
||||
|
||||
public static String formatAttachmentSize(long fileSizeBytes) {
|
||||
if (fileSizeBytes >= 1024L * 1024L) {
|
||||
return String.format(java.util.Locale.US, "%.1f MB", fileSizeBytes / (1024f * 1024f));
|
||||
}
|
||||
if (fileSizeBytes >= 1024L) {
|
||||
return Math.max(1, Math.round(fileSizeBytes / 1024f)) + " KB";
|
||||
}
|
||||
return Math.max(fileSizeBytes, 0L) + " B";
|
||||
}
|
||||
|
||||
public static ReplyWaitSpec resolveReplyWaitAfterSend(@Nullable JSONObject response) {
|
||||
if (response == null) {
|
||||
return new ReplyWaitSpec(false, null);
|
||||
}
|
||||
JSONObject task = response.optJSONObject("task");
|
||||
if (task == null) {
|
||||
return new ReplyWaitSpec(false, null);
|
||||
}
|
||||
String taskStatus = task.optString("status", "");
|
||||
if ("completed".equals(taskStatus) || "failed".equals(taskStatus)) {
|
||||
return new ReplyWaitSpec(false, null);
|
||||
}
|
||||
JSONObject replyMessage = response.optJSONObject("replyMessage");
|
||||
if (replyMessage != null) {
|
||||
String replyMessageId = replyMessage.optString("id", "").trim();
|
||||
if (!replyMessageId.isEmpty()) {
|
||||
return new ReplyWaitSpec(true, replyMessageId);
|
||||
}
|
||||
}
|
||||
JSONObject message = response.optJSONObject("message");
|
||||
return new ReplyWaitSpec(true, message == null ? null : message.optString("id", ""));
|
||||
}
|
||||
|
||||
public static ReplyWaitSpec resolveReplyWaitAfterDispatchConfirm(@Nullable JSONObject response) {
|
||||
if (response == null) {
|
||||
return new ReplyWaitSpec(false, null);
|
||||
}
|
||||
JSONArray executions = response.optJSONArray("executions");
|
||||
if (executions == null || executions.length() == 0) {
|
||||
return new ReplyWaitSpec(false, null);
|
||||
}
|
||||
JSONObject notice = response.optJSONObject("notice");
|
||||
return new ReplyWaitSpec(true, notice == null ? null : notice.optString("id", ""));
|
||||
}
|
||||
|
||||
public static boolean hasReplyBeyondBaseline(@Nullable JSONObject project, @Nullable String baselineMessageId) {
|
||||
if (project == null || isBlank(baselineMessageId)) {
|
||||
return false;
|
||||
}
|
||||
String latestMessageId = latestMessageId(project.optJSONArray("messages"));
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
JSONObject latestMessage = messages.optJSONObject(messages.length() - 1);
|
||||
if (latestMessage == null) {
|
||||
return null;
|
||||
}
|
||||
String messageId = latestMessage.optString("id", "").trim();
|
||||
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) {
|
||||
return normalized;
|
||||
}
|
||||
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
@@ -1,106 +1,30 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class ProjectForwardActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
|
||||
private String projectId;
|
||||
private String projectName;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
|
||||
configureScreen("消息转发", projectName == null ? "选择目标项目并写备注" : "源项目:" + projectName);
|
||||
reload();
|
||||
configureScreen("消息转发", "正在切换到微信式转发");
|
||||
|
||||
Intent intent = new Intent(this, ForwardTargetActivity.class);
|
||||
intent.putExtra(ForwardTargetActivity.EXTRA_SOURCE_PROJECT_ID, projectId);
|
||||
intent.putExtra(ForwardTargetActivity.EXTRA_FORWARD_MODE, "single_legacy");
|
||||
startActivity(intent);
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getConversations();
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> renderTargets(response.json.optJSONArray("conversations")));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "转发目标加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void renderTargets(@Nullable JSONArray conversations) {
|
||||
replaceContent(BossUi.buildCard(
|
||||
this,
|
||||
"原生转发入口",
|
||||
"选择一个目标项目,填写备注后会走现有 `/api/v1/projects/{projectId}/forwards`。",
|
||||
"源项目:" + (projectName == null ? projectId : projectName)
|
||||
));
|
||||
if (conversations == null || conversations.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前没有可转发的目标项目。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < conversations.length(); i++) {
|
||||
JSONObject item = conversations.optJSONObject(i);
|
||||
if (item == null) continue;
|
||||
String targetProjectId = item.optString("projectId");
|
||||
if (projectId.equals(targetProjectId)) continue;
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
item.optString("projectTitle", "未命名项目"),
|
||||
item.optString("preview", ""),
|
||||
item.optString("latestReplyLabel", "最近更新"),
|
||||
v -> openForwardDialog(targetProjectId, item.optString("projectTitle", targetProjectId))
|
||||
));
|
||||
}
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void openForwardDialog(String targetProjectId, String targetTitle) {
|
||||
final android.widget.EditText input = BossUi.buildInput(this, "请输入要附带的转发说明", true);
|
||||
input.setText("请同步关注 " + targetTitle + " 的当前进展。");
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("转发到 " + targetTitle)
|
||||
.setView(input)
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("转发", (dialog, which) -> forwardMessage(targetProjectId, input.getText().toString().trim()))
|
||||
.show();
|
||||
}
|
||||
|
||||
private void forwardMessage(String targetProjectId, String note) {
|
||||
if (note.isEmpty()) {
|
||||
showMessage("请先填写转发说明");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.forwardProjectMessage(projectId, targetProjectId, note);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("转发成功");
|
||||
finish();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("转发失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
// 兼容页只负责跳转,不再承载旧的备注转发链路。
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.hyzq.boss;
|
||||
import android.os.Bundle;
|
||||
import android.widget.Button;
|
||||
import android.widget.LinearLayout;
|
||||
import android.widget.TextView;
|
||||
import android.view.Gravity;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
@@ -10,12 +12,19 @@ import androidx.appcompat.app.AlertDialog;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class ProjectGoalsActivity 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 long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
|
||||
private String projectId;
|
||||
private String projectName;
|
||||
private @Nullable BossRealtimeClient realtimeClient;
|
||||
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
@@ -23,12 +32,36 @@ public class ProjectGoalsActivity extends BossScreenActivity {
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
|
||||
configureScreen("项目目标", projectName == null ? "原生目标清单" : projectName);
|
||||
setHeaderAction("新增", v -> openGoalEditor(null, ""));
|
||||
setHeaderAction("编辑目标", v -> openGoalEditor(null, ""));
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
stopRealtimeUpdates();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
stopRealtimeUpdates();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
replaceContent(BossUi.buildEmptyCard(this, "缺少 projectId。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
@@ -44,6 +77,70 @@ public class ProjectGoalsActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void updateRealtimeSubscription() {
|
||||
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
|
||||
realtimeClient.start();
|
||||
return;
|
||||
}
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
private void stopRealtimeUpdates() {
|
||||
if (realtimeClient != null) {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
void handleRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!shouldReloadForRealtimeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
|
||||
if (eventFingerprint.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(this::reload);
|
||||
}
|
||||
|
||||
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (!"conversation.updated".equals(event.eventName)) {
|
||||
return false;
|
||||
}
|
||||
String payloadProjectId = event.payload.optString("projectId", "").trim();
|
||||
if (payloadProjectId.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String payloadNote = event.payload.optString("note", "").trim();
|
||||
return payloadProjectId.equals(projectId) && GOAL_REFRESH_NOTE.equals(payloadNote);
|
||||
}
|
||||
|
||||
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
||||
pruneRecentRealtimeEvents(now);
|
||||
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
|
||||
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
|
||||
return true;
|
||||
}
|
||||
recentRealtimeEventTimestamps.put(eventFingerprint, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void pruneRecentRealtimeEvents(long now) {
|
||||
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, Long> entry = iterator.next();
|
||||
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void renderGoals(@Nullable JSONObject project) {
|
||||
replaceContent();
|
||||
if (project == null) {
|
||||
@@ -63,20 +160,40 @@ public class ProjectGoalsActivity extends BossScreenActivity {
|
||||
}
|
||||
}
|
||||
|
||||
int goalCount = goals == null ? 0 : goals.length();
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"主 Agent 已整理项目目标",
|
||||
"已完成 " + completedCount + "/" + (goals == null ? 0 : goals.length()),
|
||||
"用户可编辑,点按钮即可标记完成或修改正文。"
|
||||
"主 Agent 已整理项目目标 · 已完成 " + completedCount + "/" + goalCount,
|
||||
"最近更新 09:18 · 用户可编辑,点选圆圈标记完成后自动划线",
|
||||
""
|
||||
));
|
||||
|
||||
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 {
|
||||
for (int i = 0; i < goals.length(); i++) {
|
||||
JSONObject goal = goals.optJSONObject(i);
|
||||
if (goal == null) continue;
|
||||
appendContent(buildGoalCard(goal));
|
||||
appendContent(buildGoalChecklistCard(goal));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,29 +201,76 @@ public class ProjectGoalsActivity extends BossScreenActivity {
|
||||
this,
|
||||
"当前约束",
|
||||
"• 只能使用已绑定设备\n• 审计证据必须可回放\n• 版本记录仅主 Agent 可发布",
|
||||
"原生目标页已覆盖 Web 目标清单"
|
||||
""
|
||||
));
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private LinearLayout buildGoalCard(JSONObject goal) {
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
this,
|
||||
goal.optString("text", "未命名目标"),
|
||||
goal.optString("note", "暂无备注"),
|
||||
"状态 " + goal.optString("state", "pending")
|
||||
);
|
||||
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());
|
||||
}
|
||||
|
||||
Button toggle = BossUi.buildPrimaryButton(
|
||||
this,
|
||||
"completed".equals(goal.optString("state")) ? "标记未完成" : "标记完成"
|
||||
);
|
||||
toggle.setOnClickListener(v -> toggleGoal(goal.optString("id")));
|
||||
card.addView(toggle);
|
||||
private LinearLayout buildGoalChecklistCard(JSONObject goal) {
|
||||
LinearLayout card = BossUi.buildCard(this, "", "", "");
|
||||
card.removeAllViews();
|
||||
card.setClickable(true);
|
||||
card.setFocusable(true);
|
||||
card.setOnClickListener(v -> toggleGoal(goal.optString("id")));
|
||||
card.setOnLongClickListener(v -> {
|
||||
openGoalEditor(goal.optString("id"), goal.optString("text"));
|
||||
return true;
|
||||
});
|
||||
|
||||
Button edit = BossUi.buildSecondaryButton(this, "编辑目标");
|
||||
edit.setOnClickListener(v -> openGoalEditor(goal.optString("id"), goal.optString("text")));
|
||||
card.addView(edit);
|
||||
boolean completed = "completed".equals(goal.optString("state"));
|
||||
|
||||
LinearLayout row = new LinearLayout(this);
|
||||
row.setOrientation(LinearLayout.HORIZONTAL);
|
||||
row.setGravity(Gravity.TOP);
|
||||
|
||||
TextView indicator = new TextView(this);
|
||||
LinearLayout.LayoutParams indicatorParams = new LinearLayout.LayoutParams(
|
||||
BossUi.dp(this, 28),
|
||||
BossUi.dp(this, 28)
|
||||
);
|
||||
indicatorParams.rightMargin = BossUi.dp(this, 12);
|
||||
indicator.setLayoutParams(indicatorParams);
|
||||
indicator.setGravity(Gravity.CENTER);
|
||||
indicator.setText(completed ? "✓" : "○");
|
||||
indicator.setTextSize(14);
|
||||
indicator.setTextColor(getColor(completed ? R.color.boss_green : R.color.boss_text_muted));
|
||||
row.addView(indicator);
|
||||
|
||||
LinearLayout texts = new LinearLayout(this);
|
||||
texts.setOrientation(LinearLayout.VERTICAL);
|
||||
LinearLayout.LayoutParams textParams = new LinearLayout.LayoutParams(
|
||||
0,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
1f
|
||||
);
|
||||
texts.setLayoutParams(textParams);
|
||||
|
||||
TextView title = new TextView(this);
|
||||
title.setText(goal.optString("text", "未命名目标"));
|
||||
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(12);
|
||||
note.setTextColor(getColor(R.color.boss_text_muted));
|
||||
note.setPadding(0, BossUi.dp(this, 8), 0, 0);
|
||||
texts.addView(note);
|
||||
|
||||
row.addView(texts);
|
||||
card.addView(row);
|
||||
return card;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,22 +7,54 @@ import androidx.annotation.Nullable;
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class ProjectVersionsActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
private static final String VERSION_REFRESH_NOTE = "project_versions.updated";
|
||||
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
|
||||
private String projectId;
|
||||
private @Nullable BossRealtimeClient realtimeClient;
|
||||
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
|
||||
|
||||
@Override
|
||||
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();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
stopRealtimeUpdates();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
stopRealtimeUpdates();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
replaceContent(BossUi.buildEmptyCard(this, "缺少 projectId。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
@@ -38,12 +70,76 @@ public class ProjectVersionsActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void updateRealtimeSubscription() {
|
||||
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
|
||||
realtimeClient.start();
|
||||
return;
|
||||
}
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
private void stopRealtimeUpdates() {
|
||||
if (realtimeClient != null) {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
void handleRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!shouldReloadForRealtimeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
|
||||
if (eventFingerprint.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(this::reload);
|
||||
}
|
||||
|
||||
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (!"conversation.updated".equals(event.eventName)) {
|
||||
return false;
|
||||
}
|
||||
String payloadProjectId = event.payload.optString("projectId", "").trim();
|
||||
if (payloadProjectId.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String payloadNote = event.payload.optString("note", "").trim();
|
||||
return payloadProjectId.equals(projectId) && VERSION_REFRESH_NOTE.equals(payloadNote);
|
||||
}
|
||||
|
||||
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
||||
pruneRecentRealtimeEvents(now);
|
||||
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
|
||||
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
|
||||
return true;
|
||||
}
|
||||
recentRealtimeEventTimestamps.put(eventFingerprint, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void pruneRecentRealtimeEvents(long now) {
|
||||
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, Long> entry = iterator.next();
|
||||
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void renderVersions(@Nullable JSONObject project) {
|
||||
replaceContent(BossUi.buildCard(
|
||||
this,
|
||||
"版本记录只读",
|
||||
"版本记录由主 Agent 监督各线程提交,并在复核后自动发布。",
|
||||
"原生版本页仅展示,不允许手工篡改正文。"
|
||||
"仅主 Agent 可发布迭代记录",
|
||||
"每条记录需备核线程提交内容、测试结论与版本号一致性。",
|
||||
""
|
||||
));
|
||||
if (project == null) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "项目不存在。"));
|
||||
@@ -63,9 +159,18 @@ public class ProjectVersionsActivity extends BossScreenActivity {
|
||||
this,
|
||||
item.optString("version", "未命名版本"),
|
||||
item.optString("summary", ""),
|
||||
item.optString("createdAt", "-")
|
||||
""
|
||||
));
|
||||
}
|
||||
String reviewTime = versions.optJSONObject(0) == null
|
||||
? "-"
|
||||
: versions.optJSONObject(0).optString("createdAt", "-");
|
||||
appendContent(BossUi.buildCard(
|
||||
this,
|
||||
"主 Agent 复核记录",
|
||||
"最近一次复核 " + reviewTime + " · 对比线程提交摘要、测试结果和补丁说明后发布。",
|
||||
""
|
||||
));
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
public final class RootRefreshPolicy {
|
||||
private RootRefreshPolicy() {}
|
||||
|
||||
public static boolean shouldShowFailure(
|
||||
String activeTab,
|
||||
boolean conversationsOk,
|
||||
boolean devicesOk,
|
||||
boolean otaOk,
|
||||
boolean settingsOk
|
||||
) {
|
||||
if ("devices".equals(activeTab)) {
|
||||
return !devicesOk;
|
||||
}
|
||||
if ("me".equals(activeTab)) {
|
||||
return !settingsOk && !otaOk;
|
||||
}
|
||||
return !conversationsOk;
|
||||
}
|
||||
}
|
||||
28
android/app/src/main/java/com/hyzq/boss/RootTabMemory.java
Normal file
28
android/app/src/main/java/com/hyzq/boss/RootTabMemory.java
Normal file
@@ -0,0 +1,28 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
public final class RootTabMemory {
|
||||
private RootTabMemory() {}
|
||||
|
||||
public static String resolveInitialTab(String explicitTab, String storedTab, String preferredTab) {
|
||||
String explicit = normalize(explicitTab);
|
||||
if (explicit != null) {
|
||||
return explicit;
|
||||
}
|
||||
String stored = normalize(storedTab);
|
||||
if (stored != null) {
|
||||
return stored;
|
||||
}
|
||||
String preferred = normalize(preferredTab);
|
||||
if (preferred != null) {
|
||||
return preferred;
|
||||
}
|
||||
return "conversations";
|
||||
}
|
||||
|
||||
private static String normalize(String tab) {
|
||||
if ("conversations".equals(tab) || "devices".equals(tab) || "me".equals(tab)) {
|
||||
return tab;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,14 @@ import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class SecurityActivity extends BossScreenActivity {
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("账号与安全", "原生会话与设备安全");
|
||||
configureScreen("账号与安全", "登录会话与设备保护");
|
||||
reload();
|
||||
}
|
||||
|
||||
@@ -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,34 +37,62 @@ public class SecurityActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void renderSecurity(@Nullable JSONObject session) {
|
||||
replaceContent(
|
||||
BossUi.buildCard(
|
||||
this,
|
||||
"当前登录模式",
|
||||
"当前登录页已临时切到免验证模式,点击登录会直接创建最高管理员会话。",
|
||||
"后续如收口认证,再切回账号密码 / 验证码登录。"
|
||||
)
|
||||
);
|
||||
private void renderSecurity(@Nullable JSONObject session, @Nullable JSONArray sessions) {
|
||||
replaceContent();
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"当前登录模式",
|
||||
"当前客户端仍使用快速进入模式。",
|
||||
"需要更严格认证时,再切回账号密码或验证码登录。",
|
||||
null,
|
||||
null
|
||||
));
|
||||
if (session != null) {
|
||||
appendContent(BossUi.buildCard(
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"当前会话",
|
||||
"账号 " + session.optString("account", "-")
|
||||
+ "\n角色 " + session.optString("role", "-")
|
||||
+ "\n登录方式 " + session.optString("loginMethod", "-"),
|
||||
"到期 " + session.optString("expiresAt", "-")
|
||||
+ " · " + BossUi.formatRoleLabel(session.optString("role", "-")),
|
||||
"登录方式 " + session.optString("loginMethod", "-")
|
||||
+ " · 到期 " + session.optString("expiresAt", "-"),
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
android.widget.Button devicesButton = BossUi.buildPrimaryButton(this, "打开设备页");
|
||||
devicesButton.setOnClickListener(v -> {
|
||||
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");
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
|
||||
startActivity(intent);
|
||||
});
|
||||
appendContent(devicesButton);
|
||||
}));
|
||||
|
||||
android.widget.Button logoutButton = BossUi.buildSecondaryButton(this, "退出登录");
|
||||
logoutButton.setOnClickListener(v -> logout());
|
||||
@@ -67,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(() -> {
|
||||
@@ -78,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();
|
||||
|
||||
@@ -15,13 +15,15 @@ public class SettingsActivity extends BossScreenActivity {
|
||||
private SwitchCompat riskBadgesSwitch;
|
||||
private SwitchCompat confirmActionsSwitch;
|
||||
private Spinner preferredEntrySpinner;
|
||||
private boolean settingsLoaded = false;
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
configureScreen("设置", "原生偏好配置");
|
||||
configureScreen("设置", "默认首页与提醒偏好");
|
||||
setHeaderAction("保存", v -> saveSettings());
|
||||
buildForm();
|
||||
buildFormContent();
|
||||
updateSaveAvailability();
|
||||
reload();
|
||||
}
|
||||
|
||||
@@ -36,48 +38,54 @@ public class SettingsActivity extends BossScreenActivity {
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
settingsLoaded = false;
|
||||
updateSaveAvailability();
|
||||
replaceContent(BossUi.buildEmptyCard(this, "设置加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void buildForm() {
|
||||
replaceContent(
|
||||
BossUi.buildCard(
|
||||
this,
|
||||
"设置说明",
|
||||
"当前设置会持久化到 data/boss-state.json,下一线程接手不会丢失。",
|
||||
"原生设置页直接走 /api/v1/settings"
|
||||
)
|
||||
);
|
||||
private void buildFormContent() {
|
||||
if (liveUpdatesSwitch == null) {
|
||||
liveUpdatesSwitch = new SwitchCompat(this);
|
||||
liveUpdatesSwitch.setText("启用实时刷新");
|
||||
}
|
||||
if (riskBadgesSwitch == null) {
|
||||
riskBadgesSwitch = new SwitchCompat(this);
|
||||
riskBadgesSwitch.setText("显示风险徽标");
|
||||
}
|
||||
if (confirmActionsSwitch == null) {
|
||||
confirmActionsSwitch = new SwitchCompat(this);
|
||||
confirmActionsSwitch.setText("危险操作前确认");
|
||||
}
|
||||
if (preferredEntrySpinner == null) {
|
||||
preferredEntrySpinner = new Spinner(this);
|
||||
ArrayAdapter<String> adapter = new ArrayAdapter<>(
|
||||
this,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
new String[]{"conversations", "devices", "me"}
|
||||
);
|
||||
preferredEntrySpinner.setAdapter(adapter);
|
||||
}
|
||||
|
||||
liveUpdatesSwitch = new SwitchCompat(this);
|
||||
liveUpdatesSwitch.setText("启用实时刷新");
|
||||
|
||||
riskBadgesSwitch = new SwitchCompat(this);
|
||||
riskBadgesSwitch.setText("显示风险徽标");
|
||||
|
||||
confirmActionsSwitch = new SwitchCompat(this);
|
||||
confirmActionsSwitch.setText("危险操作前确认");
|
||||
|
||||
preferredEntrySpinner = new Spinner(this);
|
||||
ArrayAdapter<String> adapter = new ArrayAdapter<>(
|
||||
replaceContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
android.R.layout.simple_spinner_dropdown_item,
|
||||
new String[]{"conversations", "devices", "me"}
|
||||
);
|
||||
preferredEntrySpinner.setAdapter(adapter);
|
||||
"偏好设置",
|
||||
"调整默认首页和提醒行为。",
|
||||
"保存后会直接写入当前账号设置",
|
||||
null,
|
||||
null
|
||||
));
|
||||
|
||||
LinearLayout card = BossUi.buildCard(this, "交互偏好", "可切换默认首页与提醒行为。", "保存后立即生效");
|
||||
card.addView(liveUpdatesSwitch);
|
||||
card.addView(riskBadgesSwitch);
|
||||
card.addView(confirmActionsSwitch);
|
||||
card.addView(preferredEntrySpinner);
|
||||
appendContent(card);
|
||||
appendContent(BossUi.buildFormCell(this, "实时刷新", "会话、设备和 OTA 状态变化时自动更新", liveUpdatesSwitch));
|
||||
appendContent(BossUi.buildFormCell(this, "风险徽标", "在列表中显示风险状态提示", riskBadgesSwitch));
|
||||
appendContent(BossUi.buildFormCell(this, "危险操作确认", "执行修复或切换前再次确认", confirmActionsSwitch));
|
||||
appendContent(BossUi.buildFormCell(this, "默认首页", "下次打开 App 优先进入这里", preferredEntrySpinner));
|
||||
}
|
||||
|
||||
private void populate(@Nullable JSONObject settings) {
|
||||
buildFormContent();
|
||||
if (settings != null) {
|
||||
liveUpdatesSwitch.setChecked(settings.optBoolean("liveUpdates", true));
|
||||
riskBadgesSwitch.setChecked(settings.optBoolean("showRiskBadges", true));
|
||||
@@ -91,10 +99,16 @@ public class SettingsActivity extends BossScreenActivity {
|
||||
preferredEntrySpinner.setSelection(0);
|
||||
}
|
||||
}
|
||||
settingsLoaded = settings != null;
|
||||
updateSaveAvailability();
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void saveSettings() {
|
||||
if (!settingsLoaded) {
|
||||
showMessage("设置尚未加载完成,请先刷新成功后再保存。");
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
@@ -117,4 +131,11 @@ public class SettingsActivity extends BossScreenActivity {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateSaveAvailability() {
|
||||
if (headerActionButton != null) {
|
||||
headerActionButton.setEnabled(settingsLoaded);
|
||||
headerActionButton.setAlpha(settingsLoaded ? 1f : 0.45f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
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;
|
||||
import java.util.Map;
|
||||
|
||||
public class SkillInventoryActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_DEVICE_ID = "device_id";
|
||||
public static final String EXTRA_DEVICE_NAME = "device_name";
|
||||
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
|
||||
private String deviceId;
|
||||
private String deviceName;
|
||||
private @Nullable BossRealtimeClient realtimeClient;
|
||||
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
@@ -22,9 +34,28 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
deviceId = getIntent().getStringExtra(EXTRA_DEVICE_ID);
|
||||
deviceName = getIntent().getStringExtra(EXTRA_DEVICE_NAME);
|
||||
configureScreen("技能", deviceName == null ? "当前设备 Skill 清单" : deviceName);
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
stopRealtimeUpdates();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
stopRealtimeUpdates();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
setRefreshing(true);
|
||||
@@ -33,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);
|
||||
@@ -44,9 +85,82 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private void updateRealtimeSubscription() {
|
||||
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
|
||||
realtimeClient.start();
|
||||
return;
|
||||
}
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
private void stopRealtimeUpdates() {
|
||||
if (realtimeClient != null) {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
void handleRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (event == null || event.eventName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!shouldReloadForRealtimeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
|
||||
if (eventFingerprint.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(this::reload);
|
||||
}
|
||||
|
||||
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (!"devices.skills.updated".equals(event.eventName) && !"devices.updated".equals(event.eventName)) {
|
||||
return false;
|
||||
}
|
||||
String payloadDeviceId = event.payload.optString("deviceId", "").trim();
|
||||
if (payloadDeviceId.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
if (deviceId == null || deviceId.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
return payloadDeviceId.equals(deviceId);
|
||||
}
|
||||
|
||||
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
||||
pruneRecentRealtimeEvents(now);
|
||||
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
|
||||
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
|
||||
return true;
|
||||
}
|
||||
recentRealtimeEventTimestamps.put(eventFingerprint, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void pruneRecentRealtimeEvents(long now) {
|
||||
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, Long> entry = iterator.next();
|
||||
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveTargetDeviceId() throws Exception {
|
||||
if (deviceId != null && !deviceId.isEmpty()) {
|
||||
return deviceId;
|
||||
String explicitDeviceId = deviceId;
|
||||
String boundDeviceId = null;
|
||||
BossApiClient.ApiResponse settingsResponse = apiClient.getSettings();
|
||||
if (settingsResponse.ok()) {
|
||||
JSONObject user = settingsResponse.json.optJSONObject("user");
|
||||
if (user != null) {
|
||||
String candidate = user.optString("boundDeviceId", "");
|
||||
boundDeviceId = candidate.isEmpty() ? null : candidate;
|
||||
}
|
||||
}
|
||||
BossApiClient.ApiResponse response = apiClient.getDevices();
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
@@ -54,25 +168,83 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
if (devices == null || devices.length() == 0) {
|
||||
throw new IllegalStateException("NO_DEVICE");
|
||||
}
|
||||
return devices.optJSONObject(0).optString("id");
|
||||
return chooseTargetDeviceId(explicitDeviceId, boundDeviceId, apiClient.getAccountLabel(), devices);
|
||||
}
|
||||
|
||||
private static String chooseTargetDeviceId(
|
||||
@Nullable String explicitDeviceId,
|
||||
@Nullable String boundDeviceId,
|
||||
String account,
|
||||
JSONArray devices
|
||||
) {
|
||||
String explicitMatch = findDeviceId(devices, explicitDeviceId);
|
||||
if (explicitMatch != null) {
|
||||
return explicitMatch;
|
||||
}
|
||||
|
||||
String boundMatch = findDeviceId(devices, boundDeviceId);
|
||||
if (boundMatch != null) {
|
||||
return boundMatch;
|
||||
}
|
||||
|
||||
for (int i = 0; i < devices.length(); i++) {
|
||||
JSONObject device = devices.optJSONObject(i);
|
||||
if (device == null) continue;
|
||||
if (account.equals(device.optString("account", ""))) {
|
||||
return device.optString("id", "");
|
||||
}
|
||||
}
|
||||
if (devices.length() == 1) {
|
||||
JSONObject onlyDevice = devices.optJSONObject(0);
|
||||
if (onlyDevice != null) {
|
||||
return onlyDevice.optString("id", "");
|
||||
}
|
||||
}
|
||||
JSONObject fallback = devices.optJSONObject(0);
|
||||
return fallback == null ? "" : fallback.optString("id", "");
|
||||
}
|
||||
|
||||
private static @Nullable String findDeviceId(JSONArray devices, @Nullable String candidateDeviceId) {
|
||||
if (candidateDeviceId == null || candidateDeviceId.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
for (int i = 0; i < devices.length(); i++) {
|
||||
JSONObject device = devices.optJSONObject(i);
|
||||
if (device == null) continue;
|
||||
if (candidateDeviceId.equals(device.optString("id", ""))) {
|
||||
return candidateDeviceId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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);
|
||||
configureScreen("技能", deviceName);
|
||||
appendContent(BossUi.buildCard(
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
deviceName,
|
||||
"当前页按设备查看 Skill 清单。",
|
||||
"Skill 由 local-agent 从本机 ~/.codex/skills 扫描并同步。"
|
||||
"Skill 由 local-agent 从本机 ~/.codex/skills 扫描并同步。",
|
||||
null,
|
||||
null
|
||||
));
|
||||
}
|
||||
|
||||
if (canManageLifecycle) {
|
||||
appendSkillManagementWorkspace(lifecyclePayload);
|
||||
}
|
||||
|
||||
if (skills == null || skills.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前设备还没有同步 Skill。"));
|
||||
setRefreshing(false);
|
||||
@@ -81,21 +253,233 @@ public class SkillInventoryActivity extends BossScreenActivity {
|
||||
for (int i = 0; i < skills.length(); i++) {
|
||||
JSONObject skill = skills.optJSONObject(i);
|
||||
if (skill == null) continue;
|
||||
LinearLayout card = BossUi.buildCard(
|
||||
LinearLayout card = new LinearLayout(this);
|
||||
card.setOrientation(LinearLayout.VERTICAL);
|
||||
card.addView(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
skill.optString("name", "未命名 Skill"),
|
||||
skill.optString("description", "未提供说明"),
|
||||
skill.optString("category", "-")
|
||||
+ " · " + skill.optString("updatedAt", "-")
|
||||
);
|
||||
Button copyInvocation = BossUi.buildPrimaryButton(this, "复制调用语句");
|
||||
+ " · " + skill.optString("updatedAt", "-"),
|
||||
null,
|
||||
null
|
||||
));
|
||||
Button copyInvocation = BossUi.buildMiniActionButton(this, "复制调用", true);
|
||||
copyInvocation.setOnClickListener(v -> BossUi.copyText(this, "Skill 调用", skill.optString("invocation", "")));
|
||||
card.addView(copyInvocation);
|
||||
Button copyPath = BossUi.buildSecondaryButton(this, "复制路径");
|
||||
Button copyPath = BossUi.buildMiniActionButton(this, "复制路径", false);
|
||||
copyPath.setOnClickListener(v -> BossUi.copyText(this, "Skill 路径", skill.optString("path", "")));
|
||||
card.addView(copyPath);
|
||||
card.addView(BossUi.buildInlineActionRow(this, copyInvocation, copyPath));
|
||||
if (canManageLifecycle) {
|
||||
Button update = BossUi.buildMiniActionButton(this, "更新下发", true);
|
||||
update.setOnClickListener(v -> queueSkillLifecycleRequest("update", skill, null, null, null, null, null));
|
||||
Button rollback = BossUi.buildMiniActionButton(this, "回滚", false);
|
||||
rollback.setOnClickListener(v -> showVersionedSkillRequestDialog("rollback", skill, "回滚", "rollbackToVersion"));
|
||||
Button versionLock = BossUi.buildMiniActionButton(this, "版本锁定", false);
|
||||
versionLock.setOnClickListener(v -> showVersionedSkillRequestDialog("version_lock", skill, "版本锁定", "lockedVersion"));
|
||||
card.addView(BossUi.buildInlineActionRow(this, update, rollback, versionLock));
|
||||
}
|
||||
appendContent(card);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class ThreadStatusActivity extends BossScreenActivity {
|
||||
public static final String EXTRA_PROJECT_ID = "project_id";
|
||||
public static final String EXTRA_PROJECT_NAME = "project_name";
|
||||
private static final long REALTIME_RELOAD_THROTTLE_MS = 900L;
|
||||
|
||||
private String projectId;
|
||||
private String projectName;
|
||||
private @Nullable BossRealtimeClient realtimeClient;
|
||||
private final Map<String, Long> recentRealtimeEventTimestamps = new LinkedHashMap<>();
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
projectId = getIntent().getStringExtra(EXTRA_PROJECT_ID);
|
||||
projectName = getIntent().getStringExtra(EXTRA_PROJECT_NAME);
|
||||
configureScreen("线程状态", projectName == null ? "线程状态文档" : projectName);
|
||||
hideHeaderAction();
|
||||
realtimeClient = new BossRealtimeClient(apiClient, this::handleRealtimeEvent);
|
||||
reload();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
super.onResume();
|
||||
updateRealtimeSubscription();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPause() {
|
||||
stopRealtimeUpdates();
|
||||
super.onPause();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
stopRealtimeUpdates();
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (projectId == null || projectId.isEmpty()) {
|
||||
replaceContent(BossUi.buildEmptyCard(this, "缺少 projectId。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.getThreadStatus(projectId);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> renderThreadStatus(response.json));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
replaceContent(BossUi.buildEmptyCard(this, "线程状态加载失败:" + error.getMessage()));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void updateRealtimeSubscription() {
|
||||
if (apiClient != null && apiClient.hasSessionHints() && realtimeClient != null) {
|
||||
realtimeClient.start();
|
||||
return;
|
||||
}
|
||||
stopRealtimeUpdates();
|
||||
}
|
||||
|
||||
private void stopRealtimeUpdates() {
|
||||
if (realtimeClient != null) {
|
||||
realtimeClient.stop();
|
||||
}
|
||||
}
|
||||
|
||||
void handleRealtimeEvent(BossRealtimeEvent event) {
|
||||
if (event == null || event.eventName.isEmpty() || projectId == null || projectId.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (!shouldReloadForRealtimeEvent(event)) {
|
||||
return;
|
||||
}
|
||||
String eventFingerprint = BossRealtimeClient.buildEventFingerprint(event);
|
||||
if (eventFingerprint.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (isDuplicateRealtimeEvent(eventFingerprint, now)) {
|
||||
return;
|
||||
}
|
||||
runOnUiThread(this::reload);
|
||||
}
|
||||
|
||||
private boolean shouldReloadForRealtimeEvent(BossRealtimeEvent event) {
|
||||
String payloadProjectId = event.payload.optString("projectId", "").trim();
|
||||
if (payloadProjectId.isEmpty() || !payloadProjectId.equals(projectId)) {
|
||||
return false;
|
||||
}
|
||||
return "conversation.updated".equals(event.eventName)
|
||||
|| "project.messages.updated".equals(event.eventName)
|
||||
|| "project.context_risk.updated".equals(event.eventName)
|
||||
|| "master_agent.task.updated".equals(event.eventName);
|
||||
}
|
||||
|
||||
private boolean isDuplicateRealtimeEvent(String eventFingerprint, long now) {
|
||||
pruneRecentRealtimeEvents(now);
|
||||
Long previousEventAt = recentRealtimeEventTimestamps.get(eventFingerprint);
|
||||
if (previousEventAt != null && now - previousEventAt < REALTIME_RELOAD_THROTTLE_MS) {
|
||||
return true;
|
||||
}
|
||||
recentRealtimeEventTimestamps.put(eventFingerprint, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
private void pruneRecentRealtimeEvents(long now) {
|
||||
java.util.Iterator<Map.Entry<String, Long>> iterator = recentRealtimeEventTimestamps.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Map.Entry<String, Long> entry = iterator.next();
|
||||
if (now - entry.getValue() >= REALTIME_RELOAD_THROTTLE_MS) {
|
||||
iterator.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void renderThreadStatus(JSONObject payload) {
|
||||
replaceContent();
|
||||
JSONObject document = payload.optJSONObject("threadStatusDocument");
|
||||
JSONArray recentProgressEvents = payload.optJSONArray("recentProgressEvents");
|
||||
|
||||
if (document == null) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前还没有线程状态文档。"));
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
String threadDisplayName = document.optString(
|
||||
"threadDisplayName",
|
||||
projectName == null ? "线程状态" : projectName
|
||||
);
|
||||
configureScreen("线程状态", buildSubtitle(document, recentProgressEvents));
|
||||
|
||||
appendContent(BossUi.buildSimpleProfileHeader(
|
||||
this,
|
||||
threadDisplayName,
|
||||
"线程状态文档",
|
||||
buildHeaderDetail(document, recentProgressEvents)
|
||||
));
|
||||
|
||||
appendStatusCard("当前目标", document.optString("projectGoal", "暂无目标"));
|
||||
appendStatusCard("当前阶段", document.optString("currentPhase", "暂无阶段"));
|
||||
appendStatusCard("当前进度", document.optString("currentProgress", "暂无进度"));
|
||||
appendStatusCard("技术架构", document.optString("technicalArchitecture", "暂无架构"));
|
||||
appendStatusCard("当前阻塞", document.optString("currentBlockers", "暂无阻塞"));
|
||||
appendStatusCard("建议下一步", document.optString("recommendedNextStep", "暂无建议"));
|
||||
appendRecentEvents(recentProgressEvents);
|
||||
|
||||
setRefreshing(false);
|
||||
}
|
||||
|
||||
private void appendStatusCard(String title, String body) {
|
||||
appendContent(BossUi.buildCard(this, title, body, ""));
|
||||
}
|
||||
|
||||
private void appendRecentEvents(@Nullable JSONArray recentProgressEvents) {
|
||||
int count = recentProgressEvents == null ? 0 : recentProgressEvents.length();
|
||||
appendContent(BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"最近进展事件",
|
||||
count <= 0 ? "当前还没有进展事件。" : "共 " + count + " 条",
|
||||
"自动同步 · 最近 5 条",
|
||||
null,
|
||||
null
|
||||
));
|
||||
if (recentProgressEvents == null || recentProgressEvents.length() == 0) {
|
||||
appendContent(BossUi.buildEmptyCard(this, "当前还没有进展事件。"));
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < recentProgressEvents.length(); i++) {
|
||||
JSONObject event = recentProgressEvents.optJSONObject(i);
|
||||
if (event == null) continue;
|
||||
LinearLayout row = BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
event.optString("summary", "线程状态更新"),
|
||||
event.optString("phase", event.optString("eventType", "progress_updated")),
|
||||
buildEventMeta(event),
|
||||
null,
|
||||
null
|
||||
);
|
||||
appendContent(row);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildSubtitle(JSONObject document, @Nullable JSONArray recentProgressEvents) {
|
||||
int count = recentProgressEvents == null ? 0 : recentProgressEvents.length();
|
||||
String folderName = document.optString("folderName", "");
|
||||
String suffix = count <= 0 ? "暂无进展事件" : "最近 " + count + " 条进展事件";
|
||||
if (folderName.isEmpty()) {
|
||||
return suffix;
|
||||
}
|
||||
return folderName + " · " + suffix;
|
||||
}
|
||||
|
||||
private String buildHeaderDetail(JSONObject document, @Nullable JSONArray recentProgressEvents) {
|
||||
int count = recentProgressEvents == null ? 0 : recentProgressEvents.length();
|
||||
StringBuilder builder = new StringBuilder();
|
||||
String threadId = document.optString("threadId", "");
|
||||
if (!threadId.isEmpty()) {
|
||||
builder.append(threadId);
|
||||
}
|
||||
String deviceId = document.optString("deviceId", "");
|
||||
if (!deviceId.isEmpty()) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(deviceId);
|
||||
}
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(count <= 0 ? "暂无进展事件" : count + " 条进展事件");
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String buildEventMeta(JSONObject event) {
|
||||
StringBuilder builder = new StringBuilder();
|
||||
String deviceId = event.optString("deviceId", "");
|
||||
if (!deviceId.isEmpty()) {
|
||||
builder.append(deviceId);
|
||||
}
|
||||
String createdAt = event.optString("createdAt", "");
|
||||
if (!createdAt.isEmpty()) {
|
||||
if (builder.length() > 0) {
|
||||
builder.append(" · ");
|
||||
}
|
||||
builder.append(createdAt);
|
||||
}
|
||||
if (builder.length() == 0) {
|
||||
return event.optString("eventType", "progress_updated");
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
1505
android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java
Normal file
1505
android/app/src/main/java/com/hyzq/boss/WechatSurfaceMapper.java
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/boss_quick_actions_menu_bg" />
|
||||
<corners android:radius="18dp" />
|
||||
</shape>
|
||||
16
android/app/src/main/res/drawable/bg_list_row.xml
Normal file
16
android/app/src/main/res/drawable/bg_list_row.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_pressed="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/boss_bg_app" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/boss_surface" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/boss_divider" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
12
android/app/src/main/res/drawable/bg_message_incoming.xml
Normal file
12
android/app/src/main/res/drawable/bg_message_incoming.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?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:topLeftRadius="8dp"
|
||||
android:topRightRadius="18dp"
|
||||
android:bottomLeftRadius="18dp"
|
||||
android:bottomRightRadius="18dp" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/boss_card_stroke" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/boss_green" />
|
||||
<corners
|
||||
android:topLeftRadius="18dp"
|
||||
android:topRightRadius="8dp"
|
||||
android:bottomLeftRadius="18dp"
|
||||
android:bottomRightRadius="18dp" />
|
||||
</shape>
|
||||
6
android/app/src/main/res/drawable/bg_tab_active.xml
Normal file
6
android/app/src/main/res/drawable/bg_tab_active.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#1407C160" />
|
||||
<corners android:radius="18dp" />
|
||||
</shape>
|
||||
6
android/app/src/main/res/drawable/bg_tab_inactive.xml
Normal file
6
android/app/src/main/res/drawable/bg_tab_inactive.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/boss_surface" />
|
||||
<corners android:radius="18dp" />
|
||||
</shape>
|
||||
9
android/app/src/main/res/drawable/bg_top_icon_button.xml
Normal file
9
android/app/src/main/res/drawable/bg_top_icon_button.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:color="@color/boss_text_soft">
|
||||
<item>
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="@android:color/transparent" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
10
android/app/src/main/res/drawable/ic_boss_add.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_add.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="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z" />
|
||||
</vector>
|
||||
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_back.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_back.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="M15.41,7.41L14,6L8,12L14,18L15.41,16.59L10.83,12L15.41,7.41Z" />
|
||||
</vector>
|
||||
10
android/app/src/main/res/drawable/ic_boss_check.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_check.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="M9,16.17L4.83,12L3.41,13.41L9,19L21,7L19.59,5.59L9,16.17Z" />
|
||||
</vector>
|
||||
10
android/app/src/main/res/drawable/ic_boss_close.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_close.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="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z" />
|
||||
</vector>
|
||||
10
android/app/src/main/res/drawable/ic_boss_edit.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_edit.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="M3,17.25V21H6.75L17.81,9.94L14.06,6.19L3,17.25ZM20.71,7.04C21.1,6.65 21.1,6.02 20.71,5.63L18.37,3.29C17.98,2.9 17.35,2.9 16.96,3.29L15.13,5.12L18.88,8.87L20.71,7.04Z" />
|
||||
</vector>
|
||||
10
android/app/src/main/res/drawable/ic_boss_info.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_info.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="M11,7H13V9H11V7ZM11,11H13V17H11V11ZM12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2Z" />
|
||||
</vector>
|
||||
10
android/app/src/main/res/drawable/ic_boss_more.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_more.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="M6,10A2,2 0 1,0 6,14A2,2 0 1,0 6,10ZM12,10A2,2 0 1,0 12,14A2,2 0 1,0 12,10ZM18,10A2,2 0 1,0 18,14A2,2 0 1,0 18,10Z" />
|
||||
</vector>
|
||||
10
android/app/src/main/res/drawable/ic_boss_refresh.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_refresh.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="M17.65,6.35C16.2,4.9 14.21,4 12,4C7.58,4 4,7.58 4,12C4,16.42 7.58,20 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18C8.69,18 6,15.31 6,12C6,8.69 8.69,6 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z" />
|
||||
</vector>
|
||||
10
android/app/src/main/res/drawable/ic_boss_search.xml
Normal file
10
android/app/src/main/res/drawable/ic_boss_search.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="M15.5,14H14.71L14.43,13.73C15.41,12.59 16,11.11 16,9.5C16,5.91 13.09,3 9.5,3C5.91,3 3,5.91 3,9.5C3,13.09 5.91,16 9.5,16C11.11,16 12.59,15.41 13.73,14.43L14,14.71V15.5L19,20.49L20.49,19L15.5,14ZM9.5,14C7.01,14 5,11.99 5,9.5C5,7.01 7.01,5 9.5,5C11.99,5 14,7.01 14,9.5C14,11.99 11.99,14 9.5,14Z" />
|
||||
</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>
|
||||
108
android/app/src/main/res/layout/activity_conversation_info.xml
Normal file
108
android/app/src/main/res/layout/activity_conversation_info.xml
Normal file
@@ -0,0 +1,108 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_top_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
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="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:layout_marginRight="12dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:text="会话信息"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
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="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="更多"
|
||||
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="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="刷新"
|
||||
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"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_panel"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="24dp" />
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
</LinearLayout>
|
||||
108
android/app/src/main/res/layout/activity_forward_target.xml
Normal file
108
android/app/src/main/res/layout/activity_forward_target.xml
Normal file
@@ -0,0 +1,108 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_top_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
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="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:layout_marginRight="12dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:text="标题"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
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="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="更多"
|
||||
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="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="刷新"
|
||||
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"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_panel"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="24dp" />
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
</LinearLayout>
|
||||
108
android/app/src/main/res/layout/activity_group_create.xml
Normal file
108
android/app/src/main/res/layout/activity_group_create.xml
Normal file
@@ -0,0 +1,108 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_top_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
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="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:layout_marginRight="12dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:text="发起群聊"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
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="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="更多"
|
||||
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="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="刷新"
|
||||
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"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_panel"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="24dp" />
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
</LinearLayout>
|
||||
108
android/app/src/main/res/layout/activity_group_info.xml
Normal file
108
android/app/src/main/res/layout/activity_group_info.xml
Normal file
@@ -0,0 +1,108 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_top_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
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="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:layout_marginRight="12dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:text="群资料"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
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="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="更多"
|
||||
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="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="刷新"
|
||||
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"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_panel"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="24dp" />
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
</LinearLayout>
|
||||
@@ -2,7 +2,7 @@
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_app_gradient">
|
||||
android:background="@color/boss_bg_app">
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/login_panel"
|
||||
@@ -11,6 +11,7 @@
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/login_shell"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
@@ -18,84 +19,184 @@
|
||||
android:paddingLeft="24dp"
|
||||
android:paddingTop="72dp"
|
||||
android:paddingRight="24dp"
|
||||
android:paddingBottom="32dp">
|
||||
android:paddingBottom="40dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:layout_width="60dp"
|
||||
android:layout_height="60dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:gravity="center"
|
||||
android:text="B"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textSize="30sp"
|
||||
android:textColor="@color/boss_green"
|
||||
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="24dp"
|
||||
android:text="Boss 原生控制台"
|
||||
android:layout_marginTop="18dp"
|
||||
android:text=""
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="30sp"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/login_hint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:gravity="center"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:text="原生 Android 客户端已启用。点击下方按钮直接进入系统。"
|
||||
android:text=""
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="15sp" />
|
||||
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="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:background="@drawable/bg_card"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
android:layout_height="44dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="horizontal"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="当前临时模式"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:text="1. 这是原生 Android Activity,不再打开 WebView。\n2. 登录暂时不做验证,点击按钮会直接进入最高管理员会话。\n3. 会话 / 设备 / 我的三栏都直接调用现有 Boss API。"
|
||||
<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
|
||||
android:id="@+id/login_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:layout_marginTop="22dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingBottom="14dp"
|
||||
android:text="登录"
|
||||
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>
|
||||
|
||||
@@ -107,32 +208,31 @@
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/main_top_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="18dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="16dp">
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="7dp">
|
||||
|
||||
<Button
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/back_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginRight="12dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="14dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:text="返回"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/top_title_group"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
@@ -140,11 +240,13 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/top_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:text="会话"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="24sp"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
@@ -152,24 +254,50 @@
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="原生 Android 客户端,直接消费 Boss API。"
|
||||
android:text=""
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="13sp" />
|
||||
android:textSize="11sp"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/refresh_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
<EditText
|
||||
android:id="@+id/top_search_input"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:text="刷新"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
android:hint="搜索项目或线程"
|
||||
android:imeOptions="actionSearch"
|
||||
android:inputType="text"
|
||||
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="14sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/search_button"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="搜索"
|
||||
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="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="快捷操作"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_add"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
@@ -177,77 +305,141 @@
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/screen_refresh"
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/root_pager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/screen_scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="20dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="20dp"
|
||||
android:paddingBottom="88dp" />
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
android:layout_height="match_parent" />
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="76dp"
|
||||
android:layout_height="54dp"
|
||||
android:background="@color/boss_surface"
|
||||
android:elevation="10dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
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_primary_button"
|
||||
android:background="@android:color/transparent"
|
||||
android:text="会话"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
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_secondary_button"
|
||||
android:background="@android:color/transparent"
|
||||
android:text="设备"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
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_secondary_button"
|
||||
android:background="@android:color/transparent"
|
||||
android:text="我的"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textColor="@color/boss_text_muted"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/conversation_quick_actions_overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:visibility="gone">
|
||||
|
||||
<View
|
||||
android:id="@+id/conversation_quick_actions_scrim"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:alpha="0"
|
||||
android:background="@color/boss_overlay_scrim" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/conversation_quick_actions_anchor"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:paddingTop="58dp"
|
||||
android:paddingRight="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/conversation_quick_actions_menu"
|
||||
android:layout_width="196dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|end"
|
||||
android:alpha="0"
|
||||
android:background="@drawable/bg_conversation_quick_actions_menu"
|
||||
android:elevation="14dp"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:translationY="-6dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<Button
|
||||
android:id="@+id/quick_action_add_device"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:gravity="center_vertical|start"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:text="添加设备"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_quick_actions_menu_text"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/quick_action_scan"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:gravity="center_vertical|start"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:text="扫一扫"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_quick_actions_menu_text"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/quick_action_group_chat"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:background="@android:color/transparent"
|
||||
android:gravity="center_vertical|start"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:text="发起群聊"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_quick_actions_menu_text"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</FrameLayout>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
266
android/app/src/main/res/layout/activity_project_chat.xml
Normal file
266
android/app/src/main/res/layout/activity_project_chat.xml
Normal file
@@ -0,0 +1,266 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_top_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
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="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginLeft="12dp"
|
||||
android:layout_marginRight="12dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:ellipsize="end"
|
||||
android:includeFontPadding="false"
|
||||
android:maxLines="1"
|
||||
android:text="项目详情"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/screen_subtitle"
|
||||
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="11sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="更多"
|
||||
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="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="刷新"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_refresh"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false">
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/screen_refresh_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_quick_actions_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_quick_actions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal" />
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/project_chat_scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fillViewport="true"
|
||||
android:overScrollMode="ifContentScrolls">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="0dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="20dp" />
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/project_chat_scroll_bottom"
|
||||
android:layout_width="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"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="bottom"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="10dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/project_chat_attach"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:contentDescription="发送附件"
|
||||
android:padding="10dp"
|
||||
android:scaleType="center"
|
||||
android:src="@drawable/ic_boss_add"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/project_chat_input"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:gravity="top|start"
|
||||
android:hint="输入消息"
|
||||
android:inputType="textCapSentences|textMultiLine"
|
||||
android:maxLines="4"
|
||||
android:minHeight="40dp"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:textColor="@color/boss_text_primary"
|
||||
android:textColorHint="@color/boss_text_muted"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/project_chat_send"
|
||||
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>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/project_chat_multi_select_actions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="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="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>
|
||||
@@ -2,31 +2,30 @@
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_app_gradient"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/screen_top_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_surface"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="14dp">
|
||||
android:paddingBottom="7dp">
|
||||
|
||||
<Button
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_back_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="返回"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold" />
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="返回"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_back"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
@@ -38,50 +37,51 @@
|
||||
|
||||
<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>
|
||||
|
||||
<Button
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_header_action"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:background="@drawable/bg_secondary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="操作"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_green"
|
||||
android:textStyle="bold"
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="更多"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_more"
|
||||
android:tint="@color/boss_text_primary"
|
||||
android:visibility="gone" />
|
||||
|
||||
<Button
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/screen_refresh_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="40dp"
|
||||
android:background="@drawable/bg_primary_button"
|
||||
android:minWidth="0dp"
|
||||
android:paddingLeft="14dp"
|
||||
android:paddingRight="14dp"
|
||||
android:text="刷新"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/boss_surface"
|
||||
android:textStyle="bold" />
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="34dp"
|
||||
android:layout_marginLeft="8dp"
|
||||
android:background="@drawable/bg_top_icon_button"
|
||||
android:contentDescription="刷新"
|
||||
android:padding="6dp"
|
||||
android:src="@drawable/ic_boss_refresh"
|
||||
android:tint="@color/boss_text_primary" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
@@ -99,10 +99,11 @@
|
||||
android:id="@+id/screen_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_panel"
|
||||
android:orientation="vertical"
|
||||
android:paddingLeft="18dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingRight="18dp"
|
||||
android:paddingLeft="12dp"
|
||||
android:paddingRight="12dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingBottom="24dp" />
|
||||
</ScrollView>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
40
android/app/src/main/res/layout/view_root_tab_page.xml
Normal file
40
android/app/src/main/res/layout/view_root_tab_page.xml
Normal file
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/root_page_refresh"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/root_page_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:clipToPadding="false"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="88dp"
|
||||
android:scrollbars="vertical" />
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/root_page_scroll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/root_page_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@color/boss_bg_app"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingLeft="0dp"
|
||||
android:paddingRight="0dp"
|
||||
android:paddingBottom="88dp" />
|
||||
</ScrollView>
|
||||
</FrameLayout>
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
21
android/app/src/main/res/values-night/colors.xml
Normal file
21
android/app/src/main/res/values-night/colors.xml
Normal file
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="boss_green">#07C160</color>
|
||||
<color name="boss_green_dark">#04984B</color>
|
||||
<color name="boss_surface">#FF151515</color>
|
||||
<color name="boss_bg_start">#FF0E0E0E</color>
|
||||
<color name="boss_bg_end">#FF0E0E0E</color>
|
||||
<color name="boss_bg_app">#FF0E0E0E</color>
|
||||
<color name="boss_panel">#FF151515</color>
|
||||
<color name="boss_card_stroke">#22FFFFFF</color>
|
||||
<color name="boss_divider">#1FFFFFFF</color>
|
||||
<color name="boss_text_primary">#FFF5F5F5</color>
|
||||
<color name="boss_text_muted">#FFB4B4B8</color>
|
||||
<color name="boss_text_soft">#FF8E8E93</color>
|
||||
<color name="boss_overlay_scrim">#66000000</color>
|
||||
<color name="boss_quick_actions_menu_bg">#FF2B2B2E</color>
|
||||
<color name="boss_quick_actions_menu_text">#FFF5F5F5</color>
|
||||
<color name="colorPrimary">@color/boss_green</color>
|
||||
<color name="colorPrimaryDark">@color/boss_green_dark</color>
|
||||
<color name="colorAccent">@color/boss_green</color>
|
||||
</resources>
|
||||
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>
|
||||
@@ -3,11 +3,18 @@
|
||||
<color name="boss_green">#07C160</color>
|
||||
<color name="boss_green_dark">#04984B</color>
|
||||
<color name="boss_surface">#FFFFFFFF</color>
|
||||
<color name="boss_bg_start">#FFF1F6EE</color>
|
||||
<color name="boss_bg_end">#FFE3F0E3</color>
|
||||
<color name="boss_card_stroke">#1A0F1B12</color>
|
||||
<color name="boss_bg_start">#FFF7F7F7</color>
|
||||
<color name="boss_bg_end">#FFF7F7F7</color>
|
||||
<color name="boss_bg_app">#FFF7F7F7</color>
|
||||
<color name="boss_panel">#FFFFFFFF</color>
|
||||
<color name="boss_card_stroke">#14000000</color>
|
||||
<color name="boss_divider">#FFEAEAEA</color>
|
||||
<color name="boss_text_primary">#FF111111</color>
|
||||
<color name="boss_text_muted">#FF5F6B63</color>
|
||||
<color name="boss_text_soft">#FF8E8E93</color>
|
||||
<color name="boss_overlay_scrim">#22000000</color>
|
||||
<color name="boss_quick_actions_menu_bg">#FFF7F7F7</color>
|
||||
<color name="boss_quick_actions_menu_text">#FF111111</color>
|
||||
<color name="colorPrimary">@color/boss_green</color>
|
||||
<color name="colorPrimaryDark">@color/boss_green_dark</color>
|
||||
<color name="colorAccent">@color/boss_green</color>
|
||||
|
||||
@@ -6,17 +6,17 @@
|
||||
<item name="colorPrimary">@color/colorPrimary</item>
|
||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
<item name="android:windowBackground">@drawable/bg_app_gradient</item>
|
||||
<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">@drawable/bg_app_gradient</item>
|
||||
<item name="android:windowBackground">@color/boss_bg_app</item>
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
|
||||
<item name="android:windowBackground">@drawable/bg_app_gradient</item>
|
||||
<item name="android:windowBackground">@color/boss_bg_app</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
public class AboutActivityStaleDownloadCleanupTest {
|
||||
@Test
|
||||
public void collectStaleDownloadIdsForRemoval_returnsIdsWhenReleaseChanged() throws Exception {
|
||||
JSONObject availableRelease = new StubJSONObject()
|
||||
.withString("packageFileName", "boss-android-v1.2.9-release.apk")
|
||||
.withString("version", "v1.2.9");
|
||||
|
||||
long[] ids = AboutActivity.collectStaleDownloadIdsForRemoval(
|
||||
availableRelease,
|
||||
"boss-android-v1.2.8-release.apk",
|
||||
"v1.2.8",
|
||||
true,
|
||||
42L,
|
||||
77L
|
||||
);
|
||||
|
||||
assertArrayEquals(new long[]{42L, 77L}, ids);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void collectStaleDownloadIdsForRemoval_returnsEmptyWhenReleaseMatchesLocalPackage() throws Exception {
|
||||
JSONObject availableRelease = new StubJSONObject()
|
||||
.withString("packageFileName", "boss-android-v1.2.9-release.apk")
|
||||
.withString("version", "v1.2.9");
|
||||
|
||||
long[] ids = AboutActivity.collectStaleDownloadIdsForRemoval(
|
||||
availableRelease,
|
||||
"boss-android-v1.2.9-release.apk",
|
||||
"v1.2.9",
|
||||
true,
|
||||
42L,
|
||||
77L
|
||||
);
|
||||
|
||||
assertArrayEquals(new long[0], ids);
|
||||
}
|
||||
|
||||
private static final class StubJSONObject extends JSONObject {
|
||||
private final java.util.Map<String, Object> values = new java.util.HashMap<>();
|
||||
|
||||
StubJSONObject withString(String key, String value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(String key) {
|
||||
Object value = values.get(key);
|
||||
return value instanceof String ? (String) value : "";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String optString(String key, String fallback) {
|
||||
String value = optString(key);
|
||||
return value.isEmpty() ? fallback : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
|
||||
import android.content.Intent;
|
||||
|
||||
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 java.lang.reflect.Method;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class AboutActivityTest {
|
||||
@Test
|
||||
public void otaUpdatedEventTriggersReload() throws Exception {
|
||||
TestAboutActivity activity = Robolectric
|
||||
.buildActivity(TestAboutActivity.class, new Intent())
|
||||
.setup()
|
||||
.resume()
|
||||
.get();
|
||||
activity.reloadEnabled = true;
|
||||
activity.reloadCount = 0;
|
||||
|
||||
Method handleRealtimeEvent = findHandleRealtimeEvent();
|
||||
assertNotNull(handleRealtimeEvent);
|
||||
|
||||
handleRealtimeEvent.invoke(
|
||||
activity,
|
||||
new BossRealtimeEvent("ota.updated", new JSONObject().put("deviceId", "mac-studio"))
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertEquals(1, activity.reloadCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unrelatedConversationEventDoesNotTriggerReload() throws Exception {
|
||||
TestAboutActivity activity = Robolectric
|
||||
.buildActivity(TestAboutActivity.class, new Intent())
|
||||
.setup()
|
||||
.resume()
|
||||
.get();
|
||||
activity.reloadEnabled = true;
|
||||
activity.reloadCount = 0;
|
||||
|
||||
Method handleRealtimeEvent = findHandleRealtimeEvent();
|
||||
assertNotNull(handleRealtimeEvent);
|
||||
|
||||
handleRealtimeEvent.invoke(
|
||||
activity,
|
||||
new BossRealtimeEvent("conversation.updated", new JSONObject().put("projectId", "master-agent"))
|
||||
);
|
||||
Shadows.shadowOf(activity.getMainLooper()).idle();
|
||||
|
||||
assertEquals(0, activity.reloadCount);
|
||||
}
|
||||
|
||||
private static Method findHandleRealtimeEvent() {
|
||||
for (Method method : AboutActivity.class.getDeclaredMethods()) {
|
||||
if ("handleRealtimeEvent".equals(method.getName())
|
||||
&& method.getParameterTypes().length == 1
|
||||
&& method.getParameterTypes()[0] == BossRealtimeEvent.class) {
|
||||
method.setAccessible(true);
|
||||
return method;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static class TestAboutActivity extends AboutActivity {
|
||||
private boolean reloadEnabled;
|
||||
private int reloadCount;
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
if (!reloadEnabled) {
|
||||
return;
|
||||
}
|
||||
reloadCount += 1;
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,964 @@
|
||||
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;
|
||||
import android.os.Looper;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.EditText;
|
||||
import android.widget.Spinner;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
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.Shadows;
|
||||
import org.robolectric.shadows.ShadowActivity;
|
||||
import org.robolectric.shadows.ShadowDialog;
|
||||
import org.robolectric.shadows.ShadowToast;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
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;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.AbstractExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class AiAccountsActivityTest {
|
||||
@Test
|
||||
public void activeIdentityCardOffersMainAgentTestEntry() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
JSONObject activeIdentity = new JSONObject()
|
||||
.put("accountId", "acc-1")
|
||||
.put("label", "主Agent")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("statusLabel", "ready")
|
||||
.put("note", "当前账号可直接生成主 Agent 回复。")
|
||||
.put("canGenerate", true);
|
||||
|
||||
View card = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"buildActiveIdentityCard",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, activeIdentity)
|
||||
);
|
||||
|
||||
View testButton = findClickableViewContainingText(card, "测试主 Agent 对话");
|
||||
assertNotNull(testButton);
|
||||
testButton.performClick();
|
||||
|
||||
ShadowActivity shadowActivity = Shadows.shadowOf(activity);
|
||||
Intent nextIntent = shadowActivity.getNextStartedActivity();
|
||||
assertNotNull(nextIntent);
|
||||
assertEquals(ProjectDetailActivity.class.getName(), nextIntent.getComponent().getClassName());
|
||||
assertEquals("master-agent", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderAccountsShowsStructuredSectionsAndExpandedEntries() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("activeIdentity", new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主Agent")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("statusLabel", "ready")
|
||||
.put("canGenerate", true))
|
||||
.put("accounts", new org.json.JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主Agent")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("provider", "chatgpt_oauth")
|
||||
.put("role", "primary")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", true))
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "hyzq-backup")
|
||||
.put("label", "备用API")
|
||||
.put("displayName", "环宇智擎 备用账号")
|
||||
.put("roleLabel", "备用链路")
|
||||
.put("providerLabel", "环宇智擎")
|
||||
.put("provider", "hyzq_api")
|
||||
.put("role", "backup")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", false)
|
||||
.put("apiKeyConfigured", true)
|
||||
.put("apiBaseUrl", "https://api.hyzq2046.com/v1"))
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "master-node")
|
||||
.put("label", "主Agent")
|
||||
.put("displayName", "绑定电脑上的 Codex 节点")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "主Agent 节点")
|
||||
.put("provider", "master_codex_node")
|
||||
.put("role", "primary")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", false)));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
assertNotNull(root);
|
||||
assertTrue(viewTreeContainsText(root, "主要API配置"));
|
||||
assertTrue(viewTreeContainsText(root, "备用API配置"));
|
||||
assertFalse(viewTreeContainsText(root, "OAuth 登录"));
|
||||
assertFalse(viewTreeContainsText(root, "API 接入"));
|
||||
assertFalse(viewTreeContainsText(root, "谷歌登录"));
|
||||
assertFalse(viewTreeContainsText(root, "ChatGPT登录"));
|
||||
assertFalse(viewTreeContainsText(root, "阿里"));
|
||||
assertFalse(viewTreeContainsText(root, "Minimax"));
|
||||
assertFalse(viewTreeContainsText(root, "GLM"));
|
||||
assertFalse(viewTreeContainsText(root, "环宇智擎"));
|
||||
assertFalse(viewTreeContainsText(root, "自定义"));
|
||||
assertFalse(viewTreeContainsText(root, "绑定设备节点"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tappingPrimaryConfigEntryOpensPrimaryDetailPage() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
View entry = findClickableViewContainingText(root, "主要API配置");
|
||||
assertNotNull(entry);
|
||||
entry.performClick();
|
||||
|
||||
ShadowActivity shadowActivity = Shadows.shadowOf(activity);
|
||||
Intent nextIntent = shadowActivity.getNextStartedActivity();
|
||||
assertNotNull(nextIntent);
|
||||
assertEquals(AiAccountsActivity.class.getName(), nextIntent.getComponent().getClassName());
|
||||
assertEquals("primary", nextIntent.getStringExtra("ai_accounts_role"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detailPageShowsOnlySelectedRoleConfiguration() throws Exception {
|
||||
Intent intent = new Intent(
|
||||
org.robolectric.RuntimeEnvironment.getApplication(),
|
||||
TestAiAccountsActivity.class
|
||||
);
|
||||
intent.putExtra("ai_accounts_role", "primary");
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("accounts", new org.json.JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主要API")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("provider", "chatgpt_oauth")
|
||||
.put("role", "primary")
|
||||
.put("model", "gpt-5.4-mini")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", true))
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "hyzq-primary")
|
||||
.put("label", "主要API")
|
||||
.put("displayName", "环宇智擎 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "环宇智擎")
|
||||
.put("provider", "hyzq_api")
|
||||
.put("role", "primary")
|
||||
.put("model", "gpt-5.4")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", false)
|
||||
.put("apiKeyConfigured", true)
|
||||
.put("apiBaseUrl", "https://api.hyzq2046.com/v1")));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
assertNotNull(root);
|
||||
assertTrue(viewTreeContainsText(root, "当前使用方式"));
|
||||
assertTrue(viewTreeContainsText(root, "主Agent模式"));
|
||||
assertTrue(viewTreeContainsText(root, "快速反应模型"));
|
||||
assertTrue(viewTreeContainsText(root, "深度思考模型"));
|
||||
assertTrue(viewTreeContainsText(root, "ChatGPT登录"));
|
||||
assertTrue(viewTreeContainsText(root, "OAuth 登录"));
|
||||
assertTrue(viewTreeContainsText(root, "当前模型:gpt-5.4-mini"));
|
||||
assertTrue(viewTreeContainsText(root, "当前:沿用默认"));
|
||||
assertTrue(viewTreeContainsText(root, "当前:gpt-5.4-mini"));
|
||||
assertTrue(viewTreeContainsText(root, "当前:gpt-5.4"));
|
||||
assertTrue(viewTreeContainsText(root, "API 接入"));
|
||||
assertTrue(viewTreeContainsText(root, "已配置:ChatGPT登录"));
|
||||
assertTrue(viewTreeContainsText(root, "已配置:环宇智擎"));
|
||||
assertFalse(viewTreeContainsText(root, "谷歌登录"));
|
||||
assertFalse(viewTreeContainsText(root, "阿里"));
|
||||
assertFalse(viewTreeContainsText(root, "Minimax"));
|
||||
assertFalse(viewTreeContainsText(root, "GLM"));
|
||||
assertFalse(viewTreeContainsText(root, "自定义"));
|
||||
assertFalse(viewTreeContainsText(root, "可编辑配置"));
|
||||
assertFalse(viewTreeContainsText(root, "当前已保存"));
|
||||
assertFalse(viewTreeContainsText(root, "只读状态"));
|
||||
assertFalse(viewTreeContainsText(root, "备用API配置"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void currentMethodEntryOpensCurrentAccountEditor() throws Exception {
|
||||
Intent intent = new Intent(
|
||||
org.robolectric.RuntimeEnvironment.getApplication(),
|
||||
TestAiAccountsActivity.class
|
||||
);
|
||||
intent.putExtra("ai_accounts_role", "primary");
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
|
||||
ReflectionHelpers.setField(activity, "currentMasterAgentModelOverride", "gpt-5.4-mini");
|
||||
ReflectionHelpers.setField(activity, "currentMasterAgentReasoningEffortOverride", "low");
|
||||
ReflectionHelpers.setField(activity, "currentFastModelOverride", "gpt-5.4-mini");
|
||||
ReflectionHelpers.setField(activity, "currentDeepModelOverride", "gpt-5.4");
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("accounts", new org.json.JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主要API")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("provider", "chatgpt_oauth")
|
||||
.put("role", "primary")
|
||||
.put("model", "gpt-5.4-mini")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", true)));
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
View entry = findClickableViewContainingText(root, "当前使用方式");
|
||||
assertNotNull(entry);
|
||||
entry.performClick();
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
View dialogRoot = dialog.getWindow().getDecorView();
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "账号快捷登录"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "选择模型"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fastModeEntryOpensDedicatedModelPicker() throws Exception {
|
||||
Intent intent = new Intent(
|
||||
org.robolectric.RuntimeEnvironment.getApplication(),
|
||||
TestAiAccountsActivity.class
|
||||
);
|
||||
intent.putExtra("ai_accounts_role", "primary");
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
|
||||
ReflectionHelpers.setField(activity, "currentFastModelOverride", "gpt-4.1");
|
||||
ReflectionHelpers.setField(activity, "currentDeepModelOverride", "gpt-5.4");
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject()
|
||||
.put("accounts", new org.json.JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("accountId", "chatgpt-primary")
|
||||
.put("label", "主要API")
|
||||
.put("displayName", "ChatGPT OAuth 主链路账号")
|
||||
.put("roleLabel", "主链路")
|
||||
.put("providerLabel", "ChatGPT登录")
|
||||
.put("provider", "chatgpt_oauth")
|
||||
.put("role", "primary")
|
||||
.put("model", "gpt-5.4-mini")
|
||||
.put("statusLabel", "ready")
|
||||
.put("enabled", true)
|
||||
.put("isActive", true))))
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
View entry = findClickableViewContainingText(root, "快速反应模型");
|
||||
assertNotNull(entry);
|
||||
entry.performClick();
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
View dialogRoot = dialog.getWindow().getDecorView();
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "快速反应模型"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "gpt-4.1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tappingOauthEntryShowsOauthProviderChooser() throws Exception {
|
||||
Intent intent = new Intent(
|
||||
org.robolectric.RuntimeEnvironment.getApplication(),
|
||||
TestAiAccountsActivity.class
|
||||
);
|
||||
intent.putExtra("ai_accounts_role", "primary");
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
View entry = findClickableViewContainingText(root, "OAuth 登录");
|
||||
assertNotNull(entry);
|
||||
entry.performClick();
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
View dialogRoot = dialog.getWindow().getDecorView();
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "谷歌登录"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "ChatGPT登录"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void tappingApiEntryShowsApiProviderChooser() throws Exception {
|
||||
Intent intent = new Intent(
|
||||
org.robolectric.RuntimeEnvironment.getApplication(),
|
||||
TestAiAccountsActivity.class
|
||||
);
|
||||
intent.putExtra("ai_accounts_role", "primary");
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderAccounts",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
|
||||
);
|
||||
|
||||
View root = activity.findViewById(R.id.screen_content);
|
||||
View entry = findClickableViewContainingText(root, "API 接入");
|
||||
assertNotNull(entry);
|
||||
entry.performClick();
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
View dialogRoot = dialog.getWindow().getDecorView();
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "阿里"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "Minimax"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "GLM"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "环宇智擎"));
|
||||
assertTrue(viewTreeContainsText(dialogRoot, "自定义"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void defaultApiBaseUrlForProviderSupportsExpandedApiProviders() {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
|
||||
String openai = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"defaultApiBaseUrlForProvider",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "openai_api")
|
||||
);
|
||||
String aliyun = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"defaultApiBaseUrlForProvider",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "aliyun_qwen_api")
|
||||
);
|
||||
String minimax = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"defaultApiBaseUrlForProvider",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "minimax_api")
|
||||
);
|
||||
String glm = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"defaultApiBaseUrlForProvider",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "glm_api")
|
||||
);
|
||||
String hyzq = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"defaultApiBaseUrlForProvider",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api")
|
||||
);
|
||||
String custom = ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"defaultApiBaseUrlForProvider",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "custom_api")
|
||||
);
|
||||
|
||||
assertEquals("https://api.openai.com/v1", openai);
|
||||
assertEquals("https://dashscope.aliyuncs.com/compatible-mode/v1", aliyun);
|
||||
assertEquals("https://api.minimaxi.com/v1", minimax);
|
||||
assertEquals("https://open.bigmodel.cn/api/paas/v4", glm);
|
||||
assertEquals("https://api.hyzq2046.com/v1", hyzq);
|
||||
assertEquals("", custom);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void openOauthAccountDialogShowsLoginAction() throws Exception {
|
||||
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"openOauthAccountDialog",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "primary"),
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "google_oauth"),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
|
||||
);
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
|
||||
assertNotNull(dialog);
|
||||
View root = dialog.getWindow().getDecorView();
|
||||
assertTrue(viewTreeContainsText(root, "账号快捷登录"));
|
||||
assertTrue(viewTreeContainsText(root, "谷歌登录"));
|
||||
Spinner modelSpinner = findSpinner(root);
|
||||
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();
|
||||
assertNotNull(findEditTextWithHint(root, "账号标识 / 备注"));
|
||||
assertNotNull(findEditTextWithHint(root, "API Key"));
|
||||
Spinner modelSpinner = findSpinner(root);
|
||||
assertNotNull(modelSpinner);
|
||||
assertFalse(modelSpinner.isEnabled());
|
||||
assertEquals(0, ((android.widget.ArrayAdapter<?>) modelSpinner.getAdapter()).getCount());
|
||||
}
|
||||
|
||||
@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 {
|
||||
private int reloadCount = 0;
|
||||
|
||||
@Override
|
||||
protected void reload() {
|
||||
reloadCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DirectExecutorService extends AbstractExecutorService {
|
||||
private boolean shutdown;
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
shutdown = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Runnable> shutdownNow() {
|
||||
shutdown = true;
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isShutdown() {
|
||||
return shutdown;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTerminated() {
|
||||
return shutdown;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean awaitTermination(long timeout, TimeUnit unit) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Runnable command) {
|
||||
command.run();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ScriptedBossApiClient extends BossApiClient {
|
||||
private final Map<String, RecordingConnection> connections;
|
||||
|
||||
ScriptedBossApiClient(RecordingConnection... connections) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
this.connections = new HashMap<>();
|
||||
for (RecordingConnection connection : connections) {
|
||||
this.connections.put(connection.getURL().getPath(), connection);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
RecordingConnection connection = connections.get(path);
|
||||
if (connection == null) {
|
||||
throw new IllegalStateException("Missing scripted connection for " + path);
|
||||
}
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
String encode(String value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
void rememberIdentity(JSONObject json) {
|
||||
// JVM 单测不需要落 Android 侧身份缓存。
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConnection extends HttpURLConnection {
|
||||
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
|
||||
private final Map<String, String> requestHeaders = new HashMap<>();
|
||||
private final int responseCodeValue;
|
||||
private final String responseBody;
|
||||
private final String errorBody;
|
||||
|
||||
RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
|
||||
super(url);
|
||||
this.responseCodeValue = responseCodeValue;
|
||||
this.responseBody = responseBody;
|
||||
this.errorBody = errorBody;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect() {}
|
||||
|
||||
@Override
|
||||
public boolean usingProxy() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect() {}
|
||||
|
||||
@Override
|
||||
public void setRequestMethod(String method) throws ProtocolException {}
|
||||
|
||||
@Override
|
||||
public void setRequestProperty(String key, String value) {
|
||||
requestHeaders.put(key, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getResponseCode() {
|
||||
return responseCodeValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getErrorStream() {
|
||||
return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, List<String>> getHeaderFields() {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
String getCapturedRequestBody() {
|
||||
return requestBody.toString(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 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 boolean commit() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply() {}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
|
||||
|
||||
@Override
|
||||
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
|
||||
}
|
||||
|
||||
private static View findClickableViewContainingText(View root, String expectedText) {
|
||||
if (root == null) {
|
||||
return null;
|
||||
}
|
||||
if (root.isClickable() && viewTreeContainsText(root, expectedText)) {
|
||||
return root;
|
||||
}
|
||||
if (!(root instanceof ViewGroup)) {
|
||||
return null;
|
||||
}
|
||||
ViewGroup group = (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;
|
||||
}
|
||||
|
||||
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 EditText findEditTextWithHint(View root, String expectedText) {
|
||||
if (root instanceof EditText) {
|
||||
CharSequence hint = ((EditText) root).getHint();
|
||||
if (hint != null && hint.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 = findEditTextWithHint(group.getChildAt(index), expectedText);
|
||||
if (match != null) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class AttachmentComposerStateTest {
|
||||
@Test
|
||||
public void imageAttachments_requireConfirmationBeforeSending() {
|
||||
AttachmentComposerState.PendingAttachment attachment =
|
||||
new AttachmentComposerState.PendingAttachment(
|
||||
"image",
|
||||
"现场照片.png",
|
||||
"image/png",
|
||||
4096L,
|
||||
null
|
||||
);
|
||||
|
||||
assertTrue(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType));
|
||||
assertTrue(attachment.requiresConfirmation());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void videoAttachments_requireConfirmationBeforeSending() {
|
||||
AttachmentComposerState.PendingAttachment attachment =
|
||||
new AttachmentComposerState.PendingAttachment(
|
||||
"video",
|
||||
"巡检录屏.mp4",
|
||||
"video/mp4",
|
||||
8192L,
|
||||
null
|
||||
);
|
||||
|
||||
assertTrue(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType));
|
||||
assertTrue(attachment.requiresConfirmation());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fileAttachments_doNotRequireConfirmation() {
|
||||
AttachmentComposerState.PendingAttachment attachment =
|
||||
new AttachmentComposerState.PendingAttachment(
|
||||
"file",
|
||||
"日报.xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
16384L,
|
||||
null
|
||||
);
|
||||
|
||||
assertFalse(ProjectChatUiState.requiresAttachmentConfirmation(attachment.sourceType));
|
||||
assertFalse(attachment.requiresConfirmation());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
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;
|
||||
|
||||
public class BossApiClientAttachmentTest {
|
||||
@Test
|
||||
public void uploadAttachment_postsMultipartBodyWithSourceType() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/projects/project-1/attachments")
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.uploadAttachment(
|
||||
"project-1",
|
||||
"现场照片.png",
|
||||
"image/png",
|
||||
new byte[] {1, 2, 3, 4},
|
||||
"image"
|
||||
);
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/project-1/attachments", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertTrue(connection.contentTypeValue.startsWith("multipart/form-data; boundary="));
|
||||
assertTrue(connection.requestBody().contains("name=\"sourceType\""));
|
||||
assertTrue(connection.requestBody().contains("\r\nimage\r\n"));
|
||||
assertTrue(connection.requestBody().contains("name=\"file\"; filename=\"现场照片.png\""));
|
||||
assertTrue(connection.requestBody().contains("Content-Type: image/png"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void analyzeAttachment_postsToAnalyzeEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/projects/project-1/attachments/att-1/analyze")
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.analyzeAttachment("project-1", "att-1");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/project-1/attachments/att-1/analyze", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals("{}", connection.requestBody());
|
||||
}
|
||||
|
||||
private static final class RecordingBossApiClient extends BossApiClient {
|
||||
private final RecordingConnection connection;
|
||||
private String lastPath = "";
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
lastPath = path;
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
String encode(String value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
void rememberIdentity(JSONObject json) {
|
||||
// JVM 单测不需要落 Android 侧身份缓存。
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConnection extends HttpURLConnection {
|
||||
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
|
||||
private final Map<String, String> requestHeaders = new HashMap<>();
|
||||
private String requestMethodValue = "GET";
|
||||
private String contentTypeValue = "";
|
||||
|
||||
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 void setRequestProperty(String key, String value) {
|
||||
requestHeaders.put(key, value);
|
||||
if ("Content-Type".equalsIgnoreCase(key)) {
|
||||
contentTypeValue = value;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRequestProperty(String key) {
|
||||
return requestHeaders.get(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getResponseCode() {
|
||||
return 200;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, List<String>> getHeaderFields() {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
String requestBody() {
|
||||
return requestBody.toString(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,264 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
|
||||
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.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.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossApiClientDeviceModeTest {
|
||||
@Test
|
||||
public void updateDevicePreferredExecutionModeWritesModeToPatchBody() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/devices/device-1")
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
apiClient.updateDevicePreferredExecutionMode("device-1", "gui");
|
||||
|
||||
assertEquals("/api/v1/devices/device-1", apiClient.lastPath);
|
||||
assertEquals("PATCH", connection.requestMethodValue);
|
||||
assertEquals("{\"preferredExecutionMode\":\"gui\"}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateProjectConflictDecisionWritesProjectScopedPatchBody() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/devices/device-1")
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
apiClient.updateProjectConflictDecision("device-1", "thread-ui", "mac-studio:boss", "allow_always");
|
||||
|
||||
assertEquals("/api/v1/devices/device-1", apiClient.lastPath);
|
||||
assertEquals("PATCH", connection.requestMethodValue);
|
||||
assertEquals(
|
||||
"{\"projectId\":\"thread-ui\",\"folderKey\":\"mac-studio:boss\",\"conflictDecision\":\"allow_always\"}",
|
||||
connection.requestBody()
|
||||
);
|
||||
}
|
||||
|
||||
@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 = "";
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
lastPath = path;
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
String encode(String value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
void rememberIdentity(JSONObject json) {
|
||||
// JVM 单测只关心 patch body。
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConnection extends HttpURLConnection {
|
||||
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
|
||||
private final Map<String, String> requestHeaders = new HashMap<>();
|
||||
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 void setRequestProperty(String key, String value) {
|
||||
requestHeaders.put(key, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRequestProperty(String key) {
|
||||
return requestHeaders.get(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getResponseCode() {
|
||||
return 200;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
String requestBody() {
|
||||
return requestBody.toString(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,890 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import org.json.JSONArray;
|
||||
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.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.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossApiClientDispatchPlansTest {
|
||||
@Test
|
||||
public void getDispatchPlansUsesProjectScopedEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getDispatchPlans("p1");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/p1/dispatch-plans", apiClient.lastPath);
|
||||
assertEquals("GET", connection.requestMethodValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getConversationsUsesExtendedReadTimeoutForFullThreadList() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/conversations"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getConversations();
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/conversations", apiClient.lastPath);
|
||||
assertEquals("GET", connection.requestMethodValue);
|
||||
assertEquals(12000, connection.connectTimeoutValue);
|
||||
assertEquals(30000, connection.readTimeoutValue);
|
||||
assertEquals("no-cache, no-store, max-age=0", connection.getRequestProperty("Cache-Control"));
|
||||
assertEquals("no-cache", connection.getRequestProperty("Pragma"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getConversationHomeUsesExtendedReadTimeoutForSlowHomeFeed() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/conversations/home"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getConversationHome();
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/conversations/home", apiClient.lastPath);
|
||||
assertEquals("GET", connection.requestMethodValue);
|
||||
assertEquals(12000, connection.connectTimeoutValue);
|
||||
assertEquals(30000, connection.readTimeoutValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getConversationHomeSendsNoCacheHeadersToAvoidStaleMobileFeed() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/conversations/home"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getConversationHome();
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("no-cache, no-store, max-age=0", connection.getRequestProperty("Cache-Control"));
|
||||
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"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.confirmDispatchPlan("p1", "plan-1", new JSONArray().put("target-1").put("target-2"));
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/p1/dispatch-plans/plan-1/confirm", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(12000, connection.connectTimeoutValue);
|
||||
assertEquals(65000, connection.readTimeoutValue);
|
||||
assertEquals(
|
||||
"{\"approvedTargetProjectIds\":[\"target-1\",\"target-2\"],\"rememberLightReminder\":false}",
|
||||
connection.requestBody()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectDispatchPlanUsesProjectScopedRejectEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/reject"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.rejectDispatchPlan("p1", "plan-1");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/p1/dispatch-plans/plan-1/reject", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(12000, connection.connectTimeoutValue);
|
||||
assertEquals(65000, connection.readTimeoutValue);
|
||||
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"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.retryDispatchPlan("p1", "plan-1");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/p1/dispatch-plans/plan-1/retry", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(12000, connection.connectTimeoutValue);
|
||||
assertEquals(65000, connection.readTimeoutValue);
|
||||
assertEquals("{}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getProjectAgentControlsUsesScopedEndpoint() 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.getProjectAgentControls("master-agent");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/master-agent/agent-controls", apiClient.lastPath);
|
||||
assertEquals("GET", connection.requestMethodValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateProjectAgentControlsWritesModelAndReasoningOverrides() 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.updateProjectAgentControls("master-agent", "gpt-5.4", "high");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/master-agent/agent-controls", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals("{\"modelOverride\":\"gpt-5.4\",\"reasoningEffortOverride\":\"high\"}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateProjectAgentControlsWritesPromptOverrideWhenProvided() 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.updateProjectAgentControls("master-agent", "gpt-5.4", "high", "当前对话提示词");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/master-agent/agent-controls", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(
|
||||
"{\"modelOverride\":\"gpt-5.4\",\"reasoningEffortOverride\":\"high\",\"promptOverride\":\"当前对话提示词\"}",
|
||||
connection.requestBody()
|
||||
);
|
||||
}
|
||||
|
||||
@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"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getMasterAgentPromptProfile("master-agent");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/master-agent/prompt-profile", apiClient.lastPath);
|
||||
assertEquals("GET", connection.requestMethodValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateMasterAgentPromptProfileWritesUserPromptAndOverride() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/prompt-profile"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("userPromptContent", "用户私有主提示词")
|
||||
.put("promptOverride", "当前对话提示词");
|
||||
BossApiClient.ApiResponse response = apiClient.updateMasterAgentPromptProfile("master-agent", payload);
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/master-agent/prompt-profile", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals("{\"userPromptContent\":\"用户私有主提示词\",\"promptOverride\":\"当前对话提示词\"}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getMasterAgentMemoriesUsesScopedEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/memories"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getMasterAgentMemories("master-agent");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/master-agent/memories", apiClient.lastPath);
|
||||
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"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("scope", "project")
|
||||
.put("projectId", "boss-console")
|
||||
.put("title", "项目目标")
|
||||
.put("content", "把会话页收成微信式列表")
|
||||
.put("memoryType", "project_progress")
|
||||
.put("tags", new JSONArray().put("ui").put("progress"));
|
||||
BossApiClient.ApiResponse response = apiClient.createMasterAgentMemory("master-agent", payload);
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/master-agent/memories", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(
|
||||
"{\"scope\":\"project\",\"projectId\":\"boss-console\",\"title\":\"项目目标\",\"content\":\"把会话页收成微信式列表\",\"memoryType\":\"project_progress\",\"tags\":[\"ui\",\"progress\"]}",
|
||||
connection.requestBody()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sendProjectMessageUsesQueueFriendlyReadTimeoutForMasterAgent() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/master-agent/messages"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.sendProjectMessage("master-agent", "你好", "text");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/master-agent/messages", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(12000, connection.connectTimeoutValue);
|
||||
assertEquals(20000, connection.readTimeoutValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sendProjectMessageUsesQueueFriendlyReadTimeoutForNormalThread() 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.sendProjectMessage("thread-1", "你好", "text");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/thread-1/messages", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(12000, connection.connectTimeoutValue);
|
||||
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"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("label", "主 GPT")
|
||||
.put("displayName", "OpenAI 平台账号")
|
||||
.put("accountIdentifier", "sk-test")
|
||||
.put("model", "gpt-5.4")
|
||||
.put("apiKey", "sk-test-key");
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.onboardOpenAiApiAccount(payload);
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/accounts/onboard/openai-api", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(
|
||||
"{\"label\":\"主 GPT\",\"displayName\":\"OpenAI 平台账号\",\"accountIdentifier\":\"sk-test\",\"model\":\"gpt-5.4\",\"apiKey\":\"sk-test-key\",\"setActive\":true}",
|
||||
connection.requestBody()
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rememberIdentityDoesNotOverwriteSessionIdentityFromAiAccountOnboardingResponse() throws Exception {
|
||||
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
|
||||
prefs.edit()
|
||||
.putString("account", "krisolo")
|
||||
.putString("display_name", "Boss 超级管理员")
|
||||
.apply();
|
||||
BossApiClient apiClient = new BossApiClient(prefs, "https://boss.hyzq.net");
|
||||
|
||||
JSONObject onboardingResponse = new JSONObject()
|
||||
.put("ok", true)
|
||||
.put("accountId", "openai-api-primary")
|
||||
.put("displayName", "OpenAI 平台账号")
|
||||
.put("message", "OpenAI 平台账号已登录,并设为当前主控。");
|
||||
|
||||
apiClient.rememberIdentity(onboardingResponse);
|
||||
|
||||
assertEquals("krisolo", apiClient.getAccountLabel());
|
||||
assertEquals("Boss 超级管理员", apiClient.getDisplayName());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void onboardMasterNodeFallsBackToGenericAccountCreationWhenDedicatedRouteMissing() throws Exception {
|
||||
RecordingConnection dedicated = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/accounts/onboard/master-node"),
|
||||
404,
|
||||
"{\"ok\":false,\"message\":\"NOT_FOUND\"}",
|
||||
"{\"ok\":false,\"message\":\"NOT_FOUND\"}"
|
||||
);
|
||||
RecordingConnection fallback = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/accounts"));
|
||||
ScriptedBossApiClient apiClient = new ScriptedBossApiClient(dedicated, fallback);
|
||||
JSONObject payload = new JSONObject()
|
||||
.put("label", "主 GPT")
|
||||
.put("displayName", "Mac Studio")
|
||||
.put("accountIdentifier", "mac-studio")
|
||||
.put("nodeId", "mac-studio")
|
||||
.put("nodeLabel", "Mac Studio")
|
||||
.put("model", "gpt-5.4");
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.onboardMasterNodeAccount(payload);
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/accounts", apiClient.lastPath);
|
||||
assertEquals("POST", fallback.requestMethodValue);
|
||||
assertEquals(
|
||||
"{\"label\":\"主 GPT\",\"displayName\":\"Mac Studio\",\"accountIdentifier\":\"mac-studio\",\"nodeId\":\"mac-studio\",\"nodeLabel\":\"Mac Studio\",\"model\":\"gpt-5.4\",\"setActive\":true}",
|
||||
fallback.requestBody()
|
||||
);
|
||||
}
|
||||
|
||||
private static final class RecordingBossApiClient extends BossApiClient {
|
||||
private final RecordingConnection connection;
|
||||
private String lastPath = "";
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection) {
|
||||
this(connection, new InMemorySharedPreferences());
|
||||
}
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection, SharedPreferences prefs) {
|
||||
super(prefs, "https://boss.hyzq.net");
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
lastPath = path;
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
String encode(String value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
void rememberIdentity(JSONObject json) {
|
||||
// no-op for JVM unit test
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
this.connections = new HashMap<>();
|
||||
for (RecordingConnection connection : connections) {
|
||||
this.connections.put(connection.url().getPath(), connection);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
lastPath = path;
|
||||
RecordingConnection connection = connections.get(path);
|
||||
if (connection == null) {
|
||||
throw new IllegalStateException("Missing scripted connection for " + path);
|
||||
}
|
||||
lastConnection = connection;
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
String encode(String value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
void rememberIdentity(JSONObject json) {
|
||||
// no-op for JVM unit test
|
||||
}
|
||||
}
|
||||
|
||||
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<>();
|
||||
private String requestMethodValue = "GET";
|
||||
private int connectTimeoutValue = 0;
|
||||
private int readTimeoutValue = 0;
|
||||
private final int responseCodeValue;
|
||||
private final String responseBody;
|
||||
private final String errorBody;
|
||||
private final Map<String, java.util.List<String>> responseHeaders = new HashMap<>();
|
||||
|
||||
RecordingConnection(URL url) {
|
||||
this(
|
||||
url,
|
||||
200,
|
||||
"{\"ok\":true,\"account\":\"krisolo\",\"displayName\":\"Boss 超级管理员\"}",
|
||||
"{\"ok\":false}"
|
||||
);
|
||||
}
|
||||
|
||||
RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
|
||||
super(url);
|
||||
this.responseCodeValue = responseCodeValue;
|
||||
this.responseBody = responseBody;
|
||||
this.errorBody = errorBody;
|
||||
}
|
||||
|
||||
@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 void setRequestProperty(String key, String value) {
|
||||
requestHeaders.put(key, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setConnectTimeout(int timeout) {
|
||||
connectTimeoutValue = timeout;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setReadTimeout(int timeout) {
|
||||
readTimeoutValue = timeout;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRequestProperty(String key) {
|
||||
return requestHeaders.get(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getResponseCode() {
|
||||
return responseCodeValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getErrorStream() {
|
||||
if (responseCodeValue < 400) {
|
||||
return null;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
URL url() {
|
||||
return getURL();
|
||||
}
|
||||
}
|
||||
|
||||
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,227 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
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.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public class BossApiClientForwardingTest {
|
||||
@Test
|
||||
public void forwardProjectMessageWritesStructuredJsonBody() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/source/forwards"));
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
JSONObject payload = ForwardPayloads.build("single", "m1", java.util.List.of());
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.forwardProjectMessage("source", "target", payload);
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/source/forwards", apiClient.lastPath);
|
||||
assertEquals("POST", connection.requestMethodValue);
|
||||
assertEquals(
|
||||
"{\"targetProjectId\":\"target\",\"mode\":\"single\",\"sourceMessageId\":\"m1\"}",
|
||||
connection.requestBody()
|
||||
);
|
||||
}
|
||||
|
||||
private static final class RecordingBossApiClient extends BossApiClient {
|
||||
private final RecordingConnection connection;
|
||||
private String lastPath = "";
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
lastPath = path;
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
String encode(String value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
void rememberIdentity(JSONObject json) {
|
||||
// JVM 单测只关心 request body,不需要走 Android org.json 的身份恢复副作用。
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConnection extends HttpURLConnection {
|
||||
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
|
||||
private final Map<String, String> requestHeaders = new HashMap<>();
|
||||
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 void setRequestProperty(String key, String value) {
|
||||
requestHeaders.put(key, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRequestProperty(String key) {
|
||||
return requestHeaders.get(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getResponseCode() {
|
||||
return 200;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
String requestBody() {
|
||||
return requestBody.toString(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,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,244 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
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.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.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossApiClientOrchestrationBackendTest {
|
||||
@Test
|
||||
public void getProjectOrchestrationBackendUsesScopedEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/projects/audit-collab/orchestration-backend")
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getProjectOrchestrationBackend("audit-collab");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/audit-collab/orchestration-backend", apiClient.lastPath);
|
||||
assertEquals("GET", connection.requestMethodValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateProjectOrchestrationBackendWritesRequestedBackendId() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/projects/audit-collab/orchestration-backend")
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.updateProjectOrchestrationBackend("audit-collab", "omx-team");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/audit-collab/orchestration-backend", apiClient.lastPath);
|
||||
assertEquals("PATCH", connection.requestMethodValue);
|
||||
assertEquals("{\"requestedBackendId\":\"omx-team\"}", connection.requestBody());
|
||||
}
|
||||
|
||||
private static final class RecordingBossApiClient extends BossApiClient {
|
||||
private final RecordingConnection connection;
|
||||
private String lastPath = "";
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
lastPath = path;
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
String encode(String value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
void rememberIdentity(JSONObject json) {
|
||||
// No-op for JVM tests.
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConnection extends HttpURLConnection {
|
||||
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
|
||||
private final Map<String, String> requestHeaders = new HashMap<>();
|
||||
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 void setRequestProperty(String key, String value) {
|
||||
requestHeaders.put(key, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRequestProperty(String key) {
|
||||
return requestHeaders.get(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getResponseCode() {
|
||||
return 200;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
String requestBody() {
|
||||
return requestBody.toString(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Context;
|
||||
import android.text.Spanned;
|
||||
import android.text.style.BackgroundColorSpan;
|
||||
import android.text.style.BulletSpan;
|
||||
import android.text.style.QuoteSpan;
|
||||
import android.text.style.StyleSpan;
|
||||
import android.text.style.TypefaceSpan;
|
||||
|
||||
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 BossMarkdownTest {
|
||||
@Test
|
||||
public void render_formatsCommonMarkdownPatternsForChatReading() {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
CharSequence rendered = BossMarkdown.render(
|
||||
context,
|
||||
"# 标题\n\n" +
|
||||
"- 第一项\n" +
|
||||
"1. 第二项\n\n" +
|
||||
"> 引用内容\n\n" +
|
||||
"普通段落里有 **重点** 和 `代码`\n\n" +
|
||||
"```js\nconst ok = true;\n```",
|
||||
false
|
||||
);
|
||||
|
||||
assertTrue(rendered instanceof Spanned);
|
||||
Spanned spanned = (Spanned) rendered;
|
||||
|
||||
assertTrue(spanned.toString().contains("标题"));
|
||||
assertTrue(spanned.toString().contains("• 第一项"));
|
||||
assertTrue(spanned.toString().contains("1. 第二项"));
|
||||
assertTrue(spanned.toString().contains("引用内容"));
|
||||
assertTrue(spanned.toString().contains("重点"));
|
||||
assertTrue(spanned.toString().contains("代码"));
|
||||
assertTrue(spanned.toString().contains("const ok = true;"));
|
||||
|
||||
assertTrue(spanned.getSpans(0, spanned.length(), StyleSpan.class).length > 0);
|
||||
assertTrue(spanned.getSpans(0, spanned.length(), BulletSpan.class).length > 0);
|
||||
assertTrue(spanned.getSpans(0, spanned.length(), QuoteSpan.class).length > 0);
|
||||
assertTrue(spanned.getSpans(0, spanned.length(), TypefaceSpan.class).length > 0);
|
||||
assertTrue(spanned.getSpans(0, spanned.length(), BackgroundColorSpan.class).length > 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void render_returnsReadablePlaceholderForEmptyBody() {
|
||||
Context context = RuntimeEnvironment.getApplication();
|
||||
|
||||
CharSequence rendered = BossMarkdown.render(context, "", false);
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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 {
|
||||
@Test
|
||||
public void parseEventBlockExtractsEventNameAndJsonPayload() {
|
||||
BossRealtimeEvent event = BossRealtimeClient.parseEventBlock(
|
||||
"event: project.messages.updated\n" +
|
||||
"data: {\"projectId\":\"project-1\",\"status\":\"completed\"}\n\n"
|
||||
);
|
||||
|
||||
assertEquals("project.messages.updated", event.eventName);
|
||||
assertEquals("project-1", event.payload.optString("projectId"));
|
||||
assertEquals("completed", event.payload.optString("status"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseEventBlockReturnsNullForKeepaliveComment() {
|
||||
assertNull(BossRealtimeClient.parseEventBlock(": keepalive\n\n"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseEventBlockIgnoresHeartbeatControlEvents() {
|
||||
assertNull(BossRealtimeClient.parseEventBlock("event: heartbeat\n\n"));
|
||||
}
|
||||
|
||||
@Test
|
||||
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")));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user