345 Commits

Author SHA1 Message Date
AI Bot
3b51641d99 chore: checkpoint Boss app v2.5.11 2026-06-08 12:22:50 +08:00
AI Bot
bddbe8b5ba fix: mark backup snapshots without metadata invalid 2026-06-06 19:26:18 +08:00
AI Bot
cfd41b4fbf fix: harden backup verification before restore 2026-06-06 19:21:02 +08:00
AI Bot
58cc4a1a5a docs: record enterprise safety runtime status 2026-06-06 19:16:25 +08:00
AI Bot
1ec6d003d1 fix: return safe task recovery projection 2026-06-06 19:12:18 +08:00
AI Bot
1edfa6ecd5 feat: add backup verification and restore preview 2026-06-06 19:08:24 +08:00
AI Bot
643da5b738 feat: add master agent task recovery endpoint 2026-06-06 19:05:42 +08:00
AI Bot
755e30612c feat: explain task phase in android progress cards 2026-06-06 19:05:07 +08:00
AI Bot
7973c441e4 docs: plan enterprise safety recovery phase one 2026-06-06 17:41:11 +08:00
AI Bot
a7e4b96ce3 docs: plan enterprise backup and recovery 2026-06-06 16:42:24 +08:00
AI Bot
9e81d8a960 fix: route master agent through codex device pool 2026-06-06 12:31:04 +08:00
AI Bot
684b98c5c1 fix: expose codex cli to local agent launchd 2026-06-05 15:14:43 +08:00
AI Bot
e4e6f6597a fix: remove folder intro panels across surfaces 2026-06-05 14:50:13 +08:00
AI Bot
4e2636ec8b fix: remove folder page helper card 2026-06-05 14:44:01 +08:00
AI Bot
9807c7a275 fix: compact chat subpage typography 2026-06-05 10:53:54 +08:00
AI Bot
eb8961fc3f fix: align android typography with wechat density 2026-06-05 10:34:03 +08:00
AI Bot
a38b3a3093 fix: restore compact android ui density 2026-06-05 09:49:46 +08:00
AI Bot
6f143ea6f9 feat: add android codex remote control actions 2026-06-04 17:26:19 +08:00
AI Bot
025e749618 feat: queue codex remote control actions 2026-06-04 17:12:23 +08:00
AI Bot
b93bc22160 feat: add codex remote control daemon actions 2026-06-04 15:25:18 +08:00
AI Bot
63338c3d76 feat: expose codex remote control status 2026-06-04 15:18:52 +08:00
AI Bot
a5d44b0cac feat: show codex app server summaries on android 2026-06-04 15:08:27 +08:00
AI Bot
3080f57dbc feat: show codex app server drift on android 2026-06-04 14:56:44 +08:00
AI Bot
0eaf78c3c2 feat: expose codex app-server protocol drift summary 2026-06-04 14:43:27 +08:00
AI Bot
5bf2216cb0 feat: surface codex thread collaboration capabilities 2026-06-04 14:34:34 +08:00
AI Bot
de9f85bd21 feat: sync app server thread replies 2026-06-04 14:09:46 +08:00
AI Bot
dbdaab8d0f feat: mirror boss messages via app server 2026-06-03 15:00:25 +08:00
AI Bot
0c3437a36f feat: fork codex threads 2026-06-03 14:49:43 +08:00
AI Bot
5537fde7a6 feat: sync codex thread metadata 2026-06-03 14:38:15 +08:00
AI Bot
0186ef7057 feat: sync codex thread goals 2026-06-03 14:21:27 +08:00
AI Bot
cc31b0d836 feat: sync codex thread names 2026-06-03 14:06:15 +08:00
AI Bot
0bcdcbfb9d feat: add controlled codex thread archive 2026-06-03 13:47:50 +08:00
AI Bot
0fb588e339 feat: add controlled codex thread compaction 2026-06-03 13:39:01 +08:00
AI Bot
7a30c2a8d9 feat: add controlled codex thread rollback 2026-06-03 13:30:24 +08:00
AI Bot
13201e6aee feat: interrupt canceled codex app-server turns 2026-06-03 13:12:23 +08:00
AI Bot
142fb2a4b3 feat: summarize codex stream progress events 2026-06-03 12:53:43 +08:00
AI Bot
bc9a586e81 feat: expose codex stream delta events 2026-06-03 12:37:23 +08:00
AI Bot
b31238b6e2 feat: expose codex runtime lifecycle events 2026-06-03 12:27:41 +08:00
AI Bot
f23ed9f188 feat: expose codex mcp guardian governance 2026-06-03 12:13:57 +08:00
AI Bot
afeb352fe3 feat: expose codex review sandbox search governance 2026-06-03 12:03:36 +08:00
AI Bot
ca64a4c498 feat: expose codex migration marketplace governance 2026-06-03 11:55:48 +08:00
AI Bot
0fdee4bcf7 feat: expose codex fs command governance 2026-06-03 11:44:07 +08:00
AI Bot
21e514a895 feat: expose codex account config governance 2026-06-03 11:36:44 +08:00
AI Bot
ca92133019 feat: expose codex plugin governance capabilities 2026-06-03 11:22:03 +08:00
AI Bot
b0526215c5 feat: expose codex thread action capabilities 2026-06-03 11:14:12 +08:00
AI Bot
0071dec860 feat: surface codex app server hook governance 2026-06-03 11:03:45 +08:00
AI Bot
3c6a0c546b feat: align codex app server 0.136 2026-06-03 10:46:45 +08:00
AI Bot
1ae81fa3af feat: summarize codex app server turns 2026-06-03 10:17:07 +08:00
AI Bot
74b333ba2f feat: surface codex app server thread visibility 2026-06-03 10:09:07 +08:00
AI Bot
c0c88444ec feat: surface codex app server governance summaries 2026-06-03 09:59:06 +08:00
AI Bot
88b028ad2b feat: expand codex app server discovery 2026-06-02 23:11:21 +08:00
AI Bot
94e0cc8bad feat: surface codex windows sandbox progress 2026-06-01 19:16:23 +08:00
AI Bot
b0a778ee68 feat: surface codex hook lifecycle progress 2026-06-01 18:48:45 +08:00
AI Bot
32a9c9a26a feat: surface codex image generation progress 2026-06-01 18:41:10 +08:00
AI Bot
5d62560217 feat: surface codex reasoning summaries 2026-06-01 18:20:04 +08:00
AI Bot
2ca2737520 feat: surface codex tool activity progress 2026-06-01 18:04:39 +08:00
AI Bot
2a5dccf5cb feat: map codex thread collaboration progress 2026-06-01 17:52:29 +08:00
AI Bot
defa3da185 feat: surface codex account runtime notices 2026-06-01 17:40:03 +08:00
AI Bot
26b5e97614 feat: surface codex thread config progress 2026-06-01 17:18:28 +08:00
AI Bot
591638f35f feat: surface codex runtime status 2026-05-31 03:59:53 +08:00
AI Bot
cee1e7938e feat: map codex realtime thread status 2026-05-31 03:54:43 +08:00
AI Bot
f333676c36 feat: discover codex app-server capabilities 2026-05-31 03:44:02 +08:00
AI Bot
4800352e22 feat: surface codex app-server approval progress 2026-05-31 03:36:07 +08:00
AI Bot
b9d3cca2e7 feat: adapt codex app-server protocol updates 2026-05-31 03:25:30 +08:00
AI Bot
e1aed590f8 feat: harden enterprise control plane 2026-05-17 02:20:08 +08:00
AI Bot
67511c31f4 fix: detect helper screen recording permission 2026-05-13 10:35:59 +08:00
AI Bot
04505da747 fix: reconcile boss agent screen permission state 2026-05-13 10:10:58 +08:00
AI Bot
a2d6dbd012 fix: remove nonessential boss agent permission requests 2026-05-13 02:50:59 +08:00
AI Bot
a77c70ad0c fix: make boss agent permissions match computer use minimum 2026-05-13 02:33:15 +08:00
AI Bot
a6d57b683a fix: keep boss agent permission grants stable across updates 2026-05-13 02:15:43 +08:00
AI Bot
1ac9472c44 fix: route mac permissions through native agent requests 2026-05-13 01:59:55 +08:00
AI Bot
feba68ac2b fix: point local network permission to correct mac settings 2026-05-13 01:13:40 +08:00
AI Bot
842c2249a1 fix: sign boss agent bundle for permissions 2026-05-13 00:44:42 +08:00
AI Bot
73327be8b0 fix: sync native agent permission states 2026-05-13 00:18:19 +08:00
AI Bot
2ff75087b3 fix: add boss agent app icon 2026-05-13 00:01:01 +08:00
AI Bot
8d3f68cebe fix: split agent permission and skill tabs 2026-05-12 23:39:13 +08:00
AI Bot
29740f35c7 fix: align agent permissions with native app 2026-05-12 23:22:27 +08:00
AI Bot
5b3f43014d feat: add one-time agent permission setup 2026-05-12 18:39:58 +08:00
AI Bot
315cc5cd54 feat: surface agent permissions and skills 2026-05-12 18:27:10 +08:00
AI Bot
7c371ed644 feat: add mac boss-agent desktop app 2026-05-12 17:04:40 +08:00
AI Bot
b12a1c7401 fix: report local agent cli capability from config 2026-05-12 13:53:25 +08:00
AI Bot
1c1140b1fd feat: support deepseek api for master agent 2026-05-12 12:52:47 +08:00
AI Bot
4de64ac01c feat: route desktop control to authorized devices 2026-05-12 12:15:43 +08:00
AI Bot
bc199dcf5c test: add remote control stress budgets 2026-05-11 23:25:52 +08:00
AI Bot
9c8ffebb92 test: harden remote control stress flow 2026-05-11 23:12:47 +08:00
AI Bot
a311280238 feat: ship enterprise control and desktop governance 2026-05-11 14:59:26 +08:00
AI Bot
0757d07521 docs: add desktop dialog guard design 2026-05-09 20:49:52 +08:00
kris
2c719168b6 docs: plan multi-user rbac foundation 2026-04-26 18:50:42 +08:00
kris
ba83fe0aed docs: design multi-user rbac skill governance 2026-04-26 18:22:41 +08:00
kris
916528de2b docs: add master agent background notification plan 2026-04-21 16:26:10 +08:00
kris
a9ed7c911d docs: add master agent background notification design 2026-04-21 16:24:25 +08:00
kris
bb237fdd4f fix: keep chat scroll usable while sending 2026-04-18 05:23:14 +08:00
kris
449f84fcbc feat: refine mobile master agent sync and chat rendering 2026-04-18 04:51:50 +08:00
kris
e0c0ea1814 Strip pin metadata from web conversations 2026-04-11 03:05:37 +08:00
kris
5bf745f45f Hide pinned controls on Web conversations 2026-04-11 02:44:01 +08:00
kris
164a7568a7 Skip duplicate chat payloads and batch layouts 2026-04-10 22:45:19 +08:00
kris
05dc9d8788 Cache markdown and append realtime messages 2026-04-10 22:40:49 +08:00
kris
1b0f126d4f Patch local chat realtime and align Caddy 2026-04-10 22:12:58 +08:00
kris
a084688e35 Patch folder realtime threads locally 2026-04-10 22:01:21 +08:00
kris
0781a56aad Patch conversation home from realtime events 2026-04-10 19:21:36 +08:00
kris
7131ee9eb1 Lighten Android chat realtime refreshes 2026-04-10 17:15:39 +08:00
kris
68da424eb8 Debounce Android realtime refresh bursts 2026-04-10 16:54:21 +08:00
kris
7593cc9cea Gate Android polling behind realtime health 2026-04-10 14:07:17 +08:00
kris
b1a0516717 Harden Android realtime recovery 2026-04-10 13:38:21 +08:00
kris
c4dbfc7398 Reduce Android realtime heartbeat noise 2026-04-10 13:25:05 +08:00
kris
0cba837ed3 Scope folder realtime refreshes by device 2026-04-10 12:55:43 +08:00
kris
d1e5a1ac5e Refresh settings page in realtime 2026-04-07 18:43:26 +08:00
kris
07ecce3d0d Refresh config pages in realtime 2026-04-07 18:26:17 +08:00
kris
0c01627d67 Refresh status pages in realtime 2026-04-07 18:00:14 +08:00
kris
4c31dd7e98 Throttle realtime refresh bursts 2026-04-07 17:50:30 +08:00
kris
4093c41949 Refresh folder pages for scoped thread updates 2026-04-07 17:33:37 +08:00
kris
b06b084438 Filter goal refreshes by conversation note 2026-04-07 17:29:42 +08:00
kris
8fc94f1849 Scope project refreshes and harden deploy script 2026-04-07 17:20:53 +08:00
kris
1de9ae0492 Refresh project chat page on conversation updates 2026-04-07 17:09:58 +08:00
kris
4f59d59014 Refresh OTA screen in realtime 2026-04-07 16:53:22 +08:00
kris
b5d6495017 Refresh goals and versions on precise goal updates 2026-04-07 16:41:09 +08:00
kris
9268f64506 Refresh device import draft in realtime 2026-04-07 16:16:37 +08:00
kris
ef3bf35463 Refresh skill inventory in realtime 2026-04-07 16:08:29 +08:00
kris
0b0bc5152f Refresh ops center in realtime 2026-04-07 16:04:18 +08:00
kris
a42e5b75dc Refresh conversation info surfaces in realtime 2026-04-07 15:49:18 +08:00
kris
45329159f5 Refresh device detail screen in realtime 2026-04-07 15:19:39 +08:00
kris
e0e8d4f687 Refresh thread status screen in realtime 2026-04-07 14:48:16 +08:00
kris
17ecd56b57 Publish Android 2.5.11 release artifacts 2026-04-07 14:31:36 +08:00
kris
aaaf1926b4 Refresh project detail on context risk events 2026-04-07 14:29:54 +08:00
kris
f83ab50d6b Improve conversation realtime refresh and heartbeat defaults 2026-04-07 14:17:30 +08:00
kris
233f61a649 Disable cache storage on special route error responses 2026-04-07 14:08:27 +08:00
kris
b1fa3c9b26 Disable cache storage on device import draft 2026-04-07 14:01:56 +08:00
kris
4052822595 Disable cache storage on live config routes 2026-04-07 13:57:28 +08:00
kris
67d37c2c21 Disable cache storage on live detail routes 2026-04-07 13:54:00 +08:00
kris
cc1afe8173 Disable cache storage on live JSON routes 2026-04-07 13:42:46 +08:00
kris
6153e94000 Fix stale conversation sync labels on mobile 2026-04-07 13:35:52 +08:00
kris
a43bb92f3c Compact imported single-thread conversation copy 2026-04-07 12:49:53 +08:00
kris
c5223c7c16 Add remote build fallback to deploy script 2026-04-07 09:30:32 +08:00
kris
992f8dbba4 Compact imported folder conversation previews 2026-04-06 16:45:35 +08:00
kris
6d90123092 Hide context rings without live snapshots 2026-04-06 14:58:30 +08:00
kris
5782804df3 Refresh stale device capability timestamps 2026-04-06 14:38:18 +08:00
kris
5789707072 Polish web device conflict copy 2026-04-06 14:09:46 +08:00
kris
3564aeaf2e Harden read-only thread handling and refresh Android releases 2026-04-06 13:26:48 +08:00
kris
9d7d2f4d17 Add thread execution conflict guards to chat flows 2026-04-06 12:01:06 +08:00
kris
2c47df702e Harden deploy script SSH session flow 2026-04-06 11:19:51 +08:00
kris
43c733069c Wire device execution mode controls into UI 2026-04-06 11:10:51 +08:00
kris
27ab594921 Add gui/cli capability conflict guards 2026-04-06 10:22:07 +08:00
kris
d04eca4703 docs: add gui cli capability implementation plan 2026-04-06 09:04:19 +08:00
kris
9de4fb7d40 docs: scope conflict actions to the active project folder 2026-04-06 09:00:20 +08:00
kris
6f2206a438 docs: define gui cli device capability and conflict guard 2026-04-06 08:58:39 +08:00
kris
d28afb2df1 fix: group fallback conversation feed into folder archives on android 2026-04-06 06:55:06 +08:00
kris
b7492e4789 docs: describe folder archive homepage behavior 2026-04-06 05:37:20 +08:00
kris
6956d1ac78 fix: complete folder archive action handling 2026-04-06 05:35:42 +08:00
kris
a46f11cf6c Fix conversation folder search targeting 2026-04-05 14:51:01 +08:00
kris
272698234d Fix conversation home reachability and Android timeout 2026-04-05 14:16:18 +08:00
kris
08a746c3bf fix: use conversation home feed on android 2026-04-05 14:03:47 +08:00
kris
0fcbf2d0a0 fix: support archived thread search on android homepage 2026-04-05 13:58:08 +08:00
kris
7206be05b6 feat: align android conversation folders with drawer design 2026-04-05 13:49:16 +08:00
kris
c8156d5f40 fix: use folder project title in web rows 2026-04-05 13:38:09 +08:00
kris
447e9e0b62 feat: align web conversation folders with drawer design 2026-04-05 13:26:50 +08:00
kris
a17c702edf test: assert folder archive removal on downgrade 2026-04-05 13:23:36 +08:00
kris
f9a0d205df test: cover folder archive context tie-breakers 2026-04-05 13:17:28 +08:00
kris
20b296ce4f feat: tighten conversation folder archive projections 2026-04-05 13:14:15 +08:00
kris
ef17947635 docs: add conversation folder drawer implementation plan 2026-04-05 13:05:28 +08:00
kris
b60da88e37 docs: add conversation folder drawer design 2026-04-05 12:40:16 +08:00
kris
f046adc393 fix: recover thread takeover session on android 2026-04-05 12:15:05 +08:00
kris
e00f7a55ea fix: recover master-agent takeover session on android 2026-04-05 11:56:20 +08:00
kris
35913f9d1d feat: add standalone web master-agent takeover page 2026-04-05 09:15:43 +08:00
kris
7cc33d391b feat: move global takeover into master-agent menu 2026-04-05 08:52:21 +08:00
kris
2a5962f767 feat: add master-agent takeover controls 2026-04-05 08:45:07 +08:00
kris
52f7d08b9e perf: coalesce root tab realtime refresh bursts 2026-04-05 08:26:39 +08:00
kris
71aa1a7143 perf: diff root list updates by stable item keys 2026-04-05 08:18:15 +08:00
kris
6083079be9 perf: coalesce project chat realtime reload bursts 2026-04-05 08:07:09 +08:00
kris
0bae3a78ec perf: refresh only active root tab data on realtime events 2026-04-05 08:02:14 +08:00
kris
28f692706b refactor: remove stale import understanding surfaces 2026-04-05 07:53:03 +08:00
kris
50d5327afd refactor: disable automatic main-agent thread sync pushes 2026-04-05 07:39:59 +08:00
kris
93c4574130 docs: add adb wireless keeper plan 2026-04-05 06:06:14 +08:00
kris
e649e2f9ac docs: add adb wireless keeper design 2026-04-05 06:00:06 +08:00
kris
af0cc3fead refactor: clarify thread execution failure notices 2026-04-05 05:19:18 +08:00
kris
276beb3486 refactor: keep thread sync in collaborative mode 2026-04-05 04:47:32 +08:00
kris
2e4d6f693d refactor: remove readonly wording from thread status surfaces 2026-04-05 04:02:34 +08:00
kris
2a34c19cc9 feat: surface thread status summary in conversation info 2026-04-05 03:32:12 +08:00
kris
5a53b60f13 feat: narrow thread sync context and dedupe realtime refresh 2026-04-05 03:23:11 +08:00
kris
da78e82a90 fix: fail closed on invalid codex resume bindings 2026-04-04 13:08:08 +08:00
kris
40b78c5cae fix: fail closed on invalid codex resume bindings 2026-04-04 13:04:12 +08:00
kris
4ab0414d43 docs: describe thread status sync runtime 2026-04-04 12:22:23 +08:00
kris
4d9b8e2976 feat: add thread status documents and safe thread reply handling 2026-04-04 11:50:46 +08:00
kris
010d8eda2d feat: add thread status read views 2026-04-04 11:39:06 +08:00
kris
7d578aa12f feat: prioritize thread status in master agent prompt 2026-04-04 11:37:41 +08:00
kris
f69eebd82d feat: sync thread status events 2026-04-04 11:06:00 +08:00
kris
8e12fa1e8c docs: add thread status sync implementation plan 2026-04-04 10:47:47 +08:00
kris
f180a5e4a8 docs: add thread status sync design 2026-04-04 10:43:39 +08:00
kris
48a45f04c6 fix: migrate legacy boss console state 2026-04-04 10:16:57 +08:00
kris
99acf26b1b refactor: remove seeded boss console conversation 2026-04-04 10:06:57 +08:00
kris
4e78fb0a34 fix: prefer fresh codex activity in conversation timestamps 2026-04-04 09:25:28 +08:00
kris
22406ed587 feat: suggest master-agent takeover for synced projects 2026-04-04 08:59:40 +08:00
kris
c4ce8d4b0a feat: surface auto project sync guidance in master chat 2026-04-04 08:53:28 +08:00
kris
6bad739ab9 feat: mirror project understanding sync into master chat 2026-04-04 08:49:08 +08:00
kris
cf57b5058f refactor: keep imported project understanding sync automatic 2026-04-04 08:43:53 +08:00
kris
432cf97541 feat: sync project understanding for imported devices 2026-04-04 08:29:17 +08:00
kris
01f438e3af feat: keep project understanding in sync after import 2026-04-04 08:09:47 +08:00
kris
908ad8858b feat: add realtime sync and import project understanding 2026-04-04 07:53:27 +08:00
kris
9c53e583ba style: align native top bar icons with wechat chrome 2026-04-04 04:13:37 +08:00
kris
2f741c327f refactor: slim thread execution prompts 2026-04-04 03:43:21 +08:00
kris
829005ba66 feat: improve chat readability with markdown 2026-04-04 03:10:16 +08:00
kris
5ebb37cbfc feat: streamline group dispatch reminders 2026-04-04 03:00:34 +08:00
kris
425d8992ef style: tune quick actions menu position and theming 2026-04-04 02:36:22 +08:00
kris
d126c46479 style: switch conversation quick actions to dropdown menu 2026-04-04 02:30:54 +08:00
kris
9d19163b0d feat: add conversation quick actions fan menu 2026-04-04 02:19:27 +08:00
kris
17dca04b6f feat: move conversation search into header mode 2026-04-04 01:50:06 +08:00
kris
062cab8e9a fix: lock android app to portrait 2026-04-04 01:41:13 +08:00
kris
bf4b27b062 perf: move root surfaces to recycler-backed pages 2026-04-04 01:31:02 +08:00
kris
35bcf92d72 perf: reduce high-refresh ui churn 2026-04-04 01:16:48 +08:00
kris
31004c512a feat: use pager-based root tab navigation 2026-04-04 01:09:50 +08:00
kris
05b9bee9e8 fix: mirror dispatch replies back to thread chats 2026-04-04 00:57:40 +08:00
kris
1a64fd9f29 fix: enable swipe tabs and clamp group titles 2026-04-04 00:53:53 +08:00
kris
5157a0ac07 feat: add conversation selection mode and swipe tabs 2026-04-04 00:32:57 +08:00
kris
c30b0a66ae feat: auto refresh conversation tab 2026-04-03 14:31:40 +08:00
kris
da7d4e0a7d style: tighten pinned header and context ring 2026-04-03 11:14:45 +08:00
kris
4d2d567bf9 feat: polish pinned conversations and context ring 2026-04-03 10:48:34 +08:00
kris
da55071a99 fix: show conversation context status 2026-04-03 10:09:19 +08:00
kris
459b301939 style: unify project detail top menu 2026-04-03 09:51:21 +08:00
kris
95f164e552 fix: stabilize conversation refresh and group create 2026-04-03 09:43:03 +08:00
kris
354c8b1f0b feat: let master-agent dispatch real threads 2026-04-03 05:29:38 +08:00
kris
ad7dd94d95 fix: keep imported thread candidates distinct 2026-04-03 05:02:18 +08:00
kris
42cf489450 style: unify native overflow menus 2026-04-03 04:48:41 +08:00
kris
24241d1f64 feat: execute omx dispatches via local-agent 2026-04-03 03:51:16 +08:00
kris
ec45bed59f feat: add omx orchestration backend selection 2026-04-03 03:17:12 +08:00
kris
60f5e2d7d6 feat: add omx team adapter skeleton 2026-04-03 02:22:02 +08:00
kris
8e2350e89d feat: gate claw runtime selection by availability 2026-04-03 02:11:41 +08:00
kris
6c999fb951 chore: add claw smoke runtime sample 2026-04-03 01:41:29 +08:00
kris
39b576cc42 feat: add claw backend adapter 2026-04-03 01:36:29 +08:00
kris
8daaea01fd docs: add claw backend adapter plan 2026-04-03 00:48:51 +08:00
kris
bfb7c43447 docs: add claw backend adapter design 2026-04-03 00:46:44 +08:00
kris
519ecb56eb docs: record execution foundation rollout 2026-04-03 00:26:44 +08:00
kris
70e8a13368 refactor: add remote runtime adapter 2026-04-03 00:24:11 +08:00
kris
8a62e72fd5 refactor: add execution backend selection 2026-04-03 00:21:19 +08:00
kris
a3a4f3e980 refactor: extract execution permission policy 2026-04-02 22:46:41 +08:00
kris
384dd570de refactor: extract execution prompt assembly 2026-04-02 22:32:19 +08:00
kris
e348d6cc5d feat: add execution foundation contracts 2026-04-02 21:37:10 +08:00
kris
5e23e1d408 docs: add execution foundation implementation plan 2026-04-02 20:43:59 +08:00
kris
3a45e41b1b docs: add execution foundation design 2026-04-02 20:12:26 +08:00
kris
22442979fe docs: add external runtime fusion strategy 2026-04-02 19:13:43 +08:00
kris
3a03ec4cbd docs: add claw-code integration strategy 2026-04-02 19:09:41 +08:00
kris
a4655439dd feat: add preset aliyun qwen model switching 2026-04-01 08:04:31 +08:00
kris
e52932e8ef feat: polish web master-agent controls and dispatch recovery 2026-04-01 07:50:20 +08:00
kris
87093677b8 feat: add web master-agent chat menu 2026-04-01 07:42:17 +08:00
kris
a27c7da7d4 refactor: harden master-agent prompt assembly 2026-04-01 06:53:38 +08:00
kris
1c45a88205 refactor: tighten master-agent memory ingestion 2026-04-01 06:42:41 +08:00
kris
60d69eb222 feat: add aliyun qwen backup provider 2026-04-01 05:33:35 +08:00
kris
ba01ae5393 feat: finish master-agent prompt and memory runtime 2026-04-01 04:56:07 +08:00
kris
d316f0490e feat: add master-agent prompts and memory management 2026-04-01 04:10:11 +08:00
kris
9000a9f185 docs: add master-agent prompts and memory plan 2026-04-01 03:36:09 +08:00
kris
4312b248a7 docs: add master-agent prompts and memory design 2026-04-01 03:32:27 +08:00
kris
811d011178 fix: shorten android chat send timeout 2026-03-31 23:40:07 +08:00
kris
d518878faa chore: publish android release v2.5.8 2026-03-31 23:36:17 +08:00
kris
ee2fab7ceb feat: apply per-chat master-agent execution config 2026-03-31 23:36:12 +08:00
kris
c3ee76909d fix: persist master-agent wait state in chat 2026-03-31 22:58:13 +08:00
kris
5c69eaa26d docs: sync device import review runtime 2026-03-31 22:43:23 +08:00
kris
87ffe19f78 feat: queue device import review tasks 2026-03-31 22:38:57 +08:00
kris
dcbff3cc7d feat: add dispatch retry and import recovery flows 2026-03-31 22:10:03 +08:00
kris
be31503d22 fix: serialize local-agent heartbeats 2026-03-31 21:49:46 +08:00
kris
02fcc56332 fix: harden production chat runtime 2026-03-31 20:20:07 +08:00
kris
ec7081f6cc docs: sync master-agent async chat flow 2026-03-31 20:03:07 +08:00
kris
013d9566be feat: queue master-agent chat replies 2026-03-31 19:59:08 +08:00
kris
e741952295 feat: add master agent chat controls 2026-03-31 19:30:26 +08:00
kris
bc464905a5 docs: add master-agent chat controls plan 2026-03-31 17:29:39 +08:00
kris
e9ae37028e docs: add master-agent chat controls spec 2026-03-31 17:26:34 +08:00
kris
71d0979292 fix: preserve user chat messages after ai onboarding 2026-03-31 16:30:21 +08:00
kris
70494fc15b fix: keep chat composer above ime on android 2026-03-31 16:12:31 +08:00
kris
f417fe1955 feat: harden agent onboarding and device import flows 2026-03-31 05:18:58 +08:00
kris
4aed93e90c feat: add master-agent jump after openai onboarding 2026-03-31 04:43:16 +08:00
kris
9d7f38412a feat: add browser-assisted openai onboarding flow 2026-03-31 04:31:58 +08:00
kris
0cb2171dd3 feat: harden ai onboarding and approval chat flows 2026-03-31 04:18:57 +08:00
kris
4336dc22a7 feat: add group repair and dispatch rejection flows 2026-03-31 03:56:28 +08:00
kris
9c02ebb574 feat: complete chat routing and openai onboarding 2026-03-31 03:31:22 +08:00
kris
5b590f7cc1 feat: add ai account onboarding entry points 2026-03-30 21:08:08 +08:00
kris
7c6101f22b fix: restore master agent relay guidance 2026-03-30 17:42:21 +08:00
kris
5eb1246f02 feat: restore dispatch confirmation flows 2026-03-30 17:11:07 +08:00
kris
40861c63da fix: filter codex subthreads during auto import 2026-03-30 14:25:25 +08:00
kris
03ac40f427 feat: group imported threads into project archives 2026-03-30 13:50:26 +08:00
kris
98dd0e3cd5 feat: auto-sync bound codex threads into conversations 2026-03-30 13:01:37 +08:00
kris
9c15c30a41 style: align native me surfaces with wechat ui 2026-03-30 12:26:14 +08:00
kris
038c2bd088 fix: harden dispatch and device import flows 2026-03-30 12:03:43 +08:00
kris
745b47e812 feat: wire dispatch execution and device import flows 2026-03-30 11:08:43 +08:00
kris
3b2bf59b65 feat: add dispatch plan confirmation flow 2026-03-30 10:41:52 +08:00
kris
11724e9834 feat: create dispatch plans from group messages 2026-03-30 01:49:07 +08:00
kris
74ea7151ad fix: reject dispatch plan retry mismatches 2026-03-30 01:40:52 +08:00
kris
1cd3a15a5d fix: tighten dispatch retry validation 2026-03-30 01:33:06 +08:00
kris
81f4245763 fix: harden dispatch confirmation flow 2026-03-30 01:24:45 +08:00
kris
40d93c05d1 feat: add orchestration dispatch state 2026-03-30 01:13:45 +08:00
kris
949dcf7845 docs: plan orchestration and device import 2026-03-30 01:09:05 +08:00
kris
1b55072d9b test: lock wechat root surface expectations 2026-03-29 23:52:19 +08:00
kris
6402096639 docs: add wechat ui polish plan 2026-03-29 23:47:59 +08:00
kris
ffefc62b35 docs: add wechat ui polish spec 2026-03-29 23:40:57 +08:00
kris
3724b3b444 chore: publish native ui polish release v2.5.3 2026-03-29 20:43:16 +08:00
kris
ef630f3572 chore: publish native ui polish release v2.5.2 2026-03-29 20:30:23 +08:00
kris
062b46bd41 chore: polish native wechat ui release v2.5.1 2026-03-29 20:00:00 +08:00
kris
fe186ad8d5 style: align group creation list with conversations 2026-03-29 19:20:37 +08:00
kris
e94e91a0f7 style: unify native top action buttons 2026-03-29 19:10:20 +08:00
kris
32960f8ecc fix: restyle conversations plus action 2026-03-29 19:02:13 +08:00
kris
c6e8d19ee5 fix: restore safe top actions and home group chat entry 2026-03-29 18:44:53 +08:00
kris
e9ab62e94d fix: restore native conversation row text layout 2026-03-29 17:54:34 +08:00
kris
e051a49f7a chore: publish attachment storage release v2.5.0 2026-03-29 17:22:07 +08:00
kris
5fb75b50b4 fix: harden attachment analysis delivery 2026-03-29 17:10:58 +08:00
kris
88ab2d011a feat: add attachment analysis access links 2026-03-29 17:06:54 +08:00
kris
18dc7c6120 fix: stabilize attachment upload and storage flows 2026-03-29 16:59:10 +08:00
kris
1e476a2097 android: add attachment composer flow 2026-03-29 16:27:04 +08:00
kris
9e4b64ba9e Implement attachment analysis task flow 2026-03-29 16:21:05 +08:00
kris
8273340f7f feat(web): add me storage page 2026-03-29 16:20:25 +08:00
kris
3307f79162 feat: add aliyun oss storage config 2026-03-29 16:05:25 +08:00
kris
de23a6e921 fix: harden attachment access and file paths 2026-03-29 15:47:07 +08:00
kris
aa75506364 feat: add server file attachment pipeline 2026-03-29 15:26:06 +08:00
kris
c3900a11ec fix: align attachment storage model 2026-03-29 15:18:27 +08:00
kris
4262c8fb5c feat: add attachment storage config model 2026-03-29 15:11:17 +08:00
kris
e4ff24a18f docs: add attachment storage implementation plan 2026-03-29 15:06:16 +08:00
kris
3cb4405b14 docs: add attachment storage and ai processing spec 2026-03-29 15:03:02 +08:00
kris
a5e8ba2b7e chore: publish wechat forwarding release v2.4.0 2026-03-28 09:00:53 +08:00
kris
cc08ca28aa fix: exercise native chat actions via ui callbacks 2026-03-28 08:54:44 +08:00
kris
a3a7f43626 test: verify native chat mode transitions 2026-03-28 08:50:57 +08:00
kris
64ad401d0c test: cover native chat chrome transitions 2026-03-28 08:47:55 +08:00
kris
d2291af32c fix: stabilize native chat selection chrome 2026-03-28 08:45:20 +08:00
kris
7109f1d3db feat: add wechat style native message forwarding 2026-03-28 08:39:08 +08:00
kris
200fc18210 test: cover native forward request boundary 2026-03-28 08:33:08 +08:00
kris
13c67425ab refactor: isolate forward payload serialization 2026-03-28 08:29:05 +08:00
kris
0783f4da14 fix: serialize forward target payloads 2026-03-28 08:21:37 +08:00
kris
42063db78f fix: tighten chat selection state invariants 2026-03-28 08:18:22 +08:00
kris
c90dea4b7c feat: add native forward target picker 2026-03-28 07:22:48 +08:00
kris
9613c3c154 feat: add structured message forwarding payloads 2026-03-28 07:20:49 +08:00
kris
227d270505 feat: add native chat forward selection state 2026-03-28 07:18:58 +08:00
kris
b606af66f6 docs: add wechat forwarding implementation plan 2026-03-28 07:12:15 +08:00
kris
a9e8bb9ddd docs: add wechat message forwarding spec 2026-03-28 07:08:05 +08:00
kris
f0735b31e5 feat: restore wechat thread ui and group chat 2026-03-28 05:21:44 +08:00
kris
afa7e79ad2 docs: add wechat ui restore implementation plan 2026-03-28 02:09:29 +08:00
kris
e27ea1e071 docs: add thread and group chat ui restore spec 2026-03-28 02:04:10 +08:00
kris
0a3390b132 chore: publish native ui polish release v2.2.1 2026-03-27 15:58:50 +08:00
kris
4dbf4ac1de feat: polish native root tab memory 2026-03-27 15:41:26 +08:00
kris
6559ad5bce feat: add native ota progress feedback 2026-03-27 15:39:19 +08:00
kris
ae571a76ff feat: polish chat composer feedback 2026-03-27 14:26:57 +08:00
kris
63ceef9871 docs: add native ui phase 2 spec and plan 2026-03-27 14:22:58 +08:00
kris
8da592bddf chore: publish wechat native rollback release v2.2.0 2026-03-27 13:41:23 +08:00
kris
9e0b5b223f android: preserve device detail summary context 2026-03-27 13:28:15 +08:00
kris
ff56617fdb android: simplify wechat device and me surfaces 2026-03-27 13:20:46 +08:00
kris
05e26afbf1 Restore chat-first project detail surface 2026-03-27 02:14:30 +08:00
kris
b794ba05fa fix: preserve abnormal device status in root list 2026-03-27 02:07:05 +08:00
kris
ce8dcad41c feat: restore wechat-style root shell 2026-03-27 01:58:34 +08:00
kris
17300c49ea fix: tighten wechat surface mapper contract 2026-03-27 01:52:29 +08:00
kris
efcefd8a62 test: freeze wechat surface contract 2026-03-27 01:49:01 +08:00
kris
785db90a7a docs: add wechat native ui rollback plan 2026-03-27 01:47:28 +08:00
kris
8439428479 docs: add wechat native ui rollback spec 2026-03-27 01:42:05 +08:00
2294 changed files with 388771 additions and 2622 deletions

27
.env.server.example Normal file
View 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
View File

@@ -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
View File

@@ -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` runnerboss-agent 默认打开 `codexAppServerEnabled`,通过 `codex app-server` stdio 接入 `conversation_reply / dispatch_execution`,也可灰度切到 `ws://127.0.0.1:<port>``unix://PATH` 本机长驻 App ServerWebSocket/Unix WebSocket handshake 支持 `Authorization: Bearer <token>`,优先用 `codexAppServerAuthTokenFile` 保存本地 token。失败时只在 turn 未启动前回退 `codex exec resume`,避免重复执行同一轮对话。设备 heartbeat 会单独上报 `codexAppServer` capability并按 `codexAppServerDiscoveryTtlMs` 缓存 `model/list / skills/list / plugin/list / app/list / modelProvider/capabilities/read` 的能力摘要,供 APP/后台模型选择和治理页读取。2026-05-31 起runner 会吸收 App Server 的 plan / diff / item / subagent 事件并归一到 Boss `execution_progress` 进度卡,执行中通过 `POST /api/v1/master-agent/tasks/[taskId]/progress` 实时刷新;后续已补 `approvals / warnings / fileChanges / threadStatus / realtime / modelRoute / tokenUsage / mcpServers / remoteControl / threadGoal / threadSettings / compaction / accountStatus / modelVerification / threadCollaboration / toolActivities / reasoningSummary / windowsSandbox` 等结构化摘要。Android 原生进度卡可显示线程状态、实时状态、线程配置、线程协作、工具活动、思考摘要、账号状态、运行状态、Windows 沙箱状态、安全提醒、审批状态和文件变更摘要且不展示完整命令、diff、系统提示词、密钥、SDP、音频原始数据、raw realtime item、remote installationId、本地绝对路径或 Windows sandbox sourcePath。本机 `codex-cli 0.136.0-alpha.2` 协议快照已生成在 `docs/protocol-snapshots/codex-app-server/0.136.0-alpha.2/`,新增确认 `skills/extraRoots/set`。配置 `codexAppServerSkillExtraRoots` / `BOSS_CODEX_APP_SERVER_SKILL_EXTRA_ROOTS`runner 会先下发共享 Skill 根,再拉取 `skills/list`metadata 只保留根目录数量、basename 和下发状态。当前 Inter-Thread Broker任务携带源/目标 Codex 线程时可通过 `thread/read -> thread/inject_items -> turn/start` 完成受控线程协作;服务端新增 `POST /api/v1/projects/[projectId]/thread-collaboration` 作为 APP/后台可调用入口;任务携带 `targetCodexTurnId` 时 runner 会改用 `turn/steer` 干预活跃 turn新版官方 `ThreadItem.collabToolCall` 会额外提取目标数量和 agent 状态集合,但仍不保存源/目标线程 ID、prompt 或 agent 私有消息。
- 当前 App Server heartbeat discovery 已扩展到 `experimentalFeature/list / collaborationMode/list / permissionProfile/list / mcpServerStatus/list`,设备详情页会展示“治理:实验特性 / 协作模式 / MCP / 权限”摘要MCP 只保留服务名、工具数量、资源数量和认证状态permission profile 只保留 id/description不保存本地路径、resource URI、文件规则、token 或工具参数。
- 当前 App Server heartbeat discovery 已继续扩展到 `account/read / account/rateLimits/read / config/read / configRequirements/read / externalAgentConfig/detect`设备详情页会展示账号、套餐、额度、App 配置、托管要求和外部 Agent 迁移候选摘要该链路只保存计数、开关和状态不保存邮箱、API key、完整 config、本地路径、迁移描述或外部 Agent 原始内容。
- 当前 App Server heartbeat discovery 已新增 `thread/list / thread/loaded/list` 线程可见性摘要设备详情页会展示线程总数、已加载线程、活跃线程和最新更新时间metadata 只保留非归档线程的 `id / name / sourceKind / status / updatedAt / loaded` 轻量目录,不保存 cwd、本地路径、turn 内容、用户正文或内部 prompt。
- 当前 App Server heartbeat discovery 已新增 `thread/turns/list` turn 运行态摘要,设备详情页会展示总轮次、运行中轮次、完成轮次和最新 turn 更新时间;请求固定使用 `itemsView=summary`metadata 只保留每个线程的 turn 计数、最近状态、更新时间和最终 `agentMessage` 安全摘要不保存用户输入、reasoning 原文、命令输出、原始 items、内部 prompt 或系统提示词。
- 当前 App Server heartbeat discovery 会把非归档可见线程的最终 `agentMessage` 合并进 `projectCandidates.recentAssistantMessages`;服务端据此把 Codex Desktop 自己产生的新回复反向同步到 Boss APP 会话列表、preview、lastMessageAt 和未读数。已有本地扫描候选优先保留 folder/thread 映射App Server 只补充最新回复摘要。
- 当前 App Server heartbeat discovery 已新增线程操作能力摘要,设备详情页会展示“线程操作”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `thread/archive / thread/compact/start / thread/shellCommand / turn/interrupt` 等写操作。
- 当前 App Server heartbeat discovery 已支持 `skills/extraRoots/set` 共享 Skill 根目录下发摘要,设备详情页会展示“共享 Skill 根”metadata 不保存根目录绝对路径、Skill 文件路径、token 或配置原文。
- 当前 App Server heartbeat discovery 已支持 `hooks/list` 钩子治理摘要设备详情页会展示“Hook”metadata 只保留 hook 数、启用数、受管 / 可信 / 修改 / 未信任计数、warning / error 计数和事件 / handler 类型,不保存 hook key、command、sourcePath、statusMessage、hash、error message 或本地路径。
- 当前 App Server heartbeat discovery 已新增插件治理能力摘要,设备详情页会展示“插件治理”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `plugin/install / plugin/uninstall / plugin/share/*` 等写操作。
- 当前 App Server heartbeat discovery 已新增账号与配置治理能力摘要,设备详情页会展示“账号治理 / 配置治理”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `account/login/* / account/logout / config/* / skills/config/write` 等写操作。
- 当前 App Server heartbeat discovery 已新增文件系统与命令会话治理能力摘要,设备详情页会展示“文件治理 / 命令会话”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `fs/*``command/exec/write``command/exec/terminate` 等读写或命令控制 API。
- 当前 App Server heartbeat discovery 已新增外部 Agent 迁移、Marketplace 和实验特性治理能力摘要,设备详情页会展示“迁移治理 / 市场治理 / 实验特性治理”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `externalAgentConfig/import``marketplace/*``experimentalFeature/enablement/set` 等写操作。
- 当前 App Server heartbeat discovery 已新增审查、Windows 沙箱和文件搜索事件能力摘要,设备详情页会展示“审查治理 / Windows 沙箱 / 文件搜索事件”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `review/start``windowsSandbox/setupStart` 等动作。
- 当前 App Server heartbeat discovery 已新增 MCP、用户交互和 Guardian 治理能力摘要设备详情页会展示“MCP 治理 / 用户交互 / Guardian 治理”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中调用 `mcpServer/tool/call``item/tool/requestUserInput``thread/approveGuardianDeniedAction` 等动作。
- 当前 App Server heartbeat discovery 已新增运行事件、扩展事件和线程生命周期事件能力摘要,设备详情页会展示“运行事件 / 扩展事件 / 线程生命周期”;该摘要只来自 runner 安全 catalog 和协议快照证明,不会在 heartbeat 中主动触发进程、插件、Skill 或线程生命周期动作。
- 当前 App Server heartbeat discovery 已新增流式增量事件能力摘要,设备详情页会展示“流式增量”;该摘要只来自 runner 安全 catalog 和协议快照证明,用于识别 agent delta、plan delta、reasoning delta、MCP progress、command output 和 file output 这类实时事件,不保存或展示原始增量内容。
- `GET http://127.0.0.1:4317/api/v1/skills` 正常,已返回本机扫描到的 Codex Skill
- `POST http://127.0.0.1:4317/api/v1/heartbeat` 正常,且会顺带触发 `thread-context` 上报
- `local-agent` 当前每 5 秒轮询一次本机 Skill lifecycle 请求;默认打开 `skillLifecycleEnabled=true`。远程 `install` 或带 `sourceUrl` 的更新必须命中 `skillLifecycleAllowedSources``skillLifecycleTrustedSources`,为空时只允许既有本地 Skill 的 `update / rollback / uninstall / version_lock`;请求携带 `checksum / expectedChecksum` 时会校验 `manifest.json``SKILL.md` 的 sha256失败会清理半安装目录或尽量恢复备份。卸载 / 更新 / 回滚前会在 `skillsDir/.boss-skill-backups` 保留备份,卸载仍限制在 `skillsDir` 目录内,版本锁写入 `.boss-skill-locks.json`
- `launchd` 已加载:`~/Library/LaunchAgents/com.hyzq.boss.local-agent.plist`
服务器:
@@ -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 系统层面对“无线调试”没有真正的永久不掉线开关;重启手机、切 WiFi、切热点、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 思考中”状态,并通过后台轮询刷新真实回复

View File

@@ -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"
}

View File

@@ -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"

View File

@@ -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;
}
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -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)
);
}
}
}

View File

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

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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
}
});
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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());
});
}
});
}
}

View 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");
}
}

View File

@@ -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;
}
}

View 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;
}
}
}

View 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

View File

@@ -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);
}
}
}

View File

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

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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));
}
}

View 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",
"结论",
"最终",
"总结",
"已完成",
"已经完成",
"验证通过",
"测试通过",
"已修复",
"修好了",
"已部署",
"已安装",
"可以直接"
};
}

View File

@@ -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());
});
}
});
// 兼容页只负责跳转,不再承载旧的备注转发链路。
}
}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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();

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

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

View File

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

View File

@@ -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();
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z" />
</vector>

View File

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

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="M15.41,7.41L14,6L8,12L14,18L15.41,16.59L10.83,12L15.41,7.41Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="M9,16.17L4.83,12L3.41,13.41L9,19L21,7L19.59,5.59L9,16.17Z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#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>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="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>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="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>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="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>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="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>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF111111"
android:pathData="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>

View File

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

View File

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

View File

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

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View File

@@ -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>

View 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>

View 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>

View File

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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}
}

View File

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

View File

@@ -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;
}
}

View File

@@ -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());
}
}

View File

@@ -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) {}
}
}

View File

@@ -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) {}
}
}

View File

@@ -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) {}
}
}

View File

@@ -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) {}
}
}

View File

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

View File

@@ -0,0 +1,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) {}
}
}

View File

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

View File

@@ -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"));
}
}

View File

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

View File

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

View File

@@ -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